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 | ||
|
|
077835a30b | ||
|
|
663460d62e | ||
|
|
be818a50a8 | ||
|
|
6f4ae679e5 | ||
|
|
cdede17683 | ||
|
|
a264669151 | ||
|
|
d9c75eaec2 | ||
|
|
c868c17505 | ||
|
|
d8917fbd9a | ||
|
|
413392f608 | ||
|
|
970253cbbe | ||
|
|
31012ce825 | ||
|
|
a46f5e6638 | ||
|
|
61ed0c0ed0 | ||
|
|
9097ca4abc | ||
|
|
2ed8c04064 | ||
|
|
56135c29f8 | ||
|
|
5d74038aad | ||
|
|
db5b65ca0d | ||
|
|
45f7b1401e | ||
|
|
e349a80ddc | ||
|
|
69be42aba0 | ||
|
|
af2fdcc894 | ||
|
|
1f4421ae16 | ||
|
|
b304996ff5 | ||
|
|
c75e3921aa | ||
|
|
50d8e7f21a | ||
|
|
15978bf564 | ||
|
|
31aabd0856 | ||
|
|
56ce1d4259 | ||
|
|
448f7dc65d | ||
|
|
e8ff99a768 | ||
|
|
27231c8eec | ||
|
|
eb7ec9e835 | ||
|
|
f0fb3fe05d | ||
|
|
3f26f52f8b | ||
|
|
cb3ff714d9 | ||
|
|
d0037747b5 | ||
|
|
34b4313a36 | ||
|
|
2d017231eb | ||
|
|
e7797017f6 | ||
|
|
6c24a8bb46 | ||
|
|
9aecdb1aaa | ||
|
|
2be43d3177 | ||
|
|
ef60ec0330 | ||
|
|
567c0de501 | ||
|
|
6a5daa0c18 | ||
|
|
4d730d7860 | ||
|
|
068d85e0e2 | ||
|
|
1f857ddf52 | ||
|
|
2953ba35ba | ||
|
|
c0193ae440 | ||
|
|
affeaccae8 | ||
|
|
4a97172346 | ||
|
|
5d6ff07f0b | ||
|
|
c5290a1796 | ||
|
|
227ec62d53 | ||
|
|
fa9d9b12f1 | ||
|
|
96b6a7e291 | ||
|
|
b13b66160c | ||
|
|
9744619eb4 | ||
|
|
5d2d0ad312 | ||
|
|
3d5a226a5a | ||
|
|
4b82a0852c | ||
|
|
41eb1bb788 | ||
|
|
5ff2075a45 | ||
|
|
38e0f0a52e | ||
|
|
14ae39c5aa | ||
|
|
3054a9baaa | ||
|
|
4dc37f1b42 | ||
|
|
47b6064964 | ||
|
|
002c854497 | ||
|
|
8b960458ee | ||
|
|
1b784a6c73 | ||
|
|
200c12651b | ||
|
|
5f983ca84e | ||
|
|
53ecd701c0 | ||
|
|
bfee0dde03 | ||
|
|
8476eeb247 | ||
|
|
1914d9462b | ||
|
|
4c0edde4f7 | ||
|
|
cfc48ff86f | ||
|
|
bb9819ec96 | ||
|
|
8cdbd70966 | ||
|
|
ee0b73dd52 | ||
|
|
a8d48bedad | ||
|
|
5d061c6f21 | ||
|
|
2ddc0f920c | ||
|
|
58645bd7a7 | ||
|
|
008e9c395a | ||
|
|
70cce640aa | ||
|
|
0f8a764a44 | ||
|
|
d66490b6be | ||
|
|
b00c5821f2 | ||
|
|
658fcdbced | ||
|
|
e94eaf7700 | ||
|
|
a9ded6497a | ||
|
|
46aca513b8 | ||
|
|
6c01d7e6bd | ||
|
|
3cffba6e98 | ||
|
|
865daa137d | ||
|
|
4735490759 | ||
|
|
4c7e8696cb | ||
|
|
7727dbfddc | ||
|
|
58b86ad1af | ||
|
|
7f2759b2fc | ||
|
|
a8a97f09b7 | ||
|
|
f3bf4cf94a | ||
|
|
87e1c82610 | ||
|
|
51c87a7dc9 | ||
|
|
28cb4ce6ae | ||
|
|
cf6e2d7654 | ||
|
|
fe320584eb | ||
|
|
6fe31964e1 | ||
|
|
8ab8bbd8b3 | ||
|
|
71a604c8a5 |
No files matched your search
@@ -0,0 +1,80 @@
|
||||
name: Bug Report
|
||||
description: Report a problem with Palladium Wallet
|
||||
labels: ["bug"]
|
||||
body:
|
||||
- type: markdown
|
||||
attributes:
|
||||
value: |
|
||||
Thanks for taking the time to report a bug.
|
||||
Please fill in every section — incomplete reports are hard to reproduce and may be closed.
|
||||
|
||||
- type: input
|
||||
id: version
|
||||
attributes:
|
||||
label: Wallet version
|
||||
description: Shown in Help → Info (e.g. `1.0.0`).
|
||||
placeholder: "1.0.0"
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: dropdown
|
||||
id: platform
|
||||
attributes:
|
||||
label: Platform
|
||||
options:
|
||||
- Windows
|
||||
- Linux
|
||||
- Android
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: description
|
||||
attributes:
|
||||
label: What happened?
|
||||
description: Describe the bug clearly and concisely. What did you see? What did you expect?
|
||||
placeholder: "When I click … the wallet shows … instead of …"
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: steps
|
||||
attributes:
|
||||
label: Steps to reproduce
|
||||
description: Numbered steps that reliably trigger the bug.
|
||||
placeholder: |
|
||||
1. Open the wallet
|
||||
2. Go to …
|
||||
3. Click …
|
||||
4. Observe …
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: expected
|
||||
attributes:
|
||||
label: Expected behavior
|
||||
description: What should have happened instead?
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: logs
|
||||
attributes:
|
||||
label: Logs / error messages
|
||||
description: |
|
||||
Paste any error dialogs, crash messages, or stack traces.
|
||||
Leave blank if none.
|
||||
render: text
|
||||
validations:
|
||||
required: false
|
||||
|
||||
- type: checkboxes
|
||||
id: checklist
|
||||
attributes:
|
||||
label: Before submitting
|
||||
options:
|
||||
- label: I searched existing issues and this is not a duplicate
|
||||
required: true
|
||||
- label: I reproduced this on the latest available version
|
||||
required: false
|
||||
@@ -0,0 +1,5 @@
|
||||
blank_issues_enabled: false
|
||||
contact_links:
|
||||
- name: Security vulnerability
|
||||
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,55 @@
|
||||
name: Feature Request
|
||||
description: Suggest an idea or improvement for Palladium Wallet
|
||||
labels: ["enhancement"]
|
||||
body:
|
||||
- type: markdown
|
||||
attributes:
|
||||
value: |
|
||||
Thanks for taking the time to suggest an improvement.
|
||||
|
||||
- type: textarea
|
||||
id: problem
|
||||
attributes:
|
||||
label: Problem
|
||||
description: What problem are you trying to solve? Is it related to something you find missing or frustrating?
|
||||
placeholder: "I'm always frustrated when …"
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: solution
|
||||
attributes:
|
||||
label: Proposed solution
|
||||
description: Describe what you'd like to happen.
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: alternatives
|
||||
attributes:
|
||||
label: Alternatives considered
|
||||
description: Any alternative solutions or workarounds you've considered.
|
||||
validations:
|
||||
required: false
|
||||
|
||||
- type: dropdown
|
||||
id: platform
|
||||
attributes:
|
||||
label: Affected platform(s)
|
||||
multiple: true
|
||||
options:
|
||||
- Windows
|
||||
- Linux
|
||||
- Android
|
||||
- CLI
|
||||
- All
|
||||
validations:
|
||||
required: false
|
||||
|
||||
- type: checkboxes
|
||||
id: checklist
|
||||
attributes:
|
||||
label: Before submitting
|
||||
options:
|
||||
- label: I searched existing issues and this is not a duplicate
|
||||
required: true
|
||||
@@ -0,0 +1,26 @@
|
||||
## Summary
|
||||
|
||||
<!-- What does this PR change and why? -->
|
||||
|
||||
## Related issue
|
||||
|
||||
<!-- Closes #... (if applicable) -->
|
||||
|
||||
## Type of change
|
||||
|
||||
- [ ] Bug fix
|
||||
- [ ] New feature
|
||||
- [ ] Refactor / cleanup
|
||||
- [ ] Documentation
|
||||
- [ ] Build / CI
|
||||
|
||||
## Testing
|
||||
|
||||
<!-- How was this verified? e.g. `dotnet test`, manual run on Windows/Linux/Android, golden-vector comparison -->
|
||||
|
||||
## Checklist
|
||||
|
||||
- [ ] `dotnet test` passes
|
||||
- [ ] No seed/private key material logged or persisted in plaintext (if touching Crypto/Wallet/Storage)
|
||||
- [ ] Server responses still validated with Merkle + checkpoints (if touching Spv/Net)
|
||||
- [ ] Code/comments/commit messages are in English
|
||||
@@ -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
|
||||
@@ -74,6 +75,8 @@ temp/
|
||||
*.key
|
||||
*.pfx
|
||||
*.p12
|
||||
*.keystore
|
||||
*.jks
|
||||
secrets/
|
||||
|
||||
# Wallet data and local runtime state
|
||||
@@ -91,6 +94,13 @@ settings.local.json
|
||||
# Local agent/tooling metadata
|
||||
.agents/
|
||||
.codex/
|
||||
CLAUDE.md
|
||||
.claude/
|
||||
|
||||
blueprint.md
|
||||
# Android packages and local SDK config
|
||||
*.apk
|
||||
*.aab
|
||||
local.properties
|
||||
|
||||
# Fuzzing artifacts (tests/PalladiumWallet.Fuzz)
|
||||
findings/
|
||||
crash-*.bin
|
||||
@@ -0,0 +1,111 @@
|
||||
# AGENTS.md
|
||||
|
||||
This file provides guidance to coding agents (OpenAI Codex and others following the AGENTS.md convention) when working with code in this repository.
|
||||
|
||||
> **Sync rule:** `CLAUDE.md` (read by Claude Code) carries the same content as this file, differing only in this header block. Any edit here must be mirrored there in the same commit.
|
||||
|
||||
## Role
|
||||
|
||||
Operate as an **expert in cryptocurrencies and cryptography**: reason with domain rigor about UTXO consensus, HD key derivation (BIP32/39/SLIP-132), signature schemes and scripts (P2PKH/P2SH/P2WPKH, PSBT), address encoding (base58/bech32), Merkle/SPV proofs, and at-rest encryption. When a choice touches cryptographic correctness or fund safety, judge it through that lens and flag known risks (nonce reuse, missing validation, exposed keys/seed, wrong fee/coin-selection, unverified server responses). Explain trade-offs with technical precision; never take for granted what hasn't been verified.
|
||||
|
||||
## Language policy
|
||||
|
||||
- **Conversation with the user**: Italian.
|
||||
- **Code, comments, commit messages, documentation files**: English only.
|
||||
|
||||
## How to assist
|
||||
|
||||
Before implementing any requested change, judge whether it makes sense and say so plainly. Useful and consistent with the project → proceed. Useless, redundant, already covered elsewhere, or likely to degrade the code → say so with a short rationale and propose the better alternative (or doing nothing). No automatic agreement — an honest opinion is worth more than blind execution.
|
||||
|
||||
After implementing a **new feature**, propose the tests needed for proper coverage (unit tests for the new logic, edge cases, error paths; property-based tests where invariants apply; integration tests against `FakeElectrumServer` if network/SPV code is involved; a fuzz target if a new untrusted-input parser was added) — don't just write the feature and stop there.
|
||||
|
||||
## What it is
|
||||
|
||||
SPV wallet (Sparrow-style) for the **Palladium (PLM)** cryptocurrency, a Bitcoin-derived UTXO chain. Targets desktop (Windows/Linux) and Android from the same source. Lightning is excluded from the first release.
|
||||
|
||||
## Stack and structure
|
||||
|
||||
.NET 10 + Avalonia UI 12 + NBitcoin.
|
||||
|
||||
```
|
||||
src/Core/ Chain/ Crypto/ Wallet/ Spv/ Net/ Storage/ (no UI dependency)
|
||||
src/App/ shared Avalonia UI library (App, Views, ViewModels, Loc, Assets)
|
||||
src/App.Desktop/ desktop head (WinExe): Program.cs, app.manifest, .ico → runnable
|
||||
src/App.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/
|
||||
```
|
||||
|
||||
- The Avalonia UI lives **once** in `src/App` (a library); the two heads only carry the per-platform entry point and packages.
|
||||
- `MainView` (UserControl) is the shared root, hosted by `MainWindow` on desktop and as the single-view root on Android.
|
||||
- **Non-negotiable dependency rule:** `App`/`Cli` depend only on `Core`; the UI reaches network/cryptography only through the wallet domain, never directly. `Core` knows nothing about the UI.
|
||||
|
||||
## Commands
|
||||
|
||||
.NET 10 SDK lives in `~/.dotnet10` — in non-interactive shells, before any `dotnet` command run
|
||||
`export PATH="$HOME/.dotnet10:$PATH" DOTNET_ROOT="$HOME/.dotnet10"`.
|
||||
|
||||
- **Build:** `dotnet build`
|
||||
- **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 --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`
|
||||
- Coverage-guided campaign: `tests/PalladiumWallet.Fuzz/fuzz.sh <target>` (needs afl++ + the SharpFuzz.CommandLine tool)
|
||||
- After fixing a finding: add the crashing input to `SeedCorpus` in the fuzz project's `Program.cs` and regenerate with `--make-seeds Corpus`
|
||||
- **GUI hot reload:** `dotnet watch --project src/App.Desktop` (on WSL2/WSLg the window shows on the Windows desktop, no graphics dependencies to install)
|
||||
- **CLI:** `dotnet run --project src/Cli -- <command>` (no args → usage)
|
||||
- **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`
|
||||
- Android release builds need a persistent signing keystore, generated once with `docker/keystore/generate-keystore.sh` (never commit it, see `docker/keystore/README.md`) — without it every build gets a random signature and users must uninstall to update instead of updating in place
|
||||
- **Manual publish:** `dotnet publish src/App.Desktop -r win-x64|linux-x64 -p:PublishSingleFile=true -p:IncludeNativeLibrariesForSelfExtract=true --self-contained` (AppImage via PupNet Deploy is a future step, no pupnet.conf yet)
|
||||
|
||||
**Android (apk):** needs the `android` workload (`dotnet workload install android`), a JDK (`JAVA_HOME`), and the Android SDK (`ANDROID_HOME`, or pass `-p:AndroidSdkDirectory=...`; a plain solution-level `dotnet build` needs it too).
|
||||
- Provision once: `dotnet build src/App.Android -t:InstallAndroidDependencies -p:AndroidSdkDirectory=$HOME/android-sdk -p:AcceptAndroidSDKLicenses=true`
|
||||
- Debug apk: `JAVA_HOME=<jdk> dotnet build src/App.Android -c Debug -t:SignAndroidPackage -p:AndroidSdkDirectory=$HOME/android-sdk` → `src/App.Android/bin/Debug/net10.0-android/*-Signed.apk`
|
||||
- Head is an app, not a library (`<OutputType>Exe</OutputType>`); min SDK 23 (AndroidX requirement)
|
||||
|
||||
**CLI** (`src/Cli`): `create`/`restore`/`restore-xpub`/`info`; `sync`/`send`/`servers`/`reset-certs` (`--server host:port [--ssl]`); `newseed`/`addresses`. Default wallet file `~/.palladium-wallet/<network>/wallets/default.wallet.json` (`--file` to change it).
|
||||
|
||||
## Architecture (points that require reading multiple files)
|
||||
|
||||
- **Layers:** GUI → wallet domain → SPV/Sync → Network → Cryptography → Persistence; each layer depends only downward.
|
||||
- **Network profile:** all chain constants (address prefixes, BIP32 headers, bech32 HRP, genesis, ports, coin_type 746) **centralized in `Core/Chain`** (`ChainProfiles`/`PalladiumNetworks`), selectable per network (mainnet/testnet/regtest). No scattered magic numbers.
|
||||
- **LWMA / skip PoW:** LWMA difficulty, 2-minute blocks; an SPV client cannot recompute it → `SkipPowValidation = true`, trust anchored to **hardcoded checkpoints**. This is a custom layer: NBitcoin assumes Bitcoin's retargeting.
|
||||
- **NBitcoin vs custom:** NBitcoin covers the custom network, BIP32/39, addresses, transactions, PSBT, signing, encoding, hashing — **do not reimplement these**. Hand-written custom code: JSON-RPC client for the indexing server (ElectrumX-like); SPV sync with Merkle verification; header/checkpoint validation; coin selection and fee policy; versioned encrypted JSON wallet file.
|
||||
- **PSBT-centric:** every signing flow goes through PSBT (offline/air-gapped/multisig/hardware).
|
||||
- **Ports:** 50001/50002 = indexing server (what the SPV wallet talks to), **not** the node's P2P port (2333).
|
||||
|
||||
## GUI conventions (`src/App`)
|
||||
|
||||
- **Shared `MainView` + heads:** the whole UI is a single `MainView` (UserControl), working both as a desktop window's content and as Android's single-view root.
|
||||
- Top-level APIs (file/folder picker, clipboard) are reached via `TopLevel.GetTopLevel(this)` since a UserControl doesn't expose them.
|
||||
- `MainWindowViewModel.IsDesktop` (from `OperatingSystem.IsAndroid()`) hides filesystem-only features (open-from-file; the data-location wizard step auto-skips on Android because the head sets `AppPaths.OverrideDataRoot`).
|
||||
- **Single ViewModel** `MainWindowViewModel` (CommunityToolkit.Mvvm: `[ObservableProperty]`, `[RelayCommand]`); `Core` is driven directly from here.
|
||||
- Split into **partial classes by feature** (`MainWindowViewModel.Send.cs`, `.Receive.cs`, `.Sync.cs`, `.Settings.cs`, `.Wizard.cs`, `.Contacts.cs`, `.Update.cs`, …): new feature logic goes in the matching partial (or a new one), not in the main file.
|
||||
- **In-app overlays, not OS windows:** details (address, transaction), settings, and help are full-screen `Border`s gated by an `IsXxxOpen` flag, not separate `Window`s — instant open/close, mobile-friendly, and popups/top-levels are slow on WSLg.
|
||||
- Pattern: bool property + Open/Close commands + backdrop handler and Esc key in `MainView`'s code-behind; overlay close buttons bind via `$parent[UserControl]` (not `$parent[Window]`, absent on mobile).
|
||||
- Heavy network work runs off the UI thread (`Task.Run`) so the overlay never freezes.
|
||||
- **Localization:** `Localization/Loc.cs`, key→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.
|
||||
|
||||
## Working rules
|
||||
|
||||
- **Cross-implementation tests:** compare addresses, txids, and PSBTs against a reference wallet (golden vectors). A different address or txid is a blocking bug.
|
||||
- **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
|
||||
@@ -0,0 +1,544 @@
|
||||
# Changelog
|
||||
|
||||
Technical changelog for PalladiumWallet. Format loosely follows
|
||||
[Keep a Changelog](https://keepachangelog.com/en/1.0.0/); entries are grouped
|
||||
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
|
||||
trust was not actually anchored to any checkpoint), fixes several crash
|
||||
paths found by a new fuzzing harness, and adds OP_RETURN/coinbase-tag
|
||||
decoding to the transaction detail view.
|
||||
|
||||
### Security
|
||||
|
||||
- `WalletSynchronizer.AnchorToCheckpointAsync`: header trust is now actually
|
||||
anchored to `ChainProfiles.Mainnet.Checkpoints` (24 real `[height, hash,
|
||||
bits]` checkpoints pulled from a fully-synced node, every 20,000 blocks
|
||||
plus one near tip). Previously the checkpoint array was empty and the
|
||||
methods meant to enforce it (`MatchesCheckpoint`/`IsValidChild`) were
|
||||
never called — on this LWMA chain, where PoW can't be recomputed
|
||||
client-side, a malicious or eclipsing server could hand back any
|
||||
internally-consistent header for a Merkle proof with nothing tying it to
|
||||
the real chain. For every header used in a Merkle proof, the intervening
|
||||
headers are now downloaded back to the nearest checkpoint and verified as
|
||||
an unbroken prev-hash chain terminating in that checkpoint's exact hash
|
||||
(memoized per sync session). Testnet/Regtest remain unanchored (no node
|
||||
available to source checkpoints from); a missing checkpoint is a no-op,
|
||||
not a failure.
|
||||
- New fuzzing harness (`tests/PalladiumWallet.Fuzz`, SharpFuzz-based): one
|
||||
target per untrusted-input parser (header, Merkle proof, peer list,
|
||||
wallet file, mnemonic/key/address/amount), each encoding that parser's
|
||||
documented error contract. Found and fixed:
|
||||
- `Bip39.TryParse` threw `NotSupportedException` on text resembling no
|
||||
wordlist instead of failing gracefully.
|
||||
- `ElectrumApi.ParsePeers` threw on any `server.peers.subscribe` response
|
||||
shape other than the expected `[ip, hostname, [features...]]`, and on a
|
||||
JSON string containing invalid UTF-8.
|
||||
- `EncryptedFile.Decrypt` let a tampered/corrupted wallet file escape as
|
||||
raw `JsonException`/`FormatException`/`ArgumentNullException` instead of
|
||||
the documented `WrongPasswordException`/`InvalidDataException`
|
||||
contract; worse, the PBKDF2 iteration count was read from the
|
||||
(attacker-controlled) container with no upper bound — a tampered file
|
||||
demanding e.g. 2³¹ iterations would hang the wallet on open. Iteration
|
||||
count is now clamped to 10,000,000.
|
||||
- `CertificatePinStore.Load`: a corrupted pin file blocked every SSL
|
||||
connection until manually deleted; now falls back to first-contact
|
||||
TOFU like `ServerRegistry` already did.
|
||||
- The seed corpus (incl. a regression input per fixed finding) replays
|
||||
inside `dotnet test` via `FuzzCorpusTests`, so a fix can't silently
|
||||
regress.
|
||||
- `SECURITY.md` corrected: PBKDF2 parameters (600,000 iterations / 16-byte
|
||||
salt, not the pre-hardening 100,000 / 32-byte), a stale file reference,
|
||||
and disclosure of the AI-assisted testing methodology used (adversarial
|
||||
fake-server simulation, property-based fuzzing, targeted security
|
||||
review) as a complement to, not a replacement for, independent review.
|
||||
|
||||
### Added
|
||||
|
||||
- Transaction detail view now decodes OP_RETURN output payloads (UTF-8, or
|
||||
hex if the bytes aren't valid text — multiple OP_RETURN outputs in one tx
|
||||
are each decoded independently) and coinbase scriptSig pool tags (e.g.
|
||||
`/slush/`, extracted as printable-ASCII runs amid the binary BIP34
|
||||
height/extranonce). Both were previously dropped entirely — discarded
|
||||
once no destination address could be derived from the script.
|
||||
- Help overlay: "User guide" button next to "Report a bug", linking to
|
||||
`USERGUIDE.md` on GitHub.
|
||||
- `USERGUIDE.md`: full end-user reference for GUI and CLI (wizard flows,
|
||||
script types, fees, gap limit, TOFU cert pinning, CLI commands) with the
|
||||
exact numbers the software enforces.
|
||||
|
||||
### Fixed
|
||||
|
||||
- Send/Donate/Sync/Wizard view models wrote status/error strings directly
|
||||
in Italian regardless of the active language; routed through `Loc` with
|
||||
the missing keys added. `CertificatePinMismatchException` no longer
|
||||
bakes an Italian message into `.Message` — it exposes `Host`/`Port` for
|
||||
the UI to translate.
|
||||
- CLI (`src/Cli/Program.cs`) printed all output in Italian regardless of
|
||||
the code/docs-are-English-only policy; translated every user-facing
|
||||
string and comment.
|
||||
|
||||
### Testing
|
||||
|
||||
- Test suite expanded from 307 to 392 tests, closing coverage gaps in:
|
||||
checkpoint-anchoring (including the memoization and non-generic-retry
|
||||
branches), the PoW-checked branch of `BlockHeaderInfo.IsValidChild`
|
||||
(never run since every profile sets `SkipPowValidation`), all 8 BIP-39
|
||||
wordlist languages plus the empty-input guard, SLIP-132 rejection of
|
||||
malformed/corrupted keys, `TransactionFactory`'s standardness-policy
|
||||
rejection, `ImportedKeyAccount`'s fund-safety fallbacks,
|
||||
`WalletLoader`'s defensive branches for corrupted files,
|
||||
`PalladiumNetworks.For`/`INetworkSet`, `AppPaths`' full data-root
|
||||
precedence chain (via new internal override seams), `UpdateChecker`
|
||||
end-to-end via a stub-transport seam, and `ElectrumClient`'s
|
||||
multi-segment response dispatch.
|
||||
- OP_RETURN/coinbase-tag decoding covered: UTF-8 text, binary fallback to
|
||||
hex, multiple OP_RETURN outputs in one tx, pool-tag extraction, and the
|
||||
absence of false positives on standard outputs/inputs.
|
||||
|
||||
### Documentation
|
||||
|
||||
- `AGENTS.md`/`CLAUDE.md` re-synced (had drifted) and reformatted from
|
||||
dense prose to scannable bullet lists; a stale `SECURITY.md` file
|
||||
reference fixed.
|
||||
- `README.md` test-coverage section and `SECURITY.md` updated to describe
|
||||
the checkpoint anchoring and fuzzing guarantees actually enforced now.
|
||||
|
||||
## [0.9.1] — 2026-07-02
|
||||
|
||||
### Testing
|
||||
|
||||
- Test suite expanded from 239 to 307 tests; `Core` line coverage raised
|
||||
from ~50% to ~92% (branch coverage from ~41% to ~79%).
|
||||
- In-process fake ElectrumX server (`tests/.../Net/FakeElectrumServer.cs`):
|
||||
a real loopback TCP socket speaking newline-delimited JSON-RPC 2.0,
|
||||
optionally TLS with a self-signed certificate, with per-method handlers
|
||||
and call counters — exercises the network stack against real socket code
|
||||
instead of mocks.
|
||||
- New end-to-end coverage for previously untested network/SPV code:
|
||||
`ElectrumClient` (request pipelining, error mapping, notifications,
|
||||
disconnection, cancellation, TLS/TOFU handshake), `WalletSynchronizer`
|
||||
(gap-limit scanning, UTXO/history reconstruction, unconfirmed/immature
|
||||
balances, busy-retry, disk-cache reuse, and the security-critical path
|
||||
where a lying server fails Merkle verification and the sync aborts),
|
||||
`TransactionInspector` (fee calculation, mine/theirs attribution, RBF,
|
||||
coinbase handling), `CertificatePinStore` (TOFU pin/match/mismatch/reset),
|
||||
`ServerRegistry` peer discovery, and `UpdateChecker` tag parsing.
|
||||
- `TransactionFactory`: added coverage for legacy/P2SH/segwit destinations,
|
||||
multi-UTXO selection, dust change absorbed into the fee, and a golden
|
||||
txid anchoring the PSBT signing path (deterministic via RFC 6979).
|
||||
- Property-based tests extended: SLIP-132 roundtrip for every script kind
|
||||
and network, `WalletDocument` JSON roundtrip with arbitrary
|
||||
labels/contacts, `Scripthash` cross-checked against an independent
|
||||
SHA-256 computation.
|
||||
- `update-version.sh`: single script to bump the version across the project
|
||||
ahead of a release tag.
|
||||
|
||||
### Fixed
|
||||
|
||||
- `CertificatePinStore.Load`: a corrupted pin file threw and blocked every
|
||||
SSL connection until the user manually deleted it; now falls back to
|
||||
first-contact TOFU, same as `ServerRegistry` already did for its own file.
|
||||
- `EncryptedFile.IsEncrypted`: threw on valid JSON with a non-object root
|
||||
(e.g. a bare number or array) or on invalid UTF-16 input, instead of
|
||||
returning `false`. Both bugs were found by the expanded property tests.
|
||||
|
||||
### Documentation
|
||||
|
||||
- `README.md`: "Running tests" section rewritten with a per-area coverage
|
||||
table, the coverage-measurement command, and a description of the fake
|
||||
ElectrumX server.
|
||||
- `CLAUDE.md`/`AGENTS.md` kept in sync, pointing future work at extending
|
||||
the fake ElectrumX server instead of mocking `ElectrumClient` (not an
|
||||
interface, by design).
|
||||
|
||||
## [0.9.0] — 2026-07-02
|
||||
|
||||
First release. SPV wallet (Sparrow-style) for the Palladium (PLM) network —
|
||||
Bitcoin-derived UTXO chain — targeting desktop (Windows/Linux) and Android
|
||||
from a single Avalonia UI codebase on .NET 10 + NBitcoin.
|
||||
|
||||
### Core — Chain
|
||||
|
||||
- Network profiles and consensus constants centralized in `Core/Chain`
|
||||
(`ChainProfiles`/`PalladiumNetworks`): address prefixes, BIP32 headers,
|
||||
bech32 HRP, genesis, ports, `coin_type` 746 — selectable per network
|
||||
(mainnet/testnet/regtest), no scattered magic numbers.
|
||||
- LWMA difficulty / 2-minute blocks: SPV cannot recompute LWMA retargeting,
|
||||
so PoW validation is skipped and trust is anchored to hardcoded
|
||||
checkpoints instead.
|
||||
|
||||
### Core — Crypto
|
||||
|
||||
- HD key derivation: BIP32/39/84, HD accounts, SLIP-132 extended key
|
||||
serialization.
|
||||
- Address types: P2PKH, P2SH-P2WPKH, and P2TR (Taproot/BIP86).
|
||||
- `IWalletAccount` abstraction with WIF/xpub/xprv keystore import (including
|
||||
watch-only accounts from public material only).
|
||||
|
||||
### Core — Net
|
||||
|
||||
- Custom `ElectrumClient`: JSON-RPC 2.0 client for the ElectrumX-like
|
||||
indexing server, with TLS support and TOFU certificate pinning.
|
||||
- `ServerRegistry`: bootstrap server list, peer discovery, persisted last-used
|
||||
server, fallback resolution for `--server`.
|
||||
- Batched writes, zero-allocation reads, bounded in-flight requests for
|
||||
network throughput.
|
||||
- Fix: allow changing the indexing server at any time, including during an
|
||||
active sync.
|
||||
|
||||
### Core — Spv
|
||||
|
||||
- Header sync with Merkle proof verification and scripthash subscriptions
|
||||
against hardcoded checkpoints (no full PoW recomputation, per the chain
|
||||
profile above).
|
||||
- Per-address balance and transaction-count aggregation in sync results.
|
||||
- Electrum-style continuous updates: incremental, parallelized sync across
|
||||
address chains, with a persistent header cache.
|
||||
- Fix: resilient sync — history discovery via `GetHistory`, transaction
|
||||
caching, automatic reconnect fallback.
|
||||
|
||||
### Core — Storage
|
||||
|
||||
- Versioned, encrypted JSON wallet file (`WalletDocument`) with dedicated
|
||||
persistence and loader layer.
|
||||
- `WalletLock`: prevents concurrent access to the same wallet file; acquired
|
||||
before load and before close (fixed a race where it wasn't).
|
||||
- XDG-compliant data paths (`AppPaths`) with per-platform override seam used
|
||||
by the Android head; English as default UI language.
|
||||
- Contacts list (name + address) persisted in `WalletDocument`.
|
||||
- Documented caveat: `WalletStore.Save` writes plaintext JSON when the
|
||||
wallet is unencrypted — seed/keys are only ever encrypted-at-rest when the
|
||||
user opts into a password.
|
||||
|
||||
### Core — Wallet
|
||||
|
||||
- Coin selection, PSBT-centric transaction factory, and wallet loader.
|
||||
- Confirmation-threshold enforcement before spending UTXOs; immature
|
||||
(coinbase) balance surfaced separately from confirmed balance; pending
|
||||
mempool balance shown, with unconfirmed UTXOs excluded from spending by
|
||||
default.
|
||||
- Fix: reject amounts with sub-satoshi precision.
|
||||
|
||||
### App — Avalonia UI (shared, `src/App`)
|
||||
|
||||
- Single shared `MainView` (UserControl) hosted by `MainWindow` on desktop
|
||||
and as the single-view root on Android; single `MainWindowViewModel`
|
||||
(CommunityToolkit.Mvvm), later split into partial files by area
|
||||
(Wizard/Settings/Sync/Send/Contacts/Receive/...).
|
||||
- Step-by-step setup wizard (replacing a single-form panel), including a
|
||||
first-run data-location step, multi-wallet chooser, confirm-password and
|
||||
encrypt toggle, and dedicated flows for creating a wallet vs. importing
|
||||
from xpub/xprv/WIF.
|
||||
- In-app overlay pattern for details/settings/help (`IsXxxOpen` flags, no OS
|
||||
windows) replacing earlier nested submenus and a separate
|
||||
`AddressInfoWindow`: server settings, app settings (language/unit),
|
||||
wallet info (xpub, password-gated seed reveal), address detail
|
||||
(password-gated private key reveal), transaction detail with full
|
||||
on-chain data (inputs/outputs, no truncation), and a Help overlay
|
||||
(Info/Donate tabs).
|
||||
- Connection-status indicator in the bottom bar; connect-before-wallet-open
|
||||
flow; persisted last-used server; server discovery split into its own
|
||||
always-usable button; mainnet hardcoded (network selector removed for the
|
||||
first release).
|
||||
- Localization: `Loc` key → 6-language dictionary (it/en/es/fr/pt/de) with
|
||||
live language switching.
|
||||
- Receive: QR code generation and copy-to-clipboard for the receive address.
|
||||
- Android: QR code scanner for the Send address field.
|
||||
- Centralized design system (color tokens, gradient hero, SVG tab icons);
|
||||
responsive layout for portrait mobile, unified tab bar (desktop +
|
||||
mobile), two-column desktop layout for Send/Receive; various mobile-only
|
||||
fixes (tab bar sizing/indicator overlap, text overflow, full-screen server
|
||||
overlay on mobile).
|
||||
- App version shown in window title and Help overlay, read from the single
|
||||
`<Version>` source in the App csproj.
|
||||
- In-app update check: compares the running version against the latest
|
||||
GitHub release tag on startup and shows an overlay with the new tag when
|
||||
one is available (best-effort — silent on network/parse failure).
|
||||
- In-app bug-report button (Help overlay) opening a pre-filled GitHub issue
|
||||
template; issue/PR templates completed.
|
||||
|
||||
### Android head (`src/App.Android`)
|
||||
|
||||
- Architecture split: shared UI library (`src/App`) + `src/App.Desktop` +
|
||||
`src/App.Android` heads from the same source (`refactor(arch)`), each
|
||||
carrying only the per-platform entry point and packages.
|
||||
- App logo as launcher icon.
|
||||
- Persistent release-signing keystore workflow
|
||||
(`docker/keystore/generate-keystore.sh`, git-ignored output): every
|
||||
release APK is signed with the same key so installing a newer build
|
||||
updates a previous install in place instead of requiring an uninstall.
|
||||
`versionCode` derived from `<Version>` instead of a fixed constant.
|
||||
|
||||
### CLI (`src/Cli`)
|
||||
|
||||
- Commands: `create`/`restore`/`restore-xpub`/`info`,
|
||||
`sync`/`send`/`servers`/`reset-certs`, `newseed`/`addresses`.
|
||||
- `servers` command, `info --addresses`, and registry-based fallback
|
||||
resolution for `--server`.
|
||||
|
||||
### Build & Distribution
|
||||
|
||||
- Docker-based reproducible build system (`docker/build.sh` +
|
||||
`docker/Dockerfile.*`): pinned toolchain (.NET 10 SDK, JDK, Android SDK),
|
||||
builds Windows/Linux single-file executables and a signed Android APK
|
||||
without any SDK installed on the host.
|
||||
- Android release signing wired into `build_android` (see Android head
|
||||
above): requires the persistent keystore, prompts for its passwords at
|
||||
build time, mounts it read-only into the build container.
|
||||
|
||||
### Testing
|
||||
|
||||
- Unit test coverage across all Core modules, later expanded to 209 tests.
|
||||
- Property-based tests via CsCheck (`PropertyTests.cs`), bringing total
|
||||
coverage to 218 tests; dedicated `WalletLock` concurrency tests.
|
||||
|
||||
### Documentation
|
||||
|
||||
- `README.md` (project overview, quickstart, reproducible builds), `CLAUDE.md`
|
||||
(codebase guidance for AI tooling, kept in sync with the multi-head
|
||||
architecture and .NET 10 migration), `SECURITY.md` (threat model and SPV
|
||||
trust assumptions), coding-agent guide.
|
||||
- Code comments translated to English project-wide, per the language policy
|
||||
(Italian conversation, English code/docs).
|
||||
@@ -0,0 +1,111 @@
|
||||
# CLAUDE.md
|
||||
|
||||
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
|
||||
|
||||
> **Sync rule:** `AGENTS.md` (read by other coding agents) carries the same content as this file, differing only in this header block. Any edit here must be mirrored there in the same commit.
|
||||
|
||||
## Role
|
||||
|
||||
Operate as an **expert in cryptocurrencies and cryptography**: reason with domain rigor about UTXO consensus, HD key derivation (BIP32/39/SLIP-132), signature schemes and scripts (P2PKH/P2SH/P2WPKH, PSBT), address encoding (base58/bech32), Merkle/SPV proofs, and at-rest encryption. When a choice touches cryptographic correctness or fund safety, judge it through that lens and flag known risks (nonce reuse, missing validation, exposed keys/seed, wrong fee/coin-selection, unverified server responses). Explain trade-offs with technical precision; never take for granted what hasn't been verified.
|
||||
|
||||
## Language policy
|
||||
|
||||
- **Conversation with the user**: Italian.
|
||||
- **Code, comments, commit messages, documentation files**: English only.
|
||||
|
||||
## How to assist
|
||||
|
||||
Before implementing any requested change, judge whether it makes sense and say so plainly. Useful and consistent with the project → proceed. Useless, redundant, already covered elsewhere, or likely to degrade the code → say so with a short rationale and propose the better alternative (or doing nothing). No automatic agreement — an honest opinion is worth more than blind execution.
|
||||
|
||||
After implementing a **new feature**, propose the tests needed for proper coverage (unit tests for the new logic, edge cases, error paths; property-based tests where invariants apply; integration tests against `FakeElectrumServer` if network/SPV code is involved; a fuzz target if a new untrusted-input parser was added) — don't just write the feature and stop there.
|
||||
|
||||
## What it is
|
||||
|
||||
SPV wallet (Sparrow-style) for the **Palladium (PLM)** cryptocurrency, a Bitcoin-derived UTXO chain. Targets desktop (Windows/Linux) and Android from the same source. Lightning is excluded from the first release.
|
||||
|
||||
## Stack and structure
|
||||
|
||||
.NET 10 + Avalonia UI 12 + NBitcoin.
|
||||
|
||||
```
|
||||
src/Core/ Chain/ Crypto/ Wallet/ Spv/ Net/ Storage/ (no UI dependency)
|
||||
src/App/ shared Avalonia UI library (App, Views, ViewModels, Loc, Assets)
|
||||
src/App.Desktop/ desktop head (WinExe): Program.cs, app.manifest, .ico → runnable
|
||||
src/App.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/
|
||||
```
|
||||
|
||||
- The Avalonia UI lives **once** in `src/App` (a library); the two heads only carry the per-platform entry point and packages.
|
||||
- `MainView` (UserControl) is the shared root, hosted by `MainWindow` on desktop and as the single-view root on Android.
|
||||
- **Non-negotiable dependency rule:** `App`/`Cli` depend only on `Core`; the UI reaches network/cryptography only through the wallet domain, never directly. `Core` knows nothing about the UI.
|
||||
|
||||
## Commands
|
||||
|
||||
.NET 10 SDK lives in `~/.dotnet10` — in non-interactive shells, before any `dotnet` command run
|
||||
`export PATH="$HOME/.dotnet10:$PATH" DOTNET_ROOT="$HOME/.dotnet10"`.
|
||||
|
||||
- **Build:** `dotnet build`
|
||||
- **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 --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`
|
||||
- Coverage-guided campaign: `tests/PalladiumWallet.Fuzz/fuzz.sh <target>` (needs afl++ + the SharpFuzz.CommandLine tool)
|
||||
- After fixing a finding: add the crashing input to `SeedCorpus` in the fuzz project's `Program.cs` and regenerate with `--make-seeds Corpus`
|
||||
- **GUI hot reload:** `dotnet watch --project src/App.Desktop` (on WSL2/WSLg the window shows on the Windows desktop, no graphics dependencies to install)
|
||||
- **CLI:** `dotnet run --project src/Cli -- <command>` (no args → usage)
|
||||
- **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`
|
||||
- Android release builds need a persistent signing keystore, generated once with `docker/keystore/generate-keystore.sh` (never commit it, see `docker/keystore/README.md`) — without it every build gets a random signature and users must uninstall to update instead of updating in place
|
||||
- **Manual publish:** `dotnet publish src/App.Desktop -r win-x64|linux-x64 -p:PublishSingleFile=true -p:IncludeNativeLibrariesForSelfExtract=true --self-contained` (AppImage via PupNet Deploy is a future step, no pupnet.conf yet)
|
||||
|
||||
**Android (apk):** needs the `android` workload (`dotnet workload install android`), a JDK (`JAVA_HOME`), and the Android SDK (`ANDROID_HOME`, or pass `-p:AndroidSdkDirectory=...`; a plain solution-level `dotnet build` needs it too).
|
||||
- Provision once: `dotnet build src/App.Android -t:InstallAndroidDependencies -p:AndroidSdkDirectory=$HOME/android-sdk -p:AcceptAndroidSDKLicenses=true`
|
||||
- Debug apk: `JAVA_HOME=<jdk> dotnet build src/App.Android -c Debug -t:SignAndroidPackage -p:AndroidSdkDirectory=$HOME/android-sdk` → `src/App.Android/bin/Debug/net10.0-android/*-Signed.apk`
|
||||
- Head is an app, not a library (`<OutputType>Exe</OutputType>`); min SDK 23 (AndroidX requirement)
|
||||
|
||||
**CLI** (`src/Cli`): `create`/`restore`/`restore-xpub`/`info`; `sync`/`send`/`servers`/`reset-certs` (`--server host:port [--ssl]`); `newseed`/`addresses`. Default wallet file `~/.palladium-wallet/<network>/wallets/default.wallet.json` (`--file` to change it).
|
||||
|
||||
## Architecture (points that require reading multiple files)
|
||||
|
||||
- **Layers:** GUI → wallet domain → SPV/Sync → Network → Cryptography → Persistence; each layer depends only downward.
|
||||
- **Network profile:** all chain constants (address prefixes, BIP32 headers, bech32 HRP, genesis, ports, coin_type 746) **centralized in `Core/Chain`** (`ChainProfiles`/`PalladiumNetworks`), selectable per network (mainnet/testnet/regtest). No scattered magic numbers.
|
||||
- **LWMA / skip PoW:** LWMA difficulty, 2-minute blocks; an SPV client cannot recompute it → `SkipPowValidation = true`, trust anchored to **hardcoded checkpoints**. This is a custom layer: NBitcoin assumes Bitcoin's retargeting.
|
||||
- **NBitcoin vs custom:** NBitcoin covers the custom network, BIP32/39, addresses, transactions, PSBT, signing, encoding, hashing — **do not reimplement these**. Hand-written custom code: JSON-RPC client for the indexing server (ElectrumX-like); SPV sync with Merkle verification; header/checkpoint validation; coin selection and fee policy; versioned encrypted JSON wallet file.
|
||||
- **PSBT-centric:** every signing flow goes through PSBT (offline/air-gapped/multisig/hardware).
|
||||
- **Ports:** 50001/50002 = indexing server (what the SPV wallet talks to), **not** the node's P2P port (2333).
|
||||
|
||||
## GUI conventions (`src/App`)
|
||||
|
||||
- **Shared `MainView` + heads:** the whole UI is a single `MainView` (UserControl), working both as a desktop window's content and as Android's single-view root.
|
||||
- Top-level APIs (file/folder picker, clipboard) are reached via `TopLevel.GetTopLevel(this)` since a UserControl doesn't expose them.
|
||||
- `MainWindowViewModel.IsDesktop` (from `OperatingSystem.IsAndroid()`) hides filesystem-only features (open-from-file; the data-location wizard step auto-skips on Android because the head sets `AppPaths.OverrideDataRoot`).
|
||||
- **Single ViewModel** `MainWindowViewModel` (CommunityToolkit.Mvvm: `[ObservableProperty]`, `[RelayCommand]`); `Core` is driven directly from here.
|
||||
- Split into **partial classes by feature** (`MainWindowViewModel.Send.cs`, `.Receive.cs`, `.Sync.cs`, `.Settings.cs`, `.Wizard.cs`, `.Contacts.cs`, `.Update.cs`, …): new feature logic goes in the matching partial (or a new one), not in the main file.
|
||||
- **In-app overlays, not OS windows:** details (address, transaction), settings, and help are full-screen `Border`s gated by an `IsXxxOpen` flag, not separate `Window`s — instant open/close, mobile-friendly, and popups/top-levels are slow on WSLg.
|
||||
- Pattern: bool property + Open/Close commands + backdrop handler and Esc key in `MainView`'s code-behind; overlay close buttons bind via `$parent[UserControl]` (not `$parent[Window]`, absent on mobile).
|
||||
- Heavy network work runs off the UI thread (`Task.Run`) so the overlay never freezes.
|
||||
- **Localization:** `Localization/Loc.cs`, key→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.
|
||||
|
||||
## Working rules
|
||||
|
||||
- **Cross-implementation tests:** compare addresses, txids, and PSBTs against a reference wallet (golden vectors). A different address or txid is a blocking bug.
|
||||
- **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
|
||||
@@ -0,0 +1,21 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2026 Davide Grilli
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
@@ -1,50 +1,149 @@
|
||||
|
||||
Microsoft Visual Studio Solution File, Format Version 12.00
|
||||
# Visual Studio Version 17
|
||||
VisualStudioVersion = 17.0.31903.59
|
||||
MinimumVisualStudioVersion = 10.0.40219.1
|
||||
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "src", "src", "{84E60614-5042-48EC-B349-290FB0CA7BA8}"
|
||||
EndProject
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "PalladiumWallet.Core", "src\Core\PalladiumWallet.Core.csproj", "{A7D0EF95-B206-4646-99DD-1D2BBB7AF978}"
|
||||
EndProject
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "PalladiumWallet.App", "src\App\PalladiumWallet.App.csproj", "{13EE9780-5810-4229-BFCF-6003172534DD}"
|
||||
EndProject
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "PalladiumWallet.Cli", "src\Cli\PalladiumWallet.Cli.csproj", "{D1AE035A-6DAC-46F4-90FB-F1AE2A79D416}"
|
||||
EndProject
|
||||
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "tests", "tests", "{FDF1822C-58D6-4B35-93EA-6A85E1292933}"
|
||||
EndProject
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "PalladiumWallet.Tests", "tests\PalladiumWallet.Tests\PalladiumWallet.Tests.csproj", "{C7E79E8E-B1DE-4053-9FB4-853814766CE0}"
|
||||
EndProject
|
||||
Global
|
||||
GlobalSection(SolutionConfigurationPlatforms) = preSolution
|
||||
Debug|Any CPU = Debug|Any CPU
|
||||
Release|Any CPU = Release|Any CPU
|
||||
EndGlobalSection
|
||||
GlobalSection(SolutionProperties) = preSolution
|
||||
HideSolutionNode = FALSE
|
||||
EndGlobalSection
|
||||
GlobalSection(ProjectConfigurationPlatforms) = postSolution
|
||||
{A7D0EF95-B206-4646-99DD-1D2BBB7AF978}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{A7D0EF95-B206-4646-99DD-1D2BBB7AF978}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{A7D0EF95-B206-4646-99DD-1D2BBB7AF978}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{A7D0EF95-B206-4646-99DD-1D2BBB7AF978}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{13EE9780-5810-4229-BFCF-6003172534DD}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{13EE9780-5810-4229-BFCF-6003172534DD}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{13EE9780-5810-4229-BFCF-6003172534DD}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{13EE9780-5810-4229-BFCF-6003172534DD}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{D1AE035A-6DAC-46F4-90FB-F1AE2A79D416}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{D1AE035A-6DAC-46F4-90FB-F1AE2A79D416}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{D1AE035A-6DAC-46F4-90FB-F1AE2A79D416}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{D1AE035A-6DAC-46F4-90FB-F1AE2A79D416}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{C7E79E8E-B1DE-4053-9FB4-853814766CE0}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{C7E79E8E-B1DE-4053-9FB4-853814766CE0}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{C7E79E8E-B1DE-4053-9FB4-853814766CE0}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{C7E79E8E-B1DE-4053-9FB4-853814766CE0}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
EndGlobalSection
|
||||
GlobalSection(NestedProjects) = preSolution
|
||||
{A7D0EF95-B206-4646-99DD-1D2BBB7AF978} = {84E60614-5042-48EC-B349-290FB0CA7BA8}
|
||||
{13EE9780-5810-4229-BFCF-6003172534DD} = {84E60614-5042-48EC-B349-290FB0CA7BA8}
|
||||
{D1AE035A-6DAC-46F4-90FB-F1AE2A79D416} = {84E60614-5042-48EC-B349-290FB0CA7BA8}
|
||||
{C7E79E8E-B1DE-4053-9FB4-853814766CE0} = {FDF1822C-58D6-4B35-93EA-6A85E1292933}
|
||||
EndGlobalSection
|
||||
EndGlobal
|
||||
|
||||
Microsoft Visual Studio Solution File, Format Version 12.00
|
||||
# Visual Studio Version 17
|
||||
VisualStudioVersion = 17.0.31903.59
|
||||
MinimumVisualStudioVersion = 10.0.40219.1
|
||||
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "src", "src", "{84E60614-5042-48EC-B349-290FB0CA7BA8}"
|
||||
EndProject
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "PalladiumWallet.Core", "src\Core\PalladiumWallet.Core.csproj", "{A7D0EF95-B206-4646-99DD-1D2BBB7AF978}"
|
||||
EndProject
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "PalladiumWallet.App", "src\App\PalladiumWallet.App.csproj", "{13EE9780-5810-4229-BFCF-6003172534DD}"
|
||||
EndProject
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "PalladiumWallet.Cli", "src\Cli\PalladiumWallet.Cli.csproj", "{D1AE035A-6DAC-46F4-90FB-F1AE2A79D416}"
|
||||
EndProject
|
||||
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "tests", "tests", "{FDF1822C-58D6-4B35-93EA-6A85E1292933}"
|
||||
EndProject
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "PalladiumWallet.Tests", "tests\PalladiumWallet.Tests\PalladiumWallet.Tests.csproj", "{C7E79E8E-B1DE-4053-9FB4-853814766CE0}"
|
||||
EndProject
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "PalladiumWallet.App.Desktop", "src\App.Desktop\PalladiumWallet.App.Desktop.csproj", "{A5D1DD48-7485-43F0-BFE3-2F645EC4D1E7}"
|
||||
EndProject
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "PalladiumWallet.App.Android", "src\App.Android\PalladiumWallet.App.Android.csproj", "{BCC5BE4A-B909-4043-B0FB-B5A839349578}"
|
||||
EndProject
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "PalladiumWallet.Fuzz", "tests\PalladiumWallet.Fuzz\PalladiumWallet.Fuzz.csproj", "{BC5932E5-E6F7-42A2-AC39-697C9A737A3C}"
|
||||
EndProject
|
||||
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Core", "Core", "{BF9B33E3-314A-3621-C1F0-AFD074692421}"
|
||||
EndProject
|
||||
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
|
||||
Debug|x64 = Debug|x64
|
||||
Debug|x86 = Debug|x86
|
||||
Release|Any CPU = Release|Any CPU
|
||||
Release|x64 = Release|x64
|
||||
Release|x86 = Release|x86
|
||||
EndGlobalSection
|
||||
GlobalSection(ProjectConfigurationPlatforms) = postSolution
|
||||
{A7D0EF95-B206-4646-99DD-1D2BBB7AF978}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{A7D0EF95-B206-4646-99DD-1D2BBB7AF978}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{A7D0EF95-B206-4646-99DD-1D2BBB7AF978}.Debug|x64.ActiveCfg = Debug|Any CPU
|
||||
{A7D0EF95-B206-4646-99DD-1D2BBB7AF978}.Debug|x64.Build.0 = Debug|Any CPU
|
||||
{A7D0EF95-B206-4646-99DD-1D2BBB7AF978}.Debug|x86.ActiveCfg = Debug|Any CPU
|
||||
{A7D0EF95-B206-4646-99DD-1D2BBB7AF978}.Debug|x86.Build.0 = Debug|Any CPU
|
||||
{A7D0EF95-B206-4646-99DD-1D2BBB7AF978}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{A7D0EF95-B206-4646-99DD-1D2BBB7AF978}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{A7D0EF95-B206-4646-99DD-1D2BBB7AF978}.Release|x64.ActiveCfg = Release|Any CPU
|
||||
{A7D0EF95-B206-4646-99DD-1D2BBB7AF978}.Release|x64.Build.0 = Release|Any CPU
|
||||
{A7D0EF95-B206-4646-99DD-1D2BBB7AF978}.Release|x86.ActiveCfg = Release|Any CPU
|
||||
{A7D0EF95-B206-4646-99DD-1D2BBB7AF978}.Release|x86.Build.0 = Release|Any CPU
|
||||
{13EE9780-5810-4229-BFCF-6003172534DD}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{13EE9780-5810-4229-BFCF-6003172534DD}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{13EE9780-5810-4229-BFCF-6003172534DD}.Debug|x64.ActiveCfg = Debug|Any CPU
|
||||
{13EE9780-5810-4229-BFCF-6003172534DD}.Debug|x64.Build.0 = Debug|Any CPU
|
||||
{13EE9780-5810-4229-BFCF-6003172534DD}.Debug|x86.ActiveCfg = Debug|Any CPU
|
||||
{13EE9780-5810-4229-BFCF-6003172534DD}.Debug|x86.Build.0 = Debug|Any CPU
|
||||
{13EE9780-5810-4229-BFCF-6003172534DD}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{13EE9780-5810-4229-BFCF-6003172534DD}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{13EE9780-5810-4229-BFCF-6003172534DD}.Release|x64.ActiveCfg = Release|Any CPU
|
||||
{13EE9780-5810-4229-BFCF-6003172534DD}.Release|x64.Build.0 = Release|Any CPU
|
||||
{13EE9780-5810-4229-BFCF-6003172534DD}.Release|x86.ActiveCfg = Release|Any CPU
|
||||
{13EE9780-5810-4229-BFCF-6003172534DD}.Release|x86.Build.0 = Release|Any CPU
|
||||
{D1AE035A-6DAC-46F4-90FB-F1AE2A79D416}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{D1AE035A-6DAC-46F4-90FB-F1AE2A79D416}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{D1AE035A-6DAC-46F4-90FB-F1AE2A79D416}.Debug|x64.ActiveCfg = Debug|Any CPU
|
||||
{D1AE035A-6DAC-46F4-90FB-F1AE2A79D416}.Debug|x64.Build.0 = Debug|Any CPU
|
||||
{D1AE035A-6DAC-46F4-90FB-F1AE2A79D416}.Debug|x86.ActiveCfg = Debug|Any CPU
|
||||
{D1AE035A-6DAC-46F4-90FB-F1AE2A79D416}.Debug|x86.Build.0 = Debug|Any CPU
|
||||
{D1AE035A-6DAC-46F4-90FB-F1AE2A79D416}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{D1AE035A-6DAC-46F4-90FB-F1AE2A79D416}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{D1AE035A-6DAC-46F4-90FB-F1AE2A79D416}.Release|x64.ActiveCfg = Release|Any CPU
|
||||
{D1AE035A-6DAC-46F4-90FB-F1AE2A79D416}.Release|x64.Build.0 = Release|Any CPU
|
||||
{D1AE035A-6DAC-46F4-90FB-F1AE2A79D416}.Release|x86.ActiveCfg = Release|Any CPU
|
||||
{D1AE035A-6DAC-46F4-90FB-F1AE2A79D416}.Release|x86.Build.0 = Release|Any CPU
|
||||
{C7E79E8E-B1DE-4053-9FB4-853814766CE0}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{C7E79E8E-B1DE-4053-9FB4-853814766CE0}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{C7E79E8E-B1DE-4053-9FB4-853814766CE0}.Debug|x64.ActiveCfg = Debug|Any CPU
|
||||
{C7E79E8E-B1DE-4053-9FB4-853814766CE0}.Debug|x64.Build.0 = Debug|Any CPU
|
||||
{C7E79E8E-B1DE-4053-9FB4-853814766CE0}.Debug|x86.ActiveCfg = Debug|Any CPU
|
||||
{C7E79E8E-B1DE-4053-9FB4-853814766CE0}.Debug|x86.Build.0 = Debug|Any CPU
|
||||
{C7E79E8E-B1DE-4053-9FB4-853814766CE0}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{C7E79E8E-B1DE-4053-9FB4-853814766CE0}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{C7E79E8E-B1DE-4053-9FB4-853814766CE0}.Release|x64.ActiveCfg = Release|Any CPU
|
||||
{C7E79E8E-B1DE-4053-9FB4-853814766CE0}.Release|x64.Build.0 = Release|Any CPU
|
||||
{C7E79E8E-B1DE-4053-9FB4-853814766CE0}.Release|x86.ActiveCfg = Release|Any CPU
|
||||
{C7E79E8E-B1DE-4053-9FB4-853814766CE0}.Release|x86.Build.0 = Release|Any CPU
|
||||
{A5D1DD48-7485-43F0-BFE3-2F645EC4D1E7}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{A5D1DD48-7485-43F0-BFE3-2F645EC4D1E7}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{A5D1DD48-7485-43F0-BFE3-2F645EC4D1E7}.Debug|x64.ActiveCfg = Debug|Any CPU
|
||||
{A5D1DD48-7485-43F0-BFE3-2F645EC4D1E7}.Debug|x64.Build.0 = Debug|Any CPU
|
||||
{A5D1DD48-7485-43F0-BFE3-2F645EC4D1E7}.Debug|x86.ActiveCfg = Debug|Any CPU
|
||||
{A5D1DD48-7485-43F0-BFE3-2F645EC4D1E7}.Debug|x86.Build.0 = Debug|Any CPU
|
||||
{A5D1DD48-7485-43F0-BFE3-2F645EC4D1E7}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{A5D1DD48-7485-43F0-BFE3-2F645EC4D1E7}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{A5D1DD48-7485-43F0-BFE3-2F645EC4D1E7}.Release|x64.ActiveCfg = Release|Any CPU
|
||||
{A5D1DD48-7485-43F0-BFE3-2F645EC4D1E7}.Release|x64.Build.0 = Release|Any CPU
|
||||
{A5D1DD48-7485-43F0-BFE3-2F645EC4D1E7}.Release|x86.ActiveCfg = Release|Any CPU
|
||||
{A5D1DD48-7485-43F0-BFE3-2F645EC4D1E7}.Release|x86.Build.0 = Release|Any CPU
|
||||
{BCC5BE4A-B909-4043-B0FB-B5A839349578}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{BCC5BE4A-B909-4043-B0FB-B5A839349578}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{BCC5BE4A-B909-4043-B0FB-B5A839349578}.Debug|x64.ActiveCfg = Debug|Any CPU
|
||||
{BCC5BE4A-B909-4043-B0FB-B5A839349578}.Debug|x64.Build.0 = Debug|Any CPU
|
||||
{BCC5BE4A-B909-4043-B0FB-B5A839349578}.Debug|x86.ActiveCfg = Debug|Any CPU
|
||||
{BCC5BE4A-B909-4043-B0FB-B5A839349578}.Debug|x86.Build.0 = Debug|Any CPU
|
||||
{BCC5BE4A-B909-4043-B0FB-B5A839349578}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{BCC5BE4A-B909-4043-B0FB-B5A839349578}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{BCC5BE4A-B909-4043-B0FB-B5A839349578}.Release|x64.ActiveCfg = Release|Any CPU
|
||||
{BCC5BE4A-B909-4043-B0FB-B5A839349578}.Release|x64.Build.0 = Release|Any CPU
|
||||
{BCC5BE4A-B909-4043-B0FB-B5A839349578}.Release|x86.ActiveCfg = Release|Any CPU
|
||||
{BCC5BE4A-B909-4043-B0FB-B5A839349578}.Release|x86.Build.0 = Release|Any CPU
|
||||
{BC5932E5-E6F7-42A2-AC39-697C9A737A3C}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{BC5932E5-E6F7-42A2-AC39-697C9A737A3C}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{BC5932E5-E6F7-42A2-AC39-697C9A737A3C}.Debug|x64.ActiveCfg = Debug|Any CPU
|
||||
{BC5932E5-E6F7-42A2-AC39-697C9A737A3C}.Debug|x64.Build.0 = Debug|Any CPU
|
||||
{BC5932E5-E6F7-42A2-AC39-697C9A737A3C}.Debug|x86.ActiveCfg = Debug|Any CPU
|
||||
{BC5932E5-E6F7-42A2-AC39-697C9A737A3C}.Debug|x86.Build.0 = Debug|Any CPU
|
||||
{BC5932E5-E6F7-42A2-AC39-697C9A737A3C}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{BC5932E5-E6F7-42A2-AC39-697C9A737A3C}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{BC5932E5-E6F7-42A2-AC39-697C9A737A3C}.Release|x64.ActiveCfg = Release|Any CPU
|
||||
{BC5932E5-E6F7-42A2-AC39-697C9A737A3C}.Release|x64.Build.0 = Release|Any CPU
|
||||
{BC5932E5-E6F7-42A2-AC39-697C9A737A3C}.Release|x86.ActiveCfg = Release|Any CPU
|
||||
{BC5932E5-E6F7-42A2-AC39-697C9A737A3C}.Release|x86.Build.0 = Release|Any CPU
|
||||
{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
|
||||
EndGlobalSection
|
||||
GlobalSection(NestedProjects) = preSolution
|
||||
{A7D0EF95-B206-4646-99DD-1D2BBB7AF978} = {84E60614-5042-48EC-B349-290FB0CA7BA8}
|
||||
{13EE9780-5810-4229-BFCF-6003172534DD} = {84E60614-5042-48EC-B349-290FB0CA7BA8}
|
||||
{D1AE035A-6DAC-46F4-90FB-F1AE2A79D416} = {84E60614-5042-48EC-B349-290FB0CA7BA8}
|
||||
{C7E79E8E-B1DE-4053-9FB4-853814766CE0} = {FDF1822C-58D6-4B35-93EA-6A85E1292933}
|
||||
{A5D1DD48-7485-43F0-BFE3-2F645EC4D1E7} = {84E60614-5042-48EC-B349-290FB0CA7BA8}
|
||||
{BCC5BE4A-B909-4043-B0FB-B5A839349578} = {84E60614-5042-48EC-B349-290FB0CA7BA8}
|
||||
{BC5932E5-E6F7-42A2-AC39-697C9A737A3C} = {FDF1822C-58D6-4B35-93EA-6A85E1292933}
|
||||
{BF9B33E3-314A-3621-C1F0-AFD074692421} = {84E60614-5042-48EC-B349-290FB0CA7BA8}
|
||||
{4CB51620-11B7-47E9-9971-A999E49D1CE1} = {FDF1822C-58D6-4B35-93EA-6A85E1292933}
|
||||
EndGlobalSection
|
||||
EndGlobal
|
||||
@@ -0,0 +1,402 @@
|
||||
# Palladium Wallet
|
||||
|
||||
**An SPV wallet built specifically for the Palladium (PLM) cryptocurrency** and optimized for its chain. Runs on desktop (Windows/Linux) and Android from a single shared codebase.
|
||||
|
||||
Unlike generic wallets adapted to many coins, Palladium Wallet is designed around Palladium's consensus parameters — a Bitcoin-derived UTXO chain with 2-minute blocks and LWMA difficulty — and centralizes them in a single network profile. This keeps it lightweight, predictable and faithful to the chain: no client-side difficulty recalculation (trust is anchored to hardcoded checkpoints), mandatory Merkle verification on every confirmed transaction, and a network client written specifically for Palladium's indexing server.
|
||||
|
||||
## Features
|
||||
|
||||
- **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 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, Chinese (Simplified).
|
||||
|
||||
## Architecture
|
||||
|
||||
```
|
||||
PalladiumWallet.sln
|
||||
├─ src/Core/ Chain/ Crypto/ Wallet/ Spv/ Net/ Storage/ (no UI dependency)
|
||||
├─ src/App/ shared Avalonia UI library (Views, ViewModels, Loc, Assets)
|
||||
├─ src/App.Desktop/ desktop head (Windows/Linux) → runnable
|
||||
├─ src/App.Android/ Android head → apk
|
||||
├─ src/Cli/ CLI on the same Core
|
||||
└─ tests/ xUnit
|
||||
```
|
||||
|
||||
The UI is written **once** in `src/App`; the desktop and Android heads only add the per-platform
|
||||
entry point and packages.
|
||||
|
||||
Stack: **.NET 10 + Avalonia UI 12 + NBitcoin**.
|
||||
|
||||
---
|
||||
|
||||
## Development environment
|
||||
|
||||
For desktop and the CLI you only need the **.NET 10 SDK**. The core and crypto are fully testable without the GUI or a real network.
|
||||
|
||||
### Windows
|
||||
|
||||
1. Install the .NET 10 SDK:
|
||||
```powershell
|
||||
winget install Microsoft.DotNet.SDK.10
|
||||
```
|
||||
(alternatively, the installer from <https://dotnet.microsoft.com/download/dotnet/10.0>)
|
||||
2. Clone the repository and restore dependencies:
|
||||
```powershell
|
||||
git clone https://github.com/palladium-coin/palladium-wallet.git
|
||||
cd PalladiumWallet
|
||||
dotnet restore
|
||||
```
|
||||
|
||||
### Linux
|
||||
|
||||
1. Install the .NET 10 SDK through your distro's package manager, or without root via the official script:
|
||||
```bash
|
||||
curl -sSL https://dot.net/v1/dotnet-install.sh | bash -s -- --channel 10.0 --install-dir "$HOME/.dotnet10"
|
||||
export PATH="$HOME/.dotnet10:$PATH" DOTNET_ROOT="$HOME/.dotnet10"
|
||||
```
|
||||
(add the two `export` lines to your `~/.bashrc` to make them permanent)
|
||||
2. Clone and restore:
|
||||
```bash
|
||||
git clone https://github.com/palladium-coin/palladium-wallet.git
|
||||
cd PalladiumWallet
|
||||
dotnet restore
|
||||
```
|
||||
|
||||
> The GUI uses Avalonia, which runs natively on Windows and Linux with no extra graphics dependencies.
|
||||
|
||||
### Android (additional setup)
|
||||
|
||||
Building the apk also requires the Android workload, a JDK, and the Android SDK:
|
||||
|
||||
```bash
|
||||
dotnet workload install android # .NET Android build packs
|
||||
# JDK 17+ must be available (set JAVA_HOME)
|
||||
# Provision the Android SDK once into ~/android-sdk:
|
||||
dotnet build src/App.Android -t:InstallAndroidDependencies \
|
||||
-p:AndroidSdkDirectory=$HOME/android-sdk -p:AcceptAndroidSDKLicenses=true
|
||||
```
|
||||
|
||||
To run the apk on an emulator (instead of a physical device), see
|
||||
[*Android emulator (developer setup)*](#android-emulator-developer-setup) below.
|
||||
|
||||
---
|
||||
|
||||
## Running it
|
||||
|
||||
### Desktop GUI in debug (Linux & Windows)
|
||||
|
||||
The desktop head runs the same way on both OSes (`Debug` is the default configuration). Run it from
|
||||
the repo root:
|
||||
|
||||
```bash
|
||||
dotnet run --project src/App.Desktop # single run (Debug)
|
||||
dotnet watch --project src/App.Desktop # with hot reload (edit XAML/C# and see changes live)
|
||||
dotnet run --project src/App.Desktop -c Release # to try the Release config
|
||||
```
|
||||
|
||||
- **Linux** — runs natively on X11/Wayland, no extra graphics packages. On **WSL2** the window
|
||||
appears on the Windows desktop through WSLg (already working here, nothing to install).
|
||||
- **Windows** — runs natively; use the same commands from PowerShell or a terminal.
|
||||
|
||||
The app writes its data under the per-user data folder (see *User guide → First launch*); delete it
|
||||
to start from a clean first-run wizard.
|
||||
|
||||
### CLI
|
||||
|
||||
Same core, useful for scripts and headless environments:
|
||||
```bash
|
||||
dotnet run --project src/Cli -- <command>
|
||||
```
|
||||
Run without arguments for the full list of commands.
|
||||
|
||||
### Android
|
||||
|
||||
There is no `dotnet run` for a phone: build the apk and install it (see *Building → Android apk*),
|
||||
or run it on an emulator (see *Android emulator (developer setup)*).
|
||||
|
||||
---
|
||||
|
||||
## Running tests
|
||||
|
||||
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
|
||||
```
|
||||
|
||||
Run a single test (or a group) by name:
|
||||
```bash
|
||||
dotnet test --filter "FullyQualifiedName~TestName"
|
||||
```
|
||||
|
||||
Run only the tests in one project:
|
||||
```bash
|
||||
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/`). `tests/coverage.runsettings` excludes the test tooling, and `tests/check-coverage.py` applies the same gate as CI:
|
||||
```bash
|
||||
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.
|
||||
|
||||
### What the suite covers
|
||||
|
||||
The tests mirror the `Core` layout (`tests/PalladiumWallet.Tests/<area>/`):
|
||||
|
||||
| Area | What is verified |
|
||||
|---|---|
|
||||
| `Chain/` | Network profiles (prefixes, ports, coin type 746, SLIP-132 headers), NBitcoin network registration and the `INetworkSet` plumbing |
|
||||
| `Crypto/` | BIP39 (official Trezor vectors, NFKD normalisation, all 8 supported wordlist languages), BIP32/44/49/84/86 derivation against public golden vectors, SLIP-132 encode/decode including corrupted-payload rejection, HD and imported-key accounts (fund-safety fallbacks for out-of-range indices), watch-only isolation |
|
||||
| `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**
|
||||
(`tests/PalladiumWallet.Tests/Net/FakeElectrumServer.cs`): a real loopback TCP
|
||||
socket speaking newline-delimited JSON-RPC (optionally TLS with a self-signed
|
||||
certificate), with per-method handlers and call counters. Client, synchroniser
|
||||
and inspector therefore exercise the same code paths used in production —
|
||||
framing, retries, TLS pinning included — without any external dependency.
|
||||
|
||||
The suite also includes **property-based tests** ([CsCheck](https://github.com/AnthonyLloyd/CsCheck)) in `tests/PalladiumWallet.Tests/PropertyTests.cs`. These generate hundreds of random inputs per test and verify invariants that must hold universally — no crash on arbitrary strings, encrypt/decrypt roundtrip for any plaintext and password, SLIP-132 key roundtrip for every script kind and network, every leaf in a randomly-built Merkle tree verifies against its root. They run automatically with `dotnet test` and take ~30 s.
|
||||
|
||||
### Fuzzing
|
||||
|
||||
`tests/PalladiumWallet.Fuzz` fuzzes every parser that consumes untrusted input
|
||||
(server-supplied headers/proofs/peer lists, wallet files, user-pasted
|
||||
keys/mnemonics/addresses/amounts) via [SharpFuzz](https://github.com/Metalnem/sharpfuzz):
|
||||
each target enforces the parser's documented error contract, so any other
|
||||
exception escaping is a finding. The seed corpus — including a regression input
|
||||
for every crash found so far — replays automatically inside `dotnet test`;
|
||||
coverage-guided campaigns run separately with afl++ (`tests/PalladiumWallet.Fuzz/fuzz.sh`),
|
||||
and a built-in random-mutation mode (`dotnet run -- <target> --random N`) needs
|
||||
no external tooling. See `tests/PalladiumWallet.Fuzz/README.md`.
|
||||
|
||||
---
|
||||
|
||||
## Building
|
||||
|
||||
### Reproducible builds (Docker) — recommended for release binaries
|
||||
|
||||
All three distribution targets (Windows exe, Linux binary, Android apk) can be
|
||||
built with one command inside Docker, with **no SDK installed on the host**:
|
||||
|
||||
```bash
|
||||
./docker/build.sh # interactive menu, or: ./docker/build.sh all
|
||||
```
|
||||
|
||||
Why build this way: the whole toolchain is pinned in the Dockerfiles, so every
|
||||
build uses exactly the same SDK versions regardless of the host machine — no
|
||||
toolchain drift between releases — and the build environment itself is
|
||||
reviewable in the repo. For a wallet this is a trust property, not a
|
||||
convenience: anyone can rebuild the published binaries from source and check
|
||||
they were produced by the process the repository declares.
|
||||
|
||||
See [docker/README.md](docker/README.md) for prerequisites, usage, how to run
|
||||
each produced artifact, and troubleshooting. The sections below cover manual
|
||||
builds with a locally installed SDK (the normal path during development).
|
||||
|
||||
### Development build
|
||||
|
||||
```bash
|
||||
dotnet build # whole solution (debug)
|
||||
dotnet build src/App.Desktop # desktop head only
|
||||
```
|
||||
|
||||
> A solution-wide `dotnet build` also builds the Android head, which needs the Android SDK
|
||||
> (see *Android emulator (developer setup)* below). If you don't have it, build the specific
|
||||
> non-Android projects (`src/App.Desktop`, `src/Cli`, `tests/...`).
|
||||
|
||||
### Desktop release (self-contained)
|
||||
|
||||
```bash
|
||||
# Windows — single self-contained .exe (output: src/App.Desktop/bin/Release/net10.0/win-x64/publish/PalladiumWallet.exe)
|
||||
# IncludeNativeLibrariesForSelfExtract embeds Avalonia's native libs (Skia, HarfBuzz, ANGLE):
|
||||
# without it they stay as separate DLLs and the .exe alone silently fails to start.
|
||||
dotnet publish src/App.Desktop -c Release -r win-x64 -p:PublishSingleFile=true \
|
||||
-p:IncludeNativeLibrariesForSelfExtract=true --self-contained
|
||||
|
||||
# Linux — self-contained; AppImage then produced with PupNet Deploy
|
||||
# (output: src/App.Desktop/bin/Release/net10.0/linux-x64/publish/PalladiumWallet)
|
||||
dotnet publish src/App.Desktop -c Release -r linux-x64 --self-contained
|
||||
```
|
||||
|
||||
[PupNet Deploy](https://github.com/kuiperzone/PupNet-Deploy) turns the Linux publish into an AppImage.
|
||||
The executable is named `PalladiumWallet` (set via `<AssemblyName>` in the desktop head).
|
||||
|
||||
### Android apk
|
||||
|
||||
Prerequisites: the Android workload + SDK (see *Development environment → Android*). The Android
|
||||
head already sets `<EmbedAssembliesIntoApk>true</EmbedAssembliesIntoApk>`, so the apk is
|
||||
**self-contained** and installs/runs standalone (a Fast-Deployment debug apk crashes at launch
|
||||
with "No assemblies found" when installed without `adb`).
|
||||
|
||||
```bash
|
||||
# Default debug apk — all ABIs (arm64-v8a + x86_64): runs on phones AND the x86_64 emulator.
|
||||
# ~79 MB. Output: src/App.Android/bin/Debug/net10.0-android/*-Signed.apk
|
||||
JAVA_HOME=<jdk-path> dotnet build src/App.Android -c Debug -t:SignAndroidPackage \
|
||||
-p:AndroidSdkDirectory=$HOME/android-sdk
|
||||
|
||||
# Smaller apk for a real phone — arm64 only (~41 MB):
|
||||
JAVA_HOME=<jdk-path> dotnet build src/App.Android -c Debug -t:SignAndroidPackage \
|
||||
-p:AndroidSdkDirectory=$HOME/android-sdk -p:AbiArm64Only=true
|
||||
```
|
||||
|
||||
The ABI restriction uses the `AbiArm64Only` flag, which is scoped to the Android head's
|
||||
`<RuntimeIdentifiers>` in its csproj — do **not** pass `-p:RuntimeIdentifiers=android-arm64` on the
|
||||
command line, it leaks to the `net10.0` projects (`Core`/`App`) and breaks the build. (The legacy
|
||||
`AndroidSupportedAbis` property is deprecated and ignored.)
|
||||
|
||||
(Set `ANDROID_HOME` to skip the `-p:AndroidSdkDirectory` flag.) This debug apk is fine for personal
|
||||
sideloading, but debug builds are signed with a key regenerated per build machine/container, so a
|
||||
newer debug apk won't install over an older one without an uninstall first. For a stable signature
|
||||
across releases (needed to update an existing install in place), build via
|
||||
`./docker/build.sh android` instead — see [docker/keystore/README.md](docker/keystore/README.md).
|
||||
|
||||
> **Verification status.** The default multi-ABI apk is verified running on the x86_64 emulator
|
||||
> (UI renders, connects to a server over TLS). The arm64-only apk builds correctly (41 MB,
|
||||
> `arm64-v8a` only) but is meant for a physical arm64 phone — on the x86_64 emulator it only runs
|
||||
> through slow ARM translation and stalls on the splash, so **verify it on a real device**.
|
||||
|
||||
### Version
|
||||
|
||||
The application **version** is set in a single place: the `<Version>` tag in
|
||||
[`src/App/PalladiumWallet.App.csproj`](src/App/PalladiumWallet.App.csproj). It appears in the desktop
|
||||
window title, in the Help dialog, and is stamped into the published binaries (and the apk's versionName).
|
||||
|
||||
---
|
||||
|
||||
## Android emulator (developer setup)
|
||||
|
||||
How to run the apk without a physical device. Paths assume the Android SDK in `~/android-sdk`
|
||||
and a JDK at `JAVA_HOME` (JDK 17+). Tested on Linux / WSL2.
|
||||
|
||||
**1. Install the emulator, a system image and the matching platform** (once):
|
||||
```bash
|
||||
SDK=$HOME/android-sdk
|
||||
$SDK/cmdline-tools/latest/bin/sdkmanager --sdk_root=$SDK \
|
||||
"emulator" "system-images;android-34;google_apis;x86_64" "platforms;android-34"
|
||||
```
|
||||
Use an `x86_64` image so the emulator runs with hardware acceleration (KVM); the app's min SDK is 23.
|
||||
|
||||
**2. Hardware acceleration (Linux/WSL2)** — the emulator needs access to `/dev/kvm`. Add yourself
|
||||
to the `kvm` group once, then start a new shell (or prefix the launch with `sg kvm -c '…'`):
|
||||
```bash
|
||||
sudo usermod -aG kvm $USER
|
||||
```
|
||||
On WSL2, KVM must be enabled on the Windows host (nested virtualization); the emulator window is
|
||||
shown on the Windows desktop through WSLg.
|
||||
|
||||
**3. Create an AVD** (virtual device):
|
||||
```bash
|
||||
echo no | $SDK/cmdline-tools/latest/bin/avdmanager create avd \
|
||||
-n plm -k "system-images;android-34;google_apis;x86_64" -d pixel
|
||||
```
|
||||
|
||||
**4. Launch the emulator** (software GL is the most robust under WSLg):
|
||||
```bash
|
||||
$SDK/emulator/emulator -avd plm -gpu swiftshader_indirect -no-snapshot -no-audio &
|
||||
$SDK/platform-tools/adb wait-for-device
|
||||
# wait for full boot:
|
||||
until [ "$($SDK/platform-tools/adb shell getprop sys.boot_completed | tr -d '\r')" = 1 ]; do sleep 2; done
|
||||
```
|
||||
If the window won't render, add `-no-window` and rely on `adb` + screenshots
|
||||
(`adb exec-out screencap -p > shot.png`).
|
||||
|
||||
**5. Install and run the apk; capture logs to debug crashes:**
|
||||
```bash
|
||||
ADB=$SDK/platform-tools/adb
|
||||
$ADB install -r src/App.Android/bin/Debug/net10.0-android/*-Signed.apk
|
||||
$ADB shell monkey -p io.github.davide3011.palladiumwallet -c android.intent.category.LAUNCHER 1
|
||||
$ADB logcat -d | grep -iE "monodroid|exception|fatal|avalonia"
|
||||
```
|
||||
|
||||
**VS Code "Android iOS Emulator" extension** (optional, click-to-launch): it only starts an
|
||||
existing AVD, so create one first (step 3). Point it at the emulator binary:
|
||||
```jsonc
|
||||
// VS Code settings.json
|
||||
"emulator.emulatorPathLinux": "/home/<user>/android-sdk/emulator"
|
||||
// on WSL, use: "emulator.emulatorPathWSL": "/home/<user>/android-sdk/emulator"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 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.
|
||||
3. If you create a wallet, **write the seed phrase down on paper**: it will not be shown again. You can protect the file with a password.
|
||||
|
||||
> **Desktop vs Android.** The UI and features are the same on both. Differences: on Android the
|
||||
> data-location step is skipped (fixed app sandbox) and *File → Open wallet from file* (importing a
|
||||
> wallet from an arbitrary file) is hidden — open wallets from the in-app chooser instead. The
|
||||
> version is shown in the desktop window title and, on every platform, in the Help dialog. The CLI
|
||||
> is desktop/headless only.
|
||||
|
||||
### Main tabs
|
||||
- **History** — list of transactions. *Double-click* (double-tap on touch) a row to open the full detail (amount, fee, addresses, sizes, confirmations).
|
||||
- **Send** — recipient + amount (or "send all"), adjustable fee; for watch-only wallets a PSBT is produced to be signed offline.
|
||||
- **Receive** — next unused address, with a **QR code** and a **Copy** button.
|
||||
- **Addresses** — all derived addresses with balances; click for details (keys, derivation path).
|
||||
- **Contacts** — address book with labels.
|
||||
|
||||
### Connection
|
||||
- The status indicator at the bottom shows the connection to the **indexing server**; tapping it opens the server settings.
|
||||
- Sync is SPV: it downloads only what concerns your wallet and verifies every confirmed transaction with a Merkle proof.
|
||||
|
||||
### Settings and Help
|
||||
- **Settings**: language, display unit (PLM / mPLM / µPLM / sat), server.
|
||||
- **Help**: software information and version.
|
||||
|
||||
### CLI in brief
|
||||
```bash
|
||||
# Wallet
|
||||
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.
|
||||
|
||||
---
|
||||
|
||||
## Changelog
|
||||
|
||||
Technical, per-version changes are tracked in [CHANGELOG.md](CHANGELOG.md).
|
||||
|
||||
## License
|
||||
|
||||
Released under the MIT License. See the [LICENSE](LICENSE) file.
|
||||
@@ -0,0 +1,121 @@
|
||||
# Security
|
||||
|
||||
## Threat model
|
||||
|
||||
Palladium Wallet is a self-custody SPV wallet. It is designed to protect funds against:
|
||||
|
||||
- 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 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:
|
||||
|
||||
- A fully compromised operating system or process (malware with memory access can extract keys from RAM)
|
||||
- An attacker who obtains the wallet file **and** the password
|
||||
- Denial of service or eclipse attacks against the indexing server
|
||||
- Network-level traffic analysis (no Tor/proxy support in the first release)
|
||||
|
||||
---
|
||||
|
||||
## SPV trust model
|
||||
|
||||
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) **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:
|
||||
|
||||
- Script execution (P2WPKH scripts are assumed valid if the server returns a confirmed transaction with a valid Merkle proof)
|
||||
- Double-spend detection beyond what the server reports (an eclipse attack on the indexing server could hide a conflicting transaction)
|
||||
- Full block validity (coinbase, consensus rules beyond the header)
|
||||
|
||||
The indexing server (ElectrumX-compatible, port 50001/50002) is a **semi-trusted** component. It can:
|
||||
|
||||
- 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`, 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
|
||||
|
||||
---
|
||||
|
||||
## Encryption at rest
|
||||
|
||||
- Algorithm: AES-256-GCM
|
||||
- Key derivation: PBKDF2-HMAC-SHA512, 600 000 iterations, 16-byte random salt (fresh salt and nonce on every save; the iteration count is stored in the file container, so future increases remain backward-compatible — bounded at 10 000 000 on read, since the count is attacker-controlled in a tampered file and an absurd value would hang the wallet at open)
|
||||
- Authentication: GCM tag (16 bytes) — any tampering is detected before decryption
|
||||
- A malformed container (broken JSON, missing fields, bad base64, wrong nonce/tag size) always fails with a typed `InvalidDataException`, never a raw parser exception — the decrypt path is fuzz-tested (`tests/PalladiumWallet.Fuzz`)
|
||||
- The user can explicitly opt out of encryption (UI shows a warning); the `WalletStore.Save` API accepts `null` password only when the caller has confirmed user intent
|
||||
|
||||
---
|
||||
|
||||
## TLS certificate pinning
|
||||
|
||||
Connections to the indexing server use TOFU (Trust On First Use): the server's TLS certificate is pinned on first connection and stored in `server-certs.json`. A certificate change triggers a hard error (`CertificatePinMismatchException`) requiring explicit reset by the user. This prevents silent MITM substitution after first connection.
|
||||
|
||||
---
|
||||
|
||||
## 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 or from a plain address), the private keys must be kept in a separate cold storage device.
|
||||
|
||||
---
|
||||
|
||||
## AI-assisted testing and vulnerability discovery
|
||||
|
||||
Part of the test suite and security review for this project is produced with **Claude Fable 5** (Anthropic), used as a targeted tool rather than a blanket "AI-reviewed" stamp. Concretely:
|
||||
|
||||
- **Adversarial network simulation**: the SPV/network layer (`ElectrumClient`, `WalletSynchronizer`, `CertificatePinStore`) is tested against an in-process fake ElectrumX server that can be programmed to lie — serve a transaction with a Merkle proof that doesn't match its claimed block header, drop connections mid-request, return malformed or throttling responses. These are exactly the behaviors a malicious or compromised indexing server would exhibit; the suite asserts the wallet detects and rejects them (see `SpvVerificationException`) instead of trusting unverified server data.
|
||||
- **Property-based fuzzing** (CsCheck): parsers and cryptographic roundtrips (amount parsing, SLIP-132 key encoding, Merkle proof verification, AES-GCM encrypt/decrypt) are exercised against hundreds of generated inputs per run, checking invariants — no crash on arbitrary input, correct rejection of a wrong password, no false-negative Merkle verification — rather than a handful of hand-picked cases. This methodology has already found and fixed two real defects: a corrupted TLS pin file that permanently blocked reconnection, and a wallet-detection routine that threw on unexpected but valid JSON instead of failing safe.
|
||||
- **Targeted security-focused code review** over the areas where a bug has direct financial impact: key derivation, transaction signing, coin selection/spendability rules, and encryption at rest — cross-checked against the invariants stated in this document (e.g. "the server cannot fabricate a confirmed transaction with a valid Merkle proof").
|
||||
|
||||
This is a complement to, not a substitute for, independent human or third-party security review — which is still recommended before relying on this wallet for significant mainnet funds, particularly ahead of a 1.0 release.
|
||||
|
||||
---
|
||||
|
||||
## Known limitations and out-of-scope for v1
|
||||
|
||||
- No Tor/proxy support (network traffic reveals which addresses are being queried)
|
||||
- No multi-server pooling (single point of failure for the indexing server)
|
||||
- No hardware wallet integration
|
||||
- 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
|
||||
@@ -0,0 +1,912 @@
|
||||
# Palladium Wallet — User Guide
|
||||
|
||||
This guide covers **every user-facing feature** of Palladium Wallet (GUI and CLI), including
|
||||
default values, validation rules, limits, and edge cases. It is written so that no behavior
|
||||
has to be guessed: when the wallet enforces a rule, the rule is stated here with its exact
|
||||
numbers.
|
||||
|
||||
Applies to version **0.9.x**. Where the GUI and the CLI differ, both are described.
|
||||
|
||||
---
|
||||
|
||||
## Table of contents
|
||||
|
||||
1. [What Palladium Wallet is (and is not)](#1-what-palladium-wallet-is-and-is-not)
|
||||
2. [Key concepts you must understand before holding funds](#2-key-concepts)
|
||||
3. [Installation and platform differences](#3-installation-and-platform-differences)
|
||||
4. [First launch: the setup wizard](#4-first-launch-the-setup-wizard)
|
||||
5. [Opening, closing and managing multiple wallets](#5-opening-closing-and-managing-multiple-wallets)
|
||||
6. [The main screen](#6-the-main-screen)
|
||||
7. [Receiving funds](#7-receiving-funds)
|
||||
8. [Sending funds](#8-sending-funds)
|
||||
9. [Transaction history and details](#9-transaction-history-and-details)
|
||||
10. [The Addresses tab](#10-the-addresses-tab)
|
||||
11. [Contacts](#11-contacts)
|
||||
12. [Connection, servers and certificate pinning](#12-connection-servers-and-certificate-pinning)
|
||||
13. [Settings](#13-settings)
|
||||
14. [Wallet Information (xpub, seed, fingerprint)](#14-wallet-information)
|
||||
15. [Backup and recovery](#15-backup-and-recovery)
|
||||
16. [Security model and known limitations](#16-security-model-and-known-limitations)
|
||||
17. [Command-line interface (CLI)](#17-command-line-interface-cli)
|
||||
18. [Troubleshooting and error messages](#18-troubleshooting-and-error-messages)
|
||||
19. [Glossary](#19-glossary)
|
||||
|
||||
---
|
||||
|
||||
## 1. What Palladium Wallet is (and is not)
|
||||
|
||||
Palladium Wallet is a **self-custody SPV (Simplified Payment Verification) wallet** for the
|
||||
Palladium (PLM) cryptocurrency, a Bitcoin-derived UTXO chain with 2-minute blocks. It runs on
|
||||
Windows, Linux and Android from the same codebase, plus a separate command-line interface.
|
||||
|
||||
**Self-custody** means: *you* hold the keys. There is no account, no server-side backup, no
|
||||
password recovery service. If you lose your seed phrase (and your wallet file plus its
|
||||
password), your funds are permanently unrecoverable. Nobody — including the developer — can
|
||||
restore them.
|
||||
|
||||
**SPV** means the wallet does **not** download or validate the full blockchain. It connects to
|
||||
an **indexing server** (ElectrumX-compatible, ports 50001 TCP / 50002 SSL) and downloads only
|
||||
the data relevant to your addresses. Every *confirmed* transaction the server reports is
|
||||
independently verified with a **Merkle proof** against the block header; a server that lies
|
||||
about confirmed transactions is detected and the synchronization aborts. See
|
||||
[section 16](#16-security-model-and-known-limitations) for exactly what the server can and
|
||||
cannot do to you.
|
||||
|
||||
What this wallet intentionally does **not** include (as of this version):
|
||||
|
||||
- Lightning Network
|
||||
- Hardware-wallet integration
|
||||
- Coin control (manual UTXO selection) — coin selection is always automatic
|
||||
- RBF fee bumping (transactions *signal* RBF, but there is no "bump fee" button)
|
||||
- Tor/proxy support — the indexing server can observe which addresses you query
|
||||
- Fiat currency conversion
|
||||
- Network selection in the GUI — the **GUI operates on mainnet only**; testnet and regtest
|
||||
are available through the CLI (`--net`)
|
||||
|
||||
---
|
||||
|
||||
## 2. Key concepts
|
||||
|
||||
Read this section once, carefully. Every rule below is enforced by the software exactly as
|
||||
written.
|
||||
|
||||
### 2.1 The seed phrase (BIP39 mnemonic)
|
||||
|
||||
- When you create a new wallet in the GUI, it generates a **12-word English** BIP39 mnemonic.
|
||||
It is shown **once**, during the wizard, and you must write it down **on paper, in order**.
|
||||
- Whoever knows these words controls the funds — from any device, forever, with no further
|
||||
information needed (unless you also set a passphrase, see below).
|
||||
- When *restoring*, the wallet accepts any valid BIP39 mnemonic (12 or 24 words in the GUI
|
||||
prompt; the parser also accepts 15/18/21-word mnemonics and auto-detects the wordlist
|
||||
language). The checksum is validated: a mistyped word is rejected, not silently accepted.
|
||||
|
||||
### 2.2 The optional passphrase ("25th word")
|
||||
|
||||
During creation or restore you may set a **BIP39 passphrase**. Understand precisely what it
|
||||
does:
|
||||
|
||||
- The passphrase is combined with the seed words to derive the keys. Seed + passphrase
|
||||
produce a **completely different wallet** than the same seed without a passphrase (or with
|
||||
any other passphrase, including one differing by a single character or by case).
|
||||
- There is **no validity check possible**: entering the "wrong" passphrase during a restore
|
||||
does not produce an error — it silently opens a different, empty wallet. If you restore and
|
||||
see a zero balance where you expected funds, the first thing to check is the passphrase
|
||||
(then the script type, see 2.3).
|
||||
- Store the passphrase **separately** from the seed words. Losing the passphrase makes the
|
||||
funds unrecoverable even if you still have the 12 words.
|
||||
|
||||
### 2.3 Script type (address format)
|
||||
|
||||
Each wallet is created with exactly one **script type**, which determines the address format
|
||||
and the derivation path. It cannot be changed later; to switch, create a new wallet and move
|
||||
the funds.
|
||||
|
||||
| Script type | Standard | Derivation | Mainnet addresses look like |
|
||||
|---|---|---|---|
|
||||
| Legacy | BIP44 | `m/44'/746'/0'` | start with `P` |
|
||||
| Wrapped SegWit | BIP49 | `m/49'/746'/0'` | start with `3` |
|
||||
| **Native SegWit** (default, recommended) | BIP84 | `m/84'/746'/0'` | start with `plm1q` |
|
||||
| Taproot | BIP86 | `m/86'/746'/0'` | start with `plm1p` |
|
||||
|
||||
**Restoring with the wrong script type is the same trap as the wrong passphrase**: the seed
|
||||
is accepted, but different addresses are derived and the balance shows zero. When restoring,
|
||||
select the same script type the wallet was created with. If unsure, try Native SegWit first
|
||||
(the default), then the others.
|
||||
|
||||
The coin type in the derivation path is **746** on mainnet (1 on testnet/regtest).
|
||||
|
||||
### 2.4 Wallet types
|
||||
|
||||
| Type | Created via | Can sign/spend | Notes |
|
||||
|---|---|---|---|
|
||||
| HD (BIP39 seed) | Create new / Restore from seed | Yes | The normal case |
|
||||
| HD (imported xprv) | Import extended key (xprv/yprv/zprv) | Yes | Full account key, no seed words |
|
||||
| Watch-only (xpub) | Import extended key (xpub/ypub/zpub) | **No** | Sees balances/history; cannot sign. In the GUI it can *prepare* a transaction preview but not sign or broadcast it. The CLI produces an unsigned PSBT for offline signing |
|
||||
| Imported WIF keys | Import WIF key(s) | Yes | Fixed address list, **no HD derivation and no change chain — change always returns to the first imported address** (this links your coins together; prefer an HD wallet for privacy) |
|
||||
|
||||
The extended-key format is **SLIP-132**: `xpub`/`xprv` (Legacy and Taproot), `ypub`/`yprv`
|
||||
(Wrapped SegWit), `zpub`/`zprv` (Native SegWit). The script type is auto-detected from the
|
||||
prefix when importing.
|
||||
|
||||
### 2.5 The wallet file and its encryption
|
||||
|
||||
Everything the wallet knows is stored in **one file per wallet**:
|
||||
`<data folder>/mainnet/wallets/<name>.wallet.json` (default name `default.wallet.json`).
|
||||
It contains the seed (or xpub/xprv/WIF keys), the derivation settings, your **contacts**, and
|
||||
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 `--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
|
||||
not allowed when encryption is enabled. Choose a strong password yourself: an attacker who
|
||||
copies the file can try passwords offline at their leisure.
|
||||
- There is currently **no "change password" function** in the GUI. To change the password,
|
||||
restore the wallet from its seed into a new wallet file with the new password, verify the
|
||||
new file opens and syncs, then delete the old file. (Contacts must be re-entered — see
|
||||
[section 15](#15-backup-and-recovery).)
|
||||
|
||||
### 2.6 Confirmations and spendability
|
||||
|
||||
Incoming funds are not immediately spendable. The exact rules on mainnet:
|
||||
|
||||
- **Unconfirmed (mempool)** transactions: shown as *"pending confirmation … not yet
|
||||
spendable"*. Never spendable, and — important — unconfirmed amounts are reported by the
|
||||
server **without cryptographic proof**, so treat them as provisional until confirmed.
|
||||
- **Regular outputs**: spendable after **6 confirmations** (~12 minutes at 2-minute blocks).
|
||||
- **Mining (coinbase) outputs**: spendable after **121 confirmations** (~4 hours). Until
|
||||
then they appear as *"maturing … not yet spendable"*.
|
||||
|
||||
The main balance shown is *confirmed minus immature*; pending and maturing amounts are shown
|
||||
as separate amber lines under the balance when present.
|
||||
|
||||
---
|
||||
|
||||
## 3. Installation and platform differences
|
||||
|
||||
### 3.1 Desktop (Windows / Linux)
|
||||
|
||||
Release binaries are self-contained — no runtime to install. Run the single executable
|
||||
(`PalladiumWallet.exe` on Windows, `PalladiumWallet` on Linux).
|
||||
|
||||
### 3.2 Android
|
||||
|
||||
Install the `.apk` (sideloading must be allowed). Minimum Android version: 6.0 (API 23).
|
||||
The app requests the **camera** permission only if you use the QR scanner; **internet** is
|
||||
required for synchronization.
|
||||
|
||||
> **Updating in place**: releases built with the project's official signing key update over
|
||||
> the installed app. If Android refuses to install an update ("app not installed" /
|
||||
> signature mismatch), the new apk was signed with a different key — you would have to
|
||||
> uninstall first, **which deletes the wallet file**. Back up your seed before uninstalling,
|
||||
> always.
|
||||
|
||||
### 3.3 What differs between desktop and Android
|
||||
|
||||
| Aspect | Desktop | Android |
|
||||
|---|---|---|
|
||||
| Data location | Chosen at first run (default or custom folder) | Fixed: the app's private sandbox (step skipped) |
|
||||
| Menu bar | File / Network / Settings / Wallet / Help | No menu bar; same functions reachable from the UI |
|
||||
| Close overlays | `Esc` key or click the dark backdrop | Hardware/gesture **Back** button or tap the backdrop |
|
||||
| QR code **scanning** (Send) | Not available | Camera scan button in the Send form |
|
||||
| CLI | Available | Not available |
|
||||
|
||||
Everything else — wallet formats, security, sync, features — is identical.
|
||||
|
||||
### 3.4 Where your data lives (desktop)
|
||||
|
||||
Resolution order:
|
||||
|
||||
1. A `palladium-data/` folder next to the executable, if present (**portable mode** — create
|
||||
the folder manually before first launch to get a fully portable install).
|
||||
2. The custom folder you picked in the first-run wizard (remembered via a small pointer file
|
||||
in the default location).
|
||||
3. The default: `%APPDATA%\PalladiumWallet` on Windows, `~/.palladium-wallet` on Linux.
|
||||
|
||||
Inside the data root: `mainnet/wallets/*.wallet.json` (your wallets),
|
||||
`mainnet/server-certs.json` (pinned TLS certificates), `mainnet/servers.json` (discovered
|
||||
servers), `config.json` (language, unit, last server — shared across wallets).
|
||||
|
||||
---
|
||||
|
||||
## 4. First launch: the setup wizard
|
||||
|
||||
### 4.1 Step: data location (desktop only, very first run)
|
||||
|
||||
Choose where the wallet stores its data: **"Use the default path"** (shown on screen) or
|
||||
**"Choose a folder…"** (native folder picker). This step never reappears once configured.
|
||||
On Android it is skipped entirely.
|
||||
|
||||
### 4.2 Step: start
|
||||
|
||||
Five options:
|
||||
|
||||
1. **Open existing wallet** — see [section 5](#5-opening-closing-and-managing-multiple-wallets).
|
||||
2. **Create a new wallet**
|
||||
3. **Restore from seed**
|
||||
4. **Import xpub / xprv**
|
||||
5. **Import WIF key**
|
||||
|
||||
### 4.3 Creating a new wallet
|
||||
|
||||
1. **Your seed (12 words)** — the freshly generated mnemonic is displayed. Write the words
|
||||
on paper, in order. *They are never shown in full again except through the password-gated
|
||||
"Show seed" function ([section 14](#14-wallet-information)).* Do not photograph them, do
|
||||
not store them in a cloud note.
|
||||
2. **Confirm seed** — retype the 12 words, space-separated. Comparison is case-insensitive
|
||||
and tolerant of extra spaces. A mismatch shows *"The words do not match: check what you
|
||||
wrote on paper."* and lets you retry.
|
||||
3. **Optional passphrase** — leave empty to skip. Re-read [section 2.2](#22-the-optional-passphrase-25th-word)
|
||||
before setting one.
|
||||
4. **Script type** — default Native SegWit. If unsure, keep the default.
|
||||
5. **Password step** — see 4.7.
|
||||
|
||||
### 4.4 Restoring from seed
|
||||
|
||||
1. Enter the mnemonic, words separated by spaces. Invalid words or a bad checksum produce
|
||||
*"Invalid mnemonic (wrong words or checksum): check again."*
|
||||
2. Enter the **same passphrase** used originally (empty if none was used).
|
||||
3. Select the **same script type** used originally.
|
||||
4. Password step (4.7).
|
||||
|
||||
After the first synchronization the balance and full history reappear. If the balance is
|
||||
zero when it shouldn't be: wrong passphrase or wrong script type
|
||||
([sections 2.2–2.3](#22-the-optional-passphrase-25th-word)).
|
||||
|
||||
### 4.5 Importing an extended key (xpub / xprv)
|
||||
|
||||
Paste a SLIP-132 extended key:
|
||||
|
||||
- **Public key** (`xpub`/`ypub`/`zpub`) → **watch-only** wallet: monitors balances and
|
||||
history, cannot spend.
|
||||
- **Private key** (`xprv`/`yprv`/`zprv`) → fully spendable HD wallet (account level, no seed
|
||||
words).
|
||||
|
||||
The script type is detected automatically from the key prefix and shown for confirmation
|
||||
(e.g. *"NativeSegwit (watch-only)"*). An unrecognized key shows *"Extended key not
|
||||
recognised for this network."* — check that it is a Palladium key of a supported format, not
|
||||
a key from another coin.
|
||||
|
||||
### 4.6 Importing WIF private keys
|
||||
|
||||
Paste one or more WIF keys, **one per line**. Each key controls exactly one address; there
|
||||
is no HD derivation. Select the script type (which address form the keys map to), then the
|
||||
password step. Remember the change-address caveat from
|
||||
[section 2.4](#24-wallet-types).
|
||||
|
||||
### 4.7 The password step (all flows)
|
||||
|
||||
- **Wallet name** (optional): letters/numbers recommended; invalid filesystem characters are
|
||||
replaced with `_`. Left blank → automatic name (`default`, then `wallet-2`, `wallet-3`, …).
|
||||
A name that already exists is rejected: *"A wallet with this name already exists."*
|
||||
- **"Encrypt the wallet file with the password"** — checked by default. If you uncheck it,
|
||||
the file (including the seed) is written **in plaintext** and the UI warns you.
|
||||
- **Password + confirmation** — must match and be non-empty when encryption is on. No other
|
||||
rules are imposed; strength is your responsibility.
|
||||
- **Create wallet** finalizes: the file is written, locked against concurrent access, opened,
|
||||
and the first connection/sync starts automatically.
|
||||
|
||||
---
|
||||
|
||||
## 5. Opening, closing and managing multiple wallets
|
||||
|
||||
- You can keep **any number of wallets**: each is a separate `*.wallet.json` file in the
|
||||
wallets folder. With more than one file, "Open existing wallet" shows a chooser list; with
|
||||
exactly one, it goes straight to the password prompt.
|
||||
- **Password prompt**: leave empty for an unencrypted wallet. Wrong password → *"Wrong
|
||||
password."* — retries are unlimited.
|
||||
- The GUI **only lists wallets inside its own wallets folder**. To use a wallet file from
|
||||
elsewhere, copy it into `<data folder>/mainnet/wallets/` first (while the app is closed,
|
||||
or before pressing Open).
|
||||
- **Single-instance lock**: a wallet file can be open in only one running instance at a
|
||||
time. A second attempt fails with *"Wallet already open in another instance of the
|
||||
application."* A stale `.lock` file left by a crash is released automatically when the
|
||||
owning process is gone.
|
||||
- **Switching wallets**: menu *File → Close wallet* (returns to the wizard), then *Open
|
||||
existing wallet*. *File → New / restore wallet…* also closes the current wallet first.
|
||||
- **Deleting a wallet**: there is no in-app delete. Close the app and delete the
|
||||
`.wallet.json` file manually — **only after verifying you have the seed on paper** (or,
|
||||
for imported wallets, the original keys). Deleting the file of an unbacked-up wallet
|
||||
destroys the funds' keys.
|
||||
|
||||
After opening, the header shows the wallet's identity, e.g.
|
||||
`Mainnet · NativeSegwit · m/84'/746'/0'`, with a ` · watch-only` or ` · imported` tag when
|
||||
applicable.
|
||||
|
||||
---
|
||||
|
||||
## 6. The main screen
|
||||
|
||||
Five tabs: **History**, **Send**, **Receive**, **Addresses**, **Contacts**.
|
||||
|
||||
- **Balance** (top): confirmed, spendable balance in your chosen unit. Below it, when
|
||||
applicable, two amber advisory lines: *pending confirmation* (mempool, unproven, not
|
||||
spendable) and *maturing* (young coinbase, not spendable — see
|
||||
[section 2.6](#26-confirmations-and-spendability)).
|
||||
- **Status bar** (bottom): left, the sync status (e.g. *"Synchronized: height 123456, 42
|
||||
SPV-verified transactions. Real-time updates active."*); right, the **connection
|
||||
indicator** (*connected host:port* / *connecting…* / *not connected* / *reconnecting…*).
|
||||
**Click/tap the connection indicator to open the server settings.**
|
||||
- All secondary screens (settings, details, wallet info, help) are **in-app overlays**, not
|
||||
separate windows. Close them with the ✕ button, by clicking the dark backdrop, with `Esc`
|
||||
(desktop) or Back (Android).
|
||||
|
||||
---
|
||||
|
||||
## 7. Receiving funds
|
||||
|
||||
The **Receive** tab always shows the **next unused receive address**, as text and as a QR
|
||||
code (the QR encodes the bare address).
|
||||
|
||||
- **Copy** puts the address on the clipboard (*"Address copied to clipboard"*).
|
||||
- Give a **fresh address to each payer**. After an address receives a payment and a sync
|
||||
runs, the tab automatically advances to the next unused address. Reusing addresses is not
|
||||
blocked, but it degrades your privacy by linking payments together.
|
||||
- Incoming payments appear in History **at the next synchronization** — normally within
|
||||
seconds, because the wallet subscribes to server push notifications. A payment shows as
|
||||
*mempool* first, then with its block height once mined.
|
||||
- The address format is fixed by the wallet's script type
|
||||
([section 2.3](#23-script-type-address-format)); senders must support that format (in
|
||||
particular, very old software may not send to `plm1…` bech32 addresses).
|
||||
|
||||
**Gap limit — the one receiving rule you must know.** The wallet derives addresses on
|
||||
demand and, when restoring from seed, scans forward until it finds **20 consecutive unused
|
||||
addresses** and then stops. Consequence: if you hand out many addresses that never receive
|
||||
funds, do not skip ahead by more than 20 — a payment to address #30, with #10–#29 unused,
|
||||
would **not be found after a restore from seed**. Under normal use (addresses handed out
|
||||
sequentially, most getting used) this never triggers. The limit (20) is stored per wallet
|
||||
and is not editable in the GUI.
|
||||
|
||||
---
|
||||
|
||||
## 8. Sending funds
|
||||
|
||||
Prerequisites: the wallet must be **connected and synchronized** (otherwise: *"Connect to
|
||||
the server and synchronize before sending"* / *"Synchronize before sending"*), and the
|
||||
wallet must be spendable (not watch-only) to actually broadcast.
|
||||
|
||||
### 8.1 The form
|
||||
|
||||
- **From contacts** — optional dropdown that fills the recipient address from your address
|
||||
book.
|
||||
- **Recipient address** — validated when you prepare: an address of the wrong network or
|
||||
with a typo is rejected (checksums make silent typos virtually impossible).
|
||||
- **Amount** — interpreted in the **currently selected display unit** (PLM by default — check
|
||||
the unit in Settings before typing!). Rejected if negative, malformed, or more precise
|
||||
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)** — 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).
|
||||
|
||||
### 8.2 Prepare, then confirm
|
||||
|
||||
Sending is a **two-step** operation:
|
||||
|
||||
1. **Prepare transaction** — builds and signs the transaction locally, without sending
|
||||
anything. The summary card shows the txid (truncated), the **exact fee**, and the virtual
|
||||
size, e.g. `txid 4f3a…| fee 0.00000141 PLM (141 vB)`. Nothing has left your wallet yet;
|
||||
you can change the fields and prepare again, or simply navigate away to abandon it.
|
||||
2. **CONFIRM AND BROADCAST** — transmits the prepared transaction to the server. Success
|
||||
shows the full txid; the form clears and a re-sync starts. After broadcasting, **a
|
||||
transaction cannot be cancelled** — it is in the network's mempool and will confirm.
|
||||
|
||||
### 8.3 How the transaction is built (facts you may need)
|
||||
|
||||
- **Coin selection is automatic** (there is no coin control). The wallet spends from its
|
||||
spendable UTXOs and sends any **change back to its own internal change chain** — this is
|
||||
why, in transaction details, one output to an address you don't recognize as "yours" may
|
||||
still be marked as yours: it is your change.
|
||||
- All transactions **signal RBF** (BIP125) and use transaction version 2. The wallet does
|
||||
not offer fee bumping; if you underpay the fee, wait — with RBF signaled, other wallets
|
||||
could in principle replace it, but this wallet has no UI for that.
|
||||
- **Dust change is absorbed into the fee**: if the change output would be uneconomically
|
||||
small, it is added to the fee instead of creating a dust output. The fee shown in the
|
||||
summary already accounts for this — always check the summary fee before confirming.
|
||||
- A prepared transaction is fully verified against standardness policy before it can be
|
||||
confirmed; a policy failure surfaces as *"Invalid transaction: …"* instead of a broadcast.
|
||||
|
||||
### 8.4 Why "insufficient funds" can appear despite a visible balance
|
||||
|
||||
The error message itemizes the reasons. Funds may be temporarily unspendable because they
|
||||
are:
|
||||
|
||||
- **in the mempool** (unconfirmed — never spendable),
|
||||
- **recently confirmed** (fewer than 6 confirmations),
|
||||
- **immature coinbase** (mining rewards younger than 121 blocks).
|
||||
|
||||
Wait for confirmations and retry. Also remember that the fee itself must fit: sending your
|
||||
exact full balance fails unless you use **Send all**.
|
||||
|
||||
### 8.5 Watch-only wallets and offline signing
|
||||
|
||||
A watch-only (xpub) wallet in the **GUI** can prepare a transaction to preview the fee and
|
||||
size — the summary is tagged *"unsigned (watch-only)"* — but the confirm button stays
|
||||
disabled: **the GUI cannot export the unsigned transaction**. To actually use the
|
||||
watch-only + offline-signing workflow, use the **CLI**, whose `send` command prints an
|
||||
**unsigned PSBT (base64)** for a watch-only wallet
|
||||
([section 17](#17-command-line-interface-cli)). Sign that PSBT on the offline machine with
|
||||
software of your choice, then broadcast the finalized transaction.
|
||||
|
||||
### 8.6 The Donate tab (Help → Donate)
|
||||
|
||||
The Help overlay includes a donation form (developer address
|
||||
`plm1qdq3gu2zvg9lyr8gxd6yln4wavc5tlp8prmvfay`) using the same prepare/confirm flow and the
|
||||
fee rate from the Send tab (an invalid rate is reported as an error). Entirely optional.
|
||||
|
||||
---
|
||||
|
||||
## 9. Transaction history and details
|
||||
|
||||
The **History** tab lists all wallet transactions, newest first; unconfirmed ones are
|
||||
labeled **mempool** and sorted on top, confirmed ones show their **block height**. The
|
||||
amount is the transaction's net effect on your wallet (`+` incoming).
|
||||
|
||||
**Double-click** (desktop) or **tap** (Android) a row to open the full details. This
|
||||
requires a live server connection (details are fetched on demand): otherwise *"Connect to
|
||||
the server to view transaction details."*
|
||||
|
||||
The details overlay shows:
|
||||
|
||||
- **Overview** — status (*"N confirmations (block H)"* or *"0 confirmations · in
|
||||
mempool"*), local date/time, counterparty addresses, and the signed net amount. Mining
|
||||
rewards show *"Newly generated (mining)"* as the sender.
|
||||
- **Amounts & fees** — the absolute fee, your net amount, and the fee rate in sat/vB.
|
||||
- **Technical details** — full transaction ID, total size, virtual size, **Replaceable
|
||||
(RBF)** yes/no, version, locktime.
|
||||
- **Inputs** — each spent outpoint with its address and amount; your own inputs are
|
||||
highlighted. Coinbase inputs display the miner's **scriptSig message** when it is readable
|
||||
text.
|
||||
- **Outputs** — each output with index, address and amount; your own outputs (including
|
||||
change) are highlighted. **OP_RETURN** outputs display their decoded text message when the
|
||||
payload is valid readable text; non-standard outputs show their script type.
|
||||
|
||||
Notes and limits:
|
||||
|
||||
- Confirmation counts advance automatically as blocks arrive (real-time header
|
||||
subscription).
|
||||
- Every **confirmed** transaction in your history has been verified with a Merkle proof
|
||||
during sync. **Unconfirmed** entries are the server's word only — do not treat a mempool
|
||||
payment as received until it confirms.
|
||||
- There are no per-transaction labels and no block-explorer links in this version. The
|
||||
public explorer is at `https://explorer.palladium-coin.com/` — paste the txid there
|
||||
manually if needed.
|
||||
|
||||
---
|
||||
|
||||
## 10. The Addresses tab
|
||||
|
||||
A complete table of every derived address: **type** (receive/change), **index**,
|
||||
**address**, **balance**, and **transaction count**. Before the first sync it shows the
|
||||
first 10 receive addresses with no data.
|
||||
|
||||
Tap (or right-click) a row to open the **address details** overlay:
|
||||
|
||||
- the full address and its **derivation path** (e.g. `m/84'/746'/0'/0/3`),
|
||||
- the **public key** (hex),
|
||||
- the **private key (WIF)**, hidden behind a **"Show"** button. For an encrypted wallet,
|
||||
revealing it requires re-entering the wallet password (*"Enter the wallet password to
|
||||
reveal the private key."*); for an unencrypted wallet it is shown directly. **Never
|
||||
reveal a private key with anyone watching your screen** — one key controls that one
|
||||
address's funds. Watch-only wallets have no private keys to show.
|
||||
|
||||
`change` addresses are where your own transactions send their change
|
||||
([section 8.3](#83-how-the-transaction-is-built-facts-you-may-need)) — seeing balance on
|
||||
them is normal.
|
||||
|
||||
---
|
||||
|
||||
## 11. Contacts
|
||||
|
||||
A simple per-wallet address book, on its own tab:
|
||||
|
||||
- **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.
|
||||
- Contacts are stored **inside the wallet file**. They travel with a file backup, but are
|
||||
**not** recoverable from the seed phrase ([section 15](#15-backup-and-recovery)).
|
||||
|
||||
---
|
||||
|
||||
## 12. Connection, servers and certificate pinning
|
||||
|
||||
### 12.1 Server settings
|
||||
|
||||
Open by clicking the **connection indicator** in the status bar (or Settings → *Indexing
|
||||
server…*, or menu *Network* on desktop). Fields:
|
||||
|
||||
- **Host** and **Port** — defaults: port **50001** (plain TCP) or **50002** (SSL). Editing
|
||||
one of the known ports auto-toggles the SSL switch to match, and toggling SSL swaps the
|
||||
port. Note these are the *indexing server* ports — not the Palladium node's P2P port
|
||||
(2333), which this wallet never uses.
|
||||
- **Use SSL** — default **on**. Strongly recommended: without SSL, traffic (including which
|
||||
addresses you query) is readable on the wire.
|
||||
- **Known servers** list — click an entry to select it. The wallet ships with four bootstrap
|
||||
mainnet servers and remembers any servers learned via discovery.
|
||||
- **Discover servers (peers)** — asks the connected server for its known peers and adds new
|
||||
ones to the list.
|
||||
|
||||
Connection behavior: the wallet tries your selected server first, then walks the rest of
|
||||
the known list until one answers. The last working server is remembered and reused at next
|
||||
launch. A 20-second keep-alive ping detects drops and reconnects automatically;
|
||||
server push notifications trigger instant re-syncs when a new block or a relevant
|
||||
transaction appears (*"Real-time updates active"*).
|
||||
|
||||
### 12.2 TLS certificate pinning (TOFU) — read before "fixing" a certificate error
|
||||
|
||||
SSL connections use **Trust On First Use**: the first time you connect to a server, its
|
||||
certificate's fingerprint is saved (in `server-certs.json`). Every later connection must
|
||||
present the **same** certificate. If it doesn't, the connection is refused with a message
|
||||
stating that the TLS certificate of that server **has changed**.
|
||||
|
||||
This is a security feature, not a bug. A changed certificate means one of two things:
|
||||
|
||||
1. **Benign**: the server operator renewed/rotated the certificate (common, e.g. Let's
|
||||
Encrypt renewals).
|
||||
2. **Hostile**: someone is intercepting your connection (man-in-the-middle).
|
||||
|
||||
You cannot distinguish the two from the wallet alone. If the change is expected or you can
|
||||
verify it out-of-band, clear the pins: menu **Network → Reset SSL certificates** (desktop),
|
||||
the **Reset certs** button in server settings, or `reset-certs` in the CLI. This deletes
|
||||
**all** pinned certificates for the network; the next connection re-pins whatever it sees.
|
||||
If you have any reason to suspect interception, switch networks (e.g. off a public Wi-Fi)
|
||||
or servers instead of resetting.
|
||||
|
||||
### 12.3 What synchronization actually does
|
||||
|
||||
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 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.
|
||||
|
||||
If the server is overloaded (busy responses), the wallet retries automatically up to 8
|
||||
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
|
||||
|
||||
The Settings overlay (gear icon / menu *Settings*) contains exactly two preferences, plus
|
||||
the door to the server settings:
|
||||
|
||||
- **Language** — Italiano, English, Español, Français, Português, Deutsch. Applies
|
||||
immediately. Default: English.
|
||||
- **Amount unit** — how amounts are displayed *and interpreted when you type them*:
|
||||
|
||||
| Unit | In satoshi | Decimals shown |
|
||||
|---|---|---|
|
||||
| PLM (default) | 100,000,000 | 8 |
|
||||
| mPLM | 100,000 | 5 |
|
||||
| µPLM | 100 | 2 |
|
||||
| sat | 1 | 0 |
|
||||
|
||||
⚠ The Send form's amount field uses this unit. If you switch to `sat` and forget, typing
|
||||
`100` means 100 satoshi, not 100 PLM. Double-check the unit before sending.
|
||||
|
||||
Settings are saved globally (per data folder, shared by all wallets), not per wallet.
|
||||
There is no theme selector (the app uses its built-in dark theme) and no fiat display.
|
||||
|
||||
---
|
||||
|
||||
## 14. Wallet Information
|
||||
|
||||
Menu **Wallet → Wallet Information** (desktop) or the equivalent button. Shows:
|
||||
|
||||
- **File name**, **network**, **wallet type** (*HD (BIP39 seed)* / *HD (imported xprv)* /
|
||||
*Imported WIF key* / *Watch-only (xpub)*), **script type**, **derivation path**.
|
||||
- **Extended public key (xpub)** — displayed as selectable text. This is how you *export*
|
||||
the xpub: select and copy it. Give the xpub to another device/app to create a
|
||||
**watch-only** copy of this wallet — it reveals your entire address history and balance to
|
||||
whoever holds it (privacy risk), but can never spend.
|
||||
- **Master fingerprint** — 4-byte identifier of the master key, used in PSBT workflows.
|
||||
- **BIP39 passphrase** — shows only whether one is *set*; the passphrase itself is never
|
||||
displayed anywhere.
|
||||
- **Show seed** — reveals the mnemonic, after re-entering the wallet password (for
|
||||
encrypted wallets). Use it to re-verify your paper backup. The on-screen warning is
|
||||
literal: *never share these words; whoever holds them controls the funds.* Watch-only and
|
||||
imported wallets have no seed to show.
|
||||
|
||||
---
|
||||
|
||||
## 15. Backup and recovery
|
||||
|
||||
### 15.1 What to back up
|
||||
|
||||
There are two complementary backups; know exactly what each one restores:
|
||||
|
||||
| Backup | Restores | Does NOT restore |
|
||||
|---|---|---|
|
||||
| **Seed words on paper** (+ passphrase if set, + script type) | All keys, addresses, balance and on-chain history, on any device, forever | Contacts; wallet name; settings |
|
||||
| **The wallet file** (+ its password) | Everything, including contacts | — |
|
||||
|
||||
Recommended: **always** have the paper seed (it survives disk failure, device loss, and
|
||||
software obsolescence); *additionally* copy the `.wallet.json` file if you care about your
|
||||
contacts list. An encrypted wallet file is safe to store on ordinary media — but it is only
|
||||
as strong as its password, and it is useless without it.
|
||||
|
||||
For a **watch-only** wallet, the file only restores the watching capability; the actual
|
||||
keys live wherever you keep the corresponding seed/xprv — back *that* up.
|
||||
|
||||
For an **imported WIF** wallet, back up the WIF keys themselves; they are not derivable from
|
||||
anything else.
|
||||
|
||||
### 15.2 Restoring, step by step
|
||||
|
||||
1. Install the wallet on the new device.
|
||||
2. Wizard → **Restore from seed** → enter the words → enter the **same passphrase** (or
|
||||
leave empty if none) → choose the **same script type** → set a (new) password.
|
||||
3. Let it synchronize. Balance and history are rebuilt from the chain.
|
||||
|
||||
If the balance is zero: wrong passphrase, or wrong script type, or (rare) funds beyond the
|
||||
gap limit ([section 7](#7-receiving-funds)). Nothing is lost — the coins are still on the
|
||||
chain; you are simply looking at the wrong derived wallet. Retry with the right parameters.
|
||||
|
||||
Restoring from a **file copy** instead: place the `.wallet.json` into
|
||||
`<data folder>/mainnet/wallets/` and open it with its password.
|
||||
|
||||
### 15.3 Unrecoverable situations — be aware
|
||||
|
||||
- Seed lost **and** wallet file (or its password) lost → funds gone. No exceptions.
|
||||
- Passphrase forgotten → funds gone, even with the 12 words in hand.
|
||||
- Encrypted file's password forgotten, no seed backup → funds gone (the encryption has no
|
||||
backdoor).
|
||||
|
||||
---
|
||||
|
||||
## 16. Security model and known limitations
|
||||
|
||||
A condensed version of the project's published threat model (`SECURITY.md`).
|
||||
|
||||
**The wallet protects you against:**
|
||||
|
||||
- Theft of the wallet file at rest (AES-256-GCM + PBKDF2, [section 2.5](#25-the-wallet-file-and-its-encryption)) —
|
||||
provided you set a good password.
|
||||
- A **lying indexing server**: it cannot fabricate a confirmed transaction or forge a
|
||||
payment to a wrong address, because every confirmed transaction must carry a valid Merkle
|
||||
proof, which the wallet checks itself.
|
||||
- **Silent TLS interception after first contact** (certificate pinning,
|
||||
[section 12.2](#122-tls-certificate-pinning-tofu--read-before-fixing-a-certificate-error)).
|
||||
|
||||
**The server can still (semi-trusted component):**
|
||||
|
||||
- lie about **unconfirmed** transactions (which is why they are marked not-spendable and
|
||||
should not be trusted until confirmed);
|
||||
- **withhold** information — hide transactions, delay new blocks, refuse to broadcast;
|
||||
- **observe** which addresses belong to you (no Tor/proxy support in this version; using
|
||||
your own indexing server is the strongest mitigation).
|
||||
|
||||
**The wallet cannot protect you against:**
|
||||
|
||||
- malware on your device (anything that can read process memory can take the keys once the
|
||||
wallet is unlocked);
|
||||
- an attacker holding both your wallet file **and** its password (or your seed);
|
||||
- yourself: there are no confirmation "cool-downs", no address whitelists, and broadcast is
|
||||
irreversible.
|
||||
|
||||
**Honest limitations of this version** (also listed in [section 1](#1-what-palladium-wallet-is-and-is-not)):
|
||||
single server connection (no pooling), no coin control, no fee estimation, no RBF bumping,
|
||||
no hardware wallets, no Tor, GUI mainnet-only, and PSBT export only via the CLI.
|
||||
|
||||
---
|
||||
|
||||
## 17. Command-line interface (CLI)
|
||||
|
||||
The CLI (`src/Cli`) runs on the same core as the GUI: same wallet files, same validation,
|
||||
same security. It is desktop-only and its console output is in English. Run it
|
||||
as `dotnet run --project src/Cli -- <command>` from the repository (or the published `Cli`
|
||||
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).
|
||||
|
||||
**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.
|
||||
|
||||
### Wallet commands
|
||||
|
||||
```
|
||||
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] [--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
|
||||
restore).
|
||||
|
||||
```
|
||||
restore "<mnemonic>" [same options as create]
|
||||
```
|
||||
Restores from an existing mnemonic (quoted, words space-separated).
|
||||
|
||||
```
|
||||
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] [--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.
|
||||
`--addresses` adds the per-address list with balances.
|
||||
|
||||
```
|
||||
addresses "<mnemonic>" [--kind K] [--net N] [--count N] [--passphrase W] [--path m/...]
|
||||
```
|
||||
Stateless tool: derives and prints receive (default 5) and change addresses from a mnemonic
|
||||
without creating any file. Useful to check "which script type was this seed used with"
|
||||
before restoring.
|
||||
|
||||
### Network commands
|
||||
|
||||
```
|
||||
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
|
||||
prints balance and history. Transactions the server reported but that are unconfirmed are
|
||||
marked as unverified.
|
||||
|
||||
```
|
||||
send --to ADDRESS (--amount X | --all) [--feerate R] [--broadcast]
|
||||
[--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**.
|
||||
Behavior by wallet type and flags:
|
||||
|
||||
- **Watch-only** wallet → prints the **unsigned PSBT in base64** and stops. This is the
|
||||
air-gapped workflow: carry the PSBT to the offline signer, sign it there, broadcast the
|
||||
final transaction by any means.
|
||||
- Spendable wallet, **without `--broadcast`** → signs and prints the raw transaction hex,
|
||||
explicitly marked as *not transmitted*. Nothing is sent — a dry run you can inspect or
|
||||
broadcast elsewhere.
|
||||
- Spendable wallet, **with `--broadcast`** → signs, transmits, prints the txid. Irreversible
|
||||
from this point.
|
||||
|
||||
```
|
||||
servers [--discover] [--server ...] [--ssl] [--net N]
|
||||
```
|
||||
Lists known servers; with `--discover`, connects and asks the server for its peers, adding
|
||||
new ones.
|
||||
|
||||
```
|
||||
reset-certs [--net N]
|
||||
```
|
||||
Deletes all pinned TLS certificates for the network (the CLI counterpart of *Network →
|
||||
Reset SSL certificates* — see
|
||||
[section 12.2](#122-tls-certificate-pinning-tofu--read-before-fixing-a-certificate-error)).
|
||||
|
||||
---
|
||||
|
||||
## 18. Troubleshooting and error messages
|
||||
|
||||
| Symptom / message | Meaning | What to do |
|
||||
|---|---|---|
|
||||
| *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. |
|
||||
| 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. |
|
||||
| *Extended key not recognised for this network.* | The pasted xpub/xprv is not a SLIP-132 key of this network. | Check the key's prefix and the source wallet's export format. |
|
||||
| *A wallet with this name already exists.* | Filename collision in the wallets folder. | Pick another name, or leave blank for an automatic one. |
|
||||
| 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 (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). |
|
||||
|
||||
---
|
||||
|
||||
## 19. Glossary
|
||||
|
||||
- **SPV** — Simplified Payment Verification: validating that transactions are included in
|
||||
blocks via Merkle proofs, without running a full node.
|
||||
- **Seed / mnemonic** — the 12 (or 24) BIP39 words that deterministically generate every key
|
||||
in the wallet.
|
||||
- **Passphrase (BIP39)** — optional extra secret combined with the seed; a different
|
||||
passphrase yields a different wallet.
|
||||
- **Script type** — the address format standard (Legacy/BIP44, Wrapped SegWit/BIP49, Native
|
||||
SegWit/BIP84, Taproot/BIP86).
|
||||
- **xpub / xprv** — extended public/private key of the wallet's account; the xpub watches,
|
||||
the xprv spends. SLIP-132 variants: ypub/zpub etc.
|
||||
- **WIF** — Wallet Import Format: a single address's private key as text.
|
||||
- **UTXO** — Unspent Transaction Output: a discrete "coin" the wallet can spend.
|
||||
- **Change** — the portion of spent UTXOs returned to your own (internal) addresses.
|
||||
- **Coinbase** — the transaction paying the miner of a block; spendable after 121
|
||||
confirmations.
|
||||
- **Mempool** — the set of broadcast-but-unconfirmed transactions.
|
||||
- **Gap limit** — how many consecutive unused addresses the wallet scans past before
|
||||
stopping (20).
|
||||
- **PSBT** — Partially Signed Bitcoin Transaction: the standard interchange format for
|
||||
signing a transaction on a device other than the one that built it.
|
||||
- **RBF** — Replace-By-Fee: a signal that a transaction may be replaced by a higher-fee
|
||||
version while unconfirmed.
|
||||
- **TOFU** — Trust On First Use: pinning a server's TLS certificate at first contact and
|
||||
refusing silent changes afterwards.
|
||||
- **Watch-only** — a wallet holding only public keys: full visibility, zero spending
|
||||
ability.
|
||||
|
||||
---
|
||||
|
||||
*This guide documents observed behavior of the software and is kept in sync with the code.
|
||||
For the formal threat model see [SECURITY.md](SECURITY.md); for per-release changes see
|
||||
[CHANGELOG.md](CHANGELOG.md).*
|
||||
@@ -0,0 +1,39 @@
|
||||
FROM mcr.microsoft.com/dotnet/sdk:10.0
|
||||
|
||||
# ── Java (required by the Android SDK tools) ─────────────────────────────────
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
openjdk-17-jdk-headless \
|
||||
wget \
|
||||
unzip \
|
||||
&& apt-get clean && rm -rf /var/lib/apt/lists/*
|
||||
|
||||
ENV JAVA_HOME=/usr/lib/jvm/java-17-openjdk-amd64
|
||||
ENV ANDROID_HOME=/opt/android-sdk
|
||||
# .NET 10 android workload compiles against API level 36 (see error XA5207
|
||||
# if this drifts: the workload's Xamarin.Android.Tooling.targets dictates it).
|
||||
ENV ANDROID_SDK_PLATFORM=36
|
||||
ENV ANDROID_SDK_BUILD_TOOLS=36.0.0
|
||||
|
||||
# ── Android command-line tools ────────────────────────────────────────────────
|
||||
# Pin the build number to a known-good release; update when Google breaks the URL.
|
||||
# Latest as of 2025: commandlinetools-linux-11076708_latest.zip (cmdline-tools 12.0)
|
||||
RUN mkdir -p "${ANDROID_HOME}/cmdline-tools" && \
|
||||
wget -q "https://dl.google.com/android/repository/commandlinetools-linux-11076708_latest.zip" \
|
||||
-O /tmp/cmdtools.zip && \
|
||||
unzip -q /tmp/cmdtools.zip -d "${ANDROID_HOME}/cmdline-tools" && \
|
||||
mv "${ANDROID_HOME}/cmdline-tools/cmdline-tools" "${ANDROID_HOME}/cmdline-tools/latest" && \
|
||||
rm /tmp/cmdtools.zip
|
||||
|
||||
ENV PATH="${JAVA_HOME}/bin:${ANDROID_HOME}/cmdline-tools/latest/bin:${ANDROID_HOME}/platform-tools:${PATH}"
|
||||
|
||||
# ── Android SDK packages ──────────────────────────────────────────────────────
|
||||
RUN yes | sdkmanager --licenses > /dev/null && \
|
||||
sdkmanager \
|
||||
"platform-tools" \
|
||||
"platforms;android-${ANDROID_SDK_PLATFORM}" \
|
||||
"build-tools;${ANDROID_SDK_BUILD_TOOLS}"
|
||||
|
||||
# ── dotnet android workload (~1 GB, baked into this layer) ───────────────────
|
||||
RUN dotnet workload install android
|
||||
|
||||
WORKDIR /tmp/build
|
||||
@@ -0,0 +1,11 @@
|
||||
FROM mcr.microsoft.com/dotnet/sdk:10.0
|
||||
|
||||
# clang and zlib are required by the .NET SDK for native compilation steps
|
||||
# that occur even during managed cross-publish (win-x64 / linux-x64).
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
clang \
|
||||
zlib1g-dev \
|
||||
libkrb5-dev \
|
||||
&& apt-get clean && rm -rf /var/lib/apt/lists/*
|
||||
|
||||
WORKDIR /tmp/build
|
||||
@@ -0,0 +1,211 @@
|
||||
# PalladiumWallet — Reproducible Builds via Docker
|
||||
|
||||
This folder builds all three distribution targets — **Windows**, **Linux**,
|
||||
**Android** — inside Docker containers. The entire toolchain (.NET 10 SDK,
|
||||
JDK, Android SDK, android workload) lives in the container images, pinned in
|
||||
the Dockerfiles, so:
|
||||
|
||||
- **anyone can produce the official binaries** with a single command, on any
|
||||
Linux machine, without installing any SDK on the host;
|
||||
- **the toolchain never drifts**: every build uses exactly the same SDK
|
||||
versions, regardless of what is installed (or updated) on the host;
|
||||
- **the build environment is auditable**: the Dockerfiles in this folder are
|
||||
the complete, reviewable definition of how release binaries are made — which
|
||||
matters for a wallet, where users must be able to trust that the shipped
|
||||
binary comes from the published source.
|
||||
|
||||
> **Scope note:** this pins the *build environment*. Bit-for-bit identical
|
||||
> output across machines is a stronger property that .NET does not guarantee
|
||||
> by default (embedded timestamps, signing); if two builds of the same commit
|
||||
> differ, they differ only in those metadata, not in code.
|
||||
|
||||
---
|
||||
|
||||
## Prerequisites
|
||||
|
||||
A Linux host (native, WSL2, or a VM) with **Docker Engine** installed and
|
||||
running. Nothing else — no .NET SDK, no JDK, no Android SDK.
|
||||
|
||||
```bash
|
||||
# Check that Docker works:
|
||||
docker info
|
||||
```
|
||||
|
||||
If Docker is missing, install it from <https://docs.docker.com/engine/install/>
|
||||
(or `sudo apt install docker.io` on Debian/Ubuntu). If `docker info` fails
|
||||
with a permission error, add yourself to the `docker` group and start a new
|
||||
shell:
|
||||
|
||||
```bash
|
||||
sudo usermod -aG docker $USER
|
||||
```
|
||||
|
||||
**Disk space:** ~2 GB for the desktop image, ~7 GB for the Android image
|
||||
(SDK + emulator-less toolchain). **First-run time:** the images are built
|
||||
automatically on first use — a few minutes for desktop, 10–20 minutes for
|
||||
Android (large downloads). Subsequent builds reuse the cached images and take
|
||||
well under a minute (desktop) / a few minutes (Android).
|
||||
|
||||
**Android release signing:** before the first `android` build, generate the
|
||||
persistent signing keystore once — see [`keystore/README.md`](keystore/README.md):
|
||||
|
||||
```bash
|
||||
./docker/keystore/generate-keystore.sh
|
||||
```
|
||||
|
||||
Without it, `build_android` refuses to run — every APK must be signed with
|
||||
the same key so future releases can update a previous install in place.
|
||||
|
||||
---
|
||||
|
||||
## Quick start
|
||||
|
||||
```bash
|
||||
# From the repository root (or from the docker/ folder — both work):
|
||||
./docker/build.sh
|
||||
```
|
||||
|
||||
Running without arguments shows an interactive menu — pick a single target or
|
||||
`all`. Non-interactive usage:
|
||||
|
||||
```
|
||||
./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)
|
||||
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)
|
||||
```
|
||||
|
||||
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
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Output — and how to use each artifact
|
||||
|
||||
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` |
|
||||
| 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.
|
||||
The first launch takes a few extra seconds (it unpacks native libraries to a
|
||||
per-user cache); later launches are normal. SmartScreen may warn because the
|
||||
binary is not code-signed — choose "Run anyway".
|
||||
|
||||
**Linux** — a single self-contained binary, already executable. Copy and run:
|
||||
|
||||
```bash
|
||||
./PalladiumWallet-{ver}-linux-x64
|
||||
```
|
||||
|
||||
Works on any desktop distro with glibc, X11/Wayland and fontconfig (i.e.
|
||||
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+
|
||||
(API 23), arm64 phones and x86_64 emulators.
|
||||
|
||||
> **Signature:** every APK is signed with the persistent keystore in
|
||||
> `docker/keystore/` (see [Prerequisites](#prerequisites)), so installing a
|
||||
> newer build over an existing one updates it in place — no uninstall, no
|
||||
> data loss. This only holds as long as every release keeps using that same
|
||||
> keystore file; see `docker/keystore/README.md` for the backup story.
|
||||
|
||||
---
|
||||
|
||||
## How it works
|
||||
|
||||
### Docker images
|
||||
|
||||
| Image | Dockerfile | Used for | Size |
|
||||
|---------------------|----------------------|-----------------|---------|
|
||||
| `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
|
||||
afterwards. Use `--rebuild` only after modifying a Dockerfile.
|
||||
|
||||
### Source isolation
|
||||
|
||||
The repository is mounted **read-only** inside the container; the build works
|
||||
on a copy at `/tmp/build`. Your working tree is never touched — no stray
|
||||
`bin/`/`obj/` directories, and a dirty working tree doesn't leak into the
|
||||
build beyond the files it contains. Artifacts are written back through a
|
||||
bind mount to `dist/` and chown'd to your user.
|
||||
|
||||
### NuGet cache
|
||||
|
||||
A Docker named volume `plm-nuget-cache` holds downloaded NuGet packages
|
||||
across builds. To reclaim the space or force a clean re-download:
|
||||
|
||||
```bash
|
||||
docker volume rm plm-nuget-cache
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
- **`Docker daemon is not running`** — start it (`sudo systemctl start
|
||||
docker`; on WSL2, start Docker Desktop or the docker service).
|
||||
- **Android image build fails downloading `commandlinetools`** — Google
|
||||
rotates the build number in the URL. Update the URL in
|
||||
`Dockerfile.android` to the current one from
|
||||
<https://developer.android.com/studio#command-tools> and rerun with
|
||||
`--rebuild`.
|
||||
- **`error XA5207: Could not find android.jar for API level N`** — the .NET
|
||||
android workload moved to a newer API level. Bump
|
||||
`ANDROID_SDK_PLATFORM` (and `ANDROID_SDK_BUILD_TOOLS`) in
|
||||
`Dockerfile.android` to the level the error names, then `--rebuild`.
|
||||
- **APK won't install over an existing app
|
||||
(`INSTALL_FAILED_UPDATE_INCOMPATIBLE`)** — the new build wasn't signed with
|
||||
the same keystore as the installed one. Make sure `docker/keystore/release.keystore`
|
||||
hasn't changed since the installed build; if it's genuinely a different key,
|
||||
the user must uninstall the old app first (this deletes app data — back up
|
||||
the wallet seed before doing this).
|
||||
- **`build_android` refuses to run, asks to generate a keystore** — run
|
||||
`./docker/keystore/generate-keystore.sh` once (see `docker/keystore/README.md`).
|
||||
- **Everything is broken / start from scratch** —
|
||||
`docker system prune -a && docker volume rm plm-nuget-cache`, then rerun
|
||||
the script (images and packages are re-downloaded).
|
||||
|
||||
---
|
||||
|
||||
## Linux AppImage (future)
|
||||
|
||||
The Linux target currently produces a single-file self-contained binary.
|
||||
Once a `pupnet.conf` is added to the repository, the `build_linux` function
|
||||
in `build.sh` can be extended to call PupNet Deploy inside the same
|
||||
`plm-build-desktop` image to also produce an AppImage with desktop
|
||||
integration (icon, menu entry).
|
||||
@@ -0,0 +1,256 @@
|
||||
#!/usr/bin/env bash
|
||||
# Reproducible build script for PalladiumWallet using Docker.
|
||||
# Run from anywhere; paths are resolved relative to this script.
|
||||
set -euo pipefail
|
||||
|
||||
# ── Paths ─────────────────────────────────────────────────────────────────────
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
PROJECT_ROOT="$(cd "${SCRIPT_DIR}/.." && pwd)"
|
||||
DIST_DIR="${PROJECT_ROOT}/dist"
|
||||
|
||||
# ── Docker image names ────────────────────────────────────────────────────────
|
||||
IMAGE_DESKTOP="plm-build-desktop"
|
||||
IMAGE_ANDROID="plm-build-android"
|
||||
|
||||
# Named volume for NuGet package cache (shared across all builds, survives reboots).
|
||||
NUGET_VOLUME="plm-nuget-cache"
|
||||
|
||||
# ── Helpers ───────────────────────────────────────────────────────────────────
|
||||
bold() { printf '\033[1m%s\033[0m\n' "$*"; }
|
||||
info() { printf '\033[1;34m>>>\033[0m %s\n' "$*"; }
|
||||
ok() { printf '\033[1;32m✓\033[0m %s\n' "$*"; }
|
||||
err() { printf '\033[1;31m✗\033[0m %s\n' "$*" >&2; }
|
||||
die() { err "$*"; exit 1; }
|
||||
|
||||
usage() {
|
||||
cat <<EOF
|
||||
$(bold "Usage:") $(basename "$0") [TARGET] [OPTIONS]
|
||||
|
||||
$(bold "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)
|
||||
-h, --help Show this help
|
||||
|
||||
$(bold "Examples:")
|
||||
$(basename "$0") # interactive menu
|
||||
$(basename "$0") all # build everything
|
||||
$(basename "$0") windows # Windows only
|
||||
$(basename "$0") android --rebuild
|
||||
EOF
|
||||
}
|
||||
|
||||
# ── Argument parsing ──────────────────────────────────────────────────────────
|
||||
REBUILD=false
|
||||
TARGET=""
|
||||
|
||||
for arg in "$@"; do
|
||||
case "$arg" in
|
||||
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
|
||||
|
||||
# ── Interactive menu if no target given ───────────────────────────────────────
|
||||
if [[ -z "$TARGET" ]]; then
|
||||
bold "PalladiumWallet — reproducible build"
|
||||
echo ""
|
||||
PS3="Select target: "
|
||||
options=("windows" "linux" "linux-arm64" "android" "all" "quit")
|
||||
select opt in "${options[@]}"; do
|
||||
case "$opt" in
|
||||
windows|linux|linux-arm64|android|all) TARGET="$opt"; break ;;
|
||||
quit) echo "Aborted."; exit 0 ;;
|
||||
*) echo "Invalid choice, try again." ;;
|
||||
esac
|
||||
done
|
||||
echo ""
|
||||
fi
|
||||
|
||||
# ── Preflight checks ──────────────────────────────────────────────────────────
|
||||
command -v docker &>/dev/null || die "Docker is not installed or not in PATH."
|
||||
docker info &>/dev/null || die "Docker daemon is not running."
|
||||
|
||||
# ── Read project version from csproj ─────────────────────────────────────────
|
||||
CSPROJ="${PROJECT_ROOT}/src/App/PalladiumWallet.App.csproj"
|
||||
[[ -f "$CSPROJ" ]] || die "Cannot find $CSPROJ — run this script from the repo."
|
||||
VERSION=$(grep -oP '(?<=<Version>)[^<]+' "$CSPROJ") || die "Cannot extract <Version> from $CSPROJ."
|
||||
info "Version: ${VERSION}"
|
||||
|
||||
# ── Ensure NuGet volume exists ────────────────────────────────────────────────
|
||||
docker volume inspect "$NUGET_VOLUME" &>/dev/null || \
|
||||
docker volume create "$NUGET_VOLUME" > /dev/null
|
||||
|
||||
# ── Image builders ────────────────────────────────────────────────────────────
|
||||
ensure_desktop_image() {
|
||||
if [[ "$REBUILD" == true ]] || ! docker image inspect "$IMAGE_DESKTOP" &>/dev/null; then
|
||||
info "Building Docker image: ${IMAGE_DESKTOP}"
|
||||
docker build \
|
||||
--tag "$IMAGE_DESKTOP" \
|
||||
--file "${SCRIPT_DIR}/Dockerfile.desktop" \
|
||||
"${SCRIPT_DIR}"
|
||||
fi
|
||||
}
|
||||
|
||||
ensure_android_image() {
|
||||
if [[ "$REBUILD" == true ]] || ! docker image inspect "$IMAGE_ANDROID" &>/dev/null; then
|
||||
info "Building Docker image: ${IMAGE_ANDROID}"
|
||||
docker build \
|
||||
--tag "$IMAGE_ANDROID" \
|
||||
--file "${SCRIPT_DIR}/Dockerfile.android" \
|
||||
"${SCRIPT_DIR}"
|
||||
fi
|
||||
}
|
||||
|
||||
# ── Common docker run wrapper ─────────────────────────────────────────────────
|
||||
# $1 = image name, $2 = inline bash commands to execute inside the container.
|
||||
# Source is copied to /tmp/build inside the container so bin/obj never pollute
|
||||
# the repo. Output is written to /output (→ host DIST_DIR sub-folder).
|
||||
# Optional extra `docker run` args (e.g. keystore mount, signing passwords via
|
||||
# -e) can be set by the caller in the EXTRA_DOCKER_ARGS array beforehand.
|
||||
EXTRA_DOCKER_ARGS=()
|
||||
|
||||
run_build() {
|
||||
local image="$1"
|
||||
local commands="$2"
|
||||
local out_dir="$3"
|
||||
|
||||
mkdir -p "$out_dir"
|
||||
|
||||
docker run --rm \
|
||||
--volume "${PROJECT_ROOT}:/src:ro" \
|
||||
--volume "${out_dir}:/output" \
|
||||
--volume "${NUGET_VOLUME}:/root/.nuget/packages" \
|
||||
"${EXTRA_DOCKER_ARGS[@]}" \
|
||||
"$image" \
|
||||
bash -euo pipefail -c "
|
||||
cp -r /src/. /tmp/build
|
||||
cd /tmp/build
|
||||
${commands}
|
||||
chown -R $(id -u):$(id -g) /output
|
||||
"
|
||||
}
|
||||
|
||||
# ── Per-target build functions ────────────────────────────────────────────────
|
||||
build_windows() {
|
||||
ensure_desktop_image
|
||||
info "Building Windows x64 …"
|
||||
run_build "$IMAGE_DESKTOP" \
|
||||
"dotnet publish src/App.Desktop \
|
||||
-r win-x64 \
|
||||
-c Release \
|
||||
-p:PublishSingleFile=true \
|
||||
-p:IncludeNativeLibrariesForSelfExtract=true \
|
||||
--self-contained \
|
||||
-o /tmp/win-out
|
||||
cp /tmp/win-out/PalladiumWallet.exe \
|
||||
\"/output/PalladiumWallet-${VERSION}-win-x64.exe\"" \
|
||||
"${DIST_DIR}/windows"
|
||||
ok "Windows → dist/windows/PalladiumWallet-${VERSION}-win-x64.exe"
|
||||
}
|
||||
|
||||
build_linux() {
|
||||
ensure_desktop_image
|
||||
info "Building Linux x64 …"
|
||||
run_build "$IMAGE_DESKTOP" \
|
||||
"dotnet publish src/App.Desktop \
|
||||
-r linux-x64 \
|
||||
-c Release \
|
||||
-p:PublishSingleFile=true \
|
||||
-p:IncludeNativeLibrariesForSelfExtract=true \
|
||||
--self-contained \
|
||||
-o /tmp/linux-out
|
||||
install -m 755 /tmp/linux-out/PalladiumWallet \
|
||||
\"/output/PalladiumWallet-${VERSION}-linux-x64\"" \
|
||||
"${DIST_DIR}/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
|
||||
|
||||
local keystore="${SCRIPT_DIR}/keystore/release.keystore"
|
||||
[[ -f "$keystore" ]] || die "No release keystore found at docker/keystore/release.keystore. Run ./docker/keystore/generate-keystore.sh once first (see docker/keystore/README.md)."
|
||||
|
||||
info "Building Android APK …"
|
||||
read -r -s -p "Keystore password: " ANDROID_KS_PASS
|
||||
echo
|
||||
read -r -s -p "Key password (press Enter to reuse the keystore password): " ANDROID_KEY_PASS
|
||||
echo
|
||||
ANDROID_KEY_PASS="${ANDROID_KEY_PASS:-$ANDROID_KS_PASS}"
|
||||
|
||||
# versionCode derived from <Version> (MAJOR.MINOR.PATCH, pre-release suffix
|
||||
# stripped) so it always increases with releases — required for some
|
||||
# installers to accept an in-place update even when the signature matches.
|
||||
local semver="${VERSION%%-*}"
|
||||
IFS='.' read -r VMAJOR VMINOR VPATCH <<< "$semver"
|
||||
local VERSION_CODE=$(( ${VMAJOR:-0} * 10000 + ${VMINOR:-0} * 100 + ${VPATCH:-0} ))
|
||||
info "versionCode: ${VERSION_CODE}"
|
||||
|
||||
EXTRA_DOCKER_ARGS=(
|
||||
--volume "${keystore}:/keystore/release.keystore:ro"
|
||||
--env "ANDROID_KS_PASS=${ANDROID_KS_PASS}"
|
||||
--env "ANDROID_KEY_PASS=${ANDROID_KEY_PASS}"
|
||||
)
|
||||
run_build "$IMAGE_ANDROID" \
|
||||
"JAVA_HOME=/usr/lib/jvm/java-17-openjdk-amd64 \
|
||||
dotnet build src/App.Android \
|
||||
-c Release \
|
||||
-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} \
|
||||
-p:AndroidSigningKeyPass=\${ANDROID_KEY_PASS}
|
||||
cp src/App.Android/bin/Release/net10.0-android/*-Signed.apk \
|
||||
\"/output/PalladiumWallet-${VERSION}.apk\"" \
|
||||
"${DIST_DIR}/android"
|
||||
EXTRA_DOCKER_ARGS=()
|
||||
ok "Android → dist/android/PalladiumWallet-${VERSION}.apk"
|
||||
}
|
||||
|
||||
# ── Dispatch ──────────────────────────────────────────────────────────────────
|
||||
START=$(date +%s)
|
||||
|
||||
case "$TARGET" in
|
||||
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
|
||||
|
||||
ELAPSED=$(( $(date +%s) - START ))
|
||||
bold ""
|
||||
bold "Done in ${ELAPSED}s. Artifacts in: ${DIST_DIR}/"
|
||||
@@ -0,0 +1,37 @@
|
||||
# Android release-signing keystore
|
||||
|
||||
This folder holds the persistent keystore used to sign release APKs built by
|
||||
`docker/build.sh android`. Every release must be signed with the **same**
|
||||
key, or Android refuses to install a new build over a previously installed
|
||||
one (`INSTALL_FAILED_UPDATE_INCOMPATIBLE`) and forces the user to uninstall
|
||||
first — which deletes the app's data.
|
||||
|
||||
## First-time setup
|
||||
|
||||
```bash
|
||||
./docker/keystore/generate-keystore.sh
|
||||
```
|
||||
|
||||
Run this **once**, on whichever machine will keep producing release builds.
|
||||
It creates `release.keystore` in this folder and asks for a keystore
|
||||
password (and, optionally, a separate key password). Nothing is written to
|
||||
git — `release.keystore` is ignored by `.gitignore`, and the script never
|
||||
saves the passwords anywhere.
|
||||
|
||||
`docker/build.sh android` will refuse to run without this file, and will
|
||||
prompt you for the same passwords at every build.
|
||||
|
||||
## Back it up
|
||||
|
||||
The keystore file and its passwords cannot be regenerated: if lost, no
|
||||
future release can ever update existing installs in place again — every
|
||||
user would need to uninstall and reinstall from scratch. Copy
|
||||
`release.keystore` and the passwords to a password manager or encrypted
|
||||
backup outside this repository as soon as you generate them.
|
||||
|
||||
## What NOT to do
|
||||
|
||||
- Do not commit `release.keystore` (or any `*.jks`/`*.keystore` file) to git.
|
||||
- Do not re-run `generate-keystore.sh` once a keystore already exists — it
|
||||
refuses on purpose; delete the file yourself only if you fully accept the
|
||||
consequences above.
|
||||
@@ -0,0 +1,65 @@
|
||||
#!/usr/bin/env bash
|
||||
# Generates the persistent Android release-signing keystore used by
|
||||
# docker/build.sh. Every APK built with this keystore carries the same
|
||||
# signature, which is what lets Android treat a new release as an update to
|
||||
# a previously installed one instead of a conflicting, different app.
|
||||
#
|
||||
# Run this ONCE. Re-running it after the keystore already exists is refused
|
||||
# below on purpose: regenerating it changes the signature, and every device
|
||||
# with a prior release installed would then need a full uninstall (and lose
|
||||
# any app data that isn't in the wallet file itself) to receive the next
|
||||
# update.
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
KEYSTORE_PATH="${SCRIPT_DIR}/release.keystore"
|
||||
ALIAS="palladiumwallet"
|
||||
|
||||
if [[ -f "$KEYSTORE_PATH" ]]; then
|
||||
echo "A keystore already exists at $KEYSTORE_PATH — refusing to overwrite it." >&2
|
||||
echo "Delete it manually first only if you understand the consequences above." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
command -v keytool &>/dev/null || {
|
||||
echo "keytool not found on PATH — install a JDK (e.g. openjdk-17-jdk) and retry." >&2
|
||||
exit 1
|
||||
}
|
||||
|
||||
echo "Creating the Palladium Wallet Android release-signing keystore."
|
||||
echo "This file and the passwords you enter now must be kept safe OUTSIDE this"
|
||||
echo "repository (password manager, encrypted backup, ...). They are never"
|
||||
echo "committed to git. Losing them means no future release can ever update"
|
||||
echo "existing installs in place — only a fresh keystore + full user reinstall."
|
||||
echo
|
||||
|
||||
read -r -s -p "Keystore password (min 6 characters): " KS_PASS
|
||||
echo
|
||||
read -r -s -p "Confirm keystore password: " KS_PASS_CONFIRM
|
||||
echo
|
||||
if [[ "$KS_PASS" != "$KS_PASS_CONFIRM" ]]; then
|
||||
echo "Passwords do not match." >&2
|
||||
exit 1
|
||||
fi
|
||||
if [[ "${#KS_PASS}" -lt 6 ]]; then
|
||||
echo "Password too short (keytool requires at least 6 characters)." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
read -r -s -p "Key password (press Enter to reuse the keystore password): " KEY_PASS
|
||||
echo
|
||||
KEY_PASS="${KEY_PASS:-$KS_PASS}"
|
||||
|
||||
export KS_PASS KEY_PASS
|
||||
keytool -genkeypair -v \
|
||||
-keystore "$KEYSTORE_PATH" \
|
||||
-alias "$ALIAS" \
|
||||
-keyalg RSA -keysize 2048 -validity 10000 \
|
||||
-storepass:env KS_PASS -keypass:env KEY_PASS \
|
||||
-dname "CN=PalladiumWallet, OU=PalladiumWallet, O=PalladiumWallet, C=IT"
|
||||
unset KS_PASS KEY_PASS
|
||||
|
||||
echo
|
||||
echo "Keystore created at $KEYSTORE_PATH (git-ignored, alias: $ALIAS)."
|
||||
echo "Back it up now, together with the passwords — it cannot be regenerated."
|
||||
echo "./docker/build.sh android will prompt for these same passwords at build time."
|
||||
@@ -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
|
||||
@@ -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.
|
||||
@@ -0,0 +1,59 @@
|
||||
using System.Threading.Tasks;
|
||||
using Android.App;
|
||||
using Android.Content;
|
||||
using Android.Content.PM;
|
||||
using Android.OS;
|
||||
using Avalonia.Android;
|
||||
using AvaloniaApp = PalladiumWallet.App.App;
|
||||
|
||||
namespace PalladiumWallet.Mobile;
|
||||
|
||||
[Activity(
|
||||
Label = "Palladium Wallet",
|
||||
Theme = "@style/MyTheme.NoActionBar",
|
||||
MainLauncher = true,
|
||||
ConfigurationChanges = ConfigChanges.Orientation | ConfigChanges.ScreenSize | ConfigChanges.UiMode)]
|
||||
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)
|
||||
{
|
||||
base.OnCreate(savedInstanceState);
|
||||
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);
|
||||
if (requestCode == ScanRequestCode)
|
||||
{
|
||||
var text = resultCode == Result.Ok ? data?.GetStringExtra(ScannerActivity.ResultKey) : null;
|
||||
ScanTcs?.TrySetResult(text);
|
||||
ScanTcs = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
using System;
|
||||
using System.Threading.Tasks;
|
||||
using Android.App;
|
||||
using Android.Content;
|
||||
using Android.Runtime;
|
||||
using Avalonia;
|
||||
using Avalonia.Android;
|
||||
using PalladiumWallet.App.Services;
|
||||
using PalladiumWallet.Core.Storage;
|
||||
|
||||
namespace PalladiumWallet.Mobile;
|
||||
|
||||
// In Avalonia 12 the Android AppBuilder is configured in the Application subclass
|
||||
// (AvaloniaAndroidApplication<TApp>), no longer in the Activity. allowBackup=false:
|
||||
// the encrypted wallet file/seed must not end up in automatic cloud backups.
|
||||
[Application(Label = "Palladium Wallet", AllowBackup = false,
|
||||
Icon = "@mipmap/ic_launcher", RoundIcon = "@mipmap/ic_launcher_round")]
|
||||
public class MainApplication : AvaloniaAndroidApplication<global::PalladiumWallet.App.App>
|
||||
{
|
||||
public MainApplication(IntPtr javaReference, JniHandleOwnership transfer)
|
||||
: base(javaReference, transfer)
|
||||
{
|
||||
}
|
||||
|
||||
public override void OnCreate()
|
||||
{
|
||||
AppPaths.OverrideDataRoot = FilesDir?.AbsolutePath;
|
||||
|
||||
// Registers the QR scanner: opens ScannerActivity and waits for its result.
|
||||
PlatformServices.ScanQrAsync = async () =>
|
||||
{
|
||||
var activity = MainActivity.Current;
|
||||
if (activity == null) return null;
|
||||
var tcs = new TaskCompletionSource<string?>();
|
||||
MainActivity.ScanTcs = tcs;
|
||||
activity.StartActivityForResult(
|
||||
new Intent(activity, typeof(ScannerActivity)),
|
||||
MainActivity.ScanRequestCode);
|
||||
return await tcs.Task;
|
||||
};
|
||||
|
||||
base.OnCreate();
|
||||
}
|
||||
|
||||
protected override AppBuilder CustomizeAppBuilder(AppBuilder builder) =>
|
||||
base.CustomizeAppBuilder(builder).WithInterFont();
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<!-- Head Android: entry-point e pacchetti specifici. La UI vera vive nella
|
||||
libreria condivisa PalladiumWallet.App (stessa di desktop). -->
|
||||
<PropertyGroup>
|
||||
<OutputType>Exe</OutputType>
|
||||
<TargetFramework>net10.0-android</TargetFramework>
|
||||
<SupportedOSPlatformVersion>23</SupportedOSPlatformVersion>
|
||||
<Nullable>enable</Nullable>
|
||||
<ApplicationId>io.github.davide3011.palladiumwallet</ApplicationId>
|
||||
<!-- ApplicationVersion = versionCode (intero), ApplicationDisplayVersion = versionName -->
|
||||
<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
|
||||
a mano — sideload sul telefono o `adb install` — crasha all'avvio con
|
||||
"monodroid: No assemblies found ... Exiting". Così l'apk è autosufficiente. -->
|
||||
<EmbedAssembliesIntoApk>true</EmbedAssembliesIntoApk>
|
||||
<!-- Default: ABI multiple (l'apk gira sia su emulatore x86_64 sia su telefoni arm64).
|
||||
Per un apk più piccolo solo-telefono: aggiungi -p:AbiArm64Only=true alla build. -->
|
||||
<RuntimeIdentifiers Condition="'$(AbiArm64Only)' == 'true'">android-arm64</RuntimeIdentifiers>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Avalonia.Android" Version="12.0.4" />
|
||||
<PackageReference Include="ZXing.Net" Version="0.16.9" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\App\PalladiumWallet.App.csproj" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
@@ -0,0 +1,10 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<!-- Necessario per parlare col server di indicizzazione (TCP/TLS).
|
||||
L'elemento <application> (label, allowBackup) è generato dall'attributo
|
||||
[Application] su MainApplication. usesCleartextTraffic resta al default
|
||||
(bloccato su Android 9+): il wallet preferisce comunque TLS. -->
|
||||
<uses-permission android:name="android.permission.INTERNET" />
|
||||
<uses-permission android:name="android.permission.CAMERA" />
|
||||
<uses-feature android:name="android.hardware.camera" android:required="false" />
|
||||
</manifest>
|
||||
|
After Width: | Height: | Size: 40 KiB |
@@ -0,0 +1,5 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<background android:drawable="@color/ic_launcher_background"/>
|
||||
<foreground android:drawable="@drawable/ic_launcher_foreground"/>
|
||||
</adaptive-icon>
|
||||
@@ -0,0 +1,5 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<background android:drawable="@color/ic_launcher_background"/>
|
||||
<foreground android:drawable="@drawable/ic_launcher_foreground"/>
|
||||
</adaptive-icon>
|
||||
|
After Width: | Height: | Size: 4.2 KiB |
|
After Width: | Height: | Size: 2.4 KiB |
|
After Width: | Height: | Size: 6.4 KiB |
|
After Width: | Height: | Size: 11 KiB |
|
After Width: | Height: | Size: 18 KiB |
@@ -0,0 +1,4 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<color name="ic_launcher_background">#FFFFFF</color>
|
||||
</resources>
|
||||
@@ -0,0 +1,7 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<!-- DEVE derivare da Theme.AppCompat: AvaloniaActivity estende AppCompatActivity,
|
||||
che con un tema non-AppCompat lancia IllegalStateException all'avvio.
|
||||
Avalonia disegna comunque l'intera UI (NoActionBar). -->
|
||||
<style name="MyTheme.NoActionBar" parent="Theme.AppCompat.Light.NoActionBar" />
|
||||
</resources>
|
||||
@@ -0,0 +1,218 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using Android.App;
|
||||
using Android.Content;
|
||||
using Android.Graphics;
|
||||
using Android.Hardware.Camera2;
|
||||
using Android.Media;
|
||||
using Android.OS;
|
||||
using Android.Views;
|
||||
using Android.Widget;
|
||||
using ZXing;
|
||||
using ZXing.Common;
|
||||
using AndroidResult = Android.App.Result;
|
||||
|
||||
namespace PalladiumWallet.Mobile;
|
||||
|
||||
/// <summary>
|
||||
/// Full-screen activity for QR scanning via Camera2 + ZXing.Net.
|
||||
/// Returns to MainActivity via SetResult with the extra "qr" = code text.
|
||||
/// </summary>
|
||||
[Activity(Theme = "@style/MyTheme.NoActionBar",
|
||||
ScreenOrientation = Android.Content.PM.ScreenOrientation.Portrait)]
|
||||
internal sealed class ScannerActivity : Activity, TextureView.ISurfaceTextureListener
|
||||
{
|
||||
internal const string ResultKey = "qr";
|
||||
private const int PermReq = 1;
|
||||
|
||||
private HandlerThread? _bgThread;
|
||||
private Handler? _bgHandler;
|
||||
private CameraDevice? _camera;
|
||||
private CameraCaptureSession? _session;
|
||||
private ImageReader? _imageReader;
|
||||
private SurfaceTexture? _surfaceTex;
|
||||
private bool _permOk;
|
||||
private volatile bool _done;
|
||||
private long _lastDecode;
|
||||
|
||||
protected override void OnCreate(Bundle? savedInstanceState)
|
||||
{
|
||||
base.OnCreate(savedInstanceState);
|
||||
Window!.SetFlags(WindowManagerFlags.Fullscreen, WindowManagerFlags.Fullscreen);
|
||||
|
||||
var root = new FrameLayout(this);
|
||||
|
||||
var preview = new TextureView(this) { SurfaceTextureListener = this };
|
||||
root.AddView(preview, new FrameLayout.LayoutParams(
|
||||
FrameLayout.LayoutParams.MatchParent, FrameLayout.LayoutParams.MatchParent));
|
||||
|
||||
var hint = new TextView(this) { Text = "Inquadra un codice QR" };
|
||||
hint.SetTextColor(Color.White);
|
||||
hint.SetBackgroundColor(Color.ParseColor("#AA000000"));
|
||||
hint.SetPadding(24, 12, 24, 12);
|
||||
root.AddView(hint, new FrameLayout.LayoutParams(
|
||||
FrameLayout.LayoutParams.WrapContent, FrameLayout.LayoutParams.WrapContent,
|
||||
GravityFlags.Top | GravityFlags.CenterHorizontal) { TopMargin = 60 });
|
||||
|
||||
var cancel = new Button(this) { Text = "Annulla" };
|
||||
cancel.Click += (_, _) => Finish();
|
||||
root.AddView(cancel, new FrameLayout.LayoutParams(
|
||||
FrameLayout.LayoutParams.WrapContent, FrameLayout.LayoutParams.WrapContent,
|
||||
GravityFlags.Bottom | GravityFlags.CenterHorizontal) { BottomMargin = 80 });
|
||||
|
||||
SetContentView(root);
|
||||
|
||||
_bgThread = new HandlerThread("CamBg");
|
||||
_bgThread.Start();
|
||||
_bgHandler = new Handler(_bgThread.Looper!);
|
||||
|
||||
if (CheckSelfPermission(Android.Manifest.Permission.Camera) == Android.Content.PM.Permission.Granted)
|
||||
_permOk = true;
|
||||
else
|
||||
RequestPermissions([Android.Manifest.Permission.Camera], PermReq);
|
||||
}
|
||||
|
||||
public override void OnRequestPermissionsResult(int req, string[] perms, Android.Content.PM.Permission[] grants)
|
||||
{
|
||||
_permOk = grants.Length > 0 && grants[0] == Android.Content.PM.Permission.Granted;
|
||||
if (_permOk) TryOpen(); else Finish();
|
||||
}
|
||||
|
||||
public void OnSurfaceTextureAvailable(SurfaceTexture st, int w, int h) { _surfaceTex = st; TryOpen(); }
|
||||
public bool OnSurfaceTextureDestroyed(SurfaceTexture st) { CloseCamera(); return true; }
|
||||
public void OnSurfaceTextureSizeChanged(SurfaceTexture st, int w, int h) { }
|
||||
public void OnSurfaceTextureUpdated(SurfaceTexture st) { }
|
||||
|
||||
private void TryOpen() { if (_permOk && _surfaceTex != null) OpenCamera(_surfaceTex); }
|
||||
|
||||
private void OpenCamera(SurfaceTexture st)
|
||||
{
|
||||
var mgr = (CameraManager)GetSystemService(CameraService)!;
|
||||
|
||||
var ids = mgr.GetCameraIdList()!;
|
||||
string cameraId = ids[0];
|
||||
foreach (var id in ids)
|
||||
{
|
||||
var chars = mgr.GetCameraCharacteristics(id)!;
|
||||
if (chars.Get(CameraCharacteristics.LensFacing) is Java.Lang.Integer f
|
||||
&& f.IntValue() == (int)LensFacing.Back)
|
||||
{ cameraId = id; break; }
|
||||
}
|
||||
|
||||
var map = mgr.GetCameraCharacteristics(cameraId)!
|
||||
.Get(CameraCharacteristics.ScalerStreamConfigurationMap)
|
||||
as Android.Hardware.Camera2.Params.StreamConfigurationMap;
|
||||
var allSizes = map!.GetOutputSizes((int)ImageFormatType.Jpeg)!;
|
||||
var size = allSizes.OrderBy(s => Math.Abs(s.Width - 640)).First();
|
||||
|
||||
st.SetDefaultBufferSize(size.Width, size.Height);
|
||||
_imageReader = ImageReader.NewInstance(size.Width, size.Height, ImageFormatType.Jpeg, 2);
|
||||
_imageReader.SetOnImageAvailableListener(new ImageListener(this), _bgHandler);
|
||||
|
||||
mgr.OpenCamera(cameraId, new OpenCb(this, st), _bgHandler);
|
||||
}
|
||||
|
||||
private void CloseCamera()
|
||||
{
|
||||
_session?.Close(); _session = null;
|
||||
_camera?.Close(); _camera = null;
|
||||
_imageReader?.Close(); _imageReader = null;
|
||||
_bgThread?.QuitSafely();
|
||||
}
|
||||
|
||||
internal void OnCameraOpened(CameraDevice dev, SurfaceTexture st)
|
||||
{
|
||||
_camera = dev;
|
||||
var prevSurf = new Surface(st);
|
||||
var capSurf = _imageReader!.Surface!;
|
||||
#pragma warning disable CA1422 // CreateCaptureSession(IList,...) obsoleted on API 30; replacement needs API 28, our min is 23
|
||||
dev.CreateCaptureSession([prevSurf, capSurf], new SessionCb(this, prevSurf, capSurf), _bgHandler);
|
||||
#pragma warning restore CA1422
|
||||
}
|
||||
|
||||
internal void OnSessionReady(CameraCaptureSession session, Surface prev, Surface cap)
|
||||
{
|
||||
_session = session;
|
||||
var req = _camera!.CreateCaptureRequest(CameraTemplate.Preview);
|
||||
req.AddTarget(prev);
|
||||
req.AddTarget(cap);
|
||||
session.SetRepeatingRequest(req.Build()!, null, _bgHandler);
|
||||
}
|
||||
|
||||
internal void TryDecode(ImageReader reader)
|
||||
{
|
||||
if (_done) return;
|
||||
var image = reader.AcquireLatestImage();
|
||||
if (image == null) return;
|
||||
try
|
||||
{
|
||||
var now = SystemClock.ElapsedRealtime();
|
||||
if (now - _lastDecode < 400) return; // max ~2.5 fps
|
||||
_lastDecode = now;
|
||||
|
||||
var buf = image.GetPlanes()![0].Buffer!;
|
||||
var bytes = new byte[buf.Remaining()];
|
||||
buf.Get(bytes);
|
||||
|
||||
var bmp = BitmapFactory.DecodeByteArray(bytes, 0, bytes.Length);
|
||||
if (bmp == null) return;
|
||||
int w = bmp.Width, h = bmp.Height;
|
||||
var pixels = new int[w * h];
|
||||
bmp.GetPixels(pixels, 0, w, 0, 0, w, h);
|
||||
bmp.Recycle();
|
||||
|
||||
// ARGB int[] → RGB byte[] for ZXing.RGBLuminanceSource
|
||||
var rgb = new byte[pixels.Length * 3];
|
||||
for (int i = 0; i < pixels.Length; i++)
|
||||
{
|
||||
rgb[i * 3] = (byte)(pixels[i] >> 16);
|
||||
rgb[i * 3 + 1] = (byte)(pixels[i] >> 8);
|
||||
rgb[i * 3 + 2] = (byte) pixels[i];
|
||||
}
|
||||
|
||||
var source = new RGBLuminanceSource(rgb, w, h, RGBLuminanceSource.BitmapFormat.RGB24);
|
||||
var zreader = new BarcodeReaderGeneric { AutoRotate = true };
|
||||
zreader.Options.PossibleFormats = [BarcodeFormat.QR_CODE];
|
||||
zreader.Options.TryHarder = true;
|
||||
var result = zreader.Decode(source);
|
||||
if (result == null) return;
|
||||
|
||||
_done = true;
|
||||
RunOnUiThread(() =>
|
||||
{
|
||||
var intent = new Intent().PutExtra(ResultKey, result.Text);
|
||||
SetResult(AndroidResult.Ok, intent);
|
||||
Finish();
|
||||
});
|
||||
}
|
||||
finally { image.Close(); }
|
||||
}
|
||||
|
||||
protected override void OnDestroy()
|
||||
{
|
||||
CloseCamera();
|
||||
base.OnDestroy();
|
||||
}
|
||||
|
||||
// ── Callback helpers ─────────────────────────────────────────────────
|
||||
|
||||
private sealed class OpenCb(ScannerActivity a, SurfaceTexture st) : CameraDevice.StateCallback
|
||||
{
|
||||
public override void OnOpened(CameraDevice cam) => a.OnCameraOpened(cam, st);
|
||||
public override void OnDisconnected(CameraDevice cam) { cam.Close(); a.Finish(); }
|
||||
public override void OnError(CameraDevice cam, CameraError err) { cam.Close(); a.Finish(); }
|
||||
}
|
||||
|
||||
private sealed class SessionCb(ScannerActivity a, Surface prev, Surface cap) : CameraCaptureSession.StateCallback
|
||||
{
|
||||
public override void OnConfigured(CameraCaptureSession s) => a.OnSessionReady(s, prev, cap);
|
||||
public override void OnConfigureFailed(CameraCaptureSession s) => a.Finish();
|
||||
}
|
||||
|
||||
private sealed class ImageListener(ScannerActivity a) : Java.Lang.Object, ImageReader.IOnImageAvailableListener
|
||||
{
|
||||
// Interface declares ImageReader? but reader is never null in practice
|
||||
public void OnImageAvailable(ImageReader? reader) => a.TryDecode(reader!);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<!-- Head Desktop (Windows/Linux): entry-point e pacchetti specifici del
|
||||
desktop. La UI vera vive nella libreria condivisa PalladiumWallet.App. -->
|
||||
<PropertyGroup>
|
||||
<OutputType>WinExe</OutputType>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<Nullable>enable</Nullable>
|
||||
<ApplicationManifest>app.manifest</ApplicationManifest>
|
||||
<ApplicationIcon>..\App\Assets\logo.ico</ApplicationIcon>
|
||||
<AssemblyName>PalladiumWallet</AssemblyName>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Avalonia" Version="12.0.4" />
|
||||
<PackageReference Include="Avalonia.Desktop" Version="12.0.4" />
|
||||
<PackageReference Include="AvaloniaUI.DiagnosticsSupport" Version="2.2.1">
|
||||
<IncludeAssets Condition="'$(Configuration)' != 'Debug'">None</IncludeAssets>
|
||||
<PrivateAssets Condition="'$(Configuration)' != 'Debug'">All</PrivateAssets>
|
||||
</PackageReference>
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\App\PalladiumWallet.App.csproj" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
@@ -8,8 +8,20 @@
|
||||
<Application.DataTemplates>
|
||||
<local:ViewLocator/>
|
||||
</Application.DataTemplates>
|
||||
|
||||
|
||||
<!-- Semantic design tokens (colors, radii, brand accent): defined for
|
||||
both light/dark variants. See Styles/Theme.axaml. -->
|
||||
<Application.Resources>
|
||||
<ResourceDictionary>
|
||||
<ResourceDictionary.MergedDictionaries>
|
||||
<ResourceInclude Source="/Styles/Theme.axaml"/>
|
||||
</ResourceDictionary.MergedDictionaries>
|
||||
</ResourceDictionary>
|
||||
</Application.Resources>
|
||||
|
||||
<Application.Styles>
|
||||
<FluentTheme />
|
||||
<!-- Control refinements and reusable classes (on top of Fluent) -->
|
||||
<StyleInclude Source="/Styles/Controls.axaml"/>
|
||||
</Application.Styles>
|
||||
</Application>
|
||||
@@ -1,31 +1,39 @@
|
||||
using Avalonia;
|
||||
using Avalonia.Controls.ApplicationLifetimes;
|
||||
using Avalonia.Data.Core;
|
||||
using Avalonia.Data.Core.Plugins;
|
||||
using System.Linq;
|
||||
using Avalonia.Markup.Xaml;
|
||||
using PalladiumWallet.App.ViewModels;
|
||||
using PalladiumWallet.App.Views;
|
||||
|
||||
namespace PalladiumWallet.App;
|
||||
|
||||
public partial class App : Application
|
||||
{
|
||||
public override void Initialize()
|
||||
{
|
||||
AvaloniaXamlLoader.Load(this);
|
||||
}
|
||||
|
||||
public override void OnFrameworkInitializationCompleted()
|
||||
{
|
||||
if (ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop)
|
||||
{
|
||||
desktop.MainWindow = new MainWindow
|
||||
{
|
||||
DataContext = new MainWindowViewModel(),
|
||||
};
|
||||
}
|
||||
|
||||
base.OnFrameworkInitializationCompleted();
|
||||
}
|
||||
}
|
||||
using Avalonia;
|
||||
using Avalonia.Controls.ApplicationLifetimes;
|
||||
using Avalonia.Markup.Xaml;
|
||||
using PalladiumWallet.App.ViewModels;
|
||||
using PalladiumWallet.App.Views;
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
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.
|
||||
switch (ApplicationLifetime)
|
||||
{
|
||||
case IClassicDesktopStyleApplicationLifetime desktop:
|
||||
desktop.MainWindow = new MainWindow { DataContext = vm };
|
||||
break;
|
||||
case ISingleViewApplicationLifetime singleView:
|
||||
singleView.MainView = new MainView { DataContext = vm };
|
||||
break;
|
||||
}
|
||||
|
||||
base.OnFrameworkInitializationCompleted();
|
||||
}
|
||||
}
|
||||
|
Before Width: | Height: | Size: 172 KiB |
|
After Width: | Height: | Size: 47 KiB |
|
After Width: | Height: | Size: 1.3 MiB |
@@ -1,166 +1,527 @@
|
||||
using System.Collections.Generic;
|
||||
using System.ComponentModel;
|
||||
|
||||
namespace PalladiumWallet.App.Localization;
|
||||
|
||||
/// <summary>
|
||||
/// Localizzazione UI (blueprint §14): dizionario chiave → [it, en], con
|
||||
/// indicizzatore bindabile da XAML ({Binding Loc[chiave]}). Al cambio lingua
|
||||
/// notifica "Item[]" e tutte le binding si aggiornano.
|
||||
/// UI localisation: key → per-language translations dictionary, with an
|
||||
/// indexer bindable from XAML ({Binding Loc[key]}). On language change the
|
||||
/// ViewModel replaces the instance so Avalonia re-evaluates all bindings.
|
||||
/// </summary>
|
||||
public sealed class Loc : INotifyPropertyChanged
|
||||
public sealed class Loc
|
||||
{
|
||||
public static Loc Instance { get; } = new();
|
||||
public static Loc Instance { get; private set; } = new();
|
||||
|
||||
public static readonly string[] Languages = ["it", "en"];
|
||||
public static readonly string[] LanguageNames = ["Italiano", "English"];
|
||||
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; } = "it";
|
||||
public string Language { get; private set; } = "en";
|
||||
|
||||
public event PropertyChangedEventHandler? PropertyChanged;
|
||||
private Loc() { }
|
||||
private Loc(string language) { Language = language; }
|
||||
|
||||
public void SetLanguage(string language)
|
||||
/// <summary>
|
||||
/// Creates a new instance for the specified language and updates the singleton
|
||||
/// used by <see cref="Tr"/>. The ViewModel assigns this instance to its Loc
|
||||
/// property so Avalonia sees a different reference and re-evaluates all
|
||||
/// {Binding Loc[key]} bindings.
|
||||
/// </summary>
|
||||
internal static Loc SwitchTo(string language)
|
||||
{
|
||||
if (Language == language || System.Array.IndexOf(Languages, language) < 0)
|
||||
return;
|
||||
Language = language;
|
||||
PropertyChanged?.Invoke(this, new PropertyChangedEventArgs("Item[]"));
|
||||
PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(Language)));
|
||||
if (System.Array.IndexOf(Languages, language) < 0) language = "en";
|
||||
var loc = new Loc(language);
|
||||
Instance = loc;
|
||||
return loc;
|
||||
}
|
||||
|
||||
public string this[string key] =>
|
||||
Strings.TryGetValue(key, out var values)
|
||||
? values[Language == "en" ? 1 : 0]
|
||||
? values[System.Math.Max(0, System.Array.IndexOf(Languages, Language))]
|
||||
: key;
|
||||
|
||||
public static string Tr(string key) => Instance[key];
|
||||
|
||||
private static readonly Dictionary<string, string[]> Strings = new()
|
||||
{
|
||||
// Menu
|
||||
["menu.file"] = ["_File", "_File"],
|
||||
["menu.file.new"] = ["Nuovo / ripristina wallet…", "New / restore wallet…"],
|
||||
["menu.file.open"] = ["Apri wallet da file…", "Open wallet from file…"],
|
||||
["menu.file.close"] = ["Chiudi wallet", "Close wallet"],
|
||||
["menu.net"] = ["_Rete", "_Network"],
|
||||
["menu.net.discover"] = ["Cerca altri server (peer)", "Discover servers (peers)"],
|
||||
["menu.net.resetcerts"] = ["Reset certificati SSL", "Reset SSL certificates"],
|
||||
["menu.settings"] = ["_Impostazioni", "_Settings"],
|
||||
["settings.unit.short"] = ["Unità", "Unit"],
|
||||
// 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.",
|
||||
"轻量级、非托管的 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", "单位"],
|
||||
|
||||
// Wizard
|
||||
["wiz.net"] = ["Rete:", "Network:"],
|
||||
["wiz.open.btn"] = ["Apri il wallet esistente", "Open existing wallet"],
|
||||
["wiz.new.btn"] = ["Crea un nuovo wallet", "Create a new wallet"],
|
||||
["wiz.restore.btn"] = ["Ripristina da seed", "Restore from seed"],
|
||||
["wiz.open.title"] = ["Apri il wallet", "Open the wallet"],
|
||||
["wiz.open.placeholder"] = ["Password del file (vuoto se non impostata)", "File password (empty if not set)"],
|
||||
["wiz.open.ok"] = ["Apri", "Open"],
|
||||
["wiz.seed.title"] = ["Il tuo seed (12 parole)", "Your seed (12 words)"],
|
||||
["wiz.seed.warning"] = [
|
||||
["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", "导入 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."],
|
||||
["wiz.seed.next"] = ["Le ho scritte — Avanti", "I wrote them down — Next"],
|
||||
["wiz.confirm.title"] = ["Conferma il seed", "Confirm the seed"],
|
||||
["wiz.confirm.placeholder"] = ["Reinserisci le 12 parole separate da spazi", "Re-enter the 12 words separated by spaces"],
|
||||
["wiz.words.title"] = ["Ripristina da seed", "Restore from seed"],
|
||||
["wiz.words.placeholder"] = ["Mnemonica BIP39 (12 o 24 parole separate da spazi)", "BIP39 mnemonic (12 or 24 words separated by spaces)"],
|
||||
["wiz.passphrase.title"] = ["Passphrase opzionale", "Optional passphrase"],
|
||||
["wiz.passphrase.placeholder"] = ["Lascia vuoto per non usarla", "Leave empty to skip"],
|
||||
["wiz.password.title"] = ["Password del file wallet", "Wallet file password"],
|
||||
["wiz.password.placeholder"] = ["Consigliata (vuoto = file in chiaro su disco)", "Recommended (empty = plaintext file on disk)"],
|
||||
["wiz.password.create"] = ["Crea il wallet", "Create wallet"],
|
||||
["wiz.back"] = ["Indietro", "Back"],
|
||||
["wiz.next"] = ["Avanti", "Next"],
|
||||
"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", "重新输入以空格分隔的 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", "脚本类型和地址"],
|
||||
["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.",
|
||||
"决定地址格式。如果不确定,请使用原生隔离见证(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.",
|
||||
"粘贴 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.",
|
||||
"粘贴一个或多个 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)", "地址…(每行一个)"],
|
||||
|
||||
// Pannello wallet
|
||||
["wallet.close"] = ["Chiudi wallet", "Close wallet"],
|
||||
["wallet.server"] = ["Server:", "Server:"],
|
||||
["wallet.connect"] = ["Connetti e sincronizza", "Connect and sync"],
|
||||
["wallet.manual"] = ["oppure host:porta manuale", "or manual host:port"],
|
||||
["wallet.discover"] = ["Cerca altri server", "Discover servers"],
|
||||
["wallet.resetcert"] = ["Reset cert.", "Reset certs"],
|
||||
["tab.receive"] = ["Ricevi", "Receive"],
|
||||
["tab.history"] = ["Storico", "History"],
|
||||
["tab.addresses"] = ["Indirizzi", "Addresses"],
|
||||
["tab.send"] = ["Invia", "Send"],
|
||||
["receive.next"] = ["Prossimo indirizzo non usato:", "Next unused address:"],
|
||||
["receive.hint"] = [
|
||||
// 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).", "请粘贴扩展密钥(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", "或手动输入 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."],
|
||||
["addr.type"] = ["Tipo", "Type"],
|
||||
["addr.index"] = ["Indice", "Index"],
|
||||
["addr.address"] = ["Indirizzo", "Address"],
|
||||
["addr.balance"] = ["Saldo", "Balance"],
|
||||
["addr.receive"] = ["ricezione", "receive"],
|
||||
["addr.change"] = ["change", "change"],
|
||||
["send.to"] = ["Indirizzo destinatario", "Recipient address"],
|
||||
["send.amount"] = ["Importo", "Amount"],
|
||||
["send.all"] = ["Invia tutto", "Send all"],
|
||||
["send.feerate"] = ["fee sat/vB:", "fee sat/vB:"],
|
||||
["send.prepare"] = ["Prepara transazione", "Prepare transaction"],
|
||||
["send.confirm"] = ["CONFERMA E TRASMETTI", "CONFIRM AND BROADCAST"],
|
||||
"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):", "私钥(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", "关闭"],
|
||||
|
||||
// Stato connessione
|
||||
["conn.none"] = ["non connesso", "not connected"],
|
||||
["conn.disconnected"] = ["disconnesso", "disconnected"],
|
||||
["conn.reconnecting"] = ["riconnessione…", "reconnecting…"],
|
||||
["conn.error"] = ["errore di connessione", "connection error"],
|
||||
["conn.certchanged"] = ["certificato cambiato", "certificate changed"],
|
||||
["conn.connectedto"] = ["connesso a", "connected to"],
|
||||
["conn.connectingto"] = ["connessione a", "connecting to"],
|
||||
// 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", "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", "您的地址"],
|
||||
|
||||
// Messaggi di stato principali
|
||||
["msg.welcome.existing"] = [
|
||||
// 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)", "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", "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", "正在连接"],
|
||||
|
||||
// Main status messages
|
||||
["msg.welcome.existing"] = [
|
||||
"Trovato un wallet esistente su questa rete: aprilo, oppure creane un altro.",
|
||||
"Found an existing wallet on this network: open it, or create another one."],
|
||||
["msg.welcome.new"] = [
|
||||
"Found an existing wallet on this network: open it, or create another one.",
|
||||
"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.",
|
||||
"在此网络上发现现有钱包:打开它,或创建新钱包。"],
|
||||
["msg.welcome.new"] = [
|
||||
"Benvenuto: crea un nuovo wallet o ripristina da seed.",
|
||||
"Welcome: create a new wallet or restore from seed."],
|
||||
["msg.open.password"] = [
|
||||
"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.",
|
||||
"欢迎:创建新钱包或从种子恢复。"],
|
||||
["msg.open.password"] = [
|
||||
"Inserisci la password del file (lascia vuoto se non impostata).",
|
||||
"Enter the file password (leave empty if not set)."],
|
||||
["msg.seed.write"] = [
|
||||
"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).",
|
||||
"输入文件密码(未设置则留空)。"],
|
||||
["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."],
|
||||
["msg.seed.retype"] = [
|
||||
"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.",
|
||||
"请将这 12 个单词按顺序写在纸上。它们是钱包唯一的备份。"],
|
||||
["msg.seed.retype"] = [
|
||||
"Reinserisci le 12 parole per confermare di averle scritte.",
|
||||
"Re-enter the 12 words to confirm you wrote them down."],
|
||||
["msg.seed.mismatch"] = [
|
||||
"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.",
|
||||
"重新输入这 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."],
|
||||
["msg.words.enter"] = [
|
||||
"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.",
|
||||
"单词不匹配:请检查您写在纸上的内容。"],
|
||||
["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)."],
|
||||
["msg.words.invalid"] = [
|
||||
"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).",
|
||||
"输入 BIP39 助记词(12 或 24 个单词,以空格分隔)。"],
|
||||
["msg.words.invalid"] = [
|
||||
"Mnemonica non valida (parole o checksum errati): ricontrolla.",
|
||||
"Invalid mnemonic (wrong words or checksum): check again."],
|
||||
["msg.passphrase.info"] = [
|
||||
"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.",
|
||||
"助记词无效(单词错误或校验和不正确):请重新检查。"],
|
||||
["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."],
|
||||
["msg.password.info"] = [
|
||||
"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.",
|
||||
"可选的 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."],
|
||||
["msg.wrongpassword"] = ["Password errata.", "Wrong password."],
|
||||
["msg.opened"] = ["Wallet aperto: connessione al server…", "Wallet opened: connecting to server…"],
|
||||
["msg.synced"] = ["Sincronizzato", "Synchronized"],
|
||||
["msg.synced.detail"] = [
|
||||
"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.", "有多个可用钱包:请选择一个。"],
|
||||
["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“).",
|
||||
"请输入密码以加密钱包(或取消勾选“加密钱包文件”)。"],
|
||||
["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", "已同步"],
|
||||
["msg.synced.detail"] = [
|
||||
"transazioni verificate SPV. Aggiornamento in tempo reale attivo.",
|
||||
"SPV-verified transactions. Real-time updates active."],
|
||||
["msg.height"] = ["altezza", "height"],
|
||||
["msg.pending"] = ["in attesa di conferma", "pending confirmation"],
|
||||
["msg.notspendable"] = ["non ancora spendibile", "not yet spendable"],
|
||||
["msg.settings.saved"] = ["Impostazioni salvate.", "Settings saved."],
|
||||
["msg.certreset"] = [
|
||||
"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.",
|
||||
"已通过 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."],
|
||||
["msg.error"] = ["Errore", "Error"],
|
||||
"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.",
|
||||
"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.",
|
||||
"{0} 的 TLS 证书与已保存的证书不同。如果服务器更新了证书,请重置 SSL 证书。"],
|
||||
|
||||
// Finestra impostazioni
|
||||
["settings.title"] = ["Impostazioni", "Settings"],
|
||||
["settings.language"] = ["Lingua", "Language"],
|
||||
["settings.unit"] = ["Unità degli importi", "Amount unit"],
|
||||
["settings.ok"] = ["Salva", "Save"],
|
||||
["settings.cancel"] = ["Annulla", "Cancel"],
|
||||
["msg.chain.wrong"] = [
|
||||
"Il server {0} non è sulla catena Palladium (genesi o checkpoint diversi). Scegli un altro server.",
|
||||
"The server {0} is not on the Palladium chain (different genesis or checkpoint). Choose another server.",
|
||||
"El servidor {0} no está en la cadena Palladium (génesis o punto de control distintos). Elige otro servidor.",
|
||||
"Le serveur {0} n'est pas sur la chaîne Palladium (genèse ou point de contrôle différent). Choisissez un autre serveur.",
|
||||
"O servidor {0} não está na cadeia Palladium (gênese ou checkpoint diferente). Escolha outro servidor.",
|
||||
"Der Server {0} befindet sich nicht auf der Palladium-Chain (anderer Genesis-Block oder Checkpoint). Wählen Sie einen anderen Server.",
|
||||
"服务器 {0} 不在 Palladium 链上(创世块或检查点不同)。请选择其他服务器。"],
|
||||
|
||||
// Contacts
|
||||
["msg.contact.address.invalid"] = [
|
||||
"Indirizzo non valido per questa rete: il contatto non è stato salvato.",
|
||||
"Invalid address for this network: the contact was not saved.",
|
||||
"Dirección no válida para esta red: el contacto no se guardó.",
|
||||
"Adresse invalide pour ce réseau : le contact n'a pas été enregistré.",
|
||||
"Endereço inválido para esta rede: o contato não foi salvo.",
|
||||
"Ungültige Adresse für dieses Netzwerk: Der Kontakt wurde nicht gespeichert.",
|
||||
"该网络的地址无效:联系人未保存。"],
|
||||
["msg.contact.duplicate"] = [
|
||||
"Esiste già un contatto con questo indirizzo.",
|
||||
"A contact with this address already exists.",
|
||||
"Ya existe un contacto con esta dirección.",
|
||||
"Un contact avec cette adresse existe déjà.",
|
||||
"Já existe um contato com este endereço.",
|
||||
"Ein Kontakt mit dieser Adresse existiert bereits.",
|
||||
"已存在相同地址的联系人。"],
|
||||
["contacts.name"] = ["Nome", "Name", "Nombre", "Nom", "Nome", "Name", "姓名"],
|
||||
["contacts.address"] = ["Indirizzo", "Address", "Dirección", "Adresse", "Endereço", "Adresse", "地址"],
|
||||
["contacts.name.ph"] = ["Nome contatto", "Contact name", "Nombre del contacto", "Nom du contact", "Nome do contato", "Kontaktname", "联系人姓名"],
|
||||
["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…", "索引服务器…"],
|
||||
|
||||
// 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.", "没有已知服务器。连接后请使用“发现服务器”。"],
|
||||
};
|
||||
}
|
||||
@@ -1,30 +1,34 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<PropertyGroup>
|
||||
<OutputType>WinExe</OutputType>
|
||||
<TargetFramework>net8.0</TargetFramework>
|
||||
<Nullable>enable</Nullable>
|
||||
<ApplicationManifest>app.manifest</ApplicationManifest>
|
||||
<AvaloniaUseCompiledBindingsByDefault>true</AvaloniaUseCompiledBindingsByDefault>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<Folder Include="Models\" />
|
||||
<AvaloniaResource Include="Assets\**" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Avalonia" Version="12.0.4" />
|
||||
<PackageReference Include="Avalonia.Desktop" Version="12.0.4" />
|
||||
<PackageReference Include="Avalonia.Themes.Fluent" Version="12.0.4" />
|
||||
<PackageReference Include="Avalonia.Fonts.Inter" Version="12.0.4" />
|
||||
<PackageReference Include="AvaloniaUI.DiagnosticsSupport" Version="2.2.1">
|
||||
<IncludeAssets Condition="'$(Configuration)' != 'Debug'">None</IncludeAssets>
|
||||
<PrivateAssets Condition="'$(Configuration)' != 'Debug'">All</PrivateAssets>
|
||||
</PackageReference>
|
||||
<PackageReference Include="CommunityToolkit.Mvvm" Version="8.4.1" />
|
||||
</ItemGroup>
|
||||
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<!-- Libreria UI condivisa: il codice Avalonia (App, Views, ViewModels, Loc,
|
||||
Assets) usato sia dall'head Desktop sia dall'head Android. Gli head
|
||||
portano l'entry-point e i pacchetti specifici di piattaforma. -->
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<!-- Versione dell'applicazione: unico punto da modificare. Compare nel
|
||||
titolo della finestra ed è incisa nei binari pubblicati. -->
|
||||
<Version>1.2.0</Version>
|
||||
<Nullable>enable</Nullable>
|
||||
<AvaloniaUseCompiledBindingsByDefault>true</AvaloniaUseCompiledBindingsByDefault>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<Folder Include="Models\" />
|
||||
<AvaloniaResource Include="Assets\**" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Avalonia" Version="12.0.4" />
|
||||
<PackageReference Include="Avalonia.Themes.Fluent" Version="12.0.4" />
|
||||
<PackageReference Include="Avalonia.Fonts.Inter" Version="12.0.4" />
|
||||
<PackageReference Include="CommunityToolkit.Mvvm" Version="8.4.1" />
|
||||
<PackageReference Include="QRCoder" Version="1.8.0" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\Core\PalladiumWallet.Core.csproj" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<InternalsVisibleTo Include="PalladiumWallet.Tests" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
@@ -0,0 +1,17 @@
|
||||
using System;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace PalladiumWallet.App.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Seam for platform-specific services (Android/desktop).
|
||||
/// The Android head sets the delegates in OnCreate; desktop leaves them null.
|
||||
/// </summary>
|
||||
public static class PlatformServices
|
||||
{
|
||||
/// <summary>
|
||||
/// Opens the native QR scanner and returns the raw code text,
|
||||
/// or null if the user cancels or the scanner is unavailable.
|
||||
/// </summary>
|
||||
public static Func<Task<string?>>? ScanQrAsync { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,172 @@
|
||||
<Styles xmlns="https://github.com/avaloniaui"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml">
|
||||
<!-- ============================================================
|
||||
Palladium wallet control styles ("clean fintech").
|
||||
Applied on top of FluentTheme: refine spacing, states and
|
||||
reusable classes without re-templating controls. The "accent"
|
||||
buttons remain managed by Fluent but inherit the brand blue
|
||||
via SystemAccentColor (see Theme.axaml).
|
||||
============================================================ -->
|
||||
|
||||
<Design.PreviewWith>
|
||||
<Border Padding="20" Width="320">
|
||||
<StackPanel Spacing="12">
|
||||
<TextBlock Classes="h1" Text="Saldo disponibile"/>
|
||||
<TextBlock Classes="amount" Text="1 234,56 PLM"/>
|
||||
<Button Classes="accent" Content="Invia"/>
|
||||
<Button Content="Annulla"/>
|
||||
<Border Classes="card" Padding="16">
|
||||
<TextBlock Text="Card di esempio"/>
|
||||
</Border>
|
||||
</StackPanel>
|
||||
</Border>
|
||||
</Design.PreviewWith>
|
||||
|
||||
<!-- ===== Typography ===== -->
|
||||
<Style Selector="TextBlock.h1">
|
||||
<Setter Property="FontSize" Value="20"/>
|
||||
<Setter Property="FontWeight" Value="SemiBold"/>
|
||||
<Setter Property="Foreground" Value="{DynamicResource TextPrimaryBrush}"/>
|
||||
</Style>
|
||||
<Style Selector="TextBlock.label">
|
||||
<Setter Property="FontSize" Value="11"/>
|
||||
<Setter Property="FontWeight" Value="Medium"/>
|
||||
<Setter Property="Foreground" Value="{DynamicResource TextSecondaryBrush}"/>
|
||||
</Style>
|
||||
<Style Selector="TextBlock.muted">
|
||||
<Setter Property="Foreground" Value="{DynamicResource TextSecondaryBrush}"/>
|
||||
</Style>
|
||||
<!-- Amounts and numeric data: tabular figures to prevent layout jumps -->
|
||||
<Style Selector="TextBlock.amount">
|
||||
<Setter Property="FontSize" Value="34"/>
|
||||
<Setter Property="FontWeight" Value="Bold"/>
|
||||
<Setter Property="Foreground" Value="{DynamicResource TextPrimaryBrush}"/>
|
||||
<Setter Property="FontFeatures" Value="+tnum"/>
|
||||
</Style>
|
||||
<Style Selector="TextBlock.mono, SelectableTextBlock.mono">
|
||||
<Setter Property="FontFamily" Value="monospace"/>
|
||||
<Setter Property="FontFeatures" Value="+tnum"/>
|
||||
</Style>
|
||||
<!-- Section heading (e.g. transaction detail) -->
|
||||
<Style Selector="TextBlock.section">
|
||||
<Setter Property="FontSize" Value="11"/>
|
||||
<Setter Property="FontWeight" Value="SemiBold"/>
|
||||
<Setter Property="Foreground" Value="{DynamicResource TextMutedBrush}"/>
|
||||
<Setter Property="LetterSpacing" Value="0.8"/>
|
||||
</Style>
|
||||
|
||||
<!-- ===== Buttons ===== -->
|
||||
<Style Selector="Button">
|
||||
<Setter Property="MinHeight" Value="36"/>
|
||||
<Setter Property="Padding" Value="14,8"/>
|
||||
<Setter Property="Cursor" Value="Hand"/>
|
||||
<Setter Property="FontWeight" Value="Medium"/>
|
||||
<Setter Property="VerticalContentAlignment" Value="Center"/>
|
||||
<Setter Property="HorizontalContentAlignment" Value="Center"/>
|
||||
</Style>
|
||||
<!-- Accent (CTA) buttons are already blue via Fluent+SystemAccentColor.
|
||||
Ensure readable white text in both light and dark variants. -->
|
||||
<Style Selector="Button.accent">
|
||||
<Setter Property="Foreground" Value="#FFFFFF"/>
|
||||
</Style>
|
||||
<!-- "ghost" variant: subtle secondary action, no fill -->
|
||||
<Style Selector="Button.ghost">
|
||||
<Setter Property="Background" Value="Transparent"/>
|
||||
<Setter Property="Foreground" Value="{DynamicResource PrimaryBrush}"/>
|
||||
<Setter Property="BorderThickness" Value="0"/>
|
||||
</Style>
|
||||
|
||||
<!-- ===== Inputs ===== -->
|
||||
<Style Selector="TextBox">
|
||||
<Setter Property="MinHeight" Value="36"/>
|
||||
<Setter Property="Padding" Value="10,7"/>
|
||||
</Style>
|
||||
<Style Selector="ComboBox">
|
||||
<Setter Property="MinHeight" Value="36"/>
|
||||
<Setter Property="Padding" Value="10,6"/>
|
||||
</Style>
|
||||
|
||||
<!-- ===== Lists ===== -->
|
||||
<Style Selector="ListBox">
|
||||
<Setter Property="Background" Value="Transparent"/>
|
||||
<Setter Property="BorderThickness" Value="0"/>
|
||||
<Setter Property="Padding" Value="0"/>
|
||||
</Style>
|
||||
<Style Selector="ListBoxItem">
|
||||
<Setter Property="Padding" Value="12,10"/>
|
||||
<Setter Property="CornerRadius" Value="8"/>
|
||||
<Setter Property="Margin" Value="0,1"/>
|
||||
</Style>
|
||||
<!-- Hover: neutral tint; selected: subtle brand-blue tint -->
|
||||
<Style Selector="ListBoxItem:pointerover /template/ ContentPresenter#PART_ContentPresenter">
|
||||
<Setter Property="Background" Value="{DynamicResource SurfaceAltBrush}"/>
|
||||
</Style>
|
||||
<Style Selector="ListBoxItem:selected /template/ ContentPresenter#PART_ContentPresenter">
|
||||
<Setter Property="Background" Value="{DynamicResource PrimarySoftBrush}"/>
|
||||
</Style>
|
||||
|
||||
<!-- ===== Tabs (dashboard) ===== -->
|
||||
<!-- Selected item: brand blue + bold; unselected: muted -->
|
||||
<Style Selector="TabItem">
|
||||
<Setter Property="Foreground" Value="{DynamicResource TextSecondaryBrush}"/>
|
||||
<Setter Property="FontWeight" Value="Medium"/>
|
||||
</Style>
|
||||
<Style Selector="TabItem:selected">
|
||||
<Setter Property="Foreground" Value="{DynamicResource PrimaryBrush}"/>
|
||||
</Style>
|
||||
<Style Selector="TabItem:selected /template/ ContentPresenter#PART_ContentPresenter">
|
||||
<Setter Property="TextElement.Foreground" Value="{DynamicResource PrimaryBrush}"/>
|
||||
</Style>
|
||||
<!-- Push tab header content up so the 2px selection indicator (drawn at Panel bottom)
|
||||
does not overlap the text label. -->
|
||||
<Style Selector="TabItem /template/ ContentPresenter#PART_ContentPresenter">
|
||||
<Setter Property="Margin" Value="0,0,0,4"/>
|
||||
</Style>
|
||||
|
||||
<!-- ===== Reusable classes ===== -->
|
||||
<!-- Card: elevated surface with a subtle border and soft corners -->
|
||||
<Style Selector="Border.card">
|
||||
<Setter Property="Background" Value="{DynamicResource SurfaceBrush}"/>
|
||||
<Setter Property="BorderBrush" Value="{DynamicResource BorderSubtleBrush}"/>
|
||||
<Setter Property="BorderThickness" Value="1"/>
|
||||
<Setter Property="CornerRadius" Value="14"/>
|
||||
<Setter Property="BoxShadow" Value="0 1 3 0 #14000000"/>
|
||||
<Setter Property="Transitions">
|
||||
<Transitions>
|
||||
<BoxShadowsTransition Property="BoxShadow" Duration="0:0:0.18"/>
|
||||
</Transitions>
|
||||
</Setter>
|
||||
</Style>
|
||||
<Style Selector="Border.card:pointerover">
|
||||
<Setter Property="BoxShadow" Value="0 6 16 -4 #26000000"/>
|
||||
</Style>
|
||||
<!-- Balance card: subtle blue accent, slightly more prominent -->
|
||||
<Style Selector="Border.balance-card">
|
||||
<Setter Property="Background" Value="{DynamicResource SurfaceBrush}"/>
|
||||
<Setter Property="BorderBrush" Value="{DynamicResource BorderSubtleBrush}"/>
|
||||
<Setter Property="BorderThickness" Value="1"/>
|
||||
<Setter Property="CornerRadius" Value="14"/>
|
||||
<Setter Property="Padding" Value="20,16"/>
|
||||
<Setter Property="BoxShadow" Value="0 2 8 0 #1A000000"/>
|
||||
</Style>
|
||||
<!-- Hero balance card: brand gradient + blue glow. Signature dashboard
|
||||
element; overlaid text is white (on-hero class). -->
|
||||
<Style Selector="Border.balance-hero">
|
||||
<Setter Property="Background" Value="{DynamicResource BalanceGradientBrush}"/>
|
||||
<Setter Property="CornerRadius" Value="16"/>
|
||||
<Setter Property="Padding" Value="24,20"/>
|
||||
<Setter Property="BoxShadow" Value="0 8 24 -6 #663B82F6"/>
|
||||
</Style>
|
||||
<Style Selector="Border.balance-hero TextBlock.amount">
|
||||
<Setter Property="Foreground" Value="#FFFFFF"/>
|
||||
</Style>
|
||||
<Style Selector="Border.balance-hero TextBlock.on-hero">
|
||||
<Setter Property="Foreground" Value="#D6E2FF"/>
|
||||
</Style>
|
||||
<!-- Status pill / badge -->
|
||||
<Style Selector="Border.chip">
|
||||
<Setter Property="CornerRadius" Value="20"/>
|
||||
<Setter Property="Padding" Value="10,5"/>
|
||||
<Setter Property="Background" Value="{DynamicResource SurfaceAltBrush}"/>
|
||||
</Style>
|
||||
</Styles>
|
||||
@@ -0,0 +1,96 @@
|
||||
<ResourceDictionary xmlns="https://github.com/avaloniaui"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml">
|
||||
<!-- ============================================================
|
||||
Palladium wallet design tokens.
|
||||
Identity: cobalt blue (trust) + green (money/positive),
|
||||
derived from the logo. "Clean fintech" style.
|
||||
|
||||
All colors are SEMANTIC (role, not name): components
|
||||
must never use inline hex but these brushes. Defined for
|
||||
both variants (light/dark) via ThemeDictionaries, so
|
||||
the app follows the system theme with no further configuration.
|
||||
============================================================ -->
|
||||
|
||||
<ResourceDictionary.ThemeDictionaries>
|
||||
|
||||
<!-- ============ LIGHT THEME ============ -->
|
||||
<ResourceDictionary x:Key="Light">
|
||||
<!-- Surfaces -->
|
||||
<SolidColorBrush x:Key="AppBackgroundBrush" Color="#F1F5F9"/> <!-- slate-100 -->
|
||||
<SolidColorBrush x:Key="SurfaceBrush" Color="#FFFFFF"/>
|
||||
<SolidColorBrush x:Key="SurfaceAltBrush" Color="#F8FAFC"/> <!-- slate-50 -->
|
||||
<SolidColorBrush x:Key="OverlayCardBrush" Color="#FFFFFF"/>
|
||||
<SolidColorBrush x:Key="ScrimBrush" Color="#66000000"/> <!-- 40% -->
|
||||
<!-- Borders and separators -->
|
||||
<SolidColorBrush x:Key="BorderSubtleBrush" Color="#E2E8F0"/> <!-- slate-200 -->
|
||||
<SolidColorBrush x:Key="BorderStrongBrush" Color="#CBD5E1"/> <!-- slate-300 -->
|
||||
<!-- Text -->
|
||||
<SolidColorBrush x:Key="TextPrimaryBrush" Color="#0F172A"/> <!-- slate-900 -->
|
||||
<SolidColorBrush x:Key="TextSecondaryBrush" Color="#475569"/> <!-- slate-600 -->
|
||||
<SolidColorBrush x:Key="TextMutedBrush" Color="#94A3B8"/> <!-- slate-400 -->
|
||||
<!-- Brand / state -->
|
||||
<SolidColorBrush x:Key="PrimaryBrush" Color="#2563EB"/> <!-- blue-600 -->
|
||||
<SolidColorBrush x:Key="PrimaryHoverBrush" Color="#1D4ED8"/> <!-- blue-700 -->
|
||||
<SolidColorBrush x:Key="OnPrimaryBrush" Color="#FFFFFF"/>
|
||||
<SolidColorBrush x:Key="PrimarySoftBrush" Color="#EFF6FF"/> <!-- blue-50 -->
|
||||
<SolidColorBrush x:Key="SuccessBrush" Color="#16A34A"/> <!-- green-600 -->
|
||||
<SolidColorBrush x:Key="WarningBrush" Color="#D97706"/> <!-- amber-600 -->
|
||||
<SolidColorBrush x:Key="DangerBrush" Color="#DC2626"/> <!-- red-600 -->
|
||||
<!-- Hero gradient of the balance card: blue → indigo -->
|
||||
<LinearGradientBrush x:Key="BalanceGradientBrush" StartPoint="0%,0%" EndPoint="100%,100%">
|
||||
<GradientStop Offset="0" Color="#2563EB"/>
|
||||
<GradientStop Offset="1" Color="#4F46E5"/>
|
||||
</LinearGradientBrush>
|
||||
</ResourceDictionary>
|
||||
|
||||
<!-- ============ DARK THEME ============ -->
|
||||
<ResourceDictionary x:Key="Dark">
|
||||
<!-- Surfaces -->
|
||||
<SolidColorBrush x:Key="AppBackgroundBrush" Color="#0F172A"/> <!-- slate-900 -->
|
||||
<SolidColorBrush x:Key="SurfaceBrush" Color="#1E293B"/> <!-- slate-800 -->
|
||||
<SolidColorBrush x:Key="SurfaceAltBrush" Color="#273449"/>
|
||||
<SolidColorBrush x:Key="OverlayCardBrush" Color="#1E293B"/>
|
||||
<SolidColorBrush x:Key="ScrimBrush" Color="#99000000"/> <!-- 60% -->
|
||||
<!-- Borders and separators -->
|
||||
<SolidColorBrush x:Key="BorderSubtleBrush" Color="#334155"/> <!-- slate-700 -->
|
||||
<SolidColorBrush x:Key="BorderStrongBrush" Color="#475569"/> <!-- slate-600 -->
|
||||
<!-- Text -->
|
||||
<SolidColorBrush x:Key="TextPrimaryBrush" Color="#F8FAFC"/> <!-- slate-50 -->
|
||||
<SolidColorBrush x:Key="TextSecondaryBrush" Color="#94A3B8"/> <!-- slate-400 -->
|
||||
<SolidColorBrush x:Key="TextMutedBrush" Color="#64748B"/> <!-- slate-500 -->
|
||||
<!-- Brand / state (brighter tones on dark background) -->
|
||||
<SolidColorBrush x:Key="PrimaryBrush" Color="#3B82F6"/> <!-- blue-500 -->
|
||||
<SolidColorBrush x:Key="PrimaryHoverBrush" Color="#60A5FA"/> <!-- blue-400 -->
|
||||
<SolidColorBrush x:Key="OnPrimaryBrush" Color="#0B1120"/>
|
||||
<SolidColorBrush x:Key="PrimarySoftBrush" Color="#1E3A8A"/> <!-- blue-900 -->
|
||||
<SolidColorBrush x:Key="SuccessBrush" Color="#22C55E"/> <!-- green-500 -->
|
||||
<SolidColorBrush x:Key="WarningBrush" Color="#F59E0B"/> <!-- amber-500 -->
|
||||
<SolidColorBrush x:Key="DangerBrush" Color="#EF4444"/> <!-- red-500 -->
|
||||
<!-- Hero gradient of the balance card: blue → indigo (deeper tones) -->
|
||||
<LinearGradientBrush x:Key="BalanceGradientBrush" StartPoint="0%,0%" EndPoint="100%,100%">
|
||||
<GradientStop Offset="0" Color="#1D4ED8"/>
|
||||
<GradientStop Offset="1" Color="#4338CA"/>
|
||||
</LinearGradientBrush>
|
||||
</ResourceDictionary>
|
||||
|
||||
</ResourceDictionary.ThemeDictionaries>
|
||||
|
||||
<!-- ============================================================
|
||||
Override of the Fluent accent → brand blue. Recolors
|
||||
consistently the controls that read SystemAccentColor (accent
|
||||
buttons, selections, tab indicator, focus) without retemplating anything.
|
||||
Variant-independent: #2563EB works on light and dark.
|
||||
============================================================ -->
|
||||
<Color x:Key="SystemAccentColor">#2563EB</Color>
|
||||
<Color x:Key="SystemAccentColorDark1">#1D4ED8</Color>
|
||||
<Color x:Key="SystemAccentColorDark2">#1E40AF</Color>
|
||||
<Color x:Key="SystemAccentColorDark3">#1E3A8A</Color>
|
||||
<Color x:Key="SystemAccentColorLight1">#3B82F6</Color>
|
||||
<Color x:Key="SystemAccentColorLight2">#60A5FA</Color>
|
||||
<Color x:Key="SystemAccentColorLight3">#93C5FD</Color>
|
||||
|
||||
<!-- Global Fluent control corner radii: rounds buttons, inputs, combos,
|
||||
and lists uniformly without retemplating each control. -->
|
||||
<CornerRadius x:Key="ControlCornerRadius">8</CornerRadius>
|
||||
<CornerRadius x:Key="OverlayCornerRadius">12</CornerRadius>
|
||||
</ResourceDictionary>
|
||||
@@ -0,0 +1,21 @@
|
||||
using System;
|
||||
using System.Globalization;
|
||||
using Avalonia.Controls;
|
||||
using Avalonia.Data.Converters;
|
||||
|
||||
namespace PalladiumWallet.App.ViewModels;
|
||||
|
||||
/// <summary>
|
||||
/// true (mobile) → Dock.Bottom — tab strip at the bottom, Android standard.
|
||||
/// false (desktop) → Dock.Top — Avalonia default behavior.
|
||||
/// </summary>
|
||||
public sealed class BoolToTabPlacementConverter : IValueConverter
|
||||
{
|
||||
public static readonly BoolToTabPlacementConverter Instance = new();
|
||||
|
||||
public object Convert(object? value, Type targetType, object? parameter, CultureInfo culture) =>
|
||||
value is true ? Dock.Bottom : Dock.Top;
|
||||
|
||||
public object ConvertBack(object? value, Type targetType, object? parameter, CultureInfo culture) =>
|
||||
throw new NotSupportedException();
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
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;
|
||||
|
||||
public partial class MainWindowViewModel
|
||||
{
|
||||
public ObservableCollection<ContactEntry> Contacts { get; } = [];
|
||||
|
||||
[ObservableProperty]
|
||||
private ContactEntry? selectedContactInList;
|
||||
|
||||
/// <summary>Contact selected in the Send panel's ComboBox: fills SendTo.</summary>
|
||||
[ObservableProperty]
|
||||
private ContactEntry? sendToContact;
|
||||
|
||||
partial void OnSendToContactChanged(ContactEntry? value)
|
||||
{
|
||||
if (value is not null)
|
||||
SendTo = value.Address;
|
||||
}
|
||||
|
||||
[ObservableProperty]
|
||||
private string newContactName = "";
|
||||
|
||||
[ObservableProperty]
|
||||
private string newContactAddress = "";
|
||||
|
||||
[RelayCommand]
|
||||
private void AddContact()
|
||||
{
|
||||
var name = NewContactName.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();
|
||||
}
|
||||
|
||||
[RelayCommand]
|
||||
private void RemoveSelectedContact()
|
||||
{
|
||||
if (SelectedContactInList is { } c)
|
||||
{
|
||||
Contacts.Remove(c);
|
||||
SelectedContactInList = null;
|
||||
PersistContacts();
|
||||
}
|
||||
}
|
||||
|
||||
private void PersistContacts()
|
||||
{
|
||||
if (_doc is null || _walletPath is null) return;
|
||||
_doc.Contacts = Contacts
|
||||
.Select(c => new StoredContact { Name = c.Name, Address = c.Address })
|
||||
.ToList();
|
||||
WalletStore.Save(_doc, _walletPath, _password);
|
||||
}
|
||||
|
||||
private void LoadContacts()
|
||||
{
|
||||
Contacts.Clear();
|
||||
if (_doc is null) return;
|
||||
foreach (var c in _doc.Contacts)
|
||||
Contacts.Add(new ContactEntry(c.Name, c.Address));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
using System;
|
||||
using System.Threading.Tasks;
|
||||
using CommunityToolkit.Mvvm.ComponentModel;
|
||||
using CommunityToolkit.Mvvm.Input;
|
||||
using NBitcoin;
|
||||
using PalladiumWallet.App.Localization;
|
||||
using PalladiumWallet.Core.Chain;
|
||||
using PalladiumWallet.Core.Net;
|
||||
using PalladiumWallet.Core.Wallet;
|
||||
|
||||
namespace PalladiumWallet.App.ViewModels;
|
||||
|
||||
public partial class MainWindowViewModel
|
||||
{
|
||||
public const string DevAddress = "plm1qdq3gu2zvg9lyr8gxd6yln4wavc5tlp8prmvfay";
|
||||
|
||||
[ObservableProperty]
|
||||
private string donateAmount = "";
|
||||
|
||||
[ObservableProperty]
|
||||
private string donatePreview = "";
|
||||
|
||||
[ObservableProperty]
|
||||
private bool hasPendingDonate;
|
||||
|
||||
private BuiltTransaction? _pendingDonate;
|
||||
|
||||
[RelayCommand]
|
||||
private async Task PrepareDonate()
|
||||
{
|
||||
_pendingDonate = null;
|
||||
HasPendingDonate = false;
|
||||
|
||||
if (_account is null || _doc?.Cache is null || _lastTransactions is null)
|
||||
{
|
||||
DonatePreview = Loc.Tr("msg.send.sync.first");
|
||||
return;
|
||||
}
|
||||
try
|
||||
{
|
||||
var destination = BitcoinAddress.Create(DevAddress, PalladiumNetworks.For(Net));
|
||||
if (!CoinAmount.TryParseIn(DonateAmount.Trim(), _config.Unit, out var amount) || amount <= 0)
|
||||
{
|
||||
DonatePreview = Loc.Tr("msg.amount.invalid");
|
||||
return;
|
||||
}
|
||||
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,
|
||||
_doc.Cache.NextChangeIndex, _doc.Cache.TipHeight, sendAll: false);
|
||||
|
||||
DonatePreview = $"txid {_pendingDonate.Txid[..16]}… · " +
|
||||
$"fee {Fmt(_pendingDonate.Fee.Satoshi)} " +
|
||||
$"({_pendingDonate.Transaction.GetVirtualSize()} vB)" +
|
||||
(_pendingDonate.Signed ? "" : " · watch-only") +
|
||||
HighFeeWarning(_pendingDonate.Fee.Satoshi, amount);
|
||||
HasPendingDonate = _pendingDonate.Signed;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_pendingDonate = null;
|
||||
HasPendingDonate = false;
|
||||
DonatePreview = $"{Loc.Tr("msg.error")}: {ex.Message}";
|
||||
}
|
||||
await Task.CompletedTask;
|
||||
}
|
||||
|
||||
[RelayCommand]
|
||||
private async Task ConfirmDonate()
|
||||
{
|
||||
if (_pendingDonate is null || _client is null)
|
||||
return;
|
||||
try
|
||||
{
|
||||
var txid = await _client.BroadcastAsync(_pendingDonate.ToHex());
|
||||
DonatePreview = $"{Loc.Tr("msg.donate.thanks")}: {txid}";
|
||||
DonateAmount = "";
|
||||
_pendingDonate = null;
|
||||
HasPendingDonate = false;
|
||||
await ConnectAndSync();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
DonatePreview = $"{Loc.Tr("msg.broadcast.error")}: {ex.Message}";
|
||||
}
|
||||
}
|
||||
|
||||
private void ResetDonate()
|
||||
{
|
||||
DonateAmount = "";
|
||||
DonatePreview = "";
|
||||
HasPendingDonate = false;
|
||||
_pendingDonate = null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,330 @@
|
||||
using System;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Avalonia.Media.Imaging;
|
||||
using CommunityToolkit.Mvvm.ComponentModel;
|
||||
using CommunityToolkit.Mvvm.Input;
|
||||
using PalladiumWallet.App.Localization;
|
||||
using PalladiumWallet.Core.Chain;
|
||||
using PalladiumWallet.Core.Spv;
|
||||
using PalladiumWallet.Core.Storage;
|
||||
using PalladiumWallet.Core.Wallet;
|
||||
using QRCoder;
|
||||
|
||||
namespace PalladiumWallet.App.ViewModels;
|
||||
|
||||
public partial class MainWindowViewModel
|
||||
{
|
||||
// ---- balance and wallet info ----
|
||||
|
||||
[ObservableProperty]
|
||||
private string balanceText = "—";
|
||||
|
||||
[ObservableProperty]
|
||||
private string unconfirmedText = "";
|
||||
|
||||
[ObservableProperty]
|
||||
private string immatureText = "";
|
||||
|
||||
[ObservableProperty]
|
||||
private string verifyingText = "";
|
||||
|
||||
[ObservableProperty]
|
||||
private string networkInfo = "";
|
||||
|
||||
// ---- receive address and QR ----
|
||||
|
||||
[ObservableProperty]
|
||||
private string receiveAddress = "";
|
||||
|
||||
[ObservableProperty]
|
||||
private Bitmap? receiveQr;
|
||||
|
||||
partial void OnReceiveAddressChanged(string value)
|
||||
{
|
||||
var previous = ReceiveQr;
|
||||
ReceiveQr = string.IsNullOrEmpty(value) ? null : GenerateQr(value);
|
||||
previous?.Dispose();
|
||||
}
|
||||
|
||||
public void NotifyAddressCopied() => StatusMessage = Loc.Tr("addr.copied");
|
||||
|
||||
private static Bitmap? GenerateQr(string text)
|
||||
{
|
||||
try
|
||||
{
|
||||
using var generator = new QRCodeGenerator();
|
||||
using var data = generator.CreateQrCode(text, QRCodeGenerator.ECCLevel.M);
|
||||
var png = new PngByteQRCode(data).GetGraphic(8);
|
||||
return new Bitmap(new MemoryStream(png));
|
||||
}
|
||||
catch
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// ---- addresses tab ----
|
||||
|
||||
[ObservableProperty]
|
||||
private AddressRow? selectedAddressRow;
|
||||
|
||||
[ObservableProperty]
|
||||
private AddressInfo? addressInfo;
|
||||
|
||||
[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]
|
||||
private bool isPrivKeyPromptOpen;
|
||||
|
||||
[ObservableProperty]
|
||||
private string privKeyPromptPassword = "";
|
||||
|
||||
[ObservableProperty]
|
||||
private string privKeyPromptError = "";
|
||||
|
||||
partial void OnAddressInfoChanged(AddressInfo? value)
|
||||
{
|
||||
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;
|
||||
PrivKeyPromptPassword = "";
|
||||
PrivKeyPromptError = "";
|
||||
}
|
||||
|
||||
public void ShowAddressInfo(AddressRow row) =>
|
||||
AddressInfo = new AddressInfo(Loc, row.Indirizzo, row.DerivPath, row.PubKey,
|
||||
row.IsChange, row.Indice, row.HasPrivKey);
|
||||
|
||||
[RelayCommand]
|
||||
private void CloseAddressInfo()
|
||||
{
|
||||
ClosePrivKeyPromptInternal();
|
||||
AddressInfo = null;
|
||||
}
|
||||
|
||||
[RelayCommand]
|
||||
private void RequestPrivKeyReveal()
|
||||
{
|
||||
if (AddressInfo is null || !AddressInfo.HasPrivKey) return;
|
||||
if (string.IsNullOrEmpty(_password))
|
||||
{
|
||||
// No encryption: reveal directly
|
||||
RevealPrivKeyInternal();
|
||||
}
|
||||
else
|
||||
{
|
||||
// Encrypted: open password prompt
|
||||
PrivKeyPromptPassword = "";
|
||||
PrivKeyPromptError = "";
|
||||
IsPrivKeyPromptOpen = true;
|
||||
}
|
||||
}
|
||||
|
||||
[RelayCommand]
|
||||
private void ConfirmPrivKeyPassword()
|
||||
{
|
||||
if (PrivKeyPromptPassword != _password)
|
||||
{
|
||||
PrivKeyPromptError = Loc.Tr("msg.wrongpassword");
|
||||
return;
|
||||
}
|
||||
RevealPrivKeyInternal();
|
||||
ClosePrivKeyPromptInternal();
|
||||
}
|
||||
|
||||
[RelayCommand]
|
||||
private void CancelPrivKeyPrompt() => ClosePrivKeyPromptInternal();
|
||||
|
||||
[RelayCommand]
|
||||
private void HideAddressPrivKey() => HidePrivKeyInternal();
|
||||
|
||||
// ---- transaction detail overlay ----
|
||||
|
||||
[ObservableProperty]
|
||||
private bool isTxDetailsOpen;
|
||||
|
||||
[ObservableProperty]
|
||||
private bool isTxDetailsLoading;
|
||||
|
||||
[ObservableProperty]
|
||||
private TransactionDetailsViewModel? txDetails;
|
||||
|
||||
public async Task ShowTransactionDetailsAsync(string txid)
|
||||
{
|
||||
if (_client is null || !_client.IsConnected)
|
||||
{
|
||||
StatusMessage = Loc.Tr("tx.needconnection");
|
||||
return;
|
||||
}
|
||||
|
||||
_txDetailsCts?.Cancel();
|
||||
_txDetailsCts = new CancellationTokenSource();
|
||||
var ct = _txDetailsCts.Token;
|
||||
|
||||
TxDetails = null;
|
||||
IsTxDetailsLoading = true;
|
||||
IsTxDetailsOpen = true;
|
||||
|
||||
var details = await BuildTransactionDetailsAsync(txid, ct);
|
||||
|
||||
if (ct.IsCancellationRequested || !IsTxDetailsOpen)
|
||||
return;
|
||||
|
||||
if (details is null)
|
||||
{
|
||||
IsTxDetailsOpen = false;
|
||||
IsTxDetailsLoading = false;
|
||||
return;
|
||||
}
|
||||
|
||||
TxDetails = details;
|
||||
IsTxDetailsLoading = false;
|
||||
}
|
||||
|
||||
[RelayCommand]
|
||||
private void CloseTransactionDetails()
|
||||
{
|
||||
_txDetailsCts?.Cancel();
|
||||
IsTxDetailsOpen = false;
|
||||
IsTxDetailsLoading = false;
|
||||
TxDetails = null;
|
||||
}
|
||||
|
||||
public async Task<TransactionDetailsViewModel?> BuildTransactionDetailsAsync(
|
||||
string txid, CancellationToken ct = default)
|
||||
{
|
||||
if (_client is null || !_client.IsConnected)
|
||||
{
|
||||
StatusMessage = Loc.Tr("tx.needconnection");
|
||||
return null;
|
||||
}
|
||||
if (_doc?.Cache is not { } cache)
|
||||
return null;
|
||||
|
||||
var client = _client;
|
||||
var network = PalladiumNetworks.For(Net);
|
||||
var row = cache.History.FirstOrDefault(t => t.Txid == txid);
|
||||
var owned = cache.Addresses.Select(a => a.Address).ToHashSet();
|
||||
var tipHeight = cache.TipHeight;
|
||||
var height = row?.Height ?? 0;
|
||||
var delta = row?.DeltaSats ?? 0;
|
||||
var verified = row?.Verified ?? false;
|
||||
var transactions = _lastTransactions;
|
||||
var loc = _loc;
|
||||
var unit = _config.Unit;
|
||||
var unanchored = Profile.IsAboveNewestCheckpoint(height);
|
||||
|
||||
try
|
||||
{
|
||||
return await Task.Run(async () =>
|
||||
{
|
||||
var details = await TransactionInspector.FetchAsync(
|
||||
client, network, txid, tipHeight, height, owned, delta, verified, transactions, ct);
|
||||
return new TransactionDetailsViewModel(details, loc, unit, unanchored);
|
||||
}, ct);
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
StatusMessage = $"{Loc.Tr("msg.error")}: {ex.Message}";
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// ---- update display from the sync result ----
|
||||
|
||||
private void ApplyCache(SyncCache? cache)
|
||||
{
|
||||
if (_account is null)
|
||||
return;
|
||||
if (cache is null)
|
||||
{
|
||||
BalanceText = $"0.00000000 {Profile.CoinUnit}";
|
||||
UnconfirmedText = "";
|
||||
ImmatureText = "";
|
||||
VerifyingText = "";
|
||||
ReceiveAddress = _account.GetReceiveAddress(0).ToString();
|
||||
History.Clear();
|
||||
Addresses.Clear();
|
||||
for (var i = 0; i < 10; i++)
|
||||
Addresses.Add(new AddressRow(_loc["addr.receive"], i,
|
||||
_account.GetReceiveAddress(i).ToString(), "—", "—",
|
||||
false,
|
||||
_account.GetPublicKey(false, i)?.ToHex() ?? "",
|
||||
$"m/{_doc!.AccountPath}/0/{i}",
|
||||
!_account.IsWatchOnly));
|
||||
return;
|
||||
}
|
||||
// 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")}"
|
||||
: "";
|
||||
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.Verified,
|
||||
Profile.IsAboveNewestCheckpoint(tx.Height)));
|
||||
|
||||
Addresses.Clear();
|
||||
foreach (var a in cache.Addresses)
|
||||
Addresses.Add(new AddressRow(
|
||||
a.IsChange ? _loc["addr.change"] : _loc["addr.receive"],
|
||||
a.Index,
|
||||
a.Address,
|
||||
a.BalanceSats > 0 ? Fmt(a.BalanceSats, withLabel: false) : (a.TxCount > 0 ? "0" : "—"),
|
||||
a.TxCount > 0 ? a.TxCount.ToString() : "—",
|
||||
a.IsChange,
|
||||
_account.GetPublicKey(a.IsChange, a.Index)?.ToHex() ?? "",
|
||||
$"m/{_doc!.AccountPath}/{(a.IsChange ? 1 : 0)}/{a.Index}",
|
||||
!_account.IsWatchOnly));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,209 @@
|
||||
using System;
|
||||
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;
|
||||
using PalladiumWallet.Core.Net;
|
||||
using PalladiumWallet.Core.Wallet;
|
||||
|
||||
namespace PalladiumWallet.App.ViewModels;
|
||||
|
||||
public partial class MainWindowViewModel
|
||||
{
|
||||
[ObservableProperty]
|
||||
private string sendTo = "";
|
||||
|
||||
[ObservableProperty]
|
||||
private string sendAmount = "";
|
||||
|
||||
[ObservableProperty]
|
||||
private string sendFeeRate = "1";
|
||||
|
||||
[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()
|
||||
{
|
||||
if (PlatformServices.ScanQrAsync is not { } scan) return;
|
||||
var raw = await scan();
|
||||
if (string.IsNullOrWhiteSpace(raw)) return;
|
||||
// Handle URIs like "palladium:ADDRESS?amount=X" — extract address only
|
||||
var address = raw.Contains(':') ? raw.Split(':')[1] : raw;
|
||||
if (address.Contains('?')) address = address.Split('?')[0];
|
||||
SendTo = address.Trim();
|
||||
}
|
||||
|
||||
[RelayCommand]
|
||||
private async Task PrepareSend()
|
||||
{
|
||||
if (_account is null || _doc?.Cache is null)
|
||||
{
|
||||
SendPreview = Loc.Tr("msg.send.sync.first");
|
||||
return;
|
||||
}
|
||||
try
|
||||
{
|
||||
if (_lastTransactions is null)
|
||||
{
|
||||
SendPreview = Loc.Tr("msg.send.connect.first");
|
||||
return;
|
||||
}
|
||||
|
||||
var destination = BitcoinAddress.Create(SendTo.Trim(), PalladiumNetworks.For(Net));
|
||||
long amount = 0;
|
||||
if (!SendAll && !CoinAmount.TryParseIn(SendAmount, _config.Unit, out amount))
|
||||
{
|
||||
SendPreview = Loc.Tr("msg.amount.invalid");
|
||||
return;
|
||||
}
|
||||
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,
|
||||
_doc.Cache.NextChangeIndex, _doc.Cache.TipHeight, SendAll);
|
||||
|
||||
SendPreview = $"txid {_pendingSend.Txid[..16]}… · " +
|
||||
$"fee {Fmt(_pendingSend.Fee.Satoshi)} " +
|
||||
$"({_pendingSend.Transaction.GetVirtualSize()} vB)" +
|
||||
(_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()
|
||||
{
|
||||
if (_pendingSend is null || _client is null)
|
||||
return;
|
||||
try
|
||||
{
|
||||
var txid = await _client.BroadcastAsync(_pendingSend.ToHex());
|
||||
SendPreview = $"{Loc.Tr("msg.broadcasted")}: {txid}";
|
||||
SendTo = SendAmount = "";
|
||||
_pendingSend = null;
|
||||
HasPendingSend = false;
|
||||
PendingPsbtBase64 = "";
|
||||
await ConnectAndSync();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
SendPreview = $"{Loc.Tr("msg.broadcast.error")}: {DescribeError(ex)}";
|
||||
}
|
||||
}
|
||||
}
|
||||