21 Commits

Author SHA1 Message Date
davide f0fb3fe05d docs: link CHANGELOG.md and require updating it on new version tags
CLAUDE.md: add a working rule to update CHANGELOG.md whenever <Version> is
bumped for a new release tag. README.md: add a Changelog section pointing
to it, and fix a stale claim that release signing "is not set up yet" for
Android — it now is, via docker/build.sh android and the persistent
keystore (docker/keystore/).
2026-07-02 22:01:35 +02:00
davide 3f26f52f8b docs: add CHANGELOG.md for 0.9.0
Technical changelog covering the full history from the initial commit,
grouped by subsystem (Core/Chain, Crypto, Net, Spv, Storage, Wallet, App UI,
Android, CLI, Build, Testing, Docs) rather than raw commit order, since this
is the first release.
2026-07-02 22:01:17 +02:00
davide cb3ff714d9 feat(android): sign release builds with the persistent keystore
build_android now requires the keystore generated by the previous commit,
prompts for its passwords at build time, and signs the APK with it instead
of an ephemeral debug key auto-generated per container run — that was the
actual cause of every release needing a manual uninstall to update. Also
derives versionCode from <Version> instead of leaving it fixed at 1, so
version ordering stays monotonic across releases.

Docs (docker/README.md, CLAUDE.md) updated to match the new signing flow.
2026-07-02 21:54:23 +02:00
davide d0037747b5 chore(android): add persistent release-signing keystore generator
An Android APK's signature identifies the app to the OS; a new build must
carry the same one as the last install or Android refuses it as an update.
generate-keystore.sh creates the keystore once, interactively, and refuses
to run again once one exists to avoid ever changing it by accident. The
resulting file is git-ignored — it's a secret that must live outside the
repo, backed up separately.
2026-07-02 21:53:56 +02:00
davide 34b4313a36 feat(app): check GitHub releases on startup and prompt for updates
Compares the running assembly version against the latest GitHub release
tag on every app launch (desktop and Android) and shows an in-app
overlay with the new version tag when one is available. Best-effort:
network/parse failures are silent, matching the app's existing
non-blocking startup checks.
2026-07-02 21:33:18 +02:00
davide 2d017231eb chore(github): complete issue/PR templates and wire in-app bug report to them
Add a feature request template and a config.yml (blank issues disabled,
security reports routed to SECURITY.md) alongside the existing bug report
template, plus a PR template with a checklist matching the project's
security rules. Point the app's "Report a bug" button at the new GitHub
template now that the repo is hosted there instead of the old self-hosted
tracker.
2026-07-02 21:22:42 +02:00
davide e7797017f6 feat(gui): split server discovery into its own always-usable button
Previously the server settings overlay had only "Connect and sync" and
"Reset certs" — peer discovery had no button and only ran implicitly
when reopening the dialog while connected. Add a dedicated "Sincronizza"
button wired to DiscoverServersCommand, and make it work even without
an active connection by opening a short-lived connection to a candidate
server just to query its peer list, then closing it without touching
the wallet's connection state.
2026-07-02 19:24:16 +02:00
davide 6c24a8bb46 feat(wallet): surface immature balance separately from confirmed
Extract confirmation-threshold logic into UtxoSpendability (shared by
TransactionFactory and WalletSynchronizer) and use it to compute
ImmatureSats, so coinbase/under-confirmed amounts are shown separately
from spendable balance in the GUI and CLI instead of being lumped into
"confirmed".
2026-07-02 18:24:33 +02:00
davide 9aecdb1aaa feat(wallet): enforce confirmation thresholds before spending UTXOs
Coinbase outputs require COINBASE_MATURITY + 1 = 121 confirmations before
they can be spent. The consensus rule (palladiumcore tx_verify.cpp) is
nSpendHeight - nHeight >= 120; the +1 adds one block of safety margin,
matching the Qt wallet (wallet.cpp: COINBASE_MATURITY+1). Regular UTXOs
require MinConfirmations (6 on mainnet, 1 on testnet/regtest — wallet
policy, no consensus rule).

Changes:
- ChainProfile: add CoinbaseMaturity and MinConfirmations fields
- ChainProfiles: mainnet CoinbaseMaturity=120 / MinConfirmations=6;
  testnet/regtest inherit 120, override MinConfirmations=1
- PalladiumNetworks: align NBitcoin Consensus.CoinbaseMaturity to 120
- CachedUtxo: add IsCoinbase flag (ElectrumX listunspent does not expose
  this; derived from Transaction.IsCoinBase on the raw tx)
- WalletSynchronizer: set IsCoinbase during local UTXO reconstruction
- TransactionFactory.Build: add tipHeight param; filter by threshold;
  error reports reason (immature coinbase with X/121 counter, under-
  confirmed regulars with best-seen count, mempool amounts)
- All Build() call sites updated (App, Donate, CLI)
- Two new tests: coinbase maturity boundary (119→220 tip) and mainnet
  min-conf boundary (5→6 confirmations)
2026-07-02 16:25:42 +02:00
davide 2be43d3177 fix(sync): allow server change at any time, even during an active sync
When IsSyncing and the user requests a different server, cancel the
running CancellationTokenSource instead of silently ignoring the click.
OperationCanceledException is caught separately (not an error), and
ConnectAndSync restarts automatically with the new server after the
cancelled task unwinds.

The "Connect" button no longer has IsEnabled="{Binding !IsSyncing}":
it is always clickable, and clicking it with the same server sets
_resyncRequested as before. CancellationToken is now passed to both
ElectrumClient.ConnectAsync and WalletSynchronizer.SyncOnceAsync.
2026-07-02 14:56:58 +02:00
davide ef60ec0330 loc: expand About description to include non-custodial and local security details
All six languages updated: the one-liner was replaced with a two-sentence
description that names the network (PLM), the lightweight SPV design,
the non-custodial model, and the local encryption guarantee.
2026-07-02 12:29:38 +02:00
davide 567c0de501 docs: document reproducible builds and fix Windows publish command
README.md: add "Reproducible builds (Docker)" section at the top of
Building, explaining why it matters for a wallet (auditable toolchain,
no drift between releases, anyone can verify binaries from source).
Fix the manual Windows publish command to include
IncludeNativeLibrariesForSelfExtract=true — the previous command produced
an exe that silently failed to start because Avalonia's native DLLs were
left outside it.

CLAUDE.md: document docker/ in the project tree, promote ./docker/build.sh
as the primary release path, record the two non-obvious gotchas (native
libs flag for single-file desktop; XA5207 / API level coupling for Android).
2026-07-02 10:45:57 +02:00
davide 6a5daa0c18 feat(build): add Docker-based reproducible build system
Adds docker/ with two Dockerfiles and a build.sh interactive script that
builds all three release targets (Windows exe, Linux binary, Android APK)
inside pinned Docker containers — no SDK required on the host.

Key design decisions:
- Source mounted read-only; build runs on an in-container copy so bin/obj
  never pollute the working tree.
- NuGet packages cached in a named Docker volume (plm-nuget-cache) across
  runs to avoid re-downloading on each build.
- Single-file desktop publishes use IncludeNativeLibrariesForSelfExtract so
  Avalonia's native libs (Skia, HarfBuzz, ANGLE) are embedded — without this
  flag the exe/binary silently fails to start.
- Android Dockerfile pins platform API 36 (dictated by the .NET 10 android
  workload, error XA5207 if mismatched) and bakes the full Android SDK +
  workload into the image layer.
- Artifacts are chown'd back to the host user so dist/ files are never
  owned by root.

All three targets verified end-to-end from a clean docker system prune -a:
Windows PE32+ exe (103 MB), Linux single-file binary launches on this host,
Android APK installs and renders UI on API-34 x86_64 emulator.
2026-07-02 10:45:43 +02:00
davide 4d730d7860 feat(ui): add bug report button in Help and GitHub issue template
Adds a "Report a bug" / "Segnala un bug" button (6 languages) in
Help → Info that opens the issue tracker in the system browser via
TopLevel.Launcher (cross-platform: desktop + Android).
URL points to the current self-hosted repo; a TODO comment marks
the constant for easy update once the project moves to GitHub.

Adds .github/ISSUE_TEMPLATE/bug_report.yml — GitHub issue form with
structured fields for version, platform, network, repro steps,
expected behavior, and logs.
2026-06-24 15:41:37 +02:00
davide 068d85e0e2 feat(ui): show full tx inputs/outputs in detail overlay without truncation
Input rows switch to a two-line layout (full prev-txid:index on the first
line, address + amount on the second) so nothing is cut by a fixed 160px
column. Output address column gets TextWrapping instead of TextTrimming.
Overlay grows to MaxWidth=660/MaxHeight=800 for better desktop use; the
same XAML wraps naturally on narrow mobile screens.
Removes the Shorten() helper that was masking input references.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-24 15:37:43 +02:00
davide 1f857ddf52 refactor(ui): remove SPV badge from tx detail and history
SPV is the only verification method the wallet uses, so showing it as a
badge adds no information — if a transaction appears confirmed, it has
already passed Merkle verification.

- Remove the SPV chip from the transaction detail overlay
- Remove the Verificata column from the history list (desktop and mobile)
- Drop VerifiedText property from TransactionDetailsViewModel
- Drop the Verificata field from HistoryRow
- Remove the orphaned tx.verified loc key
2026-06-22 00:02:47 +02:00
davide 2953ba35ba refactor(ui): remove redundant "Open wallet from file" menu item
The wizard already covers opening existing wallets; a separate file-picker
entry in the menu duplicates that flow without adding value. Remove the menu
item, its click handler, the OpenFromPath VM method, and the loc key.
2026-06-21 22:46:23 +02:00
davide c0193ae440 feat(ui): add Quit to File menu; hide Wallet item when no wallet is open
- File → Esci (separator + Quit at the bottom) calls IClassicDesktopStyleApplicationLifetime.Shutdown()
- Wallet menu item and mobile button now use IsVisible instead of IsEnabled:
  they disappear entirely on the wizard/setup screen and reappear once a
  wallet is open, avoiding a confusing greyed-out entry
2026-06-21 22:34:59 +02:00
davide affeaccae8 feat(ui): password-gated private key reveal in address detail overlay
Replace the direct private-key display in the address detail overlay with a
two-step reveal flow:

- Unencrypted wallet: clicking Show reveals the WIF key immediately.
- Encrypted wallet: clicking Show opens a modal password-prompt overlay; the
  key is displayed only if the entered password matches the wallet file
  password; a wrong password shows an inline error without closing the prompt.

Hiding the key (Hide button) clears the revealed state. Closing the address
overlay or opening a different one always resets both the reveal state and
the password prompt, so no sensitive material lingers between sessions.
2026-06-21 22:28:33 +02:00
davide 4a97172346 feat(ui): Wallet menu overlay — info, xpub, and password-protected seed reveal
Add a Wallet menu item (between File and Settings) that opens an in-app
overlay showing wallet metadata: file name, network, type (HD seed / xprv /
WIF / watch-only), script kind, derivation path, master fingerprint, and
account xpub.

Includes a seed section: if the wallet has no seed, a note is shown; if it
has one and is unencrypted the seed is revealed directly; if encrypted the
user must enter the wallet file password before the mnemonic is displayed.
The revealed seed is shown in a danger-coloured bordered box with a warning.
Closing the overlay (backdrop, Close button, or Esc) always clears the
password input and hides the seed.

Also adds the private-key password-prompt overlay and its localization keys
(shared UI scaffolding used by the following commit).
2026-06-21 22:27:55 +02:00
davide 5d6ff07f0b docs(agents): add coding agent guide 2026-06-21 20:56:31 +02:00
38 changed files with 1940 additions and 166 deletions
+80
View File
@@ -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
+5
View File
@@ -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
+26
View File
@@ -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
+2
View File
@@ -74,6 +74,8 @@ temp/
*.key
*.pfx
*.p12
*.keystore
*.jks
secrets/
# Wallet data and local runtime state
+84
View File
@@ -0,0 +1,84 @@
# AGENTS.md
This file provides guidance to coding agents when working with code in this repository.
## Role
Operate as an **expert in cryptocurrencies and cryptography**: reason with the domain's rigor about UTXO consensus, HD key derivation (BIP32/39/SLIP-132), signature schemes and scripts (P2PKH/P2SH/P2WPKH, PSBT), address encoding (base58/bech32), Merkle/SPV proofs, and at-rest encryption. When a choice touches cryptographic correctness or fund safety, judge it through that lens and flag known risks and pitfalls (nonce reuse, missing validation, exposed keys/seed, wrong fee/coin-selection, unverified server responses). Explain trade-offs with technical precision; never take for granted what hasn't been verified.
## Language policy
- **Conversation with the user**: Italian.
- **All code, comments, commit messages, and documentation files**: English only.
## How to assist
On **every requested change**, before implementing, judge whether it makes sense and say so plainly: if a request is useful and consistent with the project, proceed; if it is useless, redundant, already covered elsewhere, or risks degrading the code, **say so** with a short rationale and propose the better alternative (or doing nothing). No automatic agreement -- an honest opinion is worth more than blind execution.
## What it is
SPV wallet (Sparrow-style) for the **Palladium (PLM)** cryptocurrency, a Bitcoin-derived UTXO chain. Targets desktop (Windows/Linux) and Android, from the same source. Lightning is excluded from the first release.
## Stack and structure
.NET 10 + Avalonia UI 12 + NBitcoin.
```
src/Core/ Chain/ Crypto/ Wallet/ Spv/ Net/ Storage/ (no UI dependency)
src/App/ shared Avalonia UI library (App, Views, ViewModels, Loc, Assets)
src/App.Desktop/ desktop head (WinExe): Program.cs, app.manifest, .ico -> runnable
src/App.Android/ Android head (net10.0-android): MainApplication/MainActivity -> apk
src/Cli/ CLI on the same Core tests/ xUnit
```
The Avalonia UI lives **once** in `src/App` (a library); the two heads only carry the
per-platform entry point and packages. `MainView` (UserControl) is the shared root, hosted
by `MainWindow` on desktop and as the single-view root on Android.
**Non-negotiable dependency rule:** `App`/`Cli` depend only on `Core`; the UI goes through the wallet domain, never directly through network or cryptography. `Core` knows nothing about the UI.
## Commands
.NET 10 SDK lives in `~/.dotnet10`: in non-interactive shells, before any `dotnet` command run
`export PATH="$HOME/.dotnet10:$PATH" DOTNET_ROOT="$HOME/.dotnet10"`.
- Build: `dotnet build`
- 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
- 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)
**Android (apk).** Needs the `android` workload (`dotnet workload install android`), a JDK
(`JAVA_HOME`), and the Android SDK. To provision the SDK once:
`dotnet build src/App.Android -t:InstallAndroidDependencies -p:AndroidSdkDirectory=$HOME/android-sdk -p:AcceptAndroidSDKLicenses=true`.
Then build a debug apk (output in `src/App.Android/bin/Debug/net10.0-android/*-Signed.apk`):
`JAVA_HOME=<jdk> dotnet build src/App.Android -c Debug -t:SignAndroidPackage -p:AndroidSdkDirectory=$HOME/android-sdk`
(set `ANDROID_HOME` to skip the `-p:AndroidSdkDirectory` flag). The head is an application,
not a library, because it sets `<OutputType>Exe</OutputType>`; min SDK 23 (AndroidX requirement).
Note: a plain `dotnet build` at the solution level needs the Android SDK path for the Android head.
**CLI** (`src/Cli`): `create`/`restore`/`restore-xpub`/`info`; `sync`/`send`/`servers`/`reset-certs` (`--server host:port [--ssl]`); `newseed`/`addresses`. Default wallet file `~/.palladium-wallet/<network>/wallets/default.wallet.json` (`--file` to change it).
## Architecture (points that require reading multiple files)
- **Layers:** GUI -> wallet domain -> SPV/Sync -> Network -> Cryptography -> Persistence; each layer depends only downward.
- **Network profile:** all chain constants (address prefixes, BIP32 headers, bech32 HRP, genesis, ports, coin_type 746) **centralized in `Core/Chain`** (`ChainProfiles`/`PalladiumNetworks`), selectable per network (mainnet/testnet/regtest). No scattered magic numbers.
- **LWMA / skip PoW:** LWMA difficulty, 2-minute blocks; an SPV client cannot recompute it -> `SkipPowValidation = true`, trust anchored to **hardcoded checkpoints**. Custom layer: NBitcoin assumes Bitcoin's retargeting.
- **NBitcoin vs custom:** NBitcoin covers the custom network, BIP32/39, addresses, transactions, PSBT, signing, encoding, hashing -- **do not reimplement these**. Hand-written custom code: JSON-RPC client for the indexing server (ElectrumX-like); SPV sync with Merkle verification; header/checkpoint validation; coin selection and fee policy; versioned encrypted JSON wallet file.
- **PSBT-centric:** every signing flow goes through PSBT (offline/air-gapped/multisig/hardware).
- **Ports:** 50001/50002 = indexing server (what the SPV wallet talks to), **not** the node's P2P port (2333).
## GUI conventions (`src/App`)
- **Shared `MainView` + heads:** the whole UI is a single `MainView` (UserControl), so it works both as a desktop window's content and as Android's single-view root. Top-level APIs (file/folder picker, clipboard) are reached via `TopLevel.GetTopLevel(this)` since a UserControl doesn't expose them. `MainWindowViewModel.IsDesktop` (from `OperatingSystem.IsAndroid()`) hides filesystem-only features (open-from-file; the data-location wizard step auto-skips on Android because the head sets `AppPaths.OverrideDataRoot`).
- **Single ViewModel** `MainWindowViewModel` (CommunityToolkit.Mvvm: `[ObservableProperty]`, `[RelayCommand]`); `Core` is driven directly from here.
- **In-app overlays, not OS windows:** details (address, transaction), settings, and help are full-screen `Border`s gated by an `IsXxxOpen` flag, not separate `Window`s -- instant open/close, mobile-friendly, and popups/top-levels are slow on WSLg. Pattern: bool property + Open/Close commands + backdrop handler and Esc key in `MainView`'s code-behind; overlay close buttons bind via `$parent[UserControl]` (not `$parent[Window]`, absent on mobile). Heavy network work runs off the UI thread (`Task.Run`) so the overlay never freezes.
- **Localization:** `Localization/Loc.cs`, key->6 languages dictionary (it/en/es/fr/pt/de); in XAML `{Binding Loc[key]}`, in C# `Loc.Tr("key")`. On language change the `Loc` instance is replaced.
- **App version:** single source = `<Version>` in `src/App/PalladiumWallet.App.csproj`; read at runtime (`MainWindowViewModel.AppVersion`) and shown in the title.
- **Storage paths:** `Core/Storage/AppPaths` resolves data locations; `AppPaths.OverrideDataRoot` (top priority) is the per-platform seam -- the Android head sets it to the app sandbox (`Context.FilesDir`), desktop leaves it null.
## Working rules
- **Cross-implementation tests:** compare addresses, txids, and PSBTs against a reference wallet (golden vectors). A different address or txid is a blocking bug.
- **Security:** seed and private keys never in plaintext on disk/logs/network; every server response validated with Merkle + checkpoints; watch-only truly read-only.
+156
View File
@@ -0,0 +1,156 @@
# 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.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).
+5 -2
View File
@@ -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
@@ -46,8 +47,9 @@ by `MainWindow` on desktop and as the single-view root on Android.
- 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
- 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.
+35 -5
View File
@@ -55,8 +55,8 @@ For desktop and the CLI you only need the **.NET 10 SDK**. The core and crypto a
1. Install the .NET 10 SDK through your distro's package manager, or without root via the official script:
```bash
curl -sSL https://dot.net/v1/dotnet-install.sh | bash -s -- --channel 10.0
export PATH="$HOME/.dotnet:$PATH" DOTNET_ROOT="$HOME/.dotnet"
curl -sSL https://dot.net/v1/dotnet-install.sh | bash -s -- --channel 10.0 --install-dir "$HOME/.dotnet10"
export PATH="$HOME/.dotnet10:$PATH" DOTNET_ROOT="$HOME/.dotnet10"
```
(add the two `export` lines to your `~/.bashrc` to make them permanent)
2. Clone and restore:
@@ -147,6 +147,26 @@ The suite includes **property-based tests** ([CsCheck](https://github.com/Anthon
## 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 +182,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 +218,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 +335,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.
+39
View 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
+11
View File
@@ -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
+199
View File
@@ -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, 1020 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).
+235
View File
@@ -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}/"
+37
View File
@@ -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.
+65
View File
@@ -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."
+46 -10
View File
@@ -45,8 +45,8 @@ public sealed class Loc
// Menu it en es fr pt de
["menu.file"] = ["_File", "_File", "_Archivo", "_Fichier", "_Arquivo", "_Datei"],
["menu.file.new"] = ["Nuovo / ripristina wallet…", "New / restore wallet…", "Nuevo / restaurar wallet…", "Nouveau / restaurer le wallet…", "Novo / restaurar carteira…", "Neu / Wallet wiederherstellen…"],
["menu.file.open"] = ["Apri wallet da file…", "Open wallet from file…", "Abrir wallet desde archivo…", "Ouvrir le wallet depuis un fichier…", "Abrir carteira de arquivo…", "Wallet aus Datei öffnen…"],
["menu.file.close"] = ["Chiudi wallet", "Close wallet", "Cerrar wallet", "Fermer le wallet", "Fechar carteira", "Wallet schließen"],
["menu.file.quit"] = ["Esci", "Quit", "Salir", "Quitter", "Sair", "Beenden"],
["menu.net"] = ["_Rete", "_Network", "_Red", "_Réseau", "_Rede", "_Netzwerk"],
["menu.net.discover"] = ["Cerca altri server (peer)", "Discover servers (peers)", "Buscar otros servidores (peers)", "Rechercher d'autres serveurs (pairs)", "Procurar outros servidores (peers)", "Weitere Server suchen (Peers)"],
["menu.net.resetcerts"] = ["Reset certificati SSL", "Reset SSL certificates", "Restablecer certificados SSL", "Réinitialiser les certificats SSL", "Redefinir certificados SSL", "SSL-Zertifikate zurücksetzen"],
@@ -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"],
@@ -195,6 +200,8 @@ public sealed class Loc
["addr.pubkey"] = ["Chiave pubblica:", "Public key:", "Clave pública:", "Clé publique :", "Chave pública:", "Öffentlicher Schlüssel:"],
["addr.privkey"] = ["Chiave privata (WIF):", "Private key (WIF):", "Clave privada (WIF):", "Clé privée (WIF) :", "Chave privada (WIF):", "Privater Schlüssel (WIF):"],
["addr.show.privkey"] = ["Mostra", "Show", "Mostrar", "Afficher", "Mostrar", "Anzeigen"],
["addr.privkey.prompt.title"] = ["Conferma identità", "Confirm identity", "Confirmar identidad", "Confirmer l'identité", "Confirmar identidade", "Identität bestätigen"],
["addr.privkey.prompt.desc"] = ["Inserisci la password del wallet per visualizzare la chiave privata.", "Enter the wallet password to reveal the private key.", "Ingresa la contraseña del wallet para ver la clave privada.", "Entrez le mot de passe du wallet pour afficher la clé privée.", "Digite a senha da carteira para ver a chave privada.", "Geben Sie das Wallet-Passwort ein, um den privaten Schlüssel anzuzeigen."],
["addr.hide.privkey"] = ["Nascondi", "Hide", "Ocultar", "Masquer", "Ocultar", "Ausblenden"],
["addr.receive"] = ["ricezione", "receive", "recepción", "réception", "recebimento", "Empfang"],
["addr.change"] = ["change", "change", "cambio", "monnaie", "troco", "Wechselgeld"],
@@ -223,7 +230,6 @@ public sealed class Loc
["tx.size.total"] = ["Dimensione totale", "Total size", "Tamaño total", "Taille totale", "Tamanho total", "Gesamtgröße"],
["tx.size.virtual"] = ["Dimensione virtuale", "Virtual size", "Tamaño virtual", "Taille virtuelle", "Tamanho virtual", "Virtuelle Größe"],
["tx.rbf"] = ["Sostituibile (RBF)", "Replaceable (RBF)", "Reemplazable (RBF)", "Remplaçable (RBF)", "Substituível (RBF)", "Ersetzbar (RBF)"],
["tx.verified"] = ["Verifica SPV", "SPV verification", "Verificación SPV", "Vérification SPV", "Verificação SPV", "SPV-Prüfung"],
["tx.inputs"] = ["Input", "Inputs", "Entradas", "Entrées", "Entradas", "Eingänge"],
["tx.outputs"] = ["Output", "Outputs", "Salidas", "Sorties", "Saídas", "Ausgänge"],
["tx.yes"] = ["Sì", "Yes", "Sí", "Oui", "Sim", "Ja"],
@@ -248,6 +254,35 @@ public sealed class Loc
["send.summary"] = ["Riepilogo", "Summary", "Resumen", "Résumé", "Resumo", "Zusammenfassung"],
["receive.your.address"] = ["Il tuo indirizzo", "Your address", "Tu dirección", "Votre adresse", "Seu endereço", "Deine Adresse"],
// Wallet info overlay
["menu.wallet"] = ["_Wallet", "_Wallet", "_Wallet", "_Wallet", "_Wallet", "_Wallet"],
["walletinfo.title"] = ["Informazioni Wallet", "Wallet Information", "Información del Wallet", "Informations Wallet", "Informações da Carteira", "Wallet-Informationen"],
["walletinfo.file"] = ["File", "File", "Archivo", "Fichier", "Arquivo", "Datei"],
["walletinfo.network"] = ["Rete", "Network", "Red", "Réseau", "Rede", "Netzwerk"],
["walletinfo.type"] = ["Tipo wallet", "Wallet type", "Tipo de wallet", "Type de wallet", "Tipo de carteira", "Wallet-Typ"],
["walletinfo.type.seed"] = ["HD (seed BIP39)", "HD (BIP39 seed)", "HD (seed BIP39)", "HD (graine BIP39)", "HD (semente BIP39)", "HD (BIP39-Seed)"],
["walletinfo.type.xprv"] = ["HD (xprv importato)", "HD (imported xprv)", "HD (xprv importado)", "HD (xprv importé)", "HD (xprv importado)", "HD (importierter xprv)"],
["walletinfo.type.wif"] = ["Chiave WIF importata", "Imported WIF key", "Clave WIF importada", "Clé WIF importée", "Chave WIF importada", "Importierter WIF-Schlüssel"],
["walletinfo.type.watchonly"] = ["Watch-only (xpub)", "Watch-only (xpub)", "Watch-only (xpub)", "Watch-only (xpub)", "Watch-only (xpub)", "Watch-only (xpub)"],
["walletinfo.script"] = ["Script", "Script", "Script", "Script", "Script", "Script"],
["walletinfo.derivpath"] = ["Percorso derivazione", "Derivation path", "Ruta de derivación", "Chemin de dérivation", "Caminho de derivação", "Ableitungspfad"],
["walletinfo.xpub"] = ["Chiave pubblica estesa", "Extended public key", "Clave pública extendida", "Clé publique étendue", "Chave pública estendida", "Erweiterter öffentlicher Schlüssel"],
["walletinfo.fingerprint"] = ["Master fingerprint", "Master fingerprint", "Huella maestra", "Empreinte maître", "Impressão digital mestre", "Master-Fingerprint"],
["walletinfo.seed.section"] = ["Seed (mnemonica BIP39)", "Seed (BIP39 mnemonic)", "Semilla (mnemónico BIP39)", "Graine (mnémonique BIP39)", "Semente (mnemônico BIP39)", "Seed (BIP39-Mnemonic)"],
["walletinfo.seed.noseed"] = ["Questo wallet non ha una seed (watch-only o importato).", "This wallet has no seed (watch-only or imported).", "Este wallet no tiene seed (watch-only o importado).", "Ce wallet n'a pas de graine (watch-only ou importé).", "Esta carteira não tem semente (watch-only ou importada).", "Dieses Wallet hat keinen Seed (watch-only oder importiert)."],
["walletinfo.seed.password"] = ["Password del file wallet per sbloccare il seed:", "Wallet file password to unlock the seed:", "Contraseña del archivo para desbloquear la seed:", "Mot de passe du fichier pour déverrouiller la graine :", "Senha do arquivo para desbloquear a semente:", "Dateipasswort zum Entsperren des Seeds:"],
["walletinfo.seed.reveal"] = ["Mostra seed", "Show seed", "Mostrar seed", "Afficher la graine", "Mostrar semente", "Seed anzeigen"],
["walletinfo.seed.hide"] = ["Nascondi", "Hide", "Ocultar", "Masquer", "Ocultar", "Ausblenden"],
["walletinfo.seed.warning"] = [
"Non condividere mai queste parole. Chi le possiede controlla i fondi.",
"Never share these words. Whoever holds them controls the funds.",
"Nunca compartas estas palabras. Quien las tenga controla los fondos.",
"Ne partagez jamais ces mots. Celui qui les possède contrôle les fonds.",
"Nunca compartilhe essas palavras. Quem as tiver controla os fundos.",
"Teilen Sie diese Wörter niemals. Wer sie hat, kontrolliert die Gelder."],
["walletinfo.passphrase"] = ["Passphrase BIP39", "BIP39 passphrase", "Frase de contraseña BIP39", "Phrase de passe BIP39", "Frase-senha BIP39", "BIP39-Passphrase"],
["walletinfo.passphrase.set"] = ["(impostata)", "(set)", "(establecida)", "(définie)", "(definida)", "(gesetzt)"],
// Connection status
["conn.none"] = ["non connesso", "not connected", "no conectado", "non connecté", "não conectado", "nicht verbunden"],
["conn.disconnected"] = ["disconnesso", "disconnected", "desconectado", "déconnecté", "desconectado", "getrennt"],
@@ -358,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.",
@@ -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 = "";
@@ -68,11 +71,81 @@ public partial class MainWindowViewModel
[ObservableProperty]
private AddressInfo? addressInfo;
[ObservableProperty]
private bool isAddressPrivKeyRevealed;
// ---- private key password prompt ----
[ObservableProperty]
private bool isPrivKeyPromptOpen;
[ObservableProperty]
private string privKeyPromptPassword = "";
[ObservableProperty]
private string privKeyPromptError = "";
partial void OnAddressInfoChanged(AddressInfo? value)
{
IsAddressPrivKeyRevealed = false;
ClosePrivKeyPromptInternal();
}
private void ClosePrivKeyPromptInternal()
{
IsPrivKeyPromptOpen = false;
PrivKeyPromptPassword = "";
PrivKeyPromptError = "";
}
public void ShowAddressInfo(AddressRow row) =>
AddressInfo = new AddressInfo(Loc, row.Indirizzo, row.DerivPath, row.PubKey, row.PrivKey);
[RelayCommand]
private void CloseAddressInfo() => AddressInfo = null;
private void CloseAddressInfo()
{
ClosePrivKeyPromptInternal();
AddressInfo = null;
}
[RelayCommand]
private void RequestPrivKeyReveal()
{
if (AddressInfo is null || !AddressInfo.HasPrivKey) return;
if (string.IsNullOrEmpty(_password))
{
// No encryption: reveal directly
IsAddressPrivKeyRevealed = true;
}
else
{
// Encrypted: open password prompt
PrivKeyPromptPassword = "";
PrivKeyPromptError = "";
IsPrivKeyPromptOpen = true;
}
}
[RelayCommand]
private void ConfirmPrivKeyPassword()
{
if (PrivKeyPromptPassword != _password)
{
PrivKeyPromptError = Loc.Tr("msg.wrongpassword");
return;
}
IsAddressPrivKeyRevealed = true;
ClosePrivKeyPromptInternal();
}
[RelayCommand]
private void CancelPrivKeyPrompt() => ClosePrivKeyPromptInternal();
[RelayCommand]
private void HideAddressPrivKey()
{
IsAddressPrivKeyRevealed = false;
}
// ---- transaction detail overlay ----
@@ -179,6 +252,7 @@ public partial class MainWindowViewModel
{
BalanceText = $"0.00000000 {Profile.CoinUnit}";
UnconfirmedText = "";
ImmatureText = "";
ReceiveAddress = _account.GetReceiveAddress(0).ToString();
History.Clear();
Addresses.Clear();
@@ -191,19 +265,21 @@ 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)
History.Add(new HistoryRow(
tx.Height > 0 ? tx.Height.ToString() : "mempool",
(tx.DeltaSats >= 0 ? "+" : "") + Fmt(tx.DeltaSats, withLabel: false),
tx.Txid,
tx.Verified ? "✓ SPV" : "—"));
tx.Txid));
Addresses.Clear();
foreach (var a in cache.Addresses)
@@ -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)} " +
@@ -69,6 +69,12 @@ public partial class MainWindowViewModel
[RelayCommand]
private void OpenHelp() => IsHelpOpen = true;
[RelayCommand]
private static void QuitApp() =>
(Avalonia.Application.Current?.ApplicationLifetime
as Avalonia.Controls.ApplicationLifetimes.IClassicDesktopStyleApplicationLifetime)
?.Shutdown();
[RelayCommand]
private void CloseHelp() { IsHelpOpen = false; ResetDonate(); }
}
+80 -6
View File
@@ -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;
}
}
@@ -0,0 +1,114 @@
using System.IO;
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using PalladiumWallet.App.Localization;
namespace PalladiumWallet.App.ViewModels;
public partial class MainWindowViewModel
{
[ObservableProperty]
private bool isWalletInfoOpen;
[ObservableProperty]
private string walletInfoSeedPasswordInput = "";
[ObservableProperty]
private bool isWalletInfoSeedRevealed;
[ObservableProperty]
private string walletInfoSeedError = "";
// ---- computed wallet info (populated when overlay opens) ----
public string WalletInfoFileName =>
_walletPath is null ? "" :
Path.GetFileNameWithoutExtension(Path.GetFileNameWithoutExtension(_walletPath));
public string WalletInfoNetwork => _doc?.Network ?? "";
public string WalletInfoType
{
get
{
if (_doc is null) return "";
if (_doc.Mnemonic is not null) return Loc.Tr("walletinfo.type.seed");
if (_doc.AccountXprv is not null) return Loc.Tr("walletinfo.type.xprv");
if (_doc.WifKeys is { Count: > 0 }) return Loc.Tr("walletinfo.type.wif");
return Loc.Tr("walletinfo.type.watchonly");
}
}
public string WalletInfoScriptKind => _doc?.ScriptKind ?? "";
public string WalletInfoDerivPath => _doc?.AccountPath ?? "";
public string WalletInfoXpub => _doc?.AccountXpub ?? "";
public string WalletInfoFingerprint => _doc?.MasterFingerprint ?? "";
public bool WalletInfoHasSeed => _doc?.Mnemonic is not null;
public bool WalletInfoSeedNeedsPassword => !string.IsNullOrEmpty(_password);
public string WalletInfoSeedText => IsWalletInfoSeedRevealed ? (_doc?.Mnemonic ?? "") : "";
public bool WalletInfoHasPassphrase => !string.IsNullOrEmpty(_doc?.Passphrase);
[RelayCommand]
private void OpenWalletInfo()
{
WalletInfoSeedPasswordInput = "";
IsWalletInfoSeedRevealed = false;
WalletInfoSeedError = "";
OnPropertyChanged(nameof(WalletInfoFileName));
OnPropertyChanged(nameof(WalletInfoNetwork));
OnPropertyChanged(nameof(WalletInfoType));
OnPropertyChanged(nameof(WalletInfoScriptKind));
OnPropertyChanged(nameof(WalletInfoDerivPath));
OnPropertyChanged(nameof(WalletInfoXpub));
OnPropertyChanged(nameof(WalletInfoFingerprint));
OnPropertyChanged(nameof(WalletInfoHasSeed));
OnPropertyChanged(nameof(WalletInfoSeedNeedsPassword));
OnPropertyChanged(nameof(WalletInfoHasPassphrase));
IsWalletInfoOpen = true;
}
[RelayCommand]
private void CloseWalletInfo()
{
IsWalletInfoOpen = false;
WalletInfoSeedPasswordInput = "";
IsWalletInfoSeedRevealed = false;
WalletInfoSeedError = "";
OnPropertyChanged(nameof(WalletInfoSeedText));
}
[RelayCommand]
private void RevealSeed()
{
if (_doc?.Mnemonic is null) return;
if (WalletInfoSeedNeedsPassword)
{
if (WalletInfoSeedPasswordInput != _password)
{
WalletInfoSeedError = Loc.Tr("msg.wrongpassword");
return;
}
}
WalletInfoSeedError = "";
IsWalletInfoSeedRevealed = true;
OnPropertyChanged(nameof(WalletInfoSeedText));
}
[RelayCommand]
private void HideSeed()
{
IsWalletInfoSeedRevealed = false;
WalletInfoSeedPasswordInput = "";
WalletInfoSeedError = "";
OnPropertyChanged(nameof(WalletInfoSeedText));
}
}
@@ -477,41 +477,6 @@ public partial class MainWindowViewModel
}
}
public void OpenFromPath(string path)
{
var newLock = WalletLock.TryAcquire(path);
if (newLock is null) { StatusMessage = Loc.Tr("msg.wallet.locked"); return; }
try
{
var doc = WalletStore.Load(path);
if (IsWalletOpen)
CloseWallet();
OpenLoaded(doc, WalletLoader.ToAccount(doc), path, password: null, newLock);
}
catch (WrongPasswordException)
{
newLock.Dispose();
if (IsWalletOpen)
CloseWallet();
_pendingOpenPath = path;
WalletFileExists = true;
PasswordInput = "";
SetupStep = StepOpen;
StatusMessage = "";
}
catch (UnauthorizedAccessException)
{
newLock.Dispose();
StatusMessage = Loc.Tr("msg.wallet.noaccess");
}
catch (Exception ex)
{
newLock.Dispose();
StatusMessage = $"Errore: {ex.Message}";
}
}
[RelayCommand]
private void NewWallet()
{
+2 -1
View File
@@ -17,7 +17,7 @@ using PalladiumWallet.Core.Wallet;
namespace PalladiumWallet.App.ViewModels;
/// <summary>Transaction history row for the view.</summary>
public sealed record HistoryRow(string Conferma, string Importo, string Txid, string Verificata);
public sealed record HistoryRow(string Conferma, string Importo, string Txid);
/// <summary>Address view row with pre-computed keys and derivation path.</summary>
public sealed record AddressRow(
@@ -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()
@@ -62,10 +62,8 @@ public sealed class TransactionDetailsViewModel
VersionText = d.Version.ToString();
LockTimeText = d.LockTime.ToString();
RbfText = loc[d.RbfSignaled ? "tx.yes" : "tx.no"];
VerifiedText = d.Verified ? "✓ SPV" : "—";
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)));
@@ -93,7 +91,6 @@ public sealed class TransactionDetailsViewModel
public string VersionText { get; }
public string LockTimeText { get; }
public string RbfText { get; }
public string VerifiedText { get; }
public ObservableCollection<TxIoRow> Inputs { get; }
public ObservableCollection<TxIoRow> Outputs { get; }
@@ -111,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;
}
+275 -45
View File
@@ -31,12 +31,15 @@
<Menu Grid.Row="0" IsVisible="{Binding IsDesktop}">
<MenuItem Header="{Binding Loc[menu.file]}">
<MenuItem Header="{Binding Loc[menu.file.new]}" Command="{Binding NewWalletCommand}"/>
<MenuItem Header="{Binding Loc[menu.file.open]}" Click="OnOpenWalletFileClick"
IsVisible="{Binding IsDesktop}"/>
<Separator/>
<MenuItem Header="{Binding Loc[menu.file.close]}" Command="{Binding CloseWalletCommand}"
IsEnabled="{Binding IsWalletOpen}"/>
<Separator/>
<MenuItem Header="{Binding Loc[menu.file.quit]}" Command="{Binding QuitAppCommand}"/>
</MenuItem>
<MenuItem Header="{Binding Loc[menu.wallet]}"
Command="{Binding OpenWalletInfoCommand}"
IsVisible="{Binding IsWalletOpen}"/>
<MenuItem Header="{Binding Loc[menu.settings]}"
Command="{Binding OpenSettingsCommand}"/>
<MenuItem Header="{Binding Loc[menu.help]}"
@@ -47,6 +50,9 @@
<StackPanel Grid.Row="0" Orientation="Horizontal" Spacing="8"
HorizontalAlignment="Right" Margin="8,4"
IsVisible="{Binding IsMobile}">
<Button Content="{Binding Loc[menu.wallet]}"
Command="{Binding OpenWalletInfoCommand}"
IsVisible="{Binding IsWalletOpen}"/>
<Button Content="{Binding Loc[menu.settings]}" Command="{Binding OpenSettingsCommand}"/>
<Button Content="{Binding Loc[menu.help]}" Command="{Binding OpenHelpCommand}"/>
</StackPanel>
@@ -308,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>
@@ -363,25 +372,20 @@
<ListBox.ItemTemplate>
<DataTemplate x:DataType="vm:HistoryRow">
<Panel Cursor="Hand">
<!-- Desktop: 4 fixed columns -->
<Grid ColumnDefinitions="90,160,*,70"
<!-- Desktop: 3 fixed columns -->
<Grid ColumnDefinitions="90,160,*"
IsVisible="{Binding $parent[UserControl].((vm:MainWindowViewModel)DataContext).IsDesktop}">
<TextBlock Grid.Column="0" Text="{Binding Conferma}" Foreground="{DynamicResource TextSecondaryBrush}"/>
<TextBlock Grid.Column="1" Text="{Binding Importo}" FontFamily="monospace"/>
<TextBlock Grid.Column="2" Text="{Binding Txid}"
FontFamily="monospace" FontSize="12"
TextTrimming="CharacterEllipsis"/>
<TextBlock Grid.Column="3" Text="{Binding Verificata}" Foreground="{DynamicResource SuccessBrush}"/>
</Grid>
<!-- Mobile: vertical card -->
<StackPanel Spacing="2"
IsVisible="{Binding $parent[UserControl].((vm:MainWindowViewModel)DataContext).IsMobile}">
<Grid ColumnDefinitions="*,Auto">
<TextBlock Grid.Column="0" Text="{Binding Importo}"
<TextBlock Text="{Binding Importo}"
FontFamily="monospace" FontWeight="SemiBold"/>
<TextBlock Grid.Column="1" Text="{Binding Verificata}"
Foreground="{DynamicResource SuccessBrush}" FontSize="11"/>
</Grid>
<TextBlock Text="{Binding Conferma}" Foreground="{DynamicResource TextSecondaryBrush}" FontSize="11"/>
<TextBlock Text="{Binding Txid}" FontFamily="monospace" FontSize="11"
TextTrimming="CharacterEllipsis"/>
@@ -943,12 +947,25 @@
</StackPanel>
<!-- Private key (only when available) -->
<StackPanel Spacing="4" IsVisible="{Binding HasPrivKey}">
<TextBlock Text="{Binding Loc[addr.privkey]}" FontSize="11" Foreground="{DynamicResource DangerBrush}"/>
<StackPanel Spacing="8" IsVisible="{Binding HasPrivKey}">
<TextBlock Text="{Binding Loc[addr.privkey]}" FontSize="11"
Foreground="{DynamicResource DangerBrush}"/>
<!-- Not yet revealed: single reveal button -->
<Button IsVisible="{Binding !$parent[UserControl].((vm:MainWindowViewModel)DataContext).IsAddressPrivKeyRevealed}"
Content="{Binding Loc[walletinfo.seed.reveal]}"
Command="{Binding $parent[UserControl].((vm:MainWindowViewModel)DataContext).RequestPrivKeyRevealCommand}"/>
<!-- Revealed: key text + hide button -->
<StackPanel IsVisible="{Binding $parent[UserControl].((vm:MainWindowViewModel)DataContext).IsAddressPrivKeyRevealed}"
Spacing="6">
<SelectableTextBlock Text="{Binding PrivKey}"
FontFamily="monospace" FontSize="12"
Foreground="{DynamicResource DangerBrush}"
TextWrapping="Wrap"/>
<Button Content="{Binding Loc[walletinfo.seed.hide]}"
Command="{Binding $parent[UserControl].((vm:MainWindowViewModel)DataContext).HideAddressPrivKeyCommand}"/>
</StackPanel>
</StackPanel>
<Button Content="{Binding Loc[addr.close]}"
@@ -959,6 +976,38 @@
</Border>
</Border>
<!-- ============ PRIVATE KEY PASSWORD PROMPT ============ -->
<!-- Appears on top of the address detail overlay when the wallet is encrypted. -->
<Border Grid.Row="0" Grid.RowSpan="3"
Background="{DynamicResource ScrimBrush}"
IsVisible="{Binding IsPrivKeyPromptOpen}">
<Border Background="{DynamicResource OverlayCardBrush}"
BorderBrush="{DynamicResource BorderSubtleBrush}" BorderThickness="1" CornerRadius="8"
MaxWidth="360" Margin="16"
HorizontalAlignment="Center" VerticalAlignment="Center">
<StackPanel Margin="24" Spacing="14">
<TextBlock Text="{Binding Loc[addr.privkey.prompt.title]}"
FontSize="16" FontWeight="Bold"/>
<TextBlock Text="{Binding Loc[addr.privkey.prompt.desc]}"
FontSize="12" TextWrapping="Wrap"
Foreground="{DynamicResource TextSecondaryBrush}"/>
<TextBox Text="{Binding PrivKeyPromptPassword}"
PasswordChar="●"
PlaceholderText="{Binding Loc[wiz.open.placeholder]}"/>
<TextBlock Text="{Binding PrivKeyPromptError}"
Foreground="{DynamicResource DangerBrush}" FontSize="12"
IsVisible="{Binding PrivKeyPromptError,
Converter={x:Static StringConverters.IsNotNullOrEmpty}}"/>
<StackPanel Orientation="Horizontal" Spacing="8" HorizontalAlignment="Right">
<Button Content="{Binding Loc[addr.close]}"
Command="{Binding CancelPrivKeyPromptCommand}"/>
<Button Content="{Binding Loc[walletinfo.seed.reveal]}" Classes="accent"
Command="{Binding ConfirmPrivKeyPasswordCommand}"/>
</StackPanel>
</StackPanel>
</Border>
</Border>
<!-- ============ TRANSACTION DETAIL OVERLAY ============ -->
<!-- In-app overlay (like the address overlay): appears immediately with a
spinner; data arrives from the server in the background; instant close. -->
@@ -968,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">
@@ -1016,20 +1065,6 @@
FontSize="12" FontWeight="SemiBold" VerticalAlignment="Center"/>
</StackPanel>
</Border>
<Border Classes="chip" Margin="0,0,8,6"
Background="{DynamicResource PrimarySoftBrush}">
<StackPanel Orientation="Horizontal" Spacing="6">
<Viewbox Width="14" Height="14" VerticalAlignment="Center">
<Canvas Width="24" Height="24">
<Path Fill="{DynamicResource SuccessBrush}"
Data="M21,7L9,19L3.5,13.5L4.91,12.09L9,16.17L19.59,5.59L21,7Z"/>
</Canvas>
</Viewbox>
<TextBlock Text="{Binding VerifiedText}"
Foreground="{DynamicResource SuccessBrush}"
FontSize="12" FontWeight="Medium" VerticalAlignment="Center"/>
</StackPanel>
</Border>
<Border Classes="chip" Margin="0,0,8,6">
<TextBlock Text="{Binding StatusText}" FontSize="12"
Foreground="{DynamicResource TextSecondaryBrush}"/>
@@ -1103,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>
@@ -1191,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}"
@@ -1267,6 +1317,154 @@
</Border>
</Border>
<!-- ============ WALLET INFO OVERLAY ============ -->
<Border Grid.Row="0" Grid.RowSpan="3"
Background="{DynamicResource ScrimBrush}"
Tapped="OnWalletInfoOverlayBackdropTapped"
IsVisible="{Binding IsWalletInfoOpen}">
<Border Background="{DynamicResource OverlayCardBrush}"
BorderBrush="{DynamicResource BorderSubtleBrush}" BorderThickness="1" CornerRadius="8"
MaxWidth="500" Margin="16"
HorizontalAlignment="Center" VerticalAlignment="Center">
<ScrollViewer MaxHeight="620">
<StackPanel Margin="24" Spacing="14">
<TextBlock Text="{Binding Loc[walletinfo.title]}"
FontSize="18" FontWeight="Bold"/>
<!-- File name -->
<StackPanel Spacing="3">
<TextBlock Text="{Binding Loc[walletinfo.file]}" FontSize="11"
Foreground="{DynamicResource TextSecondaryBrush}"/>
<SelectableTextBlock Text="{Binding WalletInfoFileName}"
FontFamily="monospace" FontSize="13"/>
</StackPanel>
<!-- Network -->
<StackPanel Spacing="3">
<TextBlock Text="{Binding Loc[walletinfo.network]}" FontSize="11"
Foreground="{DynamicResource TextSecondaryBrush}"/>
<TextBlock Text="{Binding WalletInfoNetwork}" FontSize="13"/>
</StackPanel>
<!-- Wallet type -->
<StackPanel Spacing="3">
<TextBlock Text="{Binding Loc[walletinfo.type]}" FontSize="11"
Foreground="{DynamicResource TextSecondaryBrush}"/>
<TextBlock Text="{Binding WalletInfoType}" FontSize="13"/>
</StackPanel>
<!-- Script kind -->
<StackPanel Spacing="3">
<TextBlock Text="{Binding Loc[walletinfo.script]}" FontSize="11"
Foreground="{DynamicResource TextSecondaryBrush}"/>
<TextBlock Text="{Binding WalletInfoScriptKind}" FontSize="13"/>
</StackPanel>
<!-- Derivation path (only if present) -->
<StackPanel Spacing="3"
IsVisible="{Binding WalletInfoDerivPath,
Converter={x:Static StringConverters.IsNotNullOrEmpty}}">
<TextBlock Text="{Binding Loc[walletinfo.derivpath]}" FontSize="11"
Foreground="{DynamicResource TextSecondaryBrush}"/>
<SelectableTextBlock Text="{Binding WalletInfoDerivPath}"
FontFamily="monospace" FontSize="13"/>
</StackPanel>
<!-- Master fingerprint (only if present) -->
<StackPanel Spacing="3"
IsVisible="{Binding WalletInfoFingerprint,
Converter={x:Static StringConverters.IsNotNullOrEmpty}}">
<TextBlock Text="{Binding Loc[walletinfo.fingerprint]}" FontSize="11"
Foreground="{DynamicResource TextSecondaryBrush}"/>
<SelectableTextBlock Text="{Binding WalletInfoFingerprint}"
FontFamily="monospace" FontSize="13"/>
</StackPanel>
<!-- Account xpub (only if present) -->
<StackPanel Spacing="3"
IsVisible="{Binding WalletInfoXpub,
Converter={x:Static StringConverters.IsNotNullOrEmpty}}">
<TextBlock Text="{Binding Loc[walletinfo.xpub]}" FontSize="11"
Foreground="{DynamicResource TextSecondaryBrush}"/>
<SelectableTextBlock Text="{Binding WalletInfoXpub}"
FontFamily="monospace" FontSize="11"
TextWrapping="Wrap"/>
</StackPanel>
<!-- BIP39 passphrase indicator -->
<StackPanel Spacing="3" IsVisible="{Binding WalletInfoHasPassphrase}">
<TextBlock Text="{Binding Loc[walletinfo.passphrase]}" FontSize="11"
Foreground="{DynamicResource TextSecondaryBrush}"/>
<TextBlock Text="{Binding Loc[walletinfo.passphrase.set]}"
FontSize="13" Foreground="{DynamicResource TextSecondaryBrush}"/>
</StackPanel>
<!-- Separator -->
<Border Height="1" Background="{DynamicResource BorderSubtleBrush}" Margin="0,4"/>
<!-- Seed section -->
<TextBlock Text="{Binding Loc[walletinfo.seed.section]}"
FontSize="13" FontWeight="SemiBold"/>
<!-- No seed wallet -->
<TextBlock IsVisible="{Binding !WalletInfoHasSeed}"
Text="{Binding Loc[walletinfo.seed.noseed]}"
Foreground="{DynamicResource TextSecondaryBrush}"
FontSize="12" TextWrapping="Wrap"/>
<!-- Seed wallet: needs password -->
<StackPanel IsVisible="{Binding WalletInfoHasSeed}" Spacing="10">
<!-- Not yet revealed + password needed -->
<StackPanel IsVisible="{Binding WalletInfoSeedNeedsPassword}" Spacing="8">
<StackPanel IsVisible="{Binding !IsWalletInfoSeedRevealed}" Spacing="8">
<TextBlock Text="{Binding Loc[walletinfo.seed.password]}"
FontSize="12" TextWrapping="Wrap"
Foreground="{DynamicResource TextSecondaryBrush}"/>
<TextBox Text="{Binding WalletInfoSeedPasswordInput}"
PasswordChar="●"
PlaceholderText="{Binding Loc[wiz.open.placeholder]}"/>
<TextBlock Text="{Binding WalletInfoSeedError}"
Foreground="{DynamicResource DangerBrush}" FontSize="12"
IsVisible="{Binding WalletInfoSeedError,
Converter={x:Static StringConverters.IsNotNullOrEmpty}}"/>
<Button Content="{Binding Loc[walletinfo.seed.reveal]}"
Command="{Binding RevealSeedCommand}"/>
</StackPanel>
</StackPanel>
<!-- Not yet revealed + no password needed -->
<Button IsVisible="{Binding !WalletInfoSeedNeedsPassword}"
Content="{Binding Loc[walletinfo.seed.reveal]}"
Command="{Binding RevealSeedCommand}"
IsEnabled="{Binding !IsWalletInfoSeedRevealed}"/>
<!-- Revealed seed -->
<StackPanel IsVisible="{Binding IsWalletInfoSeedRevealed}" Spacing="8">
<Border BorderBrush="{DynamicResource DangerBrush}" BorderThickness="1"
CornerRadius="6" Padding="14"
Background="{DynamicResource SurfaceAltBrush}">
<SelectableTextBlock Text="{Binding WalletInfoSeedText}"
FontFamily="monospace" FontSize="15"
TextWrapping="Wrap"/>
</Border>
<TextBlock Text="{Binding Loc[walletinfo.seed.warning]}"
Foreground="{DynamicResource DangerBrush}"
FontSize="12" TextWrapping="Wrap"/>
<Button Content="{Binding Loc[walletinfo.seed.hide]}"
Command="{Binding HideSeedCommand}"/>
</StackPanel>
</StackPanel>
<Button Content="{Binding Loc[addr.close]}"
HorizontalAlignment="Right"
Command="{Binding CloseWalletInfoCommand}"/>
</StackPanel>
</ScrollViewer>
</Border>
</Border>
<!-- ============ SETTINGS OVERLAY ============ -->
<!-- In-app instead of nested submenus: avoids slow OS popups on WSLg. -->
<Border Grid.Row="0" Grid.RowSpan="3"
@@ -1365,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">
@@ -1437,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>
+36 -20
View File
@@ -1,3 +1,4 @@
using System;
using System.Linq;
using Avalonia;
using Avalonia.Controls;
@@ -23,25 +24,6 @@ public partial class MainView : UserControl
InitializeComponent();
}
private async void OnOpenWalletFileClick(object? sender, RoutedEventArgs e)
{
if (DataContext is not MainWindowViewModel vm) return;
if (TopLevel.GetTopLevel(this)?.StorageProvider is not { } storage) return;
var files = await storage.OpenFilePickerAsync(new FilePickerOpenOptions
{
Title = "Apri file wallet",
AllowMultiple = false,
FileTypeFilter =
[
new FilePickerFileType("Wallet Palladium") { Patterns = ["*.wallet.json", "*.json"] },
],
});
if (files.FirstOrDefault()?.TryGetLocalPath() is { } path)
vm.OpenFromPath(path);
}
private void OnHistoryRowDoubleTapped(object? sender, TappedEventArgs e)
{
if (sender is not ListBox lb || DataContext is not MainWindowViewModel vm) return;
@@ -126,6 +108,13 @@ public partial class MainView : UserControl
vm.IsServerSettingsOpen = true;
}
private void OnWalletInfoOverlayBackdropTapped(object? sender, TappedEventArgs e)
{
if (!ReferenceEquals(e.Source, sender)) return;
if (DataContext is MainWindowViewModel vm)
vm.CloseWalletInfoCommand.Execute(null);
}
private void OnSettingsOverlayBackdropTapped(object? sender, TappedEventArgs e)
{
if (!ReferenceEquals(e.Source, sender)) return;
@@ -140,16 +129,43 @@ 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)
{
if ((e.Key == Key.Escape || e.Key == Key.Back) && DataContext is MainWindowViewModel vm)
{
if (vm.IsTxDetailsOpen) { vm.CloseTransactionDetailsCommand.Execute(null); e.Handled = true; return; }
if (vm.AddressInfo is not null) { vm.AddressInfo = null; e.Handled = true; return; }
if (vm.IsPrivKeyPromptOpen) { vm.CancelPrivKeyPromptCommand.Execute(null); e.Handled = true; return; }
if (vm.AddressInfo is not null) { vm.CloseAddressInfoCommand.Execute(null); e.Handled = true; return; }
if (vm.IsServerSettingsOpen) { vm.IsServerSettingsOpen = false; e.Handled = true; return; }
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
View File
@@ -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)} " +
+6
View File
@@ -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; }
+3
View File
@@ -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,
+1 -1
View File
@@ -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(),
+66
View File
@@ -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>
private 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!);
}
}
+12
View File
@@ -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,
+4
View File
@@ -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; }
}
+32 -6
View File
@@ -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.");
}
+24
View File
@@ -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);
}
@@ -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