Compare commits
22 Commits
1f857ddf52
..
0.9.1
| Author | SHA1 | Date | |
|---|---|---|---|
| 15978bf564 | |||
| 31aabd0856 | |||
| 56ce1d4259 | |||
| 448f7dc65d | |||
| e8ff99a768 | |||
| 27231c8eec | |||
| eb7ec9e835 | |||
| f0fb3fe05d | |||
| 3f26f52f8b | |||
| cb3ff714d9 | |||
| d0037747b5 | |||
| 34b4313a36 | |||
| 2d017231eb | |||
| e7797017f6 | |||
| 6c24a8bb46 | |||
| 9aecdb1aaa | |||
| 2be43d3177 | |||
| ef60ec0330 | |||
| 567c0de501 | |||
| 6a5daa0c18 | |||
| 4d730d7860 | |||
| 068d85e0e2 |
@@ -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/davide3011/PalladiumWallet/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
|
||||
@@ -74,6 +74,8 @@ temp/
|
||||
*.key
|
||||
*.pfx
|
||||
*.p12
|
||||
*.keystore
|
||||
*.jks
|
||||
secrets/
|
||||
|
||||
# Wallet data and local runtime state
|
||||
|
||||
@@ -29,6 +29,7 @@ src/App/ shared Avalonia UI library (App, Views, ViewModels, Loc, Asset
|
||||
src/App.Desktop/ desktop head (WinExe): Program.cs, app.manifest, .ico -> runnable
|
||||
src/App.Android/ Android head (net10.0-android): MainApplication/MainActivity -> apk
|
||||
src/Cli/ CLI on the same Core tests/ xUnit
|
||||
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
|
||||
@@ -43,11 +44,12 @@ by `MainWindow` on desktop and as the single-view root on Android.
|
||||
`export PATH="$HOME/.dotnet10:$PATH" DOTNET_ROOT="$HOME/.dotnet10"`.
|
||||
|
||||
- Build: `dotnet build`
|
||||
- Tests (headless, the primary verification layer): `dotnet test` -- single: `dotnet test --filter "FullyQualifiedName~TestName"`; property-based tests (CsCheck, `PropertyTests.cs`) run in the same command and take ~30 s
|
||||
- Tests (headless, the primary verification layer): `dotnet test` -- single: `dotnet test --filter "FullyQualifiedName~TestName"`; property-based tests (CsCheck, `PropertyTests.cs`) run in the same command and take ~30 s; coverage: `dotnet test tests/PalladiumWallet.Tests --collect:"XPlat Code Coverage"` (coverlet, Cobertura XML). Network/SPV code (`ElectrumClient`, `WalletSynchronizer`, `TransactionInspector`, TOFU pinning) is tested against the in-process fake ElectrumX server in `tests/PalladiumWallet.Tests/Net/FakeElectrumServer.cs` (loopback TCP + optional TLS, per-method handlers, call counters): extend that, don't mock the client -- it isn't an interface, by design.
|
||||
- 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)
|
||||
- Windows publish: `dotnet publish src/App.Desktop -r win-x64 -p:PublishSingleFile=true --self-contained`
|
||||
- Linux publish: `dotnet publish src/App.Desktop -r linux-x64 --self-contained` (then AppImage via PupNet Deploy)
|
||||
- **Release binaries (all 3 targets): `./docker/build.sh [windows|linux|android|all]`** -- reproducible builds in Docker (toolchain pinned in `docker/Dockerfile.*`, no SDK needed on host), artifacts in `dist/`, version taken from the App csproj. See `docker/README.md`. Gotchas already encoded there: single-file desktop publishes need `-p:IncludeNativeLibrariesForSelfExtract=true` (without it Avalonia's native libs -- Skia/HarfBuzz/ANGLE -- stay outside the exe, which then silently fails to start); the android workload dictates the SDK API level (error XA5207 -> bump `ANDROID_SDK_PLATFORM` in `Dockerfile.android`). Android release builds need a persistent signing keystore, generated once with `docker/keystore/generate-keystore.sh` (never committed -- see `docker/keystore/README.md`): without it every build gets a different random signature and users must uninstall the old app to receive an update instead of updating in place.
|
||||
- Manual Windows publish: `dotnet publish src/App.Desktop -r win-x64 -p:PublishSingleFile=true -p:IncludeNativeLibrariesForSelfExtract=true --self-contained`
|
||||
- Manual Linux publish: same with `-r linux-x64` (single-file binary; 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. To provision the SDK once:
|
||||
@@ -82,3 +84,4 @@ Note: a plain `dotnet build` at the solution level needs the Android SDK path fo
|
||||
|
||||
- **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.
|
||||
- **Releases:** whenever a new version tag is created (bumping `<Version>` in `src/App/PalladiumWallet.App.csproj`), update `CHANGELOG.md` with an entry for that version before/with the tag -- it's the technical record of what shipped, not optional bookkeeping.
|
||||
|
||||
+204
@@ -0,0 +1,204 @@
|
||||
# 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.
|
||||
|
||||
## [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).
|
||||
@@ -29,6 +29,7 @@ src/App/ shared Avalonia UI library (App, Views, ViewModels, Loc, Asset
|
||||
src/App.Desktop/ desktop head (WinExe): Program.cs, app.manifest, .ico → runnable
|
||||
src/App.Android/ Android head (net10.0-android): MainApplication/MainActivity → apk
|
||||
src/Cli/ CLI on the same Core tests/ xUnit
|
||||
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
|
||||
@@ -43,11 +44,12 @@ by `MainWindow` on desktop and as the single-view root on Android.
|
||||
`export PATH="$HOME/.dotnet10:$PATH" DOTNET_ROOT="$HOME/.dotnet10"`.
|
||||
|
||||
- Build: `dotnet build`
|
||||
- Tests (headless, the primary verification layer): `dotnet test` — single: `dotnet test --filter "FullyQualifiedName~TestName"`; property-based tests (CsCheck, `PropertyTests.cs`) run in the same command and take ~30 s
|
||||
- Tests (headless, the primary verification layer): `dotnet test` — single: `dotnet test --filter "FullyQualifiedName~TestName"`; property-based tests (CsCheck, `PropertyTests.cs`) run in the same command and take ~30 s; coverage: `dotnet test tests/PalladiumWallet.Tests --collect:"XPlat Code Coverage"` (coverlet, Cobertura XML). Network/SPV code (`ElectrumClient`, `WalletSynchronizer`, `TransactionInspector`, TOFU pinning) is tested against the in-process fake ElectrumX server in `tests/PalladiumWallet.Tests/Net/FakeElectrumServer.cs` (loopback TCP + optional TLS, per-method handlers, call counters): extend that, don't mock the client — it isn't an interface, by design.
|
||||
- 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)
|
||||
- Windows publish: `dotnet publish src/App.Desktop -r win-x64 -p:PublishSingleFile=true --self-contained`
|
||||
- Linux publish: `dotnet publish src/App.Desktop -r linux-x64 --self-contained` (then AppImage via PupNet Deploy)
|
||||
- **Release binaries (all 3 targets): `./docker/build.sh [windows|linux|android|all]`** — reproducible builds in Docker (toolchain pinned in `docker/Dockerfile.*`, no SDK needed on host), artifacts in `dist/`, version taken from the App csproj. See `docker/README.md`. Gotchas already encoded there: single-file desktop publishes need `-p:IncludeNativeLibrariesForSelfExtract=true` (without it Avalonia's native libs — Skia/HarfBuzz/ANGLE — stay outside the exe, which then silently fails to start); the android workload dictates the SDK API level (error XA5207 → bump `ANDROID_SDK_PLATFORM` in `Dockerfile.android`). Android release builds need a persistent signing keystore, generated once with `docker/keystore/generate-keystore.sh` (never committed — see `docker/keystore/README.md`): without it every build gets a different random signature and users must uninstall the old app to receive an update instead of updating in place.
|
||||
- Manual Windows publish: `dotnet publish src/App.Desktop -r win-x64 -p:PublishSingleFile=true -p:IncludeNativeLibrariesForSelfExtract=true --self-contained`
|
||||
- Manual Linux publish: same with `-r linux-x64` (single-file binary; 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. To provision the SDK once:
|
||||
@@ -82,3 +84,4 @@ Note: a plain `dotnet build` at the solution level needs the Android SDK path fo
|
||||
|
||||
- **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.
|
||||
- **Releases:** whenever a new version tag is created (bumping `<Version>` in `src/App/PalladiumWallet.App.csproj`), update `CHANGELOG.md` with an entry for that version before/with the tag — it's the technical record of what shipped, not optional bookkeeping.
|
||||
|
||||
@@ -139,14 +139,59 @@ Run only the tests in one project:
|
||||
dotnet test tests/PalladiumWallet.Tests
|
||||
```
|
||||
|
||||
Measure code coverage (the [coverlet](https://github.com/coverlet-coverage/coverlet) collector is already referenced; the report is a Cobertura XML under `TestResults/`):
|
||||
```bash
|
||||
dotnet test tests/PalladiumWallet.Tests --collect:"XPlat Code Coverage"
|
||||
```
|
||||
|
||||
> Cross-implementation tests compare addresses, txids and PSBTs against reference golden vectors: a different address or txid is a blocking bug.
|
||||
|
||||
The suite 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, every leaf in a randomly-built Merkle tree verifies against its root. They run automatically with `dotnet test` and take ~30 s.
|
||||
### 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 |
|
||||
| `Crypto/` | BIP39 (official Trezor vectors, NFKD normalisation), BIP32/44/49/84/86 derivation against public golden vectors, SLIP-132 encode/decode, HD and imported-key accounts, watch-only isolation |
|
||||
| `Spv/` | Scripthash (vectors computed independently in Python), Merkle proofs (Bitcoin block 100000 + random trees), header parsing, and the full **`WalletSynchronizer`**: gap-limit scanning, UTXO/history reconstruction, unconfirmed/immature balances, busy-retry, disk-cache reuse — including the path where a lying server fails Merkle verification and the sync must abort |
|
||||
| `Net/` | JSON-RPC transport (pipelining, error mapping, notifications, disconnection, cancellation), typed protocol wrappers, peer discovery/persistence, TLS trust-on-first-use pinning end-to-end (pin, match, mismatch, reset), release-tag parsing |
|
||||
| `Storage/` | AES-GCM encryption (roundtrip, tampering, fresh salt/nonce), wallet document schema/versioning, atomic saves, single-instance lock, data paths |
|
||||
| `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), transaction detail assembly (fees, mine/theirs attribution, RBF, coinbase), amount parsing/formatting |
|
||||
|
||||
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.
|
||||
|
||||
---
|
||||
|
||||
## 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
|
||||
@@ -162,7 +207,10 @@ dotnet build src/App.Desktop # desktop head only
|
||||
|
||||
```bash
|
||||
# Windows — single self-contained .exe (output: src/App.Desktop/bin/Release/net10.0/win-x64/publish/PalladiumWallet.exe)
|
||||
dotnet publish src/App.Desktop -c Release -r win-x64 -p:PublishSingleFile=true --self-contained
|
||||
# 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)
|
||||
@@ -195,8 +243,11 @@ The ABI restriction uses the `AbiArm64Only` flag, which is scoped to the Android
|
||||
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.) Release signing with your own
|
||||
keystore is not set up yet; the debug apk is fine for personal sideloading.
|
||||
(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,
|
||||
@@ -309,6 +360,10 @@ The default wallet file is `~/.palladium-wallet/<network>/wallets/default.wallet
|
||||
|
||||
---
|
||||
|
||||
## 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,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,199 @@
|
||||
# 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)
|
||||
android Android APK (release-signed, prompts for keystore passwords)
|
||||
all All three targets
|
||||
|
||||
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 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` |
|
||||
| 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`.
|
||||
|
||||
**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 | ~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).
|
||||
Executable
+235
@@ -0,0 +1,235 @@
|
||||
#!/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/
|
||||
android Android APK (release-signed) → dist/android/
|
||||
all All three targets
|
||||
|
||||
$(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|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" "android" "all" "quit")
|
||||
select opt in "${options[@]}"; do
|
||||
case "$opt" in
|
||||
windows|linux|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_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: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 ;;
|
||||
android) build_android ;;
|
||||
all)
|
||||
build_windows
|
||||
build_linux
|
||||
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.
|
||||
Executable
+65
@@ -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."
|
||||
@@ -8,8 +8,8 @@
|
||||
<Nullable>enable</Nullable>
|
||||
<ApplicationId>io.github.davide3011.palladiumwallet</ApplicationId>
|
||||
<!-- ApplicationVersion = versionCode (intero), ApplicationDisplayVersion = versionName -->
|
||||
<ApplicationVersion>1</ApplicationVersion>
|
||||
<ApplicationDisplayVersion>0.9.0</ApplicationDisplayVersion>
|
||||
<ApplicationVersion>2</ApplicationVersion>
|
||||
<ApplicationDisplayVersion>0.9.1</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
|
||||
|
||||
@@ -54,14 +54,19 @@ public sealed class Loc
|
||||
["menu.help"] = ["_Help", "_Help", "_Ayuda", "_Aide", "_Ajuda", "_Hilfe"],
|
||||
["help.title"] = ["Informazioni", "About", "Información", "À propos", "Sobre", "Über"],
|
||||
["help.info"] = [
|
||||
"Wallet SPV per la criptovaluta Palladium (PLM).",
|
||||
"SPV wallet for the Palladium (PLM) cryptocurrency.",
|
||||
"Monedero SPV para la criptomoneda Palladium (PLM).",
|
||||
"Portefeuille SPV pour la cryptomonnaie Palladium (PLM).",
|
||||
"Carteira SPV para a criptomoeda Palladium (PLM).",
|
||||
"SPV-Wallet für die Kryptowährung Palladium (PLM)."],
|
||||
"Wallet SPV leggero e non-custodiale per la rete Palladium (PLM). Sicurezza locale: seed e chiavi sempre cifrati, mai esposti in rete.",
|
||||
"Lightweight, non-custodial SPV wallet for the Palladium (PLM) network. Local security: seed and keys always encrypted, never exposed on the wire.",
|
||||
"Monedero SPV ligero y sin custodia para la red Palladium (PLM). Seguridad local: semilla y claves siempre cifradas, nunca expuestas en la red.",
|
||||
"Portefeuille SPV léger et non-custodial pour le réseau Palladium (PLM). Sécurité locale : graine et clés toujours chiffrées, jamais exposées sur le réseau.",
|
||||
"Carteira SPV leve e não custodial para a rede Palladium (PLM). Segurança local: semente e chaves sempre cifradas, nunca expostas na rede.",
|
||||
"Leichtes, nicht-verwahrendes SPV-Wallet für das Palladium (PLM)-Netzwerk. Lokale Sicherheit: Seed und Schlüssel stets verschlüsselt, nie im Netzwerk exponiert."],
|
||||
["help.tab.info"] = ["Info", "Info", "Info", "Info", "Info", "Info"],
|
||||
["help.tab.donate"] = ["Dona", "Donate", "Donar", "Faire un don", "Doar", "Spenden"],
|
||||
["help.bug.report"] = ["Segnala un bug", "Report a bug", "Informar un error", "Signaler un bug", "Reportar um bug", "Fehler melden"],
|
||||
["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.",
|
||||
@@ -168,9 +173,9 @@ public sealed class Loc
|
||||
// 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 e sincronizza", "Connect and sync", "Conectar y sincronizar", "Connecter et synchroniser", "Conectar e sincronizar", "Verbinden und synchronisieren"],
|
||||
["wallet.connect"] = ["Connetti", "Connect", "Conectar", "Connecter", "Conectar", "Verbinden"],
|
||||
["wallet.manual"] = ["oppure host:porta manuale", "or manual host:port", "o host:puerto manual", "ou hôte:port manuel", "ou host:porta manual", "oder manuell host:port"],
|
||||
["wallet.discover"] = ["Cerca altri server", "Discover servers", "Buscar otros servidores", "Rechercher des serveurs", "Procurar servidores", "Server suchen"],
|
||||
["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"],
|
||||
@@ -388,6 +393,7 @@ public sealed class Loc
|
||||
["msg.height"] = ["altezza", "height", "altura", "hauteur", "altura", "Höhe"],
|
||||
["msg.pending"] = ["in attesa di conferma", "pending confirmation", "pendiente de confirmación", "en attente de confirmation", "aguardando confirmação", "ausstehende Bestätigung"],
|
||||
["msg.notspendable"] = ["non ancora spendibile", "not yet spendable", "aún no gastable", "pas encore dépensable", "ainda não gastável", "noch nicht verwendbar"],
|
||||
["msg.immature"] = ["in maturazione", "maturing", "en maduración", "en maturation", "em maturação", "in Reifung"],
|
||||
["msg.settings.saved"] = ["Impostazioni salvate.", "Settings saved.", "Configuración guardada.", "Paramètres enregistrés.", "Configurações salvas.", "Einstellungen gespeichert."],
|
||||
["msg.certreset"] = [
|
||||
"Certificati SSL azzerati: riprova la connessione.",
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<!-- Versione dell'applicazione: unico punto da modificare. Compare nel
|
||||
titolo della finestra ed è incisa nei binari pubblicati. -->
|
||||
<Version>0.9.0</Version>
|
||||
<Version>0.9.1</Version>
|
||||
<Nullable>enable</Nullable>
|
||||
<AvaloniaUseCompiledBindingsByDefault>true</AvaloniaUseCompiledBindingsByDefault>
|
||||
</PropertyGroup>
|
||||
|
||||
@@ -49,7 +49,7 @@ public partial class MainWindowViewModel
|
||||
|
||||
_pendingDonate = new TransactionFactory(_account).Build(
|
||||
_doc.Cache.Utxos, _lastTransactions, destination, amount, feeRate,
|
||||
_doc.Cache.NextChangeIndex, sendAll: false);
|
||||
_doc.Cache.NextChangeIndex, _doc.Cache.TipHeight, sendAll: false);
|
||||
|
||||
DonatePreview = $"txid {_pendingDonate.Txid[..16]}… · " +
|
||||
$"fee {Fmt(_pendingDonate.Fee.Satoshi)} " +
|
||||
|
||||
@@ -25,6 +25,9 @@ public partial class MainWindowViewModel
|
||||
[ObservableProperty]
|
||||
private string unconfirmedText = "";
|
||||
|
||||
[ObservableProperty]
|
||||
private string immatureText = "";
|
||||
|
||||
[ObservableProperty]
|
||||
private string networkInfo = "";
|
||||
|
||||
@@ -249,6 +252,7 @@ public partial class MainWindowViewModel
|
||||
{
|
||||
BalanceText = $"0.00000000 {Profile.CoinUnit}";
|
||||
UnconfirmedText = "";
|
||||
ImmatureText = "";
|
||||
ReceiveAddress = _account.GetReceiveAddress(0).ToString();
|
||||
History.Clear();
|
||||
Addresses.Clear();
|
||||
@@ -261,11 +265,14 @@ public partial class MainWindowViewModel
|
||||
$"m/{_doc!.AccountPath}/0/{i}"));
|
||||
return;
|
||||
}
|
||||
BalanceText = Fmt(cache.ConfirmedSats);
|
||||
BalanceText = Fmt(cache.ConfirmedSats - cache.ImmatureSats);
|
||||
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")}"
|
||||
: "";
|
||||
ReceiveAddress = _account.GetReceiveAddress(cache.NextReceiveIndex).ToString();
|
||||
History.Clear();
|
||||
foreach (var tx in cache.History)
|
||||
|
||||
@@ -73,7 +73,7 @@ public partial class MainWindowViewModel
|
||||
|
||||
_pendingSend = new TransactionFactory(_account).Build(
|
||||
_doc.Cache.Utxos, _lastTransactions, destination, amount, feeRate,
|
||||
_doc.Cache.NextChangeIndex, SendAll);
|
||||
_doc.Cache.NextChangeIndex, _doc.Cache.TipHeight, SendAll);
|
||||
|
||||
SendPreview = $"txid {_pendingSend.Txid[..16]}… · " +
|
||||
$"fee {Fmt(_pendingSend.Fee.Satoshi)} " +
|
||||
|
||||
@@ -2,6 +2,7 @@ using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Collections.ObjectModel;
|
||||
using System.Linq;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Avalonia.Threading;
|
||||
using CommunityToolkit.Mvvm.ComponentModel;
|
||||
@@ -55,6 +56,8 @@ public partial class MainWindowViewModel
|
||||
[ObservableProperty]
|
||||
private bool isSyncing;
|
||||
|
||||
private CancellationTokenSource _syncCts = new();
|
||||
|
||||
public ObservableCollection<KnownServer> KnownServers { get; } = [];
|
||||
|
||||
[ObservableProperty]
|
||||
@@ -165,11 +168,23 @@ public partial class MainWindowViewModel
|
||||
{
|
||||
if (IsSyncing)
|
||||
{
|
||||
var (newHost, newPort) = ParseServer();
|
||||
bool serverChanged = _client is null
|
||||
|| _client.Host != newHost
|
||||
|| _client.Port != newPort
|
||||
|| _client.UseSsl != UseSsl;
|
||||
if (serverChanged)
|
||||
_syncCts.Cancel();
|
||||
else
|
||||
_resyncRequested = true;
|
||||
return;
|
||||
}
|
||||
|
||||
_syncCts = new CancellationTokenSource();
|
||||
var ct = _syncCts.Token;
|
||||
IsSyncing = true;
|
||||
StatusMessage = "";
|
||||
bool cancelled = false;
|
||||
try
|
||||
{
|
||||
var (host, port) = ParseServer();
|
||||
@@ -194,12 +209,13 @@ public partial class MainWindowViewModel
|
||||
Exception? lastError = null;
|
||||
foreach (var (h, p) in candidates)
|
||||
{
|
||||
ct.ThrowIfCancellationRequested();
|
||||
ConnectionStatus = $"{Loc.Tr("conn.connectingto")} {h}:{p}…";
|
||||
ConnectionStatusShort = Loc.Tr("conn.connectingto") + "…";
|
||||
try
|
||||
{
|
||||
var pins = new CertificatePinStore(AppPaths.CertificatePinsPath(Net));
|
||||
_client = await ElectrumClient.ConnectAsync(h, p, UseSsl, pins);
|
||||
_client = await ElectrumClient.ConnectAsync(h, p, UseSsl, pins, ct);
|
||||
_client.NotificationReceived += OnServerNotification;
|
||||
_client.Disconnected += _ => Dispatcher.UIThread.Post(() =>
|
||||
{
|
||||
@@ -226,6 +242,10 @@ public partial class MainWindowViewModel
|
||||
lastError = null;
|
||||
break;
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
throw;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
lastError = ex;
|
||||
@@ -258,7 +278,7 @@ public partial class MainWindowViewModel
|
||||
do
|
||||
{
|
||||
_resyncRequested = false;
|
||||
var result = await _synchronizer.SyncOnceAsync();
|
||||
var result = await _synchronizer.SyncOnceAsync(ct);
|
||||
_lastTransactions = result.Transactions;
|
||||
|
||||
var (rawHex, verifiedAt, blockHeaders) = _synchronizer.ExportCaches(
|
||||
@@ -268,6 +288,7 @@ public partial class MainWindowViewModel
|
||||
TipHeight = result.TipHeight,
|
||||
ConfirmedSats = result.ConfirmedSats,
|
||||
UnconfirmedSats = result.UnconfirmedSats,
|
||||
ImmatureSats = result.ImmatureSats,
|
||||
NextReceiveIndex = result.NextReceiveIndex,
|
||||
NextChangeIndex = result.NextChangeIndex,
|
||||
History = [.. result.History],
|
||||
@@ -282,7 +303,16 @@ public partial class MainWindowViewModel
|
||||
_syncFailed = false;
|
||||
StatusMessage = $"{Loc.Tr("msg.synced")}: {Loc.Tr("msg.height")} {result.TipHeight}, " +
|
||||
$"{result.History.Count} {Loc.Tr("msg.synced.detail")}";
|
||||
} while (_resyncRequested);
|
||||
} while (_resyncRequested && !ct.IsCancellationRequested);
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
// Intentional cancellation due to server change request — not an error.
|
||||
cancelled = true;
|
||||
IsConnected = false;
|
||||
ConnectionStatus = Loc.Tr("conn.none");
|
||||
ConnectionStatusShort = Loc.Tr("conn.none");
|
||||
StatusMessage = "";
|
||||
}
|
||||
catch (CertificatePinMismatchException ex)
|
||||
{
|
||||
@@ -309,19 +339,63 @@ public partial class MainWindowViewModel
|
||||
{
|
||||
IsSyncing = false;
|
||||
}
|
||||
|
||||
// If cancelled due to a server change, restart immediately with the new server.
|
||||
if (cancelled)
|
||||
_ = ConnectAndSync();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Peer discovery. Always clickable: if the wallet is already connected, it reuses
|
||||
/// that connection; otherwise it opens a short-lived connection to a candidate server
|
||||
/// just to query its peer list, without touching the wallet's connection state.
|
||||
/// </summary>
|
||||
[RelayCommand]
|
||||
private async Task DiscoverServers()
|
||||
{
|
||||
if (_client is null || !_client.IsConnected)
|
||||
if (_client is { IsConnected: true } connected)
|
||||
{
|
||||
StatusMessage = Loc.Tr("conn.none") + ".";
|
||||
await DiscoverServersUsing(connected);
|
||||
return;
|
||||
}
|
||||
|
||||
var (host, port) = ParseServer();
|
||||
ElectrumClient? temp = null;
|
||||
Exception? lastError = null;
|
||||
foreach (var (h, p) in BuildServerCandidates(host, port))
|
||||
{
|
||||
try
|
||||
{
|
||||
var pins = new CertificatePinStore(AppPaths.CertificatePinsPath(Net));
|
||||
temp = await ElectrumClient.ConnectAsync(h, p, UseSsl, pins);
|
||||
lastError = null;
|
||||
break;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
lastError = ex;
|
||||
}
|
||||
}
|
||||
if (temp is null)
|
||||
{
|
||||
StatusMessage = $"Errore nella scoperta peer: {lastError?.Message ?? Loc.Tr("conn.none")}";
|
||||
return;
|
||||
}
|
||||
try
|
||||
{
|
||||
var added = await Registry.DiscoverAsync(_client);
|
||||
await DiscoverServersUsing(temp);
|
||||
}
|
||||
finally
|
||||
{
|
||||
await temp.DisposeAsync();
|
||||
}
|
||||
}
|
||||
|
||||
private async Task DiscoverServersUsing(ElectrumClient client)
|
||||
{
|
||||
try
|
||||
{
|
||||
var added = await Registry.DiscoverAsync(client);
|
||||
var selected = SelectedKnownServer;
|
||||
RefreshServers();
|
||||
SelectedKnownServer = KnownServers.FirstOrDefault(s => s.Host == selected?.Host)
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
using System.Threading.Tasks;
|
||||
using CommunityToolkit.Mvvm.ComponentModel;
|
||||
using CommunityToolkit.Mvvm.Input;
|
||||
using PalladiumWallet.Core.Net;
|
||||
|
||||
namespace PalladiumWallet.App.ViewModels;
|
||||
|
||||
public partial class MainWindowViewModel
|
||||
{
|
||||
// ---- update-available overlay ----
|
||||
|
||||
[ObservableProperty]
|
||||
private bool isUpdateAvailableOpen;
|
||||
|
||||
[ObservableProperty]
|
||||
private string updateAvailableTag = "";
|
||||
|
||||
public string UpdateReleaseUrl { get; private set; } = "";
|
||||
|
||||
[RelayCommand]
|
||||
private void CloseUpdateAvailable() => IsUpdateAvailableOpen = false;
|
||||
|
||||
/// <summary>
|
||||
/// Fire-and-forget check run once at startup. Best-effort: any failure or an
|
||||
/// up-to-date app silently does nothing (see <see cref="UpdateChecker"/>).
|
||||
/// </summary>
|
||||
private async Task CheckForUpdatesAsync()
|
||||
{
|
||||
var latest = await UpdateChecker.CheckAsync(AppVersion);
|
||||
if (latest is null) return;
|
||||
|
||||
UpdateReleaseUrl = latest.HtmlUrl;
|
||||
UpdateAvailableTag = latest.Tag;
|
||||
IsUpdateAvailableOpen = true;
|
||||
}
|
||||
}
|
||||
@@ -158,6 +158,7 @@ public partial class MainWindowViewModel : ViewModelBase
|
||||
_keepAliveTimer = new DispatcherTimer { Interval = System.TimeSpan.FromSeconds(20) };
|
||||
_keepAliveTimer.Tick += async (_, _) => await KeepAliveTickAsync();
|
||||
_keepAliveTimer.Start();
|
||||
_ = CheckForUpdatesAsync();
|
||||
}
|
||||
|
||||
private async System.Threading.Tasks.Task KeepAliveTickAsync()
|
||||
|
||||
@@ -63,7 +63,7 @@ public sealed class TransactionDetailsViewModel
|
||||
LockTimeText = d.LockTime.ToString();
|
||||
RbfText = loc[d.RbfSignaled ? "tx.yes" : "tx.no"];
|
||||
Inputs = new ObservableCollection<TxIoRow>(d.Inputs.Select((i, n) => new TxIoRow(
|
||||
i.IsCoinbase ? loc["tx.coinbase"] : $"{Shorten(i.PrevTxid)}:{i.PrevIndex}",
|
||||
i.IsCoinbase ? loc["tx.coinbase"] : $"{i.PrevTxid}:{i.PrevIndex}",
|
||||
i.IsCoinbase ? loc["tx.coinbase.newcoins"] : i.Address ?? "—",
|
||||
i.AmountSats is { } a ? CoinAmount.FormatIn(a, unit) : "—",
|
||||
i.IsMine)));
|
||||
@@ -108,7 +108,4 @@ public sealed class TransactionDetailsViewModel
|
||||
}
|
||||
|
||||
private string Abs(long sats) => CoinAmount.FormatIn(Math.Abs(sats), _unit);
|
||||
|
||||
private static string Shorten(string txid) =>
|
||||
txid.Length > 16 ? $"{txid[..8]}…{txid[^4..]}" : txid;
|
||||
}
|
||||
|
||||
@@ -314,6 +314,9 @@
|
||||
<TextBlock Text="{Binding UnconfirmedText}" Foreground="#FCD34D"
|
||||
TextWrapping="Wrap"
|
||||
IsVisible="{Binding UnconfirmedText, Converter={x:Static StringConverters.IsNotNullOrEmpty}}"/>
|
||||
<TextBlock Text="{Binding ImmatureText}" Foreground="#FCD34D"
|
||||
TextWrapping="Wrap"
|
||||
IsVisible="{Binding ImmatureText, Converter={x:Static StringConverters.IsNotNullOrEmpty}}"/>
|
||||
<TextBlock Text="{Binding NetworkInfo}" Classes="on-hero" FontSize="12"
|
||||
Margin="0,2,0,0"/>
|
||||
</StackPanel>
|
||||
@@ -1014,7 +1017,7 @@
|
||||
IsVisible="{Binding IsTxDetailsOpen}">
|
||||
<Border Background="{DynamicResource OverlayCardBrush}"
|
||||
BorderBrush="{DynamicResource BorderSubtleBrush}" BorderThickness="1" CornerRadius="8"
|
||||
MaxWidth="540" MaxHeight="600" Margin="16" Padding="0"
|
||||
MaxWidth="660" MaxHeight="800" Margin="16" Padding="0"
|
||||
HorizontalAlignment="Center" VerticalAlignment="Center">
|
||||
<Panel Margin="24">
|
||||
|
||||
@@ -1135,38 +1138,48 @@
|
||||
</Border>
|
||||
|
||||
<TextBlock Classes="section" Text="{Binding Loc[tx.inputs]}"/>
|
||||
<Border Classes="card" Padding="14,6">
|
||||
<Border Classes="card" Padding="14,4">
|
||||
<ItemsControl ItemsSource="{Binding Inputs}">
|
||||
<ItemsControl.ItemTemplate>
|
||||
<DataTemplate x:DataType="vm:TxIoRow">
|
||||
<Grid ColumnDefinitions="160,*,Auto" Margin="0,4">
|
||||
<TextBlock Grid.Column="0" Text="{Binding Position}"
|
||||
FontFamily="monospace" FontSize="11" Foreground="{DynamicResource TextMutedBrush}"
|
||||
TextTrimming="CharacterEllipsis" Margin="0,0,8,0"/>
|
||||
<TextBlock Grid.Column="1" Text="{Binding Address}"
|
||||
FontFamily="monospace" FontSize="11" TextTrimming="CharacterEllipsis"
|
||||
<!-- Two-line layout: txid ref on first line, address+amount on second -->
|
||||
<StackPanel Margin="0,6,0,4">
|
||||
<SelectableTextBlock Text="{Binding Position}"
|
||||
FontFamily="monospace" FontSize="10"
|
||||
Foreground="{DynamicResource TextMutedBrush}"
|
||||
TextWrapping="Wrap"/>
|
||||
<Grid ColumnDefinitions="*,Auto" Margin="0,2,0,0">
|
||||
<SelectableTextBlock Grid.Column="0" Text="{Binding Address}"
|
||||
FontFamily="monospace" FontSize="11"
|
||||
TextWrapping="Wrap" VerticalAlignment="Top"
|
||||
Foreground="{Binding IsMine, Converter={x:Static vm:MineColorConverter.Instance}}"/>
|
||||
<TextBlock Grid.Column="2" Text="{Binding Amount}"
|
||||
FontFamily="monospace" FontFeatures="+tnum" FontSize="11" Margin="8,0,0,0"/>
|
||||
<SelectableTextBlock Grid.Column="1" Text="{Binding Amount}"
|
||||
FontFamily="monospace" FontFeatures="+tnum" FontSize="11"
|
||||
VerticalAlignment="Top" Margin="12,0,0,0"/>
|
||||
</Grid>
|
||||
</StackPanel>
|
||||
</DataTemplate>
|
||||
</ItemsControl.ItemTemplate>
|
||||
</ItemsControl>
|
||||
</Border>
|
||||
|
||||
<TextBlock Classes="section" Text="{Binding Loc[tx.outputs]}"/>
|
||||
<Border Classes="card" Padding="14,6">
|
||||
<Border Classes="card" Padding="14,4">
|
||||
<ItemsControl ItemsSource="{Binding Outputs}">
|
||||
<ItemsControl.ItemTemplate>
|
||||
<DataTemplate x:DataType="vm:TxIoRow">
|
||||
<Grid ColumnDefinitions="44,*,Auto" Margin="0,4">
|
||||
<Grid ColumnDefinitions="44,*,Auto" Margin="0,6">
|
||||
<TextBlock Grid.Column="0" Text="{Binding Position}"
|
||||
FontFamily="monospace" FontSize="11" Foreground="{DynamicResource TextMutedBrush}"/>
|
||||
<TextBlock Grid.Column="1" Text="{Binding Address}"
|
||||
FontFamily="monospace" FontSize="11" TextTrimming="CharacterEllipsis"
|
||||
FontFamily="monospace" FontSize="11"
|
||||
Foreground="{DynamicResource TextMutedBrush}"
|
||||
VerticalAlignment="Top"/>
|
||||
<SelectableTextBlock Grid.Column="1" Text="{Binding Address}"
|
||||
FontFamily="monospace" FontSize="11"
|
||||
TextWrapping="Wrap" VerticalAlignment="Top"
|
||||
Foreground="{Binding IsMine, Converter={x:Static vm:MineColorConverter.Instance}}"/>
|
||||
<TextBlock Grid.Column="2" Text="{Binding Amount}"
|
||||
FontFamily="monospace" FontFeatures="+tnum" FontSize="11" Margin="8,0,0,0"/>
|
||||
<SelectableTextBlock Grid.Column="2" Text="{Binding Amount}"
|
||||
FontFamily="monospace" FontFeatures="+tnum" FontSize="11"
|
||||
VerticalAlignment="Top" Margin="12,0,0,0"/>
|
||||
</Grid>
|
||||
</DataTemplate>
|
||||
</ItemsControl.ItemTemplate>
|
||||
@@ -1223,14 +1236,19 @@
|
||||
<!-- Actions — Desktop: in a row -->
|
||||
<StackPanel Orientation="Horizontal" Spacing="8" IsVisible="{Binding IsDesktop}">
|
||||
<Button Content="{Binding Loc[wallet.connect]}" Classes="accent"
|
||||
Command="{Binding ConnectAndSyncCommand}" IsEnabled="{Binding !IsSyncing}"/>
|
||||
Command="{Binding ConnectAndSyncCommand}"/>
|
||||
<Button Content="{Binding Loc[wallet.discover]}"
|
||||
Command="{Binding DiscoverServersCommand}"/>
|
||||
<Button Content="{Binding Loc[wallet.resetcert]}"
|
||||
Command="{Binding ResetCertificatesCommand}"/>
|
||||
</StackPanel>
|
||||
<!-- Actions — Mobile: stacked -->
|
||||
<StackPanel Spacing="6" IsVisible="{Binding IsMobile}">
|
||||
<Button Content="{Binding Loc[wallet.connect]}" Classes="accent"
|
||||
Command="{Binding ConnectAndSyncCommand}" IsEnabled="{Binding !IsSyncing}"
|
||||
Command="{Binding ConnectAndSyncCommand}"
|
||||
HorizontalAlignment="Stretch" HorizontalContentAlignment="Center"/>
|
||||
<Button Content="{Binding Loc[wallet.discover]}"
|
||||
Command="{Binding DiscoverServersCommand}"
|
||||
HorizontalAlignment="Stretch" HorizontalContentAlignment="Center"/>
|
||||
<Button Content="{Binding Loc[wallet.resetcert]}"
|
||||
Command="{Binding ResetCertificatesCommand}"
|
||||
@@ -1545,6 +1563,9 @@
|
||||
Foreground="{DynamicResource TextSecondaryBrush}"/>
|
||||
</StackPanel>
|
||||
<TextBlock Text="{Binding Loc[help.info]}" TextWrapping="Wrap"/>
|
||||
<Button Content="{Binding Loc[help.bug.report]}"
|
||||
Click="OnOpenBugReportClick"
|
||||
HorizontalAlignment="Left"/>
|
||||
<Border BorderBrush="{DynamicResource BorderSubtleBrush}"
|
||||
BorderThickness="0,1,0,0" Margin="0,4,0,0"/>
|
||||
<StackPanel Spacing="4">
|
||||
@@ -1617,5 +1638,34 @@
|
||||
</StackPanel>
|
||||
</Border>
|
||||
</Border>
|
||||
|
||||
<!-- ============ UPDATE AVAILABLE OVERLAY ============ -->
|
||||
<!-- Same pattern as the help overlay: instant open/close. -->
|
||||
<Border Grid.Row="0" Grid.RowSpan="3"
|
||||
Background="{DynamicResource ScrimBrush}"
|
||||
Tapped="OnUpdateAvailableOverlayBackdropTapped"
|
||||
IsVisible="{Binding IsUpdateAvailableOpen}">
|
||||
<Border Background="{DynamicResource OverlayCardBrush}"
|
||||
BorderBrush="{DynamicResource BorderSubtleBrush}" BorderThickness="1" CornerRadius="8"
|
||||
MaxWidth="440" Margin="16"
|
||||
HorizontalAlignment="Center" VerticalAlignment="Center">
|
||||
<StackPanel Margin="24" Spacing="16">
|
||||
<TextBlock Text="{Binding Loc[update.title]}"
|
||||
FontSize="18" FontWeight="Bold"/>
|
||||
<TextBlock TextWrapping="Wrap">
|
||||
<Run Text="{Binding Loc[update.message]}"/>
|
||||
<Run Text=" "/>
|
||||
<Run Text="{Binding UpdateAvailableTag}" FontWeight="Bold"/>
|
||||
</TextBlock>
|
||||
<StackPanel Orientation="Horizontal" Spacing="8" HorizontalAlignment="Right">
|
||||
<Button Content="{Binding Loc[update.download]}"
|
||||
Classes="accent"
|
||||
Click="OnOpenReleasePageClick"/>
|
||||
<Button Content="{Binding Loc[update.dismiss]}"
|
||||
Command="{Binding CloseUpdateAvailableCommand}"/>
|
||||
</StackPanel>
|
||||
</StackPanel>
|
||||
</Border>
|
||||
</Border>
|
||||
</Grid>
|
||||
</UserControl>
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
using System;
|
||||
using System.Linq;
|
||||
using Avalonia;
|
||||
using Avalonia.Controls;
|
||||
@@ -128,6 +129,30 @@ public partial class MainView : UserControl
|
||||
vm.IsHelpOpen = false;
|
||||
}
|
||||
|
||||
private void OnUpdateAvailableOverlayBackdropTapped(object? sender, TappedEventArgs e)
|
||||
{
|
||||
if (!ReferenceEquals(e.Source, sender)) return;
|
||||
if (DataContext is MainWindowViewModel vm)
|
||||
vm.IsUpdateAvailableOpen = false;
|
||||
}
|
||||
|
||||
private async void OnOpenBugReportClick(object? sender, RoutedEventArgs e)
|
||||
{
|
||||
const string issueUrl = "https://github.com/davide3011/PalladiumWallet/issues/new?template=bug_report.yml";
|
||||
var launcher = TopLevel.GetTopLevel(this)?.Launcher;
|
||||
if (launcher is not null)
|
||||
await launcher.LaunchUriAsync(new Uri(issueUrl));
|
||||
}
|
||||
|
||||
private async void OnOpenReleasePageClick(object? sender, RoutedEventArgs e)
|
||||
{
|
||||
if (DataContext is not MainWindowViewModel vm || string.IsNullOrEmpty(vm.UpdateReleaseUrl)) return;
|
||||
var launcher = TopLevel.GetTopLevel(this)?.Launcher;
|
||||
if (launcher is not null)
|
||||
await launcher.LaunchUriAsync(new Uri(vm.UpdateReleaseUrl));
|
||||
vm.IsUpdateAvailableOpen = false;
|
||||
}
|
||||
|
||||
// Esc (desktop) or Back (Android) closes the topmost open overlay.
|
||||
protected override void OnKeyDown(KeyEventArgs e)
|
||||
{
|
||||
@@ -140,6 +165,7 @@ public partial class MainView : UserControl
|
||||
if (vm.IsWalletInfoOpen) { vm.CloseWalletInfoCommand.Execute(null); e.Handled = true; return; }
|
||||
if (vm.IsSettingsOpen) { vm.IsSettingsOpen = false; e.Handled = true; return; }
|
||||
if (vm.IsHelpOpen) { vm.IsHelpOpen = false; e.Handled = true; return; }
|
||||
if (vm.IsUpdateAvailableOpen) { vm.IsUpdateAvailableOpen = false; e.Handled = true; return; }
|
||||
}
|
||||
base.OnKeyDown(e);
|
||||
}
|
||||
|
||||
+7
-4
@@ -104,8 +104,9 @@ static int Info(string[] o)
|
||||
Console.WriteLine($"xpub: {doc.AccountXpub}");
|
||||
if (doc.Cache is { } cache)
|
||||
{
|
||||
Console.WriteLine($"saldo: {CoinAmount.Format(cache.ConfirmedSats, account.Profile.CoinUnit)} confermato"
|
||||
+ (cache.UnconfirmedSats != 0 ? $" + {CoinAmount.Format(cache.UnconfirmedSats)} in attesa (non spendibile)." : ""));
|
||||
Console.WriteLine($"saldo: {CoinAmount.Format(cache.ConfirmedSats - cache.ImmatureSats, account.Profile.CoinUnit)} spendibile"
|
||||
+ (cache.ImmatureSats != 0 ? $" + {CoinAmount.Format(cache.ImmatureSats)} in maturazione (non spendibile)" : "")
|
||||
+ (cache.UnconfirmedSats != 0 ? $" + {CoinAmount.Format(cache.UnconfirmedSats)} in attesa di conferma (non spendibile)." : ""));
|
||||
Console.WriteLine($"sync: altezza {cache.TipHeight}, {cache.History.Count} transazioni");
|
||||
Console.WriteLine($"ricezione: {account.GetReceiveAddress(cache.NextReceiveIndex)}");
|
||||
}
|
||||
@@ -147,6 +148,7 @@ static async Task<int> Sync(string[] o)
|
||||
TipHeight = result.TipHeight,
|
||||
ConfirmedSats = result.ConfirmedSats,
|
||||
UnconfirmedSats = result.UnconfirmedSats,
|
||||
ImmatureSats = result.ImmatureSats,
|
||||
NextReceiveIndex = result.NextReceiveIndex,
|
||||
NextChangeIndex = result.NextChangeIndex,
|
||||
History = [.. result.History],
|
||||
@@ -158,7 +160,8 @@ static async Task<int> Sync(string[] o)
|
||||
};
|
||||
WalletStore.Save(doc, path, Opt(o, "--password"));
|
||||
|
||||
Console.WriteLine($"Saldo: {CoinAmount.Format(result.ConfirmedSats, account.Profile.CoinUnit)} confermato"
|
||||
Console.WriteLine($"Saldo: {CoinAmount.Format(result.ConfirmedSats - result.ImmatureSats, account.Profile.CoinUnit)} spendibile"
|
||||
+ (result.ImmatureSats != 0 ? $" + {CoinAmount.Format(result.ImmatureSats)} in maturazione (non spendibile)" : "")
|
||||
+ (result.UnconfirmedSats != 0 ? $" + {CoinAmount.Format(result.UnconfirmedSats)} in attesa di conferma (non spendibile)" : ""));
|
||||
Console.WriteLine($"Storico ({result.History.Count}):");
|
||||
foreach (var tx in result.History)
|
||||
@@ -193,7 +196,7 @@ static async Task<int> Send(string[] o)
|
||||
|
||||
var built = new TransactionFactory(account).Build(
|
||||
doc.Cache.Utxos, transactions, destination, amount, feeRate,
|
||||
doc.Cache.NextChangeIndex, sendAll);
|
||||
doc.Cache.NextChangeIndex, doc.Cache.TipHeight, sendAll);
|
||||
|
||||
Console.WriteLine($"txid: {built.Txid}");
|
||||
Console.WriteLine($"fee: {CoinAmount.Format(built.Fee.Satoshi, account.Profile.CoinUnit)} " +
|
||||
|
||||
@@ -126,6 +126,12 @@ public sealed record ChainProfile
|
||||
/// <summary>Target block time in seconds (LWMA v2: 120s).</summary>
|
||||
public required int BlockTimeSeconds { get; init; }
|
||||
|
||||
/// <summary>Blocks a coinbase output must wait before it can be spent.</summary>
|
||||
public required int CoinbaseMaturity { get; init; }
|
||||
|
||||
/// <summary>Minimum confirmations required for a regular UTXO to be spendable.</summary>
|
||||
public required int MinConfirmations { get; init; }
|
||||
|
||||
/// <summary>BIP32/SLIP-132 headers for each script type.</summary>
|
||||
public required IReadOnlyDictionary<ScriptKind, ExtKeyHeaders> ExtKeyHeaders { get; init; }
|
||||
|
||||
|
||||
@@ -26,6 +26,8 @@ public static class ChainProfiles
|
||||
ExplorerUrl = "https://explorer.palladium-coin.com/",
|
||||
SkipPowValidation = true,
|
||||
BlockTimeSeconds = 120,
|
||||
CoinbaseMaturity = 120,
|
||||
MinConfirmations = 6,
|
||||
ExtKeyHeaders = new Dictionary<ScriptKind, ExtKeyHeaders>
|
||||
{
|
||||
[ScriptKind.Legacy] = new(0x0488ade4, 0x0488b21e), // xprv / xpub
|
||||
@@ -53,6 +55,7 @@ public static class ChainProfiles
|
||||
{
|
||||
Kind = NetKind.Testnet,
|
||||
NetName = "testnet",
|
||||
MinConfirmations = 1,
|
||||
WifPrefix = 0xff,
|
||||
AddrP2pkh = 127,
|
||||
AddrP2sh = 115,
|
||||
|
||||
@@ -114,7 +114,7 @@ public static class PalladiumNetworks
|
||||
MajorityRejectBlockOutdated = 950,
|
||||
MajorityWindow = 1000,
|
||||
MinimumChainWork = uint256.Zero,
|
||||
CoinbaseMaturity = 100,
|
||||
CoinbaseMaturity = 120,
|
||||
SupportSegwit = true,
|
||||
SupportTaproot = true,
|
||||
ConsensusFactory = new ConsensusFactory(),
|
||||
|
||||
@@ -58,10 +58,21 @@ public sealed class CertificatePinStore(string filePath)
|
||||
}
|
||||
}
|
||||
|
||||
private Dictionary<string, string> Load() =>
|
||||
File.Exists(filePath)
|
||||
? JsonSerializer.Deserialize<Dictionary<string, string>>(File.ReadAllText(filePath)) ?? []
|
||||
: [];
|
||||
private Dictionary<string, string> Load()
|
||||
{
|
||||
if (!File.Exists(filePath))
|
||||
return [];
|
||||
try
|
||||
{
|
||||
return JsonSerializer.Deserialize<Dictionary<string, string>>(File.ReadAllText(filePath)) ?? [];
|
||||
}
|
||||
catch (JsonException)
|
||||
{
|
||||
// A corrupt pin file must not make every SSL connection fail forever:
|
||||
// fall back to first-contact TOFU (same trust level as the bootstrap).
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
private void Save(Dictionary<string, string> pins)
|
||||
{
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
using System;
|
||||
using System.Net.Http;
|
||||
using System.Net.Http.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace PalladiumWallet.Core.Net;
|
||||
|
||||
/// <summary>A GitHub release newer than the running app.</summary>
|
||||
public sealed record LatestRelease(string Tag, string HtmlUrl);
|
||||
|
||||
/// <summary>
|
||||
/// Checks the latest GitHub release for the project against the running app version.
|
||||
/// Best-effort only: any network/parse failure or an up-to-date app both resolve to null,
|
||||
/// so callers never need to distinguish "no update" from "couldn't check".
|
||||
/// </summary>
|
||||
public static class UpdateChecker
|
||||
{
|
||||
private const string ReleasesApiUrl = "https://api.github.com/repos/davide3011/PalladiumWallet/releases/latest";
|
||||
|
||||
private sealed class GitHubReleaseResponse
|
||||
{
|
||||
[JsonPropertyName("tag_name")]
|
||||
public string? TagName { get; set; }
|
||||
|
||||
[JsonPropertyName("html_url")]
|
||||
public string? HtmlUrl { get; set; }
|
||||
}
|
||||
|
||||
public static async Task<LatestRelease?> CheckAsync(string currentVersion, CancellationToken ct = default)
|
||||
{
|
||||
try
|
||||
{
|
||||
using var http = new HttpClient { Timeout = TimeSpan.FromSeconds(10) };
|
||||
http.DefaultRequestHeaders.UserAgent.ParseAdd("PalladiumWallet");
|
||||
using var response = await http.GetAsync(ReleasesApiUrl, ct).ConfigureAwait(false);
|
||||
if (!response.IsSuccessStatusCode) return null;
|
||||
|
||||
var release = await response.Content
|
||||
.ReadFromJsonAsync<GitHubReleaseResponse>(ct)
|
||||
.ConfigureAwait(false);
|
||||
if (release?.TagName is not { Length: > 0 } tag) return null;
|
||||
|
||||
if (!TryParse(tag, out var remote) || !TryParse(currentVersion, out var local))
|
||||
return null;
|
||||
if (remote <= local) return null;
|
||||
|
||||
return new LatestRelease(tag, release.HtmlUrl ?? $"https://github.com/davide3011/PalladiumWallet/releases/tag/{tag}");
|
||||
}
|
||||
catch
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Parses "v1.2.3" / "1.2.3-beta" style tags into a comparable <see cref="Version"/>.</summary>
|
||||
internal static bool TryParse(string raw, out Version version)
|
||||
{
|
||||
var s = raw.Trim();
|
||||
if (s.StartsWith('v') || s.StartsWith('V')) s = s[1..];
|
||||
var dash = s.IndexOf('-');
|
||||
if (dash >= 0) s = s[..dash];
|
||||
return Version.TryParse(s, out version!);
|
||||
}
|
||||
}
|
||||
@@ -10,4 +10,8 @@
|
||||
<PackageReference Include="NBitcoin" Version="10.0.6" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<InternalsVisibleTo Include="PalladiumWallet.Tests" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
|
||||
@@ -5,6 +5,7 @@ using PalladiumWallet.Core.Chain;
|
||||
using PalladiumWallet.Core.Crypto;
|
||||
using PalladiumWallet.Core.Net;
|
||||
using PalladiumWallet.Core.Storage;
|
||||
using PalladiumWallet.Core.Wallet;
|
||||
|
||||
namespace PalladiumWallet.Core.Spv;
|
||||
|
||||
@@ -21,6 +22,13 @@ public sealed class SyncResult
|
||||
public required int TipHeight { get; init; }
|
||||
public required long ConfirmedSats { get; init; }
|
||||
public required long UnconfirmedSats { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Confirmed but not yet spendable: coinbase outputs below maturity or regular
|
||||
/// outputs below <see cref="Chain.ChainProfile.MinConfirmations"/>. Subset of
|
||||
/// <see cref="ConfirmedSats"/>.
|
||||
/// </summary>
|
||||
public required long ImmatureSats { get; init; }
|
||||
public required int NextReceiveIndex { get; init; }
|
||||
public required int NextChangeIndex { get; init; }
|
||||
public required IReadOnlyList<CachedTx> History { get; init; }
|
||||
@@ -225,6 +233,7 @@ public sealed class WalletSynchronizer(IWalletAccount account, ElectrumClient cl
|
||||
IsChange = addr.IsChange,
|
||||
AddressIndex = addr.Index,
|
||||
Height = txHeights[txid],
|
||||
IsCoinbase = tx.IsCoinBase,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -275,6 +284,9 @@ public sealed class WalletSynchronizer(IWalletAccount account, ElectrumClient cl
|
||||
TipHeight = tip.Height,
|
||||
ConfirmedSats = utxos.Where(u => u.Height > 0).Sum(u => u.ValueSats),
|
||||
UnconfirmedSats = utxos.Where(u => u.Height <= 0).Sum(u => u.ValueSats),
|
||||
ImmatureSats = utxos.Where(u =>
|
||||
u.Height > 0 && u.Confirmations(tip.Height) < u.RequiredConfirmations(account.Profile))
|
||||
.Sum(u => u.ValueSats),
|
||||
NextReceiveIndex = nextReceive,
|
||||
NextChangeIndex = nextChange,
|
||||
History = history,
|
||||
|
||||
@@ -26,13 +26,21 @@ public static class EncryptedFile
|
||||
try
|
||||
{
|
||||
using var doc = JsonDocument.Parse(fileContent);
|
||||
return doc.RootElement.TryGetProperty("Format", out var f)
|
||||
return doc.RootElement.ValueKind == JsonValueKind.Object
|
||||
&& doc.RootElement.TryGetProperty("Format", out var f)
|
||||
&& f.ValueKind == JsonValueKind.String
|
||||
&& f.GetString() == Format;
|
||||
}
|
||||
catch (JsonException)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
catch (ArgumentException)
|
||||
{
|
||||
// Invalid UTF-16 (lone surrogates) cannot be transcoded for parsing:
|
||||
// certainly not an encrypted container.
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
public static string Encrypt(string plaintext, string password)
|
||||
|
||||
@@ -95,6 +95,9 @@ public sealed class SyncCache
|
||||
public int TipHeight { get; set; }
|
||||
public long ConfirmedSats { get; set; }
|
||||
public long UnconfirmedSats { get; set; }
|
||||
|
||||
/// <summary>Confirmed but not yet spendable (coinbase immature or under min confirmations). Subset of ConfirmedSats.</summary>
|
||||
public long ImmatureSats { get; set; }
|
||||
public int NextReceiveIndex { get; set; }
|
||||
public int NextChangeIndex { get; set; }
|
||||
public List<CachedTx> History { get; set; } = [];
|
||||
@@ -150,5 +153,6 @@ public sealed class CachedUtxo
|
||||
public bool IsChange { get; set; }
|
||||
public int AddressIndex { get; set; }
|
||||
public int Height { get; set; }
|
||||
public bool IsCoinbase { get; set; }
|
||||
public bool Frozen { get; set; }
|
||||
}
|
||||
|
||||
@@ -40,6 +40,7 @@ public sealed class TransactionFactory(IWalletAccount account)
|
||||
/// <param name="amountSats">Amount; ignored when <paramref name="sendAll"/> is true.</param>
|
||||
/// <param name="feeRateSatPerVByte">Fixed fee rate in sat/vByte (§6.4).</param>
|
||||
/// <param name="changeIndex">Index of the next change address (internal chain).</param>
|
||||
/// <param name="tipHeight">Current chain tip height, used to enforce confirmation thresholds.</param>
|
||||
/// <param name="sendAll">Send all: fee subtracted from the amount (§6.1).</param>
|
||||
public BuiltTransaction Build(
|
||||
IReadOnlyList<CachedUtxo> utxos,
|
||||
@@ -48,16 +49,41 @@ public sealed class TransactionFactory(IWalletAccount account)
|
||||
long amountSats,
|
||||
decimal feeRateSatPerVByte,
|
||||
int changeIndex,
|
||||
int tipHeight,
|
||||
bool sendAll = false)
|
||||
{
|
||||
// Only confirmed UTXOs are spent: mempool funds appear in the "pending"
|
||||
// balance but are not spendable until confirmed.
|
||||
var spendable = utxos.Where(u => !u.Frozen && u.Height > 0).ToList();
|
||||
var profile = account.Profile;
|
||||
var spendable = utxos.Where(u => u.IsSpendable(profile, tipHeight)).ToList();
|
||||
|
||||
if (spendable.Count == 0)
|
||||
{
|
||||
var pending = utxos.Where(u => !u.Frozen && u.Height <= 0).Sum(u => u.ValueSats);
|
||||
throw new WalletSpendException(pending > 0
|
||||
? $"No confirmed funds: {CoinAmount.Format(pending)} pending confirmation (not spendable)."
|
||||
var reasons = new System.Text.StringBuilder();
|
||||
|
||||
var mempool = utxos.Where(u => !u.Frozen && u.Height <= 0).ToList();
|
||||
if (mempool.Count > 0)
|
||||
reasons.Append($"{mempool.Count} output(s) unconfirmed ({CoinAmount.Format(mempool.Sum(u => u.ValueSats))} in mempool). ");
|
||||
|
||||
var immature = utxos.Where(u =>
|
||||
!u.Frozen && u.Height > 0 && u.IsCoinbase &&
|
||||
u.Confirmations(tipHeight) < u.RequiredConfirmations(profile)).ToList();
|
||||
if (immature.Count > 0)
|
||||
{
|
||||
var best = immature.Max(u => u.Confirmations(tipHeight));
|
||||
var threshold = profile.CoinbaseMaturity + 1;
|
||||
reasons.Append($"{immature.Count} coinbase output(s) not yet mature ({best}/{threshold} confirmations). ");
|
||||
}
|
||||
|
||||
var underConf = utxos.Where(u =>
|
||||
!u.Frozen && u.Height > 0 && !u.IsCoinbase &&
|
||||
u.Confirmations(tipHeight) < u.RequiredConfirmations(profile)).ToList();
|
||||
if (underConf.Count > 0)
|
||||
{
|
||||
var best = underConf.Max(u => u.Confirmations(tipHeight));
|
||||
reasons.Append($"{underConf.Count} output(s) need {profile.MinConfirmations} confirmations ({best} so far). ");
|
||||
}
|
||||
|
||||
throw new WalletSpendException(reasons.Length > 0
|
||||
? $"No spendable UTXOs: {reasons.ToString().TrimEnd()}"
|
||||
: "No spendable UTXOs selected.");
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
using PalladiumWallet.Core.Chain;
|
||||
using PalladiumWallet.Core.Storage;
|
||||
|
||||
namespace PalladiumWallet.Core.Wallet;
|
||||
|
||||
/// <summary>
|
||||
/// Confirmation-threshold rules shared between coin selection (<see cref="TransactionFactory"/>)
|
||||
/// and balance reporting: a coinbase output needs COINBASE_MATURITY + 1 confirmations
|
||||
/// (consensus rule nSpendHeight - nHeight >= 120, plus one block of safety margin, matching
|
||||
/// the Qt wallet); a regular output needs <see cref="ChainProfile.MinConfirmations"/> (wallet
|
||||
/// policy, no consensus rule).
|
||||
/// </summary>
|
||||
public static class UtxoSpendability
|
||||
{
|
||||
public static int RequiredConfirmations(this CachedUtxo utxo, ChainProfile profile) =>
|
||||
utxo.IsCoinbase ? profile.CoinbaseMaturity + 1 : profile.MinConfirmations;
|
||||
|
||||
public static int Confirmations(this CachedUtxo utxo, int tipHeight) =>
|
||||
utxo.Height <= 0 ? 0 : tipHeight - utxo.Height + 1;
|
||||
|
||||
/// <summary>True when the UTXO has met its confirmation threshold and is not frozen.</summary>
|
||||
public static bool IsSpendable(this CachedUtxo utxo, ChainProfile profile, int tipHeight) =>
|
||||
!utxo.Frozen && utxo.Height > 0 && utxo.Confirmations(tipHeight) >= utxo.RequiredConfirmations(profile);
|
||||
}
|
||||
@@ -20,7 +20,7 @@ public class AddressDerivationTests
|
||||
// Known abandon-about address at m/44'/0'/0'/0/0 (public reference).
|
||||
var account = HdAccount.FromSeed(AbandonAboutSeed(), ScriptKind.Legacy,
|
||||
ChainProfiles.Mainnet, new KeyPath("44'/0'/0'"));
|
||||
var pubKey = account.GetPublicKey(isChange: false, 0);
|
||||
var pubKey = account.GetPublicKey(isChange: false, 0)!;
|
||||
|
||||
var bitcoinAddr = pubKey.GetAddress(ScriptPubKeyType.Legacy, Network.Main);
|
||||
Assert.Equal("1LqBGSKuX5yYUonjxT5qGfpUsXKYYWeabA", bitcoinAddr.ToString());
|
||||
@@ -37,7 +37,7 @@ public class AddressDerivationTests
|
||||
{
|
||||
var account = HdAccount.FromSeed(AbandonAboutSeed(), ScriptKind.WrappedSegwit,
|
||||
ChainProfiles.Mainnet, new KeyPath("49'/0'/0'"));
|
||||
var pubKey = account.GetPublicKey(isChange: false, 0);
|
||||
var pubKey = account.GetPublicKey(isChange: false, 0)!;
|
||||
|
||||
var bitcoinAddr = pubKey.GetAddress(ScriptPubKeyType.SegwitP2SH, Network.Main);
|
||||
Assert.Equal("37VucYSaXLCAsxYyAPfbSi9eh4iEcbShgf", bitcoinAddr.ToString());
|
||||
|
||||
@@ -83,7 +83,7 @@ public class Bip84Slip132Tests
|
||||
bool isChange, int index, string? expectedPubKeyHex, string bitcoinAddress)
|
||||
{
|
||||
var account = Account();
|
||||
var pubKey = account.GetPublicKey(isChange, index);
|
||||
var pubKey = account.GetPublicKey(isChange, index)!;
|
||||
|
||||
if (expectedPubKeyHex is not null)
|
||||
Assert.Equal(expectedPubKeyHex, pubKey.ToHex());
|
||||
|
||||
@@ -0,0 +1,160 @@
|
||||
using System.Security.Cryptography.X509Certificates;
|
||||
using PalladiumWallet.Core.Net;
|
||||
|
||||
namespace PalladiumWallet.Tests.Net;
|
||||
|
||||
/// <summary>
|
||||
/// Tests for TLS trust-on-first-use pinning (§9): first contact pins, matching
|
||||
/// certificate passes, changed certificate is rejected until an explicit reset.
|
||||
/// </summary>
|
||||
public class CertificatePinStoreTests
|
||||
{
|
||||
private static string TempPath() =>
|
||||
Path.Combine(Path.GetTempPath(), $"plm-pins-{Guid.NewGuid()}", "server-certs.json");
|
||||
|
||||
private static X509Certificate2 Cert(string cn) =>
|
||||
FakeElectrumServer.CreateSelfSignedCertificate(cn);
|
||||
|
||||
private static void Cleanup(string path)
|
||||
{
|
||||
if (Directory.Exists(Path.GetDirectoryName(path)))
|
||||
Directory.Delete(Path.GetDirectoryName(path)!, recursive: true);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Il_primo_contatto_salva_il_pin_e_lo_stesso_certificato_ripassa()
|
||||
{
|
||||
var path = TempPath();
|
||||
try
|
||||
{
|
||||
using var cert = Cert("server");
|
||||
var store = new CertificatePinStore(path);
|
||||
|
||||
Assert.True(store.VerifyOrPin("host", 50002, cert)); // first use: pinned
|
||||
Assert.True(File.Exists(path));
|
||||
Assert.True(store.VerifyOrPin("host", 50002, cert)); // same cert: ok
|
||||
|
||||
// A fresh instance reads the same file (persistence).
|
||||
Assert.True(new CertificatePinStore(path).VerifyOrPin("host", 50002, cert));
|
||||
}
|
||||
finally { Cleanup(path); }
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Un_certificato_cambiato_viene_rifiutato()
|
||||
{
|
||||
var path = TempPath();
|
||||
try
|
||||
{
|
||||
using var original = Cert("originale");
|
||||
using var changed = Cert("cambiato");
|
||||
var store = new CertificatePinStore(path);
|
||||
|
||||
Assert.True(store.VerifyOrPin("host", 50002, original));
|
||||
Assert.False(store.VerifyOrPin("host", 50002, changed));
|
||||
// The rejection must not overwrite the pin.
|
||||
Assert.True(store.VerifyOrPin("host", 50002, original));
|
||||
}
|
||||
finally { Cleanup(path); }
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Host_e_porte_diverse_hanno_pin_indipendenti()
|
||||
{
|
||||
var path = TempPath();
|
||||
try
|
||||
{
|
||||
using var cert1 = Cert("uno");
|
||||
using var cert2 = Cert("due");
|
||||
var store = new CertificatePinStore(path);
|
||||
|
||||
Assert.True(store.VerifyOrPin("host-a", 50002, cert1));
|
||||
Assert.True(store.VerifyOrPin("host-b", 50002, cert2)); // different host
|
||||
Assert.True(store.VerifyOrPin("host-a", 50012, cert2)); // different port
|
||||
Assert.False(store.VerifyOrPin("host-a", 50002, cert2));
|
||||
}
|
||||
finally { Cleanup(path); }
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Il_reset_di_un_server_permette_di_ripinnare_il_nuovo_certificato()
|
||||
{
|
||||
var path = TempPath();
|
||||
try
|
||||
{
|
||||
using var original = Cert("originale");
|
||||
using var renewed = Cert("rinnovato");
|
||||
var store = new CertificatePinStore(path);
|
||||
|
||||
Assert.True(store.VerifyOrPin("host", 50002, original));
|
||||
Assert.False(store.VerifyOrPin("host", 50002, renewed));
|
||||
|
||||
Assert.True(store.Reset("host", 50002));
|
||||
Assert.True(store.VerifyOrPin("host", 50002, renewed)); // re-pinned
|
||||
Assert.False(store.VerifyOrPin("host", 50002, original)); // old one now rejected
|
||||
}
|
||||
finally { Cleanup(path); }
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Il_reset_di_un_server_mai_visto_restituisce_false()
|
||||
{
|
||||
var path = TempPath();
|
||||
try
|
||||
{
|
||||
Assert.False(new CertificatePinStore(path).Reset("mai-visto", 50002));
|
||||
}
|
||||
finally { Cleanup(path); }
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ResetAll_cancella_il_file_e_tutti_i_pin()
|
||||
{
|
||||
var path = TempPath();
|
||||
try
|
||||
{
|
||||
using var cert1 = Cert("uno");
|
||||
using var cert2 = Cert("due");
|
||||
var store = new CertificatePinStore(path);
|
||||
store.VerifyOrPin("host-a", 50002, cert1);
|
||||
store.VerifyOrPin("host-b", 50002, cert1);
|
||||
|
||||
store.ResetAll();
|
||||
|
||||
Assert.False(File.Exists(path));
|
||||
Assert.True(store.VerifyOrPin("host-a", 50002, cert2)); // TOFU restarts
|
||||
}
|
||||
finally { Cleanup(path); }
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Un_file_pin_corrotto_non_blocca_le_connessioni()
|
||||
{
|
||||
var path = TempPath();
|
||||
try
|
||||
{
|
||||
Directory.CreateDirectory(Path.GetDirectoryName(path)!);
|
||||
File.WriteAllText(path, "{ non-json ");
|
||||
|
||||
using var cert = Cert("server");
|
||||
var store = new CertificatePinStore(path);
|
||||
Assert.True(store.VerifyOrPin("host", 50002, cert)); // falls back to first contact
|
||||
Assert.True(store.VerifyOrPin("host", 50002, cert)); // and re-persists correctly
|
||||
}
|
||||
finally { Cleanup(path); }
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void La_fingerprint_e_sha256_del_certificato_in_hex_minuscolo()
|
||||
{
|
||||
using var cert = Cert("server");
|
||||
var fingerprint = CertificatePinStore.Fingerprint(cert);
|
||||
|
||||
Assert.Equal(64, fingerprint.Length);
|
||||
Assert.Equal(fingerprint.ToLowerInvariant(), fingerprint);
|
||||
Assert.Equal(
|
||||
Convert.ToHexString(System.Security.Cryptography.SHA256.HashData(cert.RawData))
|
||||
.ToLowerInvariant(),
|
||||
fingerprint);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,189 @@
|
||||
using System.Text.Json;
|
||||
using PalladiumWallet.Core.Net;
|
||||
|
||||
namespace PalladiumWallet.Tests.Net;
|
||||
|
||||
/// <summary>
|
||||
/// Tests for the JSON-RPC transport against an in-process fake ElectrumX server
|
||||
/// (real loopback TCP socket: framing, pipelining, and failure paths are the
|
||||
/// same code paths used in production).
|
||||
/// </summary>
|
||||
public class ElectrumClientTests
|
||||
{
|
||||
private static readonly TimeSpan Timeout = TimeSpan.FromSeconds(10);
|
||||
|
||||
[Fact]
|
||||
public async Task Connessione_e_richiesta_fanno_roundtrip()
|
||||
{
|
||||
await using var server = new FakeElectrumServer();
|
||||
server.Handle("server.banner", _ => "benvenuto");
|
||||
|
||||
await using var client = await ElectrumClient.ConnectAsync(server.Host, server.Port, useSsl: false);
|
||||
|
||||
Assert.True(client.IsConnected);
|
||||
Assert.Equal("benvenuto", await client.BannerAsync());
|
||||
// ConnectAsync performs the protocol handshake up front.
|
||||
Assert.Equal(1, server.CallCount("server.version"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Un_errore_del_server_diventa_ElectrumServerException()
|
||||
{
|
||||
await using var server = new FakeElectrumServer();
|
||||
server.Handle("blockchain.transaction.get",
|
||||
_ => throw new FakeElectrumError(-32600, "tx non trovata"));
|
||||
|
||||
await using var client = await ElectrumClient.ConnectAsync(server.Host, server.Port, useSsl: false);
|
||||
|
||||
var ex = await Assert.ThrowsAsync<ElectrumServerException>(
|
||||
() => client.GetTransactionAsync("00"));
|
||||
Assert.Contains("tx non trovata", ex.Message);
|
||||
Assert.Contains("-32600", ex.Message);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Richieste_parallele_ricevono_ciascuna_la_propria_risposta()
|
||||
{
|
||||
await using var server = new FakeElectrumServer();
|
||||
server.Handle("echo", p => p[0].GetInt32());
|
||||
|
||||
await using var client = await ElectrumClient.ConnectAsync(server.Host, server.Port, useSsl: false);
|
||||
|
||||
// More requests than the in-flight cap (32): exercises both the write
|
||||
// batching and the id → response correlation.
|
||||
var tasks = Enumerable.Range(0, 200)
|
||||
.Select(i => client.RequestAsync("echo", default, i))
|
||||
.ToList();
|
||||
var results = await Task.WhenAll(tasks);
|
||||
|
||||
for (var i = 0; i < results.Length; i++)
|
||||
Assert.Equal(i, results[i].GetInt32());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Le_notifiche_arrivano_sull_evento_NotificationReceived()
|
||||
{
|
||||
await using var server = new FakeElectrumServer();
|
||||
await using var client = await ElectrumClient.ConnectAsync(server.Host, server.Port, useSsl: false);
|
||||
|
||||
var received = new TaskCompletionSource<(string Method, JsonElement Params)>(
|
||||
TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
client.NotificationReceived += (method, p) => received.TrySetResult((method, p));
|
||||
|
||||
await server.NotifyAsync("blockchain.headers.subscribe", new object[]
|
||||
{
|
||||
new { height = 4242, hex = "00" },
|
||||
});
|
||||
|
||||
var (method, parameters) = await received.Task.WaitAsync(Timeout);
|
||||
Assert.Equal("blockchain.headers.subscribe", method);
|
||||
Assert.Equal(4242, parameters[0].GetProperty("height").GetInt32());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task La_chiusura_del_server_fa_fallire_le_richieste_pendenti_e_notifica_Disconnected()
|
||||
{
|
||||
await using var server = new FakeElectrumServer();
|
||||
server.Handle("server.banner", _ => FakeElectrumServer.NoResponse);
|
||||
|
||||
await using var client = await ElectrumClient.ConnectAsync(server.Host, server.Port, useSsl: false);
|
||||
|
||||
var disconnected = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
client.Disconnected += _ => disconnected.TrySetResult();
|
||||
|
||||
var pending = client.BannerAsync();
|
||||
server.DropClients();
|
||||
|
||||
await Assert.ThrowsAnyAsync<Exception>(() => pending.WaitAsync(Timeout));
|
||||
await disconnected.Task.WaitAsync(Timeout);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task La_cancellazione_del_token_annulla_la_richiesta_pendente()
|
||||
{
|
||||
await using var server = new FakeElectrumServer();
|
||||
server.Handle("server.banner", _ => FakeElectrumServer.NoResponse);
|
||||
|
||||
await using var client = await ElectrumClient.ConnectAsync(server.Host, server.Port, useSsl: false);
|
||||
|
||||
using var cts = new CancellationTokenSource();
|
||||
var pending = client.BannerAsync(cts.Token);
|
||||
cts.Cancel();
|
||||
|
||||
await Assert.ThrowsAnyAsync<OperationCanceledException>(() => pending.WaitAsync(Timeout));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task I_wrapper_tipizzati_del_protocollo_parsano_le_risposte()
|
||||
{
|
||||
await using var server = new FakeElectrumServer();
|
||||
server.Handle("blockchain.scripthash.listunspent", _ => new object[]
|
||||
{
|
||||
new { tx_hash = "aa".PadLeft(64, '0'), tx_pos = 1, value = 12_345L, height = 99 },
|
||||
});
|
||||
server.Handle("blockchain.transaction.broadcast", p => p[0].GetString()!.Length.ToString());
|
||||
server.Handle("blockchain.estimatefee", _ => 0.00012m);
|
||||
server.Handle("blockchain.relayfee", _ => 0.00001m);
|
||||
server.Handle("server.ping", _ => null);
|
||||
|
||||
await using var client = await ElectrumClient.ConnectAsync(server.Host, server.Port, useSsl: false);
|
||||
|
||||
var unspent = Assert.Single(await client.ListUnspentAsync("sh"));
|
||||
Assert.Equal(12_345L, unspent.ValueSats);
|
||||
Assert.Equal(1, unspent.TxPos);
|
||||
Assert.Equal(99, unspent.Height);
|
||||
|
||||
Assert.Equal("4", await client.BroadcastAsync("beef"));
|
||||
Assert.Equal(0.00012m, await client.EstimateFeeAsync(2));
|
||||
Assert.Equal(0.00001m, await client.RelayFeeAsync());
|
||||
await client.PingAsync(); // must not throw
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Ssl_con_tofu_accetta_il_primo_certificato_e_rifiuta_un_certificato_cambiato()
|
||||
{
|
||||
var pinsPath = Path.Combine(Path.GetTempPath(), $"plm-pins-{Guid.NewGuid()}.json");
|
||||
try
|
||||
{
|
||||
var pins = new CertificatePinStore(pinsPath);
|
||||
int port;
|
||||
|
||||
// First contact: any certificate is pinned (trust on first use).
|
||||
using (var cert1 = FakeElectrumServer.CreateSelfSignedCertificate("primo"))
|
||||
{
|
||||
await using var server = new FakeElectrumServer(cert1);
|
||||
port = server.Port;
|
||||
await using var client = await ElectrumClient.ConnectAsync(
|
||||
server.Host, port, useSsl: true, pins);
|
||||
Assert.True(client.IsConnected);
|
||||
Assert.True(client.UseSsl);
|
||||
}
|
||||
|
||||
// Same host:port, different certificate: the pin must block the connection.
|
||||
using (var cert2 = FakeElectrumServer.CreateSelfSignedCertificate("secondo"))
|
||||
{
|
||||
await using var server = await RebindAsync(cert2, port);
|
||||
await Assert.ThrowsAsync<CertificatePinMismatchException>(() =>
|
||||
ElectrumClient.ConnectAsync(server.Host, port, useSsl: true, pins));
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
File.Delete(pinsPath);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Rebinds a fresh server to the port just released by the previous one.</summary>
|
||||
private static async Task<FakeElectrumServer> RebindAsync(
|
||||
System.Security.Cryptography.X509Certificates.X509Certificate2 cert, int port)
|
||||
{
|
||||
for (var attempt = 0; ; attempt++)
|
||||
{
|
||||
try { return new FakeElectrumServer(cert, port); }
|
||||
catch (System.Net.Sockets.SocketException) when (attempt < 20)
|
||||
{
|
||||
await Task.Delay(50);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,212 @@
|
||||
using System.Collections.Concurrent;
|
||||
using System.Net;
|
||||
using System.Net.Security;
|
||||
using System.Net.Sockets;
|
||||
using System.Security.Cryptography;
|
||||
using System.Security.Cryptography.X509Certificates;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
|
||||
namespace PalladiumWallet.Tests.Net;
|
||||
|
||||
/// <summary>
|
||||
/// Error a handler can throw to make the fake server reply with a JSON-RPC
|
||||
/// error object (e.g. code -102 "server busy" to exercise the retry path).
|
||||
/// </summary>
|
||||
public sealed class FakeElectrumError(int code, string message) : Exception(message)
|
||||
{
|
||||
public int Code { get; } = code;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// In-process ElectrumX-like server for tests: newline-delimited JSON-RPC 2.0
|
||||
/// over a loopback TCP socket, optionally TLS with a self-signed certificate.
|
||||
/// Handlers are registered per method and receive the request "params" array;
|
||||
/// whatever they return is serialised as the "result". "server.version" is
|
||||
/// pre-registered. Calls are counted per method (cache/retry assertions).
|
||||
/// </summary>
|
||||
public sealed class FakeElectrumServer : IAsyncDisposable
|
||||
{
|
||||
private readonly TcpListener _listener;
|
||||
private readonly CancellationTokenSource _cts = new();
|
||||
private readonly Task _acceptLoop;
|
||||
private readonly X509Certificate2? _certificate;
|
||||
private readonly ConcurrentDictionary<string, Func<JsonElement, object?>> _handlers = new();
|
||||
private readonly ConcurrentDictionary<string, int> _callCounts = new();
|
||||
private readonly ConcurrentBag<(Stream Stream, SemaphoreSlim WriteLock, TcpClient Tcp)> _clients = [];
|
||||
|
||||
/// <summary>Sentinel: a handler returning this leaves the request unanswered (pending forever).</summary>
|
||||
public static readonly object NoResponse = new();
|
||||
|
||||
public FakeElectrumServer(X509Certificate2? certificate = null, int port = 0)
|
||||
{
|
||||
_certificate = certificate;
|
||||
_listener = new TcpListener(IPAddress.Loopback, port);
|
||||
_listener.Start();
|
||||
Handle("server.version", _ => new[] { "FakeElectrumX 1.0", "1.4" });
|
||||
_acceptLoop = Task.Run(AcceptLoopAsync);
|
||||
}
|
||||
|
||||
public int Port => ((IPEndPoint)_listener.LocalEndpoint).Port;
|
||||
public string Host => "127.0.0.1";
|
||||
|
||||
/// <summary>Registers (or replaces) the handler for a JSON-RPC method.</summary>
|
||||
public void Handle(string method, Func<JsonElement, object?> handler) =>
|
||||
_handlers[method] = handler;
|
||||
|
||||
public int CallCount(string method) => _callCounts.GetValueOrDefault(method);
|
||||
|
||||
public void ResetCallCounts() => _callCounts.Clear();
|
||||
|
||||
/// <summary>Pushes a JSON-RPC notification to every connected client.</summary>
|
||||
public async Task NotifyAsync(string method, object parameters)
|
||||
{
|
||||
var line = JsonSerializer.SerializeToUtf8Bytes(new
|
||||
{
|
||||
jsonrpc = "2.0",
|
||||
method,
|
||||
@params = parameters,
|
||||
});
|
||||
foreach (var (stream, writeLock, _) in _clients)
|
||||
await WriteLineAsync(stream, writeLock, line);
|
||||
}
|
||||
|
||||
/// <summary>Abruptly closes every accepted connection (tests the client's failure paths).</summary>
|
||||
public void DropClients()
|
||||
{
|
||||
foreach (var (_, _, tcp) in _clients)
|
||||
tcp.Close();
|
||||
}
|
||||
|
||||
/// <summary>Self-signed certificate for TLS tests (exportable, valid now).</summary>
|
||||
public static X509Certificate2 CreateSelfSignedCertificate(string cn = "fake-electrum")
|
||||
{
|
||||
using var rsa = RSA.Create(2048);
|
||||
var request = new CertificateRequest(
|
||||
$"CN={cn}", rsa, HashAlgorithmName.SHA256, RSASignaturePadding.Pkcs1);
|
||||
using var cert = request.CreateSelfSigned(
|
||||
DateTimeOffset.UtcNow.AddDays(-1), DateTimeOffset.UtcNow.AddDays(30));
|
||||
// PFX roundtrip so the private key is usable by SslStream on every platform.
|
||||
return X509CertificateLoader.LoadPkcs12(cert.Export(X509ContentType.Pfx), null);
|
||||
}
|
||||
|
||||
private async Task AcceptLoopAsync()
|
||||
{
|
||||
try
|
||||
{
|
||||
while (!_cts.IsCancellationRequested)
|
||||
{
|
||||
var tcp = await _listener.AcceptTcpClientAsync(_cts.Token);
|
||||
_ = Task.Run(() => ServeClientAsync(tcp));
|
||||
}
|
||||
}
|
||||
catch (OperationCanceledException) { }
|
||||
catch (SocketException) { }
|
||||
}
|
||||
|
||||
private async Task ServeClientAsync(TcpClient tcp)
|
||||
{
|
||||
try
|
||||
{
|
||||
Stream stream = tcp.GetStream();
|
||||
if (_certificate is not null)
|
||||
{
|
||||
var ssl = new SslStream(stream, leaveInnerStreamOpen: false);
|
||||
await ssl.AuthenticateAsServerAsync(_certificate);
|
||||
stream = ssl;
|
||||
}
|
||||
|
||||
var writeLock = new SemaphoreSlim(1, 1);
|
||||
_clients.Add((stream, writeLock, tcp));
|
||||
|
||||
using var reader = new StreamReader(stream, Encoding.UTF8, false, 4096, leaveOpen: true);
|
||||
while (!_cts.IsCancellationRequested)
|
||||
{
|
||||
var line = await reader.ReadLineAsync(_cts.Token);
|
||||
if (line is null) break;
|
||||
if (line.Length == 0) continue;
|
||||
await HandleRequestAsync(stream, writeLock, line);
|
||||
}
|
||||
}
|
||||
catch (Exception)
|
||||
{
|
||||
// Client went away or TLS failed (e.g. rejected pin): normal in tests.
|
||||
}
|
||||
}
|
||||
|
||||
private async Task HandleRequestAsync(Stream stream, SemaphoreSlim writeLock, string line)
|
||||
{
|
||||
using var doc = JsonDocument.Parse(line);
|
||||
var root = doc.RootElement;
|
||||
var id = root.GetProperty("id").GetInt64();
|
||||
var method = root.GetProperty("method").GetString()!;
|
||||
var parameters = root.TryGetProperty("params", out var p) ? p : default;
|
||||
|
||||
_callCounts.AddOrUpdate(method, 1, (_, n) => n + 1);
|
||||
|
||||
byte[] payload;
|
||||
if (!_handlers.TryGetValue(method, out var handler))
|
||||
{
|
||||
payload = JsonSerializer.SerializeToUtf8Bytes(new
|
||||
{
|
||||
jsonrpc = "2.0",
|
||||
id,
|
||||
error = new { code = -32601, message = $"unknown method '{method}'" },
|
||||
});
|
||||
}
|
||||
else
|
||||
{
|
||||
try
|
||||
{
|
||||
var result = handler(parameters);
|
||||
if (ReferenceEquals(result, NoResponse))
|
||||
return;
|
||||
payload = JsonSerializer.SerializeToUtf8Bytes(new
|
||||
{
|
||||
jsonrpc = "2.0",
|
||||
id,
|
||||
result,
|
||||
});
|
||||
}
|
||||
catch (FakeElectrumError err)
|
||||
{
|
||||
payload = JsonSerializer.SerializeToUtf8Bytes(new
|
||||
{
|
||||
jsonrpc = "2.0",
|
||||
id,
|
||||
error = new { code = err.Code, message = err.Message },
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
await WriteLineAsync(stream, writeLock, payload);
|
||||
}
|
||||
|
||||
private static async Task WriteLineAsync(Stream stream, SemaphoreSlim writeLock, byte[] payload)
|
||||
{
|
||||
await writeLock.WaitAsync();
|
||||
try
|
||||
{
|
||||
await stream.WriteAsync(payload);
|
||||
stream.WriteByte((byte)'\n');
|
||||
await stream.FlushAsync();
|
||||
}
|
||||
catch (Exception)
|
||||
{
|
||||
// Connection already gone: irrelevant for the test in progress.
|
||||
}
|
||||
finally
|
||||
{
|
||||
writeLock.Release();
|
||||
}
|
||||
}
|
||||
|
||||
public async ValueTask DisposeAsync()
|
||||
{
|
||||
await _cts.CancelAsync();
|
||||
_listener.Stop();
|
||||
DropClients();
|
||||
try { await _acceptLoop; } catch { }
|
||||
_cts.Dispose();
|
||||
}
|
||||
}
|
||||
@@ -79,6 +79,49 @@ public class ServerRegistryTests
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task La_discovery_aggiunge_i_peer_annunciati_e_li_persiste()
|
||||
{
|
||||
var path = TempPath();
|
||||
try
|
||||
{
|
||||
await using var server = new FakeElectrumServer();
|
||||
server.Handle("server.peers.subscribe", _ => new object[]
|
||||
{
|
||||
// Full announcement, port-less announcement (→ profile defaults),
|
||||
// and a bootstrap duplicate that must not be re-added.
|
||||
new object[] { "10.0.0.9", "nuovo.esempio.org", new[] { "v1.4.2", "t50001", "s50002" } },
|
||||
new object[] { "10.0.0.8", "senza-porte.esempio.org", new[] { "v1.4", "t", "s" } },
|
||||
new object[] { "173.212.224.67", "173.212.224.67", new[] { "v1.4", "t50001" } },
|
||||
});
|
||||
await using var client = await ElectrumClient.ConnectAsync(server.Host, server.Port, useSsl: false);
|
||||
|
||||
var registry = new ServerRegistry(ChainProfiles.Mainnet, path);
|
||||
var bootstrapCount = registry.All.Count;
|
||||
|
||||
var added = await registry.DiscoverAsync(client);
|
||||
|
||||
Assert.Equal(2, added);
|
||||
Assert.Equal(bootstrapCount + 2, registry.All.Count);
|
||||
var defaulted = registry.All.Single(s => s.Host == "senza-porte.esempio.org");
|
||||
Assert.Equal(ChainProfiles.Mainnet.DefaultTcpPort, defaulted.TcpPort);
|
||||
Assert.Equal(ChainProfiles.Mainnet.DefaultSslPort, defaulted.SslPort);
|
||||
|
||||
// Persisted for the next session (bootstrap servers excluded from the file).
|
||||
var reloaded = new ServerRegistry(ChainProfiles.Mainnet, path);
|
||||
Assert.Equal(bootstrapCount + 2, reloaded.All.Count);
|
||||
var savedJson = File.ReadAllText(path);
|
||||
Assert.DoesNotContain("173.212.224.67", savedJson);
|
||||
|
||||
// A second discovery of the same peers adds nothing.
|
||||
Assert.Equal(0, await registry.DiscoverAsync(client));
|
||||
}
|
||||
finally
|
||||
{
|
||||
File.Delete(path);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Un_file_server_corrotto_non_blocca_l_avvio()
|
||||
{
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
using PalladiumWallet.Core.Net;
|
||||
|
||||
namespace PalladiumWallet.Tests.Net;
|
||||
|
||||
/// <summary>
|
||||
/// Tests for the release-tag parsing used by the update check. The network
|
||||
/// call itself is best-effort by design (any failure → null) and is not
|
||||
/// exercised here: only the version-comparison logic is deterministic.
|
||||
/// </summary>
|
||||
public class UpdateCheckerTests
|
||||
{
|
||||
[Theory]
|
||||
[InlineData("v1.2.3", "1.2.3")]
|
||||
[InlineData("V0.9.1", "0.9.1")]
|
||||
[InlineData("1.2.3", "1.2.3")]
|
||||
[InlineData("0.9.0-beta", "0.9.0")]
|
||||
[InlineData("v2.0.0-rc.1", "2.0.0")]
|
||||
[InlineData(" 1.4 ", "1.4")]
|
||||
public void I_tag_di_release_si_parsano_nella_versione_attesa(string tag, string expected)
|
||||
{
|
||||
Assert.True(UpdateChecker.TryParse(tag, out var version));
|
||||
Assert.Equal(Version.Parse(expected), version);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("")]
|
||||
[InlineData("main")]
|
||||
[InlineData("v")]
|
||||
[InlineData("1")]
|
||||
[InlineData("non-una-versione")]
|
||||
public void I_tag_non_versionati_vengono_rifiutati(string tag)
|
||||
{
|
||||
Assert.False(UpdateChecker.TryParse(tag, out _));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Il_confronto_remota_maggiore_di_locale_segue_l_ordine_semver()
|
||||
{
|
||||
// The same comparison CheckAsync applies between remote tag and running app.
|
||||
Assert.True(UpdateChecker.TryParse("v0.10.0", out var remote));
|
||||
Assert.True(UpdateChecker.TryParse("0.9.0", out var local));
|
||||
Assert.True(remote > local); // 0.10 > 0.9: numeric, not lexicographic
|
||||
}
|
||||
}
|
||||
@@ -182,6 +182,121 @@ public class PropertyTests
|
||||
});
|
||||
}
|
||||
|
||||
// ── Scripthash ────────────────────────────────────────────────────────────
|
||||
|
||||
/// The scripthash must equal an independent SHA-256-and-reverse computation
|
||||
/// for any script bytes (cross-check, not a re-run of the same code).
|
||||
[Fact]
|
||||
public void Scripthash_coincide_con_il_calcolo_indipendente_per_ogni_script()
|
||||
{
|
||||
Gen.Byte.Array[0, 64].Sample(bytes =>
|
||||
{
|
||||
var expected = System.Security.Cryptography.SHA256.HashData(bytes);
|
||||
Array.Reverse(expected);
|
||||
Assert.Equal(
|
||||
Convert.ToHexString(expected).ToLowerInvariant(),
|
||||
Core.Spv.Scripthash.FromScript(Script.FromBytesUnsafe(bytes)));
|
||||
});
|
||||
}
|
||||
|
||||
// ── Slip132 ───────────────────────────────────────────────────────────────
|
||||
|
||||
private static readonly Gen<Core.Chain.ScriptKind> GenScriptKind = Gen.OneOfConst(
|
||||
Core.Chain.ScriptKind.Legacy,
|
||||
Core.Chain.ScriptKind.WrappedSegwit,
|
||||
Core.Chain.ScriptKind.NativeSegwit,
|
||||
Core.Chain.ScriptKind.WrappedSegwitMultisig,
|
||||
Core.Chain.ScriptKind.NativeSegwitMultisig);
|
||||
|
||||
private static readonly Gen<Core.Chain.ChainProfile> GenProfile = Gen.OneOfConst(
|
||||
Core.Chain.ChainProfiles.Mainnet, Core.Chain.ChainProfiles.Testnet);
|
||||
|
||||
/// Encode → TryDecodePrivate must roundtrip key bytes and header family
|
||||
/// for any key, script kind, and network.
|
||||
[Fact]
|
||||
public void Slip132_roundtrip_chiave_privata_per_ogni_kind_e_rete()
|
||||
{
|
||||
Gen.Select(Gen.Byte.Array[32, 64], GenScriptKind, GenProfile).Sample((seed, kind, profile) =>
|
||||
{
|
||||
var key = ExtKey.CreateFromSeed(seed);
|
||||
var encoded = Core.Crypto.Slip132.Encode(key, kind, profile);
|
||||
|
||||
Assert.True(Core.Crypto.Slip132.TryDecodePrivate(encoded, profile, out var decoded, out var decodedKind));
|
||||
Assert.Equal(key.ToBytes(), decoded!.ToBytes());
|
||||
// Legacy and Taproot share the BIP32 header: compare header families, not enum values.
|
||||
Assert.Equal(profile.ExtKeyHeaders[kind], profile.ExtKeyHeaders[decodedKind]);
|
||||
// A private key must never decode as public.
|
||||
Assert.False(Core.Crypto.Slip132.TryDecodePublic(encoded, profile, out _, out _));
|
||||
});
|
||||
}
|
||||
|
||||
/// Same roundtrip for the public (watch-only import) side.
|
||||
[Fact]
|
||||
public void Slip132_roundtrip_chiave_pubblica_per_ogni_kind_e_rete()
|
||||
{
|
||||
Gen.Select(Gen.Byte.Array[32, 64], GenScriptKind, GenProfile).Sample((seed, kind, profile) =>
|
||||
{
|
||||
var xpub = ExtKey.CreateFromSeed(seed).Neuter();
|
||||
var encoded = Core.Crypto.Slip132.Encode(xpub, kind, profile);
|
||||
|
||||
Assert.True(Core.Crypto.Slip132.TryDecodePublic(encoded, profile, out var decoded, out var decodedKind));
|
||||
Assert.Equal(xpub.ToBytes(), decoded!.ToBytes());
|
||||
Assert.Equal(profile.ExtKeyHeaders[kind], profile.ExtKeyHeaders[decodedKind]);
|
||||
Assert.False(Core.Crypto.Slip132.TryDecodePrivate(encoded, profile, out _, out _));
|
||||
});
|
||||
}
|
||||
|
||||
/// TryDecode must never throw on arbitrary input strings.
|
||||
[Fact]
|
||||
public void Slip132_TryDecode_non_lancia_mai_su_input_arbitrario()
|
||||
{
|
||||
Gen.String.Sample(text =>
|
||||
{
|
||||
try
|
||||
{
|
||||
Core.Crypto.Slip132.TryDecodePublic(text, Core.Chain.ChainProfiles.Mainnet, out _, out _);
|
||||
Core.Crypto.Slip132.TryDecodePrivate(text, Core.Chain.ChainProfiles.Mainnet, out _, out _);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Assert.Fail($"TryDecode threw {ex.GetType().Name} for '{text}'");
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// ── WalletDocument ────────────────────────────────────────────────────────
|
||||
|
||||
/// ToJson → FromJson must preserve labels and contacts for arbitrary strings
|
||||
/// (unicode, quotes, control characters…).
|
||||
[Fact]
|
||||
public void WalletDocument_roundtrip_json_con_etichette_e_contatti_arbitrari()
|
||||
{
|
||||
Gen.Select(Gen.Select(Gen.String, Gen.String).Array[0, 8], Gen.String).Sample((pairs, contactName) =>
|
||||
{
|
||||
// Lone UTF-16 surrogates are not representable in JSON (the writer
|
||||
// replaces them): an exact roundtrip is impossible by design, skip.
|
||||
if (pairs.Any(p => p.Item1.Any(char.IsSurrogate) || p.Item2.Any(char.IsSurrogate))
|
||||
|| contactName.Any(char.IsSurrogate))
|
||||
return;
|
||||
|
||||
var doc = new WalletDocument
|
||||
{
|
||||
Network = "regtest",
|
||||
ScriptKind = "NativeSegwit",
|
||||
AccountPath = "84'/1'/0'",
|
||||
AccountXpub = "vpub-test",
|
||||
Contacts = { new StoredContact { Name = contactName, Address = "rplm1qtest" } },
|
||||
};
|
||||
foreach (var (k, v) in pairs)
|
||||
doc.Labels[k] = v;
|
||||
|
||||
var restored = WalletDocument.FromJson(doc.ToJson());
|
||||
|
||||
Assert.Equal(doc.Labels, restored.Labels);
|
||||
Assert.Equal(contactName, Assert.Single(restored.Contacts).Name);
|
||||
});
|
||||
}
|
||||
|
||||
// helper: builds the branch for the given position
|
||||
private static List<uint256> BuildBranch(IReadOnlyList<uint256> leaves, int position)
|
||||
{
|
||||
|
||||
@@ -0,0 +1,376 @@
|
||||
using System.Text.Json;
|
||||
using NBitcoin;
|
||||
using PalladiumWallet.Core.Chain;
|
||||
using PalladiumWallet.Core.Crypto;
|
||||
using PalladiumWallet.Core.Net;
|
||||
using PalladiumWallet.Core.Spv;
|
||||
using PalladiumWallet.Tests.Net;
|
||||
|
||||
namespace PalladiumWallet.Tests.Spv;
|
||||
|
||||
/// <summary>
|
||||
/// End-to-end tests of the SPV synchroniser against the in-process fake server:
|
||||
/// gap-limit scanning, UTXO/history reconstruction, Merkle verification (the
|
||||
/// path that must reject a lying server), busy retry, and the disk cache.
|
||||
/// Every block in these scenarios contains a single transaction, so the Merkle
|
||||
/// root is the txid itself and the branch is empty (the tree math has its own
|
||||
/// dedicated tests in <see cref="MerkleProofTests"/>).
|
||||
/// </summary>
|
||||
public class WalletSynchronizerTests
|
||||
{
|
||||
private static readonly ChainProfile Profile = ChainProfiles.Regtest;
|
||||
private static readonly Network Net = PalladiumNetworks.Regtest;
|
||||
|
||||
private static HdAccount Account()
|
||||
{
|
||||
Assert.True(Bip39.TryParse(
|
||||
"abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about",
|
||||
out var mnemonic));
|
||||
return HdAccount.FromMnemonic(mnemonic!, null, ScriptKind.NativeSegwit, Profile);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Server-side chain state: transactions, per-scripthash histories and
|
||||
/// single-transaction block headers, wired onto a FakeElectrumServer.
|
||||
/// </summary>
|
||||
private sealed class Scenario
|
||||
{
|
||||
public int TipHeight = 200;
|
||||
public readonly Dictionary<string, Transaction> Txs = [];
|
||||
public readonly Dictionary<string, List<(string Txid, int Height)>> History = [];
|
||||
public readonly Dictionary<int, string> Headers = [];
|
||||
|
||||
/// <summary>Creates a tx paying <paramref name="sats"/> to <paramref name="to"/> and registers it.</summary>
|
||||
public Transaction Pay(BitcoinAddress to, long sats, int height, OutPoint? from = null,
|
||||
bool coinbase = false, BitcoinAddress? changeTo = null, long changeSats = 0)
|
||||
{
|
||||
var tx = Net.CreateTransaction();
|
||||
tx.Inputs.Add(coinbase ? new TxIn() : new TxIn(from ?? new OutPoint(uint256.One, 0)));
|
||||
tx.Outputs.Add(Money.Satoshis(sats), to);
|
||||
if (changeTo is not null)
|
||||
tx.Outputs.Add(Money.Satoshis(changeSats), changeTo);
|
||||
Register(tx, height, to, changeTo);
|
||||
return tx;
|
||||
}
|
||||
|
||||
public void Register(Transaction tx, int height, params BitcoinAddress?[] touchedAddresses)
|
||||
{
|
||||
var txid = tx.GetHash().ToString();
|
||||
Txs[txid] = tx;
|
||||
foreach (var addr in touchedAddresses)
|
||||
{
|
||||
if (addr is null) continue;
|
||||
var sh = Scripthash.FromAddress(addr);
|
||||
if (!History.TryGetValue(sh, out var list))
|
||||
History[sh] = list = [];
|
||||
if (!list.Contains((txid, height)))
|
||||
list.Add((txid, height));
|
||||
}
|
||||
if (height > 0 && !Headers.ContainsKey(height))
|
||||
Headers[height] = SingleTxHeaderHex(tx.GetHash(), height);
|
||||
}
|
||||
|
||||
/// <summary>80-byte header of a block whose only transaction is <paramref name="txid"/>.</summary>
|
||||
public static string SingleTxHeaderHex(uint256 txid, int height, uint256? merkleRoot = null)
|
||||
{
|
||||
var header = Net.Consensus.ConsensusFactory.CreateBlockHeader();
|
||||
header.HashPrevBlock = uint256.Zero;
|
||||
header.HashMerkleRoot = merkleRoot ?? txid;
|
||||
header.BlockTime = DateTimeOffset.FromUnixTimeSeconds(1_700_000_000 + height);
|
||||
header.Bits = new Target(0x1d00ffffu);
|
||||
header.Nonce = (uint)height;
|
||||
return Convert.ToHexString(header.ToBytes()).ToLowerInvariant();
|
||||
}
|
||||
|
||||
public void WireTo(FakeElectrumServer server)
|
||||
{
|
||||
server.Handle("blockchain.headers.subscribe",
|
||||
_ => new { height = TipHeight, hex = SingleTxHeaderHex(uint256.One, TipHeight) });
|
||||
server.Handle("blockchain.scripthash.subscribe", _ => null);
|
||||
server.Handle("blockchain.scripthash.get_history", p =>
|
||||
(History.GetValueOrDefault(p[0].GetString()!) ?? [])
|
||||
.Select(h => new { tx_hash = h.Txid, height = h.Height }));
|
||||
server.Handle("blockchain.transaction.get", p =>
|
||||
Txs.TryGetValue(p[0].GetString()!, out var tx)
|
||||
? tx.ToHex()
|
||||
: throw new FakeElectrumError(-32600, "no such transaction"));
|
||||
server.Handle("blockchain.transaction.get_merkle", p =>
|
||||
new { block_height = p[1].GetInt32(), pos = 0, merkle = Array.Empty<string>() });
|
||||
server.Handle("blockchain.block.header", p =>
|
||||
Headers.TryGetValue(p[0].GetInt32(), out var hex)
|
||||
? hex
|
||||
: throw new FakeElectrumError(-32600, "no such block"));
|
||||
}
|
||||
}
|
||||
|
||||
private static async Task<(FakeElectrumServer Server, ElectrumClient Client)> StartAsync(Scenario scenario)
|
||||
{
|
||||
var server = new FakeElectrumServer();
|
||||
scenario.WireTo(server);
|
||||
var client = await ElectrumClient.ConnectAsync(server.Host, server.Port, useSsl: false);
|
||||
return (server, client);
|
||||
}
|
||||
|
||||
// ---- scansione ----
|
||||
|
||||
[Fact]
|
||||
public async Task Wallet_vuoto_scansiona_il_gap_limit_e_riporta_saldo_zero()
|
||||
{
|
||||
var scenario = new Scenario();
|
||||
var (server, client) = await StartAsync(scenario);
|
||||
await using var _ = server; await using var __ = client;
|
||||
|
||||
var result = await new WalletSynchronizer(Account(), client).SyncOnceAsync();
|
||||
|
||||
Assert.Equal(200, result.TipHeight);
|
||||
Assert.Equal(0, result.ConfirmedSats);
|
||||
Assert.Equal(0, result.UnconfirmedSats);
|
||||
Assert.Equal(0, result.NextReceiveIndex);
|
||||
Assert.Equal(0, result.NextChangeIndex);
|
||||
// Default gap limit 20 on both chains.
|
||||
Assert.Equal(40, result.Addresses.Count);
|
||||
Assert.Equal(40, server.CallCount("blockchain.scripthash.get_history"));
|
||||
Assert.Empty(result.Utxos);
|
||||
Assert.Empty(result.History);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task La_scoperta_continua_oltre_il_primo_batch_quando_un_indirizzo_e_usato()
|
||||
{
|
||||
var account = Account();
|
||||
var scenario = new Scenario();
|
||||
scenario.Pay(account.GetReceiveAddress(3), 50_000, height: 100);
|
||||
var (server, client) = await StartAsync(scenario);
|
||||
await using var _ = server; await using var __ = client;
|
||||
|
||||
var result = await new WalletSynchronizer(account, client, gapLimit: 5).SyncOnceAsync();
|
||||
|
||||
// Index 3 used → the window slides: 5 more empty addresses (5..9) close the scan.
|
||||
Assert.Equal(4, result.NextReceiveIndex);
|
||||
Assert.Equal(10, result.Addresses.Count(a => !a.IsChange));
|
||||
Assert.Equal(5, result.Addresses.Count(a => a.IsChange));
|
||||
Assert.Equal(50_000, result.ConfirmedSats);
|
||||
}
|
||||
|
||||
// ---- ricostruzione UTXO e storico ----
|
||||
|
||||
[Fact]
|
||||
public async Task Una_ricezione_confermata_produce_utxo_saldo_e_storico_verificato()
|
||||
{
|
||||
var account = Account();
|
||||
var scenario = new Scenario();
|
||||
var funding = scenario.Pay(account.GetReceiveAddress(0), 1_000_000, height: 100);
|
||||
var (server, client) = await StartAsync(scenario);
|
||||
await using var _ = server; await using var __ = client;
|
||||
|
||||
var result = await new WalletSynchronizer(account, client).SyncOnceAsync();
|
||||
|
||||
Assert.Equal(1_000_000, result.ConfirmedSats);
|
||||
Assert.Equal(0, result.ImmatureSats); // regtest: 1 conferma minima, ne ha 101
|
||||
Assert.Equal(1, result.NextReceiveIndex);
|
||||
|
||||
var utxo = Assert.Single(result.Utxos);
|
||||
Assert.Equal(funding.GetHash().ToString(), utxo.Txid);
|
||||
Assert.Equal(100, utxo.Height);
|
||||
Assert.False(utxo.IsChange);
|
||||
|
||||
var entry = Assert.Single(result.History);
|
||||
Assert.Equal(1_000_000, entry.DeltaSats);
|
||||
Assert.True(entry.Verified); // Merkle proof checked against the header
|
||||
|
||||
var row = result.AddressRows.Single(r => r.Address == account.GetReceiveAddress(0).ToString());
|
||||
Assert.Equal(1_000_000, row.BalanceSats);
|
||||
Assert.Equal(1, row.TxCount);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Una_spesa_rimuove_l_utxo_e_produce_il_delta_negativo()
|
||||
{
|
||||
var account = Account();
|
||||
var scenario = new Scenario();
|
||||
var funding = scenario.Pay(account.GetReceiveAddress(0), 1_000_000, height: 100);
|
||||
|
||||
// Spend: 600k to an external address, 390k change to change/0, 10k fee.
|
||||
var external = new Key().PubKey.GetAddress(ScriptPubKeyType.Segwit, Net);
|
||||
var spend = Net.CreateTransaction();
|
||||
spend.Inputs.Add(new TxIn(new OutPoint(funding, 0)));
|
||||
spend.Outputs.Add(Money.Satoshis(600_000), external);
|
||||
spend.Outputs.Add(Money.Satoshis(390_000), account.GetChangeAddress(0));
|
||||
scenario.Register(spend, 101, account.GetReceiveAddress(0), account.GetChangeAddress(0));
|
||||
|
||||
var (server, client) = await StartAsync(scenario);
|
||||
await using var _ = server; await using var __ = client;
|
||||
|
||||
var result = await new WalletSynchronizer(account, client).SyncOnceAsync();
|
||||
|
||||
// The funding UTXO is spent: only the change remains.
|
||||
var utxo = Assert.Single(result.Utxos);
|
||||
Assert.Equal(spend.GetHash().ToString(), utxo.Txid);
|
||||
Assert.True(utxo.IsChange);
|
||||
Assert.Equal(390_000, result.ConfirmedSats);
|
||||
Assert.Equal(1, result.NextChangeIndex);
|
||||
|
||||
// History newest-first with the net deltas.
|
||||
Assert.Equal(2, result.History.Count);
|
||||
Assert.Equal(spend.GetHash().ToString(), result.History[0].Txid);
|
||||
Assert.Equal(-610_000, result.History[0].DeltaSats); // 390k change − 1M spent
|
||||
Assert.Equal(1_000_000, result.History[1].DeltaSats);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Una_tx_in_mempool_conta_nel_saldo_non_confermato_e_non_verifica_merkle()
|
||||
{
|
||||
var account = Account();
|
||||
var scenario = new Scenario();
|
||||
scenario.Pay(account.GetReceiveAddress(0), 250_000, height: 0);
|
||||
var (server, client) = await StartAsync(scenario);
|
||||
await using var _ = server; await using var __ = client;
|
||||
|
||||
var result = await new WalletSynchronizer(account, client).SyncOnceAsync();
|
||||
|
||||
Assert.Equal(0, result.ConfirmedSats);
|
||||
Assert.Equal(250_000, result.UnconfirmedSats);
|
||||
var entry = Assert.Single(result.History);
|
||||
Assert.False(entry.Verified);
|
||||
Assert.Equal(0, server.CallCount("blockchain.transaction.get_merkle"));
|
||||
Assert.Equal(0, server.CallCount("blockchain.block.header"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Un_coinbase_immaturo_finisce_nel_saldo_immaturo()
|
||||
{
|
||||
var account = Account();
|
||||
var scenario = new Scenario { TipHeight = 200 };
|
||||
// 11 confirmations at tip 200: far below CoinbaseMaturity+1 = 121.
|
||||
scenario.Pay(account.GetReceiveAddress(0), 5_000_000_000, height: 190, coinbase: true);
|
||||
var (server, client) = await StartAsync(scenario);
|
||||
await using var _ = server; await using var __ = client;
|
||||
|
||||
var result = await new WalletSynchronizer(account, client).SyncOnceAsync();
|
||||
|
||||
Assert.Equal(5_000_000_000, result.ConfirmedSats);
|
||||
Assert.Equal(5_000_000_000, result.ImmatureSats);
|
||||
Assert.True(Assert.Single(result.Utxos).IsCoinbase);
|
||||
}
|
||||
|
||||
// ---- sicurezza SPV ----
|
||||
|
||||
[Fact]
|
||||
public async Task Una_prova_merkle_che_non_torna_con_l_header_fa_fallire_la_sync()
|
||||
{
|
||||
var account = Account();
|
||||
var scenario = new Scenario();
|
||||
var funding = scenario.Pay(account.GetReceiveAddress(0), 1_000_000, height: 100);
|
||||
// The server lies: the block 100 header commits to a different Merkle root.
|
||||
scenario.Headers[100] = Scenario.SingleTxHeaderHex(
|
||||
funding.GetHash(), 100, merkleRoot: uint256.One);
|
||||
|
||||
var (server, client) = await StartAsync(scenario);
|
||||
await using var _ = server; await using var __ = client;
|
||||
|
||||
var ex = await Assert.ThrowsAsync<SpvVerificationException>(
|
||||
() => new WalletSynchronizer(account, client).SyncOnceAsync());
|
||||
Assert.Contains(funding.GetHash().ToString(), ex.Message);
|
||||
}
|
||||
|
||||
// ---- resilienza ----
|
||||
|
||||
[Fact]
|
||||
public async Task Gli_errori_server_busy_vengono_ritentati_fino_al_successo()
|
||||
{
|
||||
var account = Account();
|
||||
var scenario = new Scenario();
|
||||
scenario.Pay(account.GetReceiveAddress(0), 1_000_000, height: 100);
|
||||
|
||||
var server = new FakeElectrumServer();
|
||||
scenario.WireTo(server);
|
||||
// The first 3 history calls fail with the ElectrumX throttling error.
|
||||
var failures = 3;
|
||||
var histories = scenario.History;
|
||||
server.Handle("blockchain.scripthash.get_history", p =>
|
||||
{
|
||||
if (Interlocked.Decrement(ref failures) >= 0)
|
||||
throw new FakeElectrumError(-102, "excessive resource usage");
|
||||
return (histories.GetValueOrDefault(p[0].GetString()!) ?? [])
|
||||
.Select(h => new { tx_hash = h.Txid, height = h.Height });
|
||||
});
|
||||
|
||||
await using var _ = server;
|
||||
await using var client = await ElectrumClient.ConnectAsync(server.Host, server.Port, useSsl: false);
|
||||
|
||||
var result = await new WalletSynchronizer(account, client).SyncOnceAsync();
|
||||
Assert.Equal(1_000_000, result.ConfirmedSats);
|
||||
}
|
||||
|
||||
// ---- cache su disco ----
|
||||
|
||||
[Fact]
|
||||
public async Task La_cache_precaricata_evita_di_riscaricare_tx_prove_e_header()
|
||||
{
|
||||
var account = Account();
|
||||
var scenario = new Scenario();
|
||||
scenario.Pay(account.GetReceiveAddress(0), 1_000_000, height: 100);
|
||||
var (server, client) = await StartAsync(scenario);
|
||||
await using var _ = server; await using var __ = client;
|
||||
|
||||
var first = new WalletSynchronizer(account, client);
|
||||
var result1 = await first.SyncOnceAsync();
|
||||
var (rawTx, verifiedAt, headers) = first.ExportCaches(Net);
|
||||
|
||||
Assert.Single(rawTx); // the confirmed tx is exported
|
||||
Assert.Single(verifiedAt); // with its verified height
|
||||
Assert.NotEmpty(headers);
|
||||
|
||||
// Fresh synchroniser (new launch), warm cache: no re-download.
|
||||
server.ResetCallCounts();
|
||||
var second = new WalletSynchronizer(account, client);
|
||||
second.PreloadCaches(rawTx, verifiedAt, headers,
|
||||
result1.NextReceiveIndex, result1.NextChangeIndex, Net);
|
||||
var result2 = await second.SyncOnceAsync();
|
||||
|
||||
Assert.Equal(result1.ConfirmedSats, result2.ConfirmedSats);
|
||||
Assert.Equal(0, server.CallCount("blockchain.transaction.get"));
|
||||
Assert.Equal(0, server.CallCount("blockchain.transaction.get_merkle"));
|
||||
Assert.Equal(0, server.CallCount("blockchain.block.header"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Le_tx_non_confermate_non_vengono_esportate_nella_cache()
|
||||
{
|
||||
var account = Account();
|
||||
var scenario = new Scenario();
|
||||
scenario.Pay(account.GetReceiveAddress(0), 250_000, height: 0); // mempool
|
||||
var (server, client) = await StartAsync(scenario);
|
||||
await using var _ = server; await using var __ = client;
|
||||
|
||||
var sync = new WalletSynchronizer(account, client);
|
||||
await sync.SyncOnceAsync();
|
||||
var (rawTx, verifiedAt, _) = sync.ExportCaches(Net);
|
||||
|
||||
// Unconfirmed txs can change (RBF): they must always be re-downloaded.
|
||||
Assert.Empty(rawTx);
|
||||
Assert.Empty(verifiedAt);
|
||||
}
|
||||
|
||||
// ---- account a indirizzi fissi (WIF importati) ----
|
||||
|
||||
[Fact]
|
||||
public async Task Un_account_con_indirizzi_fissi_scansiona_solo_quelli()
|
||||
{
|
||||
var key = new Key();
|
||||
var address = key.PubKey.GetAddress(ScriptPubKeyType.Segwit, Net);
|
||||
var account = new ImportedKeyAccount([(address, key)], ScriptKind.NativeSegwit, Profile);
|
||||
|
||||
var scenario = new Scenario();
|
||||
scenario.Pay(address, 750_000, height: 150);
|
||||
var (server, client) = await StartAsync(scenario);
|
||||
await using var _ = server; await using var __ = client;
|
||||
|
||||
var result = await new WalletSynchronizer(account, client).SyncOnceAsync();
|
||||
|
||||
Assert.Equal(750_000, result.ConfirmedSats);
|
||||
Assert.Single(result.Addresses);
|
||||
Assert.Equal(1, server.CallCount("blockchain.scripthash.get_history"));
|
||||
Assert.Equal(1, result.NextReceiveIndex);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
using PalladiumWallet.Core.Chain;
|
||||
using PalladiumWallet.Core.Storage;
|
||||
|
||||
namespace PalladiumWallet.Tests.Storage;
|
||||
|
||||
/// <summary>
|
||||
/// Tests for the data-path resolution (§8), pinned to a temporary root via
|
||||
/// <see cref="AppPaths.OverrideDataRoot"/> — the same seam the Android head and
|
||||
/// the CLI --data-dir use — so nothing outside the temp folder is touched.
|
||||
/// The pointer-file and portable-mode branches read machine-global locations
|
||||
/// and are deliberately not exercised here.
|
||||
/// </summary>
|
||||
public class AppPathsTests : IDisposable
|
||||
{
|
||||
private readonly string _root;
|
||||
private readonly string? _savedOverride;
|
||||
|
||||
public AppPathsTests()
|
||||
{
|
||||
_root = Path.Combine(Path.GetTempPath(), $"plm-data-{Guid.NewGuid()}");
|
||||
_savedOverride = AppPaths.OverrideDataRoot;
|
||||
AppPaths.OverrideDataRoot = _root;
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
AppPaths.OverrideDataRoot = _savedOverride;
|
||||
if (Directory.Exists(_root))
|
||||
Directory.Delete(_root, recursive: true);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void L_override_ha_priorita_su_tutto()
|
||||
{
|
||||
Assert.Equal(_root, AppPaths.DataRoot());
|
||||
Assert.True(AppPaths.IsDataLocationConfigured());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Ogni_rete_ha_la_propria_sottocartella_con_il_nome_del_profilo()
|
||||
{
|
||||
foreach (var net in new[] { NetKind.Mainnet, NetKind.Testnet, NetKind.Regtest })
|
||||
{
|
||||
var dir = AppPaths.ForNetwork(net);
|
||||
Assert.Equal(Path.Combine(_root, ChainProfiles.For(net).NetName), dir);
|
||||
Assert.True(Directory.Exists(dir));
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void I_percorsi_dei_file_di_rete_stanno_sotto_la_cartella_della_rete()
|
||||
{
|
||||
var netDir = AppPaths.ForNetwork(NetKind.Mainnet);
|
||||
|
||||
Assert.Equal(Path.Combine(netDir, "server-certs.json"), AppPaths.CertificatePinsPath(NetKind.Mainnet));
|
||||
Assert.Equal(Path.Combine(netDir, "servers.json"), AppPaths.ServersPath(NetKind.Mainnet));
|
||||
Assert.Equal(Path.Combine(netDir, "wallets", "default.wallet.json"),
|
||||
AppPaths.DefaultWalletPath(NetKind.Mainnet));
|
||||
Assert.Equal(Path.Combine(_root, "config.json"), AppPaths.ConfigPath());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void WalletFiles_elenca_solo_i_wallet_in_ordine_alfabetico()
|
||||
{
|
||||
var dir = AppPaths.WalletsDir(NetKind.Regtest);
|
||||
File.WriteAllText(Path.Combine(dir, "zeta.wallet.json"), "{}");
|
||||
File.WriteAllText(Path.Combine(dir, "alfa.wallet.json"), "{}");
|
||||
File.WriteAllText(Path.Combine(dir, "non-un-wallet.txt"), "x");
|
||||
File.WriteAllText(Path.Combine(dir, "default.wallet.json.lock"), "x");
|
||||
|
||||
var files = AppPaths.WalletFiles(NetKind.Regtest);
|
||||
|
||||
Assert.Equal(2, files.Count);
|
||||
Assert.EndsWith("alfa.wallet.json", files[0]);
|
||||
Assert.EndsWith("zeta.wallet.json", files[1]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Le_reti_non_condividono_le_cartelle_wallet()
|
||||
{
|
||||
Assert.NotEqual(AppPaths.WalletsDir(NetKind.Mainnet), AppPaths.WalletsDir(NetKind.Testnet));
|
||||
Assert.NotEqual(AppPaths.WalletsDir(NetKind.Testnet), AppPaths.WalletsDir(NetKind.Regtest));
|
||||
}
|
||||
}
|
||||
@@ -77,6 +77,31 @@ public class StorageTests
|
||||
Assert.False(EncryptedFile.IsEncrypted("non è json"));
|
||||
}
|
||||
|
||||
// Regression: valid JSON whose root is not an object (found by the
|
||||
// property test) must not throw from TryGetProperty.
|
||||
[Theory]
|
||||
[InlineData("5")]
|
||||
[InlineData("true")]
|
||||
[InlineData("\"stringa\"")]
|
||||
[InlineData("[1, 2]")]
|
||||
public void IsEncrypted_restituisce_false_per_json_con_radice_non_oggetto(string content)
|
||||
{
|
||||
Assert.False(EncryptedFile.IsEncrypted(content));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void IsEncrypted_restituisce_false_per_utf16_invalido()
|
||||
{
|
||||
// Lone surrogate: cannot be transcoded to UTF-8 for JSON parsing.
|
||||
Assert.False(EncryptedFile.IsEncrypted("\ud800"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void IsEncrypted_restituisce_false_se_Format_non_e_una_stringa()
|
||||
{
|
||||
Assert.False(EncryptedFile.IsEncrypted("{\"Format\": 42}"));
|
||||
}
|
||||
|
||||
// ---- WalletDocument JSON ----
|
||||
|
||||
[Fact]
|
||||
|
||||
@@ -48,7 +48,7 @@ public class TransactionFactoryTests
|
||||
|
||||
var built = new TransactionFactory(account).Build(
|
||||
utxos, txs, destination, amountSats: 400_000,
|
||||
feeRateSatPerVByte: 2, changeIndex: 0);
|
||||
feeRateSatPerVByte: 2, changeIndex: 0, tipHeight: 100);
|
||||
|
||||
Assert.True(built.Signed);
|
||||
// Output: recipient + change.
|
||||
@@ -74,7 +74,7 @@ public class TransactionFactoryTests
|
||||
|
||||
var built = new TransactionFactory(account).Build(
|
||||
utxos, txs, destination, amountSats: 0,
|
||||
feeRateSatPerVByte: 1, changeIndex: 0, sendAll: true);
|
||||
feeRateSatPerVByte: 1, changeIndex: 0, tipHeight: 100, sendAll: true);
|
||||
|
||||
var output = Assert.Single(built.Transaction.Outputs);
|
||||
Assert.Equal(500_000, output.Value.Satoshi + built.Fee.Satoshi);
|
||||
@@ -88,7 +88,7 @@ public class TransactionFactoryTests
|
||||
|
||||
Assert.Throws<WalletSpendException>(() => new TransactionFactory(account).Build(
|
||||
utxos, txs, account.GetReceiveAddress(1), amountSats: 900_000,
|
||||
feeRateSatPerVByte: 2, changeIndex: 0));
|
||||
feeRateSatPerVByte: 2, changeIndex: 0, tipHeight: 100));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -100,8 +100,8 @@ public class TransactionFactoryTests
|
||||
|
||||
var ex = Assert.Throws<WalletSpendException>(() => new TransactionFactory(account).Build(
|
||||
utxos, txs, account.GetReceiveAddress(1), amountSats: 100_000,
|
||||
feeRateSatPerVByte: 2, changeIndex: 0));
|
||||
Assert.Contains("pending confirmation", ex.Message);
|
||||
feeRateSatPerVByte: 2, changeIndex: 0, tipHeight: 100));
|
||||
Assert.Contains("unconfirmed", ex.Message);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -113,7 +113,62 @@ public class TransactionFactoryTests
|
||||
|
||||
Assert.Throws<WalletSpendException>(() => new TransactionFactory(account).Build(
|
||||
utxos, txs, account.GetReceiveAddress(1), amountSats: 100_000,
|
||||
feeRateSatPerVByte: 2, changeIndex: 0));
|
||||
feeRateSatPerVByte: 2, changeIndex: 0, tipHeight: 100));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Gli_utxo_coinbase_immaturi_non_sono_spendibili()
|
||||
{
|
||||
var account = Account();
|
||||
var (utxos, txs) = Fund(account, 1_000_000);
|
||||
utxos[0].IsCoinbase = true;
|
||||
utxos[0].Height = 100;
|
||||
// Threshold = COINBASE_MATURITY + 1 = 121 (mirrors the Qt wallet: consensus rule is
|
||||
// nSpendHeight - nHeight >= 120, plus one block of safety margin).
|
||||
// At height=100, tip=219 → confs = 219-100+1 = 120 < 121 → immature.
|
||||
var ex = Assert.Throws<WalletSpendException>(() => new TransactionFactory(account).Build(
|
||||
utxos, txs, account.GetReceiveAddress(1), amountSats: 100_000,
|
||||
feeRateSatPerVByte: 2, changeIndex: 0, tipHeight: 219));
|
||||
Assert.Contains("coinbase", ex.Message);
|
||||
Assert.Contains("120/121", ex.Message);
|
||||
|
||||
// tip=220 → confs = 121 ≥ 121 → mature and spendable.
|
||||
var built = new TransactionFactory(account).Build(
|
||||
utxos, txs, account.GetReceiveAddress(1), amountSats: 100_000,
|
||||
feeRateSatPerVByte: 2, changeIndex: 0, tipHeight: 220);
|
||||
Assert.True(built.Signed);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Gli_utxo_con_meno_di_minconf_non_sono_spendibili_su_mainnet()
|
||||
{
|
||||
var mainnetAccount = HdAccount.FromMnemonic(
|
||||
Bip39.TryParse("abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about", out var m) ? m! : throw new Exception(),
|
||||
null, ScriptKind.NativeSegwit, ChainProfiles.Mainnet);
|
||||
|
||||
var funding = PalladiumNetworks.Mainnet.CreateTransaction();
|
||||
funding.Inputs.Add(new TxIn(new OutPoint(uint256.One, 0)));
|
||||
funding.Outputs.Add(Money.Satoshis(1_000_000), mainnetAccount.GetReceiveAddress(0));
|
||||
var txid = funding.GetHash().ToString();
|
||||
var utxos = new List<CachedUtxo>
|
||||
{
|
||||
new() { Txid = txid, Vout = 0, ValueSats = 1_000_000,
|
||||
Address = mainnetAccount.GetReceiveAddress(0).ToString(),
|
||||
IsChange = false, AddressIndex = 0, Height = 100, IsCoinbase = false },
|
||||
};
|
||||
var txs = new Dictionary<string, Transaction> { [txid] = funding };
|
||||
|
||||
// 5 confirmations (tipHeight=104): below the mainnet minimum of 6.
|
||||
var ex = Assert.Throws<WalletSpendException>(() => new TransactionFactory(mainnetAccount).Build(
|
||||
utxos, txs, mainnetAccount.GetReceiveAddress(1), amountSats: 100_000,
|
||||
feeRateSatPerVByte: 2, changeIndex: 0, tipHeight: 104));
|
||||
Assert.Contains("5 so far", ex.Message);
|
||||
|
||||
// 6 confirmations (tipHeight=105): exactly at the threshold → spendable.
|
||||
var built = new TransactionFactory(mainnetAccount).Build(
|
||||
utxos, txs, mainnetAccount.GetReceiveAddress(1), amountSats: 100_000,
|
||||
feeRateSatPerVByte: 2, changeIndex: 0, tipHeight: 105);
|
||||
Assert.True(built.Signed);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -127,7 +182,7 @@ public class TransactionFactoryTests
|
||||
|
||||
var built = new TransactionFactory(watchOnly).Build(
|
||||
utxos, txs, full.GetReceiveAddress(5), amountSats: 400_000,
|
||||
feeRateSatPerVByte: 2, changeIndex: 0);
|
||||
feeRateSatPerVByte: 2, changeIndex: 0, tipHeight: 100);
|
||||
|
||||
Assert.False(built.Signed);
|
||||
// Air-gapped flow (§6.5): the online-machine PSBT is signed
|
||||
@@ -189,6 +244,104 @@ public class TransactionFactoryTests
|
||||
}
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(ScriptPubKeyType.Legacy)]
|
||||
[InlineData(ScriptPubKeyType.SegwitP2SH)]
|
||||
[InlineData(ScriptPubKeyType.Segwit)]
|
||||
public void Si_puo_pagare_ogni_tipo_di_indirizzo_standard(ScriptPubKeyType kind)
|
||||
{
|
||||
var account = Account();
|
||||
var (utxos, txs) = Fund(account, 1_000_000);
|
||||
var destination = new Key().PubKey.GetAddress(kind, Net);
|
||||
|
||||
var built = new TransactionFactory(account).Build(
|
||||
utxos, txs, destination, amountSats: 400_000,
|
||||
feeRateSatPerVByte: 2, changeIndex: 0, tipHeight: 100);
|
||||
|
||||
Assert.True(built.Signed);
|
||||
Assert.Contains(built.Transaction.Outputs,
|
||||
o => o.ScriptPubKey == destination.ScriptPubKey && o.Value.Satoshi == 400_000);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Piu_utxo_vengono_combinati_quando_uno_solo_non_basta()
|
||||
{
|
||||
var account = Account();
|
||||
var allUtxos = new List<CachedUtxo>();
|
||||
var allTxs = new Dictionary<string, Transaction>();
|
||||
for (var i = 0; i < 3; i++)
|
||||
{
|
||||
var funding = Net.CreateTransaction();
|
||||
funding.Inputs.Add(new TxIn(new OutPoint(uint256.One, (uint)i)));
|
||||
funding.Outputs.Add(Money.Satoshis(300_000), account.GetReceiveAddress(i));
|
||||
var txid = funding.GetHash().ToString();
|
||||
allTxs[txid] = funding;
|
||||
allUtxos.Add(new CachedUtxo
|
||||
{
|
||||
Txid = txid, Vout = 0, ValueSats = 300_000,
|
||||
Address = account.GetReceiveAddress(i).ToString(),
|
||||
IsChange = false, AddressIndex = i, Height = 100,
|
||||
});
|
||||
}
|
||||
|
||||
var built = new TransactionFactory(account).Build(
|
||||
allUtxos, allTxs, account.GetReceiveAddress(5), amountSats: 700_000,
|
||||
feeRateSatPerVByte: 1, changeIndex: 0, tipHeight: 100);
|
||||
|
||||
// 700k > any pair? No: it needs all three 300k coins (600k < 700k + fee).
|
||||
Assert.Equal(3, built.Transaction.Inputs.Count);
|
||||
Assert.True(built.Signed);
|
||||
Assert.Contains(built.Transaction.Outputs, o => o.Value.Satoshi == 700_000);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Un_resto_sotto_la_soglia_dust_viene_assorbito_nella_fee()
|
||||
{
|
||||
var account = Account();
|
||||
var (utxos, txs) = Fund(account, 100_000);
|
||||
|
||||
// Leaves ~200 sats after the fee: below the P2WPKH dust threshold,
|
||||
// so no change output must be created and the remainder goes to the fee.
|
||||
var built = new TransactionFactory(account).Build(
|
||||
utxos, txs, account.GetReceiveAddress(5), amountSats: 99_650,
|
||||
feeRateSatPerVByte: 1, changeIndex: 0, tipHeight: 100);
|
||||
|
||||
var output = Assert.Single(built.Transaction.Outputs);
|
||||
Assert.Equal(99_650, output.Value.Satoshi);
|
||||
Assert.Equal(100_000 - 99_650, built.Fee.Satoshi);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void La_firma_di_una_tx_fissa_produce_il_txid_golden()
|
||||
{
|
||||
// Golden vector for the signing path (derivation → sighash → witness):
|
||||
// the factory shuffles outputs for privacy, so the vector is anchored one
|
||||
// level below, on a transaction with fixed structure signed through PSBT
|
||||
// (the same flow used air-gapped, §6.5). RFC 6979 makes it deterministic:
|
||||
// any change in this txid is a blocking regression.
|
||||
var account = Account();
|
||||
|
||||
var funding = Net.CreateTransaction();
|
||||
funding.Inputs.Add(new TxIn(new OutPoint(uint256.One, 0)));
|
||||
funding.Outputs.Add(Money.Satoshis(1_000_000), account.GetReceiveAddress(0));
|
||||
|
||||
var spend = Net.CreateTransaction();
|
||||
spend.Version = 2;
|
||||
spend.Inputs.Add(new TxIn(new OutPoint(funding, 0)) { Sequence = 0xfffffffd });
|
||||
spend.Outputs.Add(Money.Satoshis(600_000), account.GetReceiveAddress(5));
|
||||
spend.Outputs.Add(Money.Satoshis(390_000), account.GetChangeAddress(0));
|
||||
|
||||
var psbt = PSBT.FromTransaction(spend, Net);
|
||||
psbt.AddCoins(new Coin(funding, 0));
|
||||
psbt.SignWithKeys(account.GetExtPrivateKey(isChange: false, 0));
|
||||
psbt.Finalize();
|
||||
var signed = psbt.ExtractTransaction();
|
||||
|
||||
Assert.Equal(
|
||||
"a943cf6bf606fa0050e490cb76ed9313959d228fb0ffa235b7e8b7f6834610b6",
|
||||
signed.GetHash().ToString());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Una_config_corrotta_torna_ai_default()
|
||||
{
|
||||
|
||||
@@ -0,0 +1,217 @@
|
||||
using NBitcoin;
|
||||
using PalladiumWallet.Core.Chain;
|
||||
using PalladiumWallet.Core.Crypto;
|
||||
using PalladiumWallet.Core.Net;
|
||||
using PalladiumWallet.Core.Wallet;
|
||||
using PalladiumWallet.Tests.Net;
|
||||
|
||||
namespace PalladiumWallet.Tests.Wallet;
|
||||
|
||||
/// <summary>
|
||||
/// Tests for the transaction detail assembly (§10): fee from resolved inputs,
|
||||
/// mine/theirs attribution, RBF flag, coinbase, and the degraded paths when the
|
||||
/// server cannot provide a previous transaction.
|
||||
/// </summary>
|
||||
public class TransactionInspectorTests
|
||||
{
|
||||
private static readonly Network Net = PalladiumNetworks.Regtest;
|
||||
|
||||
private static HdAccount Account()
|
||||
{
|
||||
Assert.True(Bip39.TryParse(
|
||||
"abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about",
|
||||
out var mnemonic));
|
||||
return HdAccount.FromMnemonic(mnemonic!, null, ScriptKind.NativeSegwit, ChainProfiles.Regtest);
|
||||
}
|
||||
|
||||
/// <summary>Funding (1M sats to receive/0) + spend (600k external, 390k change/0, 10k fee, RBF).</summary>
|
||||
private static (Transaction Funding, Transaction Spend, BitcoinAddress External, HdAccount Account) SpendPair()
|
||||
{
|
||||
var account = Account();
|
||||
var funding = Net.CreateTransaction();
|
||||
funding.Inputs.Add(new TxIn(new OutPoint(uint256.One, 0)));
|
||||
funding.Outputs.Add(Money.Satoshis(1_000_000), account.GetReceiveAddress(0));
|
||||
|
||||
var external = new Key().PubKey.GetAddress(ScriptPubKeyType.Segwit, Net);
|
||||
var spend = Net.CreateTransaction();
|
||||
spend.Inputs.Add(new TxIn(new OutPoint(funding, 0)) { Sequence = 0xfffffffd });
|
||||
spend.Outputs.Add(Money.Satoshis(600_000), external);
|
||||
spend.Outputs.Add(Money.Satoshis(390_000), account.GetChangeAddress(0));
|
||||
return (funding, spend, external, account);
|
||||
}
|
||||
|
||||
private static async Task<(FakeElectrumServer Server, ElectrumClient Client)> StartAsync(
|
||||
params Transaction[] served)
|
||||
{
|
||||
var byId = served.ToDictionary(t => t.GetHash().ToString());
|
||||
var server = new FakeElectrumServer();
|
||||
server.Handle("blockchain.transaction.get", p =>
|
||||
byId.TryGetValue(p[0].GetString()!, out var tx)
|
||||
? tx.ToHex()
|
||||
: throw new FakeElectrumError(-32600, "no such transaction"));
|
||||
server.Handle("blockchain.block.header", p =>
|
||||
{
|
||||
var height = p[0].GetInt32();
|
||||
var header = Net.Consensus.ConsensusFactory.CreateBlockHeader();
|
||||
header.BlockTime = DateTimeOffset.FromUnixTimeSeconds(1_700_000_000 + height);
|
||||
header.Bits = new Target(0x1d00ffffu);
|
||||
return Convert.ToHexString(header.ToBytes()).ToLowerInvariant();
|
||||
});
|
||||
var client = await ElectrumClient.ConnectAsync(server.Host, server.Port, useSsl: false);
|
||||
return (server, client);
|
||||
}
|
||||
|
||||
private static HashSet<string> Owned(HdAccount account) =>
|
||||
[
|
||||
account.GetReceiveAddress(0).ToString(),
|
||||
account.GetChangeAddress(0).ToString(),
|
||||
];
|
||||
|
||||
[Fact]
|
||||
public async Task Una_spesa_confermata_riporta_fee_conferme_e_attribuzione_mine_theirs()
|
||||
{
|
||||
var (funding, spend, external, account) = SpendPair();
|
||||
var (server, client) = await StartAsync(funding, spend);
|
||||
await using var _ = server; await using var __ = client;
|
||||
|
||||
var details = await TransactionInspector.FetchAsync(
|
||||
client, Net, spend.GetHash().ToString(), tipHeight: 200, height: 101,
|
||||
Owned(account), netSats: -610_000, verified: true);
|
||||
|
||||
Assert.Equal(10_000, details.FeeSats);
|
||||
Assert.Equal(1_000_000, details.TotalInSats);
|
||||
Assert.Equal(990_000, details.TotalOutSats);
|
||||
Assert.Equal(100, details.Confirmations); // 200 − 101 + 1
|
||||
Assert.True(details.RbfSignaled);
|
||||
Assert.True(details.Verified);
|
||||
Assert.False(details.IsCoinbase);
|
||||
Assert.False(details.IsIncoming);
|
||||
Assert.Equal(DateTimeOffset.FromUnixTimeSeconds(1_700_000_101), details.BlockTime);
|
||||
Assert.Equal((double)10_000 / details.VirtualSize, details.FeeRateSatPerVb);
|
||||
|
||||
var input = Assert.Single(details.Inputs);
|
||||
Assert.True(input.IsMine);
|
||||
Assert.Equal(1_000_000, input.AmountSats);
|
||||
Assert.Equal(account.GetReceiveAddress(0).ToString(), input.Address);
|
||||
|
||||
Assert.Equal(2, details.Outputs.Count);
|
||||
Assert.Equal(600_000, details.SentToOthersSats);
|
||||
Assert.Equal(390_000, details.ReceivedSats);
|
||||
Assert.Contains(details.Outputs, o => o.IsMine && o.AmountSats == 390_000);
|
||||
Assert.Contains(details.Outputs, o => !o.IsMine && o.AmountSats == 600_000);
|
||||
|
||||
// Outgoing tx: the counterparty is the external recipient.
|
||||
Assert.Equal([external.ToString()], details.CounterpartyAddresses);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Una_ricezione_indica_il_mittente_come_controparte()
|
||||
{
|
||||
var account = Account();
|
||||
var senderKey = new Key();
|
||||
var senderAddr = senderKey.PubKey.GetAddress(ScriptPubKeyType.Segwit, Net);
|
||||
|
||||
// The sender's coin, then the payment to us spending it.
|
||||
var senderFunding = Net.CreateTransaction();
|
||||
senderFunding.Inputs.Add(new TxIn(new OutPoint(uint256.One, 1)));
|
||||
senderFunding.Outputs.Add(Money.Satoshis(2_000_000), senderAddr);
|
||||
|
||||
var payment = Net.CreateTransaction();
|
||||
payment.Inputs.Add(new TxIn(new OutPoint(senderFunding, 0)));
|
||||
payment.Outputs.Add(Money.Satoshis(1_500_000), account.GetReceiveAddress(0));
|
||||
|
||||
var (server, client) = await StartAsync(senderFunding, payment);
|
||||
await using var _ = server; await using var __ = client;
|
||||
|
||||
var details = await TransactionInspector.FetchAsync(
|
||||
client, Net, payment.GetHash().ToString(), tipHeight: 200, height: 150,
|
||||
Owned(account), netSats: 1_500_000, verified: true);
|
||||
|
||||
Assert.True(details.IsIncoming);
|
||||
Assert.Equal([senderAddr.ToString()], details.CounterpartyAddresses);
|
||||
Assert.Equal(500_000, details.FeeSats); // 2M in − 1.5M out
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Una_coinbase_non_ha_fee_ne_importi_in_ingresso()
|
||||
{
|
||||
var account = Account();
|
||||
var coinbase = Net.CreateTransaction();
|
||||
coinbase.Inputs.Add(new TxIn());
|
||||
coinbase.Outputs.Add(Money.Satoshis(5_000_000_000), account.GetReceiveAddress(0));
|
||||
|
||||
var (server, client) = await StartAsync(coinbase);
|
||||
await using var _ = server; await using var __ = client;
|
||||
|
||||
var details = await TransactionInspector.FetchAsync(
|
||||
client, Net, coinbase.GetHash().ToString(), tipHeight: 200, height: 190,
|
||||
Owned(account), netSats: 5_000_000_000, verified: true);
|
||||
|
||||
Assert.True(details.IsCoinbase);
|
||||
Assert.Null(details.FeeSats);
|
||||
Assert.Null(details.TotalInSats);
|
||||
Assert.Null(details.FeeRateSatPerVb);
|
||||
Assert.Equal(11, details.Confirmations);
|
||||
var input = Assert.Single(details.Inputs);
|
||||
Assert.True(input.IsCoinbase);
|
||||
Assert.Null(input.AmountSats);
|
||||
// No previous transactions to resolve.
|
||||
Assert.Equal(0, server.CallCount("blockchain.transaction.get") - 1); // only the coinbase itself
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Se_la_tx_precedente_non_e_recuperabile_la_fee_resta_ignota()
|
||||
{
|
||||
var (funding, spend, _, account) = SpendPair();
|
||||
// The server only knows the spend: the funding lookup fails.
|
||||
var (server, client) = await StartAsync(spend);
|
||||
await using var _ = server; await using var __ = client;
|
||||
|
||||
var details = await TransactionInspector.FetchAsync(
|
||||
client, Net, spend.GetHash().ToString(), tipHeight: 200, height: 101,
|
||||
Owned(account), netSats: -610_000, verified: false);
|
||||
|
||||
Assert.Null(details.FeeSats);
|
||||
Assert.Null(details.TotalInSats);
|
||||
var input = Assert.Single(details.Inputs);
|
||||
Assert.Null(input.AmountSats);
|
||||
Assert.False(input.IsMine); // unresolvable → not attributable
|
||||
Assert.Equal(funding.GetHash().ToString(), input.PrevTxid);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Una_tx_in_mempool_non_ha_conferme_ne_timestamp()
|
||||
{
|
||||
var (funding, spend, _, account) = SpendPair();
|
||||
var (server, client) = await StartAsync(funding, spend);
|
||||
await using var _ = server; await using var __ = client;
|
||||
|
||||
var details = await TransactionInspector.FetchAsync(
|
||||
client, Net, spend.GetHash().ToString(), tipHeight: 200, height: 0,
|
||||
Owned(account), netSats: -610_000, verified: false);
|
||||
|
||||
Assert.Equal(0, details.Confirmations);
|
||||
Assert.Null(details.BlockTime);
|
||||
Assert.Equal(0, server.CallCount("blockchain.block.header"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task La_cache_delle_tx_evita_le_richieste_al_server()
|
||||
{
|
||||
var (funding, spend, _, account) = SpendPair();
|
||||
var cache = new Dictionary<string, Transaction>
|
||||
{
|
||||
[funding.GetHash().ToString()] = funding,
|
||||
[spend.GetHash().ToString()] = spend,
|
||||
};
|
||||
var (server, client) = await StartAsync();
|
||||
await using var _ = server; await using var __ = client;
|
||||
|
||||
var details = await TransactionInspector.FetchAsync(
|
||||
client, Net, spend.GetHash().ToString(), tipHeight: 200, height: 0,
|
||||
Owned(account), netSats: -610_000, verified: false, cache);
|
||||
|
||||
Assert.Equal(10_000, details.FeeSats);
|
||||
Assert.Equal(0, server.CallCount("blockchain.transaction.get"));
|
||||
}
|
||||
}
|
||||
@@ -28,8 +28,8 @@ public class WalletLoaderTests
|
||||
Assert.Equal("NativeSegwit", doc.ScriptKind);
|
||||
Assert.Equal(ValidMnemonic, doc.Mnemonic);
|
||||
Assert.Null(doc.Passphrase);
|
||||
Assert.NotEmpty(doc.AccountXpub);
|
||||
Assert.NotEmpty(doc.MasterFingerprint);
|
||||
Assert.NotEmpty(doc.AccountXpub!);
|
||||
Assert.NotEmpty(doc.MasterFingerprint!);
|
||||
Assert.False(doc.IsWatchOnly);
|
||||
}
|
||||
|
||||
@@ -66,7 +66,7 @@ public class WalletLoaderTests
|
||||
var (doc, _) = WalletLoader.NewFromMnemonic(
|
||||
ValidMnemonic24, null, ScriptKind.NativeSegwit, ChainProfiles.Mainnet);
|
||||
Assert.Equal("mainnet", doc.Network);
|
||||
Assert.NotEmpty(doc.AccountXpub);
|
||||
Assert.NotEmpty(doc.AccountXpub!);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
|
||||
Executable
+67
@@ -0,0 +1,67 @@
|
||||
#!/usr/bin/env bash
|
||||
# Bumps the app version everywhere it's tracked and stubs a CHANGELOG.md entry.
|
||||
set -euo pipefail
|
||||
|
||||
cd "$(dirname "${BASH_SOURCE[0]}")"
|
||||
|
||||
APP_CSPROJ="src/App/PalladiumWallet.App.csproj"
|
||||
ANDROID_CSPROJ="src/App.Android/PalladiumWallet.App.Android.csproj"
|
||||
CHANGELOG="CHANGELOG.md"
|
||||
|
||||
current_version=$(grep -oP '(?<=<Version>)[^<]+(?=</Version>)' "$APP_CSPROJ")
|
||||
echo "Current version: $current_version"
|
||||
|
||||
read -rp "New version (e.g. 1.0.0): " new_version
|
||||
|
||||
if [[ -z "$new_version" ]]; then
|
||||
echo "No version entered, aborting." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if ! [[ "$new_version" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
|
||||
echo "Invalid version format: '$new_version' (expected X.Y.Z)." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [[ "$new_version" == "$current_version" ]]; then
|
||||
echo "New version is the same as the current one ($current_version), aborting." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# 1. Single source of truth: App csproj <Version>
|
||||
sed -i "s|<Version>$current_version</Version>|<Version>$new_version</Version>|" "$APP_CSPROJ"
|
||||
|
||||
# 2. Android head: ApplicationDisplayVersion (versionName) must mirror it,
|
||||
# and ApplicationVersion (versionCode) must strictly increase on every
|
||||
# release or users can't update in place.
|
||||
current_code=$(grep -oP '(?<=<ApplicationVersion>)[^<]+(?=</ApplicationVersion>)' "$ANDROID_CSPROJ")
|
||||
new_code=$((current_code + 1))
|
||||
|
||||
sed -i "s|<ApplicationDisplayVersion>$current_version</ApplicationDisplayVersion>|<ApplicationDisplayVersion>$new_version</ApplicationDisplayVersion>|" "$ANDROID_CSPROJ"
|
||||
sed -i "s|<ApplicationVersion>$current_code</ApplicationVersion>|<ApplicationVersion>$new_code</ApplicationVersion>|" "$ANDROID_CSPROJ"
|
||||
|
||||
# 3. CHANGELOG.md: stub a new entry above the previous top-most one.
|
||||
today=$(date +%F)
|
||||
if grep -q "^## \[$new_version\]" "$CHANGELOG"; then
|
||||
echo "CHANGELOG.md already has an entry for $new_version, leaving it untouched."
|
||||
else
|
||||
awk -v ver="$new_version" -v date="$today" '
|
||||
!done && /^## \[/ {
|
||||
print "## [" ver "] — " date
|
||||
print ""
|
||||
print "TODO: describe the changes in this release."
|
||||
print ""
|
||||
done = 1
|
||||
}
|
||||
{ print }
|
||||
' "$CHANGELOG" > "$CHANGELOG.tmp"
|
||||
mv "$CHANGELOG.tmp" "$CHANGELOG"
|
||||
fi
|
||||
|
||||
echo
|
||||
echo "Updated:"
|
||||
echo " - $APP_CSPROJ: Version $current_version -> $new_version"
|
||||
echo " - $ANDROID_CSPROJ: ApplicationDisplayVersion $current_version -> $new_version, ApplicationVersion (versionCode) $current_code -> $new_code"
|
||||
echo " - $CHANGELOG: stubbed entry for $new_version (fill in the details manually)"
|
||||
echo
|
||||
echo "Review the diff, fill in the CHANGELOG entry, then commit and tag manually."
|
||||
Reference in New Issue
Block a user