Compare commits
8
Commits
5bb94c071f
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8ac4a05c44 | ||
|
|
0d541d0fe3 | ||
|
|
7057905d94 | ||
|
|
51f1af8786 | ||
|
|
4cd5fab736 | ||
|
|
94a474fe41 | ||
|
|
bbed21e820 | ||
|
|
06f512e2f7 |
+116
@@ -5,6 +5,122 @@ Technical changelog for PalladiumWallet. Format loosely follows
|
|||||||
by subsystem rather than strictly by date, since `0.9.0` is the first
|
by subsystem rather than strictly by date, since `0.9.0` is the first
|
||||||
release and covers the full history from the initial commit.
|
release and covers the full history from the initial commit.
|
||||||
|
|
||||||
|
## [1.1.0] — 2026-07-19
|
||||||
|
|
||||||
|
Adds a pure address-only watch-only mode (Core + CLI + App wizard + Send
|
||||||
|
PSBT export), makes SPV sync render balance/history progressively instead
|
||||||
|
of blocking on full Merkle verification, and fixes several Android
|
||||||
|
sync-reconnect bugs found by testing the previous fix on a large wallet.
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- Pure watch-only wallets from one or more plain addresses, no extended
|
||||||
|
key or private key material at all (unlike existing xpub/WIF imports,
|
||||||
|
which can still derive/hold key material): `WalletDocument.WatchAddresses`
|
||||||
|
+ `WalletLoader.NewFromAddresses`, `ScriptKind` inferred from the address
|
||||||
|
via `DerivationPaths.KindFor`, CLI `restore-address` command, and a
|
||||||
|
matching setup-wizard step. `TransactionFactory` already refused to sign
|
||||||
|
for any `IsWatchOnly` account, so the new address-only accounts inherit
|
||||||
|
that guarantee for free.
|
||||||
|
- Send flow: base64 PSBT export box + copy button and a visible warning
|
||||||
|
banner for watch-only accounts, so the unsigned PSBT built from a
|
||||||
|
watch-only wallet can actually be taken elsewhere to sign (previously
|
||||||
|
only the CLI printed it).
|
||||||
|
- Chinese (Simplified) as a 7th UI language (`Loc.Strings`/`Languages`);
|
||||||
|
the Settings language picker is a hand-written `RadioButton` list, not
|
||||||
|
generated from `Loc.Languages`, so `IsLangZh` was added to
|
||||||
|
`MainWindowViewModel.Settings.cs` and `MainView.axaml` too.
|
||||||
|
- New mainnet checkpoint at height 475124 (`ChainProfiles`).
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- SPV sync now renders balance/history as soon as transaction downloads
|
||||||
|
finish instead of blocking on every historical Merkle proof — critical
|
||||||
|
on mobile, where proof-checking can take much longer than the download.
|
||||||
|
Proofs keep verifying in the background and each transaction's
|
||||||
|
`Verified` flag catches up progressively; header ranges are fetched in
|
||||||
|
batches (`blockchain.block.headers`) instead of one call per header.
|
||||||
|
Coin selection (`UtxoSpendability.IsSpendable`) still refuses to spend a
|
||||||
|
UTXO until its Merkle proof is actually checked, regardless of
|
||||||
|
confirmation count — a server fabricating a confirmed balance can get it
|
||||||
|
displayed early but never spent before the forgery is caught. The disk
|
||||||
|
cache only ever persists the fully-verified end state. UI surfaces the
|
||||||
|
new `PendingVerificationSats`/`SpendableSats` split with a
|
||||||
|
"verifying..." badge.
|
||||||
|
- `ElectrumClient`'s in-flight request cap (`MaxInFlight`, previously a
|
||||||
|
hardcoded 32) is now an optional `ConnectAsync` parameter, since
|
||||||
|
different indexing servers tolerate different concurrency before
|
||||||
|
throttling.
|
||||||
|
|
||||||
|
### Performance
|
||||||
|
|
||||||
|
- Checkpoint-anchoring state (`_anchoredUpTo`) is now persisted across
|
||||||
|
sync sessions via `SyncCache` instead of being re-walked and
|
||||||
|
re-verified from scratch on every app restart, even when the header
|
||||||
|
bytes were already cached on disk — this dominated reconnect time on
|
||||||
|
large wallets.
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- `TransactionFactory.Build`: sending a large amount from a wallet with
|
||||||
|
many small UTXOs could produce a transaction over the standard 100 KvB
|
||||||
|
relay limit, previously surfaced only as a cryptic
|
||||||
|
`Transaction's size is too high` error after everything else had
|
||||||
|
already succeeded. UTXOs are now ordered largest-first with a binary
|
||||||
|
search for the smallest spendable prefix, naturally preferring big
|
||||||
|
coins over dust; NBitcoin's own oversized-selection case is now
|
||||||
|
recognized and translated into a clear, actionable error instead of
|
||||||
|
being read as "insufficient funds".
|
||||||
|
- Android: a connection killed silently while the phone was locked (Doze,
|
||||||
|
radio suspend, NAT timeout) still reported `IsConnected == true`
|
||||||
|
because `TcpClient.Connected` only reflects the last known socket
|
||||||
|
state, and the keep-alive ping's failure was swallowed by an empty
|
||||||
|
catch — sync kept retrying on a dead socket instead of reconnecting.
|
||||||
|
Fixed across two passes:
|
||||||
|
- A failed keep-alive ping now tears down the client and reconnects;
|
||||||
|
`OnPause`/`OnResume` on the Android activity force an immediate
|
||||||
|
health check on resume instead of waiting for the 20s timer (itself
|
||||||
|
liable to be suspended during Doze).
|
||||||
|
- The keep-alive ping had no timeout, so a half-open TCP connection
|
||||||
|
(the common outcome of a longer Doze suspend) left it awaiting a
|
||||||
|
response that never arrives, so the teardown path was never reached
|
||||||
|
— bounded to 8s, plus a re-entrancy guard against overlapping ticks.
|
||||||
|
- Resume checking bailed out whenever a sync was already in progress,
|
||||||
|
leaving a lock/unlock during an active sync hung indefinitely; it now
|
||||||
|
cancels the stuck sync and tears down the dead client instead.
|
||||||
|
- `WalletSynchronizer.ExportCaches` persisted raw transaction bytes only
|
||||||
|
for already-verified transactions, discarding anything downloaded but
|
||||||
|
not yet proof-verified when a sync was interrupted — forcing a full
|
||||||
|
re-download on every resume of a large wallet. Confirmed txids are
|
||||||
|
now tracked at download time, independent of verification status.
|
||||||
|
- Wizard/overlay `TextBox`es (wizard steps, private-key prompt, wallet
|
||||||
|
info overlay) shrank the whole panel when clicked into empty: their
|
||||||
|
`HorizontalAlignment="Center"` + `MaxWidth` containers sized from
|
||||||
|
content `DesiredSize`, and `PlaceholderText` only contributes to that
|
||||||
|
measurement while empty and unfocused. Switched to
|
||||||
|
`HorizontalAlignment="Stretch"`, which sizes from the available arrange
|
||||||
|
rect (clamped by `MaxWidth`) regardless of focus/placeholder state.
|
||||||
|
- Help overlay's Donate tab is now gated behind `IsWalletOpen`, like the
|
||||||
|
rest of the wallet-only UI — it requires an open wallet to send from,
|
||||||
|
so showing it earlier was a dead end.
|
||||||
|
|
||||||
|
### Documentation
|
||||||
|
|
||||||
|
- `USERGUIDE.md`: documents why initial sync scales with transaction
|
||||||
|
count rather than wallet age (one Merkle-proof round trip per confirmed
|
||||||
|
transaction, no batching in the Electrum-style protocol), why later
|
||||||
|
syncs are fast (cache persists proofs/headers/anchoring state), and
|
||||||
|
warns against using this wallet as a mining payout address (many small
|
||||||
|
transactions measurably slow sync).
|
||||||
|
- `SECURITY.md`: documents address-only watch-only wallets, and adds
|
||||||
|
multisig to the explicit "known limitations" list (unsupported —
|
||||||
|
derivation for it throws, not just unimplemented UI).
|
||||||
|
- `README.md` reconciled with current repo state: 7 UI languages (was
|
||||||
|
6, missing Chinese Simplified), watch-only described as xpub *and*
|
||||||
|
address-only (was xpub-only), multisig no longer overclaimed as a
|
||||||
|
working PSBT flow, CLI quick-reference includes `restore-address` and
|
||||||
|
`servers`, links the new `USERGUIDE.md`.
|
||||||
|
|
||||||
## [1.0.0] — 2026-07-09
|
## [1.0.0] — 2026-07-09
|
||||||
|
|
||||||
First stable release. Closes the last open security gap from 0.9.x (header
|
First stable release. Closes the last open security gap from 0.9.x (header
|
||||||
|
|||||||
@@ -8,11 +8,11 @@ Unlike generic wallets adapted to many coins, Palladium Wallet is designed aroun
|
|||||||
|
|
||||||
- **Lightweight SPV**: syncs against an indexing server (ElectrumX-like protocol) without downloading the full chain.
|
- **Lightweight SPV**: syncs against an indexing server (ElectrumX-like protocol) without downloading the full chain.
|
||||||
- **Security**: seed and private keys encrypted on disk (AES-GCM, PBKDF2-SHA512), never in plaintext in logs or on the wire; every server response is validated with Merkle proofs + checkpoints.
|
- **Security**: seed and private keys encrypted on disk (AES-GCM, PBKDF2-SHA512), never in plaintext in logs or on the wire; every server response is validated with Merkle proofs + checkpoints.
|
||||||
- **HD wallet** (BIP39/BIP32), SegWit/wrapped/legacy addresses, watch-only from xpub.
|
- **HD wallet** (BIP39/BIP32), SegWit/wrapped/legacy addresses, watch-only from xpub or from plain addresses (no key material at all).
|
||||||
- **PSBT-centric**: signing flows go through PSBT (offline / air-gapped / multisig).
|
- **PSBT-centric**: signing flows go through PSBT (offline / air-gapped); watch-only wallets export an unsigned PSBT for offline signing. Multisig script kinds are defined in the network profile but not yet implemented (planned, see `Core/Crypto/DerivationPaths.cs`).
|
||||||
- **Multi-network**: mainnet, testnet, regtest.
|
- **Multi-network**: mainnet, testnet, regtest.
|
||||||
- **Cross-platform**: desktop (Windows/Linux) and Android share one Avalonia UI; a **CLI** runs on the same core.
|
- **Cross-platform**: desktop (Windows/Linux) and Android share one Avalonia UI; a **CLI** runs on the same core.
|
||||||
- **Multilingual**: Italian, English, Spanish, French, Portuguese, German.
|
- **Multilingual**: Italian, English, Spanish, French, Portuguese, German, Chinese (Simplified).
|
||||||
|
|
||||||
## Architecture
|
## Architecture
|
||||||
|
|
||||||
@@ -331,6 +331,9 @@ existing AVD, so create one first (step 3). Point it at the emulator binary:
|
|||||||
|
|
||||||
## User guide (quick)
|
## User guide (quick)
|
||||||
|
|
||||||
|
A condensed overview follows; for the complete, exhaustive walkthrough (every screen, every
|
||||||
|
validation rule, troubleshooting) see [USERGUIDE.md](USERGUIDE.md).
|
||||||
|
|
||||||
### First launch
|
### First launch
|
||||||
1. On first launch (desktop), choose **where to store data** (wallet, configuration, certificates) — the default path or a folder of your choice. On Android this step is skipped: data lives in the app's private sandbox.
|
1. On first launch (desktop), choose **where to store data** (wallet, configuration, certificates) — the default path or a folder of your choice. On Android this step is skipped: data lives in the app's private sandbox.
|
||||||
2. Create a new wallet, restore from seed, or open one of the wallets already in your data folder.
|
2. Create a new wallet, restore from seed, or open one of the wallets already in your data folder.
|
||||||
@@ -360,15 +363,20 @@ existing AVD, so create one first (step 3). Point it at the emulator binary:
|
|||||||
### CLI in brief
|
### CLI in brief
|
||||||
```bash
|
```bash
|
||||||
# Wallet
|
# Wallet
|
||||||
dotnet run --project src/Cli -- create [--words 12|24] [--kind segwit|wrapped|legacy] [--net mainnet|testnet|regtest] [--password P]
|
dotnet run --project src/Cli -- create [--words 12|24] [--kind segwit|wrapped|legacy] [--net mainnet|testnet|regtest] [--password P]
|
||||||
dotnet run --project src/Cli -- restore "<mnemonic>" [...]
|
dotnet run --project src/Cli -- restore "<mnemonic>" [...]
|
||||||
dotnet run --project src/Cli -- info [--net ...] [--password P]
|
dotnet run --project src/Cli -- restore-xpub <slip132-key> [--net ...] [--password P]
|
||||||
|
dotnet run --project src/Cli -- restore-address <addr1,addr2,...> [--net ...] [--password P]
|
||||||
|
dotnet run --project src/Cli -- info [--net ...] [--password P]
|
||||||
|
|
||||||
# Network
|
# Network
|
||||||
dotnet run --project src/Cli -- sync [--server host[:port]] [--ssl]
|
dotnet run --project src/Cli -- sync [--server host[:port]] [--ssl]
|
||||||
dotnet run --project src/Cli -- send --to ADDRESS (--amount X | --all) [--feerate sat/vB] [--broadcast]
|
dotnet run --project src/Cli -- send --to ADDRESS (--amount X | --all) [--feerate sat/vB] [--broadcast]
|
||||||
|
dotnet run --project src/Cli -- servers [--discover]
|
||||||
```
|
```
|
||||||
The default wallet file is `~/.palladium-wallet/<network>/wallets/default.wallet.json` (override with `--file`).
|
The default wallet file is `~/.palladium-wallet/<network>/wallets/default.wallet.json` (override with `--file`).
|
||||||
|
Run without arguments for the full command list (also covers `newseed`, `addresses`, `reset-certs`);
|
||||||
|
see [USERGUIDE.md §17](USERGUIDE.md#17-command-line-interface-cli) for complete flag reference.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -108,3 +108,6 @@ This is a complement to, not a substitute for, independent human or third-party
|
|||||||
- No coin control (automatic UTXO selection only)
|
- No coin control (automatic UTXO selection only)
|
||||||
- No RBF/CPFP UI (RBF flag is set on all transactions, but fee bumping is not exposed)
|
- No RBF/CPFP UI (RBF flag is set on all transactions, but fee bumping is not exposed)
|
||||||
- No Lightning Network support
|
- No Lightning Network support
|
||||||
|
- No multisig (M-of-N) wallets: the network profile defines multisig SLIP-132 header
|
||||||
|
variants, but derivation for them is not implemented — attempting to use one throws
|
||||||
|
rather than silently producing an insecure/incorrect wallet
|
||||||
|
|||||||
+24
-1
@@ -578,6 +578,29 @@ If the server is overloaded (busy responses), the wallet retries automatically u
|
|||||||
times with increasing back-off — a large wallet's first sync may take a little while, but it
|
times with increasing back-off — a large wallet's first sync may take a little while, but it
|
||||||
resumes from the cache instead of restarting.
|
resumes from the cache instead of restarting.
|
||||||
|
|
||||||
|
**First sync can take noticeably longer than later ones — this is expected.** Each confirmed
|
||||||
|
transaction requires its own Merkle-proof round trip to the indexing server
|
||||||
|
(`blockchain.transaction.get_merkle`, one request per transaction — the Electrum-style
|
||||||
|
protocol has no batched form of this call), plus, on mainnet, chaining the covering block
|
||||||
|
header back to the nearest hardcoded checkpoint. Sync time therefore scales with the number
|
||||||
|
of confirmed transactions in the wallet's history, not with wall-clock time since creation.
|
||||||
|
On **Android**, the first sync is typically slower still than on desktop for the same
|
||||||
|
wallet: mobile networks add higher round-trip latency and lower sustained throughput than a
|
||||||
|
desktop's wired/Wi-Fi connection, and every proof round trip pays that latency individually.
|
||||||
|
Every subsequent sync is fast: verified proofs, raw transaction bytes, downloaded block
|
||||||
|
headers, and the checkpoint hash-chain anchoring state are all persisted into the wallet
|
||||||
|
file's cache, so a resumed or later sync only fetches and verifies what changed since the
|
||||||
|
last one — even across an app restart.
|
||||||
|
|
||||||
|
**Do not use this wallet as a mining payout address.** Pool or solo mining payouts typically
|
||||||
|
arrive as many small, frequent transactions, and — because of the per-transaction Merkle
|
||||||
|
proof cost described above — sync time grows with transaction count, not balance. A wallet
|
||||||
|
whose history has accumulated **over 5,000 transactions** has been observed taking on the
|
||||||
|
order of a couple of minutes to fully synchronize even on a stable connection, with slower
|
||||||
|
networks (see the Android note above) pushing that further. If you mine, pay out to a wallet
|
||||||
|
purpose-built for high transaction volume (or one that lets you consolidate UTXOs
|
||||||
|
aggressively), and only move funds into Palladium Wallet in batches.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 13. Settings
|
## 13. Settings
|
||||||
@@ -818,7 +841,7 @@ Reset SSL certificates* — see
|
|||||||
| Payment sent to me doesn't appear | Not yet synced/connected, or sender hasn't broadcast. | Check the connection indicator; mempool entries appear within seconds of broadcast when connected. |
|
| Payment sent to me doesn't appear | Not yet synced/connected, or sender hasn't broadcast. | Check the connection indicator; mempool entries appear within seconds of broadcast when connected. |
|
||||||
| Update prompt at startup (*"Update available"*) | A newer GitHub release exists (checked once at startup, silently skipped offline). | *Download* opens the release page; *Dismiss* continues. Never enter your seed into anything but the wallet itself. |
|
| Update prompt at startup (*"Update available"*) | A newer GitHub release exists (checked once at startup, silently skipped offline). | *Download* opens the release page; *Dismiss* continues. Never enter your seed into anything but the wallet itself. |
|
||||||
| Android: update apk refuses to install | Signature mismatch between builds. | Back up the seed **before** uninstalling; see [3.2](#32-android). |
|
| Android: update apk refuses to install | Signature mismatch between builds. | Back up the seed **before** uninstalling; see [3.2](#32-android). |
|
||||||
| First sync is slow / server busy errors | Server throttling; the wallet retries automatically (up to 8 attempts, growing back-off). | Wait; progress is cached, so restarting resumes rather than repeats. |
|
| First sync is slow / server busy errors | Server throttling (automatic retry, up to 8 attempts) and/or a large transaction history — sync time scales with transaction count, not balance, worse on mobile. | Wait; progress is cached, so restarting resumes rather than repeats. See [12.3](#123-what-synchronization-actually-does). Do not use this wallet for mining payouts (many small transactions). |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
+22
-10
@@ -72,10 +72,11 @@ Running without arguments shows an interactive menu — pick a single target or
|
|||||||
./docker/build.sh [TARGET] [--rebuild]
|
./docker/build.sh [TARGET] [--rebuild]
|
||||||
|
|
||||||
Targets:
|
Targets:
|
||||||
windows Win x64 single-file executable (native libs embedded)
|
windows Win x64 single-file executable (native libs embedded)
|
||||||
linux Linux x64 single-file binary (runs as-is, nothing to install)
|
linux Linux x64 single-file binary (runs as-is, nothing to install)
|
||||||
android Android APK (release-signed, prompts for keystore passwords)
|
linux-arm64 Linux ARM64 single-file binary (runs as-is, nothing to install)
|
||||||
all All three targets
|
android Android APK (release-signed, prompts for keystore passwords)
|
||||||
|
all All targets above
|
||||||
|
|
||||||
Options:
|
Options:
|
||||||
--rebuild Force rebuild of the Docker images (needed after editing a Dockerfile)
|
--rebuild Force rebuild of the Docker images (needed after editing a Dockerfile)
|
||||||
@@ -86,6 +87,7 @@ Examples:
|
|||||||
```bash
|
```bash
|
||||||
./docker/build.sh all # build everything
|
./docker/build.sh all # build everything
|
||||||
./docker/build.sh windows # Windows only
|
./docker/build.sh windows # Windows only
|
||||||
|
./docker/build.sh linux-arm64 # Linux ARM64 only
|
||||||
./docker/build.sh android --rebuild # Android, rebuilding the image first
|
./docker/build.sh android --rebuild # Android, rebuilding the image first
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -96,11 +98,12 @@ Examples:
|
|||||||
All artifacts land in `dist/` at the repository root. The version number is
|
All artifacts land in `dist/` at the repository root. The version number is
|
||||||
read automatically from `<Version>` in `src/App/PalladiumWallet.App.csproj`.
|
read automatically from `<Version>` in `src/App/PalladiumWallet.App.csproj`.
|
||||||
|
|
||||||
| Target | Path |
|
| Target | Path |
|
||||||
|---------|--------------------------------------------------|
|
|-------------|-----------------------------------------------------------|
|
||||||
| Windows | `dist/windows/PalladiumWallet-{ver}-win-x64.exe` |
|
| Windows | `dist/windows/PalladiumWallet-{ver}-win-x64.exe` |
|
||||||
| Linux | `dist/linux/PalladiumWallet-{ver}-linux-x64` |
|
| Linux | `dist/linux/PalladiumWallet-{ver}-linux-x64` |
|
||||||
| Android | `dist/android/PalladiumWallet-{ver}.apk` |
|
| Linux ARM64 | `dist/linux-arm64/PalladiumWallet-{ver}-linux-arm64` |
|
||||||
|
| Android | `dist/android/PalladiumWallet-{ver}.apk` |
|
||||||
|
|
||||||
**Windows** — a single self-contained `.exe` (runtime and native libraries
|
**Windows** — a single self-contained `.exe` (runtime and native libraries
|
||||||
embedded). Copy it to any 64-bit Windows 10/11 machine and double-click.
|
embedded). Copy it to any 64-bit Windows 10/11 machine and double-click.
|
||||||
@@ -119,6 +122,15 @@ effectively all of them); no .NET or other packages to install. If you
|
|||||||
transfer it through a channel that strips permissions (e.g. a web download),
|
transfer it through a channel that strips permissions (e.g. a web download),
|
||||||
restore the execute bit with `chmod +x`.
|
restore the execute bit with `chmod +x`.
|
||||||
|
|
||||||
|
**Linux ARM64** — same as above, cross-published for `aarch64` (e.g.
|
||||||
|
Raspberry Pi 4/5, ARM-based SBCs/laptops running a 64-bit distro). The build
|
||||||
|
runs on an x64 Docker host — .NET's self-contained publish cross-targets
|
||||||
|
`linux-arm64` without needing ARM hardware. Run it the same way:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./PalladiumWallet-{ver}-linux-arm64
|
||||||
|
```
|
||||||
|
|
||||||
**Android** — a release-signed APK for sideloading: transfer it to the phone
|
**Android** — a release-signed APK for sideloading: transfer it to the phone
|
||||||
and open it (enable "install from unknown sources" if prompted), or install
|
and open it (enable "install from unknown sources" if prompted), or install
|
||||||
via `adb install dist/android/PalladiumWallet-*.apk`. Supports Android 6.0+
|
via `adb install dist/android/PalladiumWallet-*.apk`. Supports Android 6.0+
|
||||||
@@ -138,7 +150,7 @@ via `adb install dist/android/PalladiumWallet-*.apk`. Supports Android 6.0+
|
|||||||
|
|
||||||
| Image | Dockerfile | Used for | Size |
|
| Image | Dockerfile | Used for | Size |
|
||||||
|---------------------|----------------------|-----------------|---------|
|
|---------------------|----------------------|-----------------|---------|
|
||||||
| `plm-build-desktop` | `Dockerfile.desktop` | windows + linux | ~1.5 GB |
|
| `plm-build-desktop` | `Dockerfile.desktop` | windows + linux + linux-arm64 | ~1.5 GB |
|
||||||
| `plm-build-android` | `Dockerfile.android` | android | ~5 GB |
|
| `plm-build-android` | `Dockerfile.android` | android | ~5 GB |
|
||||||
|
|
||||||
Images are built automatically the first time a target needs them and reused
|
Images are built automatically the first time a target needs them and reused
|
||||||
|
|||||||
+32
-12
@@ -27,10 +27,11 @@ usage() {
|
|||||||
$(bold "Usage:") $(basename "$0") [TARGET] [OPTIONS]
|
$(bold "Usage:") $(basename "$0") [TARGET] [OPTIONS]
|
||||||
|
|
||||||
$(bold "Targets:")
|
$(bold "Targets:")
|
||||||
windows Win x64 single-file executable → dist/windows/
|
windows Win x64 single-file executable → dist/windows/
|
||||||
linux Linux x64 single-file binary → dist/linux/
|
linux Linux x64 single-file binary → dist/linux/
|
||||||
android Android APK (release-signed) → dist/android/
|
linux-arm64 Linux ARM64 single-file binary → dist/linux-arm64/
|
||||||
all All three targets
|
android Android APK (release-signed) → dist/android/
|
||||||
|
all All targets above
|
||||||
|
|
||||||
$(bold "Options:")
|
$(bold "Options:")
|
||||||
--rebuild Force rebuild of Docker images (e.g. after Dockerfile change)
|
--rebuild Force rebuild of Docker images (e.g. after Dockerfile change)
|
||||||
@@ -50,9 +51,9 @@ TARGET=""
|
|||||||
|
|
||||||
for arg in "$@"; do
|
for arg in "$@"; do
|
||||||
case "$arg" in
|
case "$arg" in
|
||||||
windows|linux|android|all) TARGET="$arg" ;;
|
windows|linux|linux-arm64|android|all) TARGET="$arg" ;;
|
||||||
--rebuild) REBUILD=true ;;
|
--rebuild) REBUILD=true ;;
|
||||||
-h|--help) usage; exit 0 ;;
|
-h|--help) usage; exit 0 ;;
|
||||||
*) err "Unknown argument: $arg"; usage; exit 1 ;;
|
*) err "Unknown argument: $arg"; usage; exit 1 ;;
|
||||||
esac
|
esac
|
||||||
done
|
done
|
||||||
@@ -62,10 +63,10 @@ if [[ -z "$TARGET" ]]; then
|
|||||||
bold "PalladiumWallet — reproducible build"
|
bold "PalladiumWallet — reproducible build"
|
||||||
echo ""
|
echo ""
|
||||||
PS3="Select target: "
|
PS3="Select target: "
|
||||||
options=("windows" "linux" "android" "all" "quit")
|
options=("windows" "linux" "linux-arm64" "android" "all" "quit")
|
||||||
select opt in "${options[@]}"; do
|
select opt in "${options[@]}"; do
|
||||||
case "$opt" in
|
case "$opt" in
|
||||||
windows|linux|android|all) TARGET="$opt"; break ;;
|
windows|linux|linux-arm64|android|all) TARGET="$opt"; break ;;
|
||||||
quit) echo "Aborted."; exit 0 ;;
|
quit) echo "Aborted."; exit 0 ;;
|
||||||
*) echo "Invalid choice, try again." ;;
|
*) echo "Invalid choice, try again." ;;
|
||||||
esac
|
esac
|
||||||
@@ -172,6 +173,23 @@ build_linux() {
|
|||||||
ok "Linux → dist/linux/PalladiumWallet-${VERSION}-linux-x64"
|
ok "Linux → dist/linux/PalladiumWallet-${VERSION}-linux-x64"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
build_linux_arm64() {
|
||||||
|
ensure_desktop_image
|
||||||
|
info "Building Linux ARM64 …"
|
||||||
|
run_build "$IMAGE_DESKTOP" \
|
||||||
|
"dotnet publish src/App.Desktop \
|
||||||
|
-r linux-arm64 \
|
||||||
|
-c Release \
|
||||||
|
-p:PublishSingleFile=true \
|
||||||
|
-p:IncludeNativeLibrariesForSelfExtract=true \
|
||||||
|
--self-contained \
|
||||||
|
-o /tmp/linux-arm64-out
|
||||||
|
install -m 755 /tmp/linux-arm64-out/PalladiumWallet \
|
||||||
|
\"/output/PalladiumWallet-${VERSION}-linux-arm64\"" \
|
||||||
|
"${DIST_DIR}/linux-arm64"
|
||||||
|
ok "Linux ARM64 → dist/linux-arm64/PalladiumWallet-${VERSION}-linux-arm64"
|
||||||
|
}
|
||||||
|
|
||||||
build_android() {
|
build_android() {
|
||||||
ensure_android_image
|
ensure_android_image
|
||||||
|
|
||||||
@@ -221,12 +239,14 @@ build_android() {
|
|||||||
START=$(date +%s)
|
START=$(date +%s)
|
||||||
|
|
||||||
case "$TARGET" in
|
case "$TARGET" in
|
||||||
windows) build_windows ;;
|
windows) build_windows ;;
|
||||||
linux) build_linux ;;
|
linux) build_linux ;;
|
||||||
android) build_android ;;
|
linux-arm64) build_linux_arm64 ;;
|
||||||
|
android) build_android ;;
|
||||||
all)
|
all)
|
||||||
build_windows
|
build_windows
|
||||||
build_linux
|
build_linux
|
||||||
|
build_linux_arm64
|
||||||
build_android
|
build_android
|
||||||
;;
|
;;
|
||||||
esac
|
esac
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ using Android.Content;
|
|||||||
using Android.Content.PM;
|
using Android.Content.PM;
|
||||||
using Android.OS;
|
using Android.OS;
|
||||||
using Avalonia.Android;
|
using Avalonia.Android;
|
||||||
|
using AvaloniaApp = PalladiumWallet.App.App;
|
||||||
|
|
||||||
namespace PalladiumWallet.Mobile;
|
namespace PalladiumWallet.Mobile;
|
||||||
|
|
||||||
@@ -17,6 +18,7 @@ public class MainActivity : AvaloniaMainActivity
|
|||||||
internal const int ScanRequestCode = 9001;
|
internal const int ScanRequestCode = 9001;
|
||||||
internal static TaskCompletionSource<string?>? ScanTcs;
|
internal static TaskCompletionSource<string?>? ScanTcs;
|
||||||
internal static MainActivity? Current;
|
internal static MainActivity? Current;
|
||||||
|
private bool _wasPaused;
|
||||||
|
|
||||||
protected override void OnCreate(Bundle? savedInstanceState)
|
protected override void OnCreate(Bundle? savedInstanceState)
|
||||||
{
|
{
|
||||||
@@ -24,6 +26,26 @@ public class MainActivity : AvaloniaMainActivity
|
|||||||
Current = this;
|
Current = this;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
protected override void OnPause()
|
||||||
|
{
|
||||||
|
base.OnPause();
|
||||||
|
_wasPaused = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
protected override void OnResume()
|
||||||
|
{
|
||||||
|
base.OnResume();
|
||||||
|
if (!_wasPaused) return;
|
||||||
|
_wasPaused = false;
|
||||||
|
// The TCP socket can die silently while the screen was off/locked (Doze,
|
||||||
|
// mobile radio suspend, NAT timeout) without the app ever observing the
|
||||||
|
// failure. Force an immediate health check instead of waiting for the next
|
||||||
|
// 20s keep-alive tick, which may itself have been suspended for longer than
|
||||||
|
// the lock.
|
||||||
|
if (AvaloniaApp.MainViewModel is { } vm)
|
||||||
|
_ = vm.CheckConnectionOnResumeAsync();
|
||||||
|
}
|
||||||
|
|
||||||
protected override void OnActivityResult(int requestCode, Result resultCode, Intent? data)
|
protected override void OnActivityResult(int requestCode, Result resultCode, Intent? data)
|
||||||
{
|
{
|
||||||
base.OnActivityResult(requestCode, resultCode, data);
|
base.OnActivityResult(requestCode, resultCode, data);
|
||||||
|
|||||||
@@ -8,8 +8,8 @@
|
|||||||
<Nullable>enable</Nullable>
|
<Nullable>enable</Nullable>
|
||||||
<ApplicationId>io.github.davide3011.palladiumwallet</ApplicationId>
|
<ApplicationId>io.github.davide3011.palladiumwallet</ApplicationId>
|
||||||
<!-- ApplicationVersion = versionCode (intero), ApplicationDisplayVersion = versionName -->
|
<!-- ApplicationVersion = versionCode (intero), ApplicationDisplayVersion = versionName -->
|
||||||
<ApplicationVersion>3</ApplicationVersion>
|
<ApplicationVersion>4</ApplicationVersion>
|
||||||
<ApplicationDisplayVersion>1.0.0</ApplicationDisplayVersion>
|
<ApplicationDisplayVersion>1.1.0</ApplicationDisplayVersion>
|
||||||
<AndroidPackageFormat>apk</AndroidPackageFormat>
|
<AndroidPackageFormat>apk</AndroidPackageFormat>
|
||||||
<!-- Includi le assembly .NET DENTRO l'apk: senza, in Debug si usa il Fast
|
<!-- Includi le assembly .NET DENTRO l'apk: senza, in Debug si usa il Fast
|
||||||
Deployment (assembly spinte via adb da `dotnet run`) e un apk installato
|
Deployment (assembly spinte via adb da `dotnet run`) e un apk installato
|
||||||
|
|||||||
@@ -8,6 +8,10 @@ namespace PalladiumWallet.App;
|
|||||||
|
|
||||||
public partial class App : Application
|
public partial class App : Application
|
||||||
{
|
{
|
||||||
|
/// <summary>Set once the single ViewModel is created; lets platform heads (e.g. the
|
||||||
|
/// Android activity) reach it for lifecycle events without a second instance.</summary>
|
||||||
|
public static MainWindowViewModel? MainViewModel { get; private set; }
|
||||||
|
|
||||||
public override void Initialize()
|
public override void Initialize()
|
||||||
{
|
{
|
||||||
AvaloniaXamlLoader.Load(this);
|
AvaloniaXamlLoader.Load(this);
|
||||||
@@ -16,6 +20,7 @@ public partial class App : Application
|
|||||||
public override void OnFrameworkInitializationCompleted()
|
public override void OnFrameworkInitializationCompleted()
|
||||||
{
|
{
|
||||||
var vm = new MainWindowViewModel();
|
var vm = new MainWindowViewModel();
|
||||||
|
MainViewModel = vm;
|
||||||
|
|
||||||
// Desktop (Windows/Linux): classic window. Mobile (Android): single
|
// Desktop (Windows/Linux): classic window. Mobile (Android): single
|
||||||
// view. Same shared UI (MainView) and same ViewModel.
|
// view. Same shared UI (MainView) and same ViewModel.
|
||||||
|
|||||||
@@ -6,7 +6,7 @@
|
|||||||
<TargetFramework>net10.0</TargetFramework>
|
<TargetFramework>net10.0</TargetFramework>
|
||||||
<!-- Versione dell'applicazione: unico punto da modificare. Compare nel
|
<!-- Versione dell'applicazione: unico punto da modificare. Compare nel
|
||||||
titolo della finestra ed è incisa nei binari pubblicati. -->
|
titolo della finestra ed è incisa nei binari pubblicati. -->
|
||||||
<Version>1.0.0</Version>
|
<Version>1.1.0</Version>
|
||||||
<Nullable>enable</Nullable>
|
<Nullable>enable</Nullable>
|
||||||
<AvaloniaUseCompiledBindingsByDefault>true</AvaloniaUseCompiledBindingsByDefault>
|
<AvaloniaUseCompiledBindingsByDefault>true</AvaloniaUseCompiledBindingsByDefault>
|
||||||
</PropertyGroup>
|
</PropertyGroup>
|
||||||
|
|||||||
@@ -314,11 +314,13 @@ public partial class MainWindowViewModel
|
|||||||
}
|
}
|
||||||
catch (OperationCanceledException)
|
catch (OperationCanceledException)
|
||||||
{
|
{
|
||||||
// Intentional cancellation due to server change request — not an error.
|
// Intentional cancellation: a server change request, or CheckConnectionOnResumeAsync
|
||||||
|
// recovering a sync stuck on a socket that died while the app was suspended
|
||||||
|
// (_resumeRecovering) — neither is an error the user needs to see as one.
|
||||||
cancelled = true;
|
cancelled = true;
|
||||||
IsConnected = false;
|
IsConnected = false;
|
||||||
ConnectionStatus = Loc.Tr("conn.none");
|
ConnectionStatus = _resumeRecovering ? Loc.Tr("conn.reconnecting") : Loc.Tr("conn.none");
|
||||||
ConnectionStatusShort = Loc.Tr("conn.none");
|
ConnectionStatusShort = _resumeRecovering ? Loc.Tr("conn.reconnecting") : Loc.Tr("conn.none");
|
||||||
StatusMessage = "";
|
StatusMessage = "";
|
||||||
}
|
}
|
||||||
catch (CertificatePinMismatchException ex)
|
catch (CertificatePinMismatchException ex)
|
||||||
@@ -331,9 +333,22 @@ public partial class MainWindowViewModel
|
|||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
IsConnected = _client?.IsConnected == true;
|
IsConnected = _client?.IsConnected == true;
|
||||||
ConnectionStatus = IsConnected ? ConnectionStatus : Loc.Tr("conn.none");
|
if (_resumeRecovering && !IsConnected)
|
||||||
ConnectionStatusShort = IsConnected ? Loc.Tr("conn.connectedto") : Loc.Tr("conn.none");
|
{
|
||||||
StatusMessage = $"{Loc.Tr("msg.error")}: {DescribeError(ex)}";
|
// We tore the connection down ourselves (see CheckConnectionOnResumeAsync);
|
||||||
|
// whether that surfaces here as a cancellation or as a transport exception
|
||||||
|
// is a race, not a real failure — show "reconnecting" and restart right away
|
||||||
|
// instead of a scary error message and a wait for the next keep-alive tick.
|
||||||
|
cancelled = true;
|
||||||
|
ConnectionStatus = Loc.Tr("conn.reconnecting");
|
||||||
|
ConnectionStatusShort = Loc.Tr("conn.reconnecting");
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
ConnectionStatus = IsConnected ? ConnectionStatus : Loc.Tr("conn.none");
|
||||||
|
ConnectionStatusShort = IsConnected ? Loc.Tr("conn.connectedto") : Loc.Tr("conn.none");
|
||||||
|
StatusMessage = $"{Loc.Tr("msg.error")}: {DescribeError(ex)}";
|
||||||
|
}
|
||||||
if (_account is not null)
|
if (_account is not null)
|
||||||
{
|
{
|
||||||
_syncFailed = true;
|
_syncFailed = true;
|
||||||
@@ -345,6 +360,7 @@ public partial class MainWindowViewModel
|
|||||||
finally
|
finally
|
||||||
{
|
{
|
||||||
IsSyncing = false;
|
IsSyncing = false;
|
||||||
|
_resumeRecovering = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
// If cancelled due to a server change, restart immediately with the new server.
|
// If cancelled due to a server change, restart immediately with the new server.
|
||||||
|
|||||||
@@ -77,6 +77,7 @@ public partial class MainWindowViewModel : ViewModelBase
|
|||||||
// ---- keep-alive ----
|
// ---- keep-alive ----
|
||||||
private bool _autoReconnect;
|
private bool _autoReconnect;
|
||||||
private bool _syncFailed;
|
private bool _syncFailed;
|
||||||
|
private bool _resumeRecovering;
|
||||||
private readonly DispatcherTimer _keepAliveTimer;
|
private readonly DispatcherTimer _keepAliveTimer;
|
||||||
|
|
||||||
// ---- server UI sync ----
|
// ---- server UI sync ----
|
||||||
@@ -161,28 +162,102 @@ public partial class MainWindowViewModel : ViewModelBase
|
|||||||
_ = CheckForUpdatesAsync();
|
_ = CheckForUpdatesAsync();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private bool _keepAliveRunning;
|
||||||
|
|
||||||
private async System.Threading.Tasks.Task KeepAliveTickAsync()
|
private async System.Threading.Tasks.Task KeepAliveTickAsync()
|
||||||
{
|
{
|
||||||
if (IsSyncing)
|
// Re-entrancy guard: without it, a ping stuck on a half-open socket (see
|
||||||
|
// below) would let every subsequent 20s tick pile up another concurrent
|
||||||
|
// ping/reconnect attempt on top of it.
|
||||||
|
if (IsSyncing || _keepAliveRunning)
|
||||||
return;
|
return;
|
||||||
if (_client is { IsConnected: true })
|
_keepAliveRunning = true;
|
||||||
|
try
|
||||||
{
|
{
|
||||||
// If the wallet is open and the last sync failed, retry automatically.
|
if (_client is { IsConnected: true } client)
|
||||||
if (_syncFailed && _account is not null)
|
|
||||||
{
|
{
|
||||||
|
// If the wallet is open and the last sync failed, retry automatically.
|
||||||
|
if (_syncFailed && _account is not null)
|
||||||
|
{
|
||||||
|
await ConnectAndSync();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try
|
||||||
|
{
|
||||||
|
// A "half-open" TCP connection (remote end gone with no FIN/RST
|
||||||
|
// ever delivered — the common outcome of Android Doze/mobile-radio
|
||||||
|
// suspend killing the route silently) never fails the write, so
|
||||||
|
// PingAsync would otherwise await a response that never arrives.
|
||||||
|
// Bound it explicitly instead of relying on it to throw.
|
||||||
|
using var timeoutCts = new System.Threading.CancellationTokenSource(System.TimeSpan.FromSeconds(8));
|
||||||
|
await client.PingAsync(timeoutCts.Token);
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
// TcpClient.Connected only reflects the last known socket state, so a
|
||||||
|
// connection killed silently while the app was suspended still reports
|
||||||
|
// IsConnected == true. A failed/timed-out ping is the only reliable
|
||||||
|
// signal here: tear the dead client down so the next tick reconnects
|
||||||
|
// instead of retrying forever on a dead socket.
|
||||||
|
await DisconnectAsync();
|
||||||
|
if (_autoReconnect)
|
||||||
|
{
|
||||||
|
ConnectionStatus = Loc.Tr("conn.reconnecting");
|
||||||
|
await ConnectAndSync();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else if (_autoReconnect)
|
||||||
|
{
|
||||||
|
ConnectionStatus = Loc.Tr("conn.reconnecting");
|
||||||
await ConnectAndSync();
|
await ConnectAndSync();
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
try { await _client.PingAsync(); }
|
|
||||||
catch { }
|
|
||||||
}
|
}
|
||||||
else if (_autoReconnect)
|
finally
|
||||||
{
|
{
|
||||||
ConnectionStatus = Loc.Tr("conn.reconnecting");
|
_keepAliveRunning = false;
|
||||||
await ConnectAndSync();
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Forces an immediate connection health check, bypassing the 20s timer.
|
||||||
|
/// Called when the app resumes from background/lock screen, since the socket may
|
||||||
|
/// have died silently while suspended and the UI would otherwise show a stale
|
||||||
|
/// "connected" state until the next scheduled tick (or forever, if it never fires
|
||||||
|
/// during Doze).</summary>
|
||||||
|
public async System.Threading.Tasks.Task CheckConnectionOnResumeAsync()
|
||||||
|
{
|
||||||
|
if (IsSyncing)
|
||||||
|
{
|
||||||
|
// A sync in progress when the phone locked may be stuck awaiting a
|
||||||
|
// response on a socket that died silently while suspended (same
|
||||||
|
// half-open-TCP scenario as the keep-alive ping, but sync requests have
|
||||||
|
// no timeout of their own). Cancel it and tear the client down instead
|
||||||
|
// of leaving it hung forever. _resumeRecovering tells ConnectAndSync's
|
||||||
|
// error handling that this interruption is self-inflicted and expected,
|
||||||
|
// so it shows "reconnecting" and restarts immediately instead of
|
||||||
|
// flashing a scary error message and waiting for the next keep-alive tick.
|
||||||
|
_resumeRecovering = true;
|
||||||
|
ConnectionStatus = Loc.Tr("conn.reconnecting");
|
||||||
|
ConnectionStatusShort = Loc.Tr("conn.reconnecting");
|
||||||
|
_syncCts.Cancel();
|
||||||
|
// Deliberately not DisconnectAsync() here: it also nulls _synchronizer,
|
||||||
|
// which still holds whatever this sync already downloaded/verified (e.g.
|
||||||
|
// thousands of Merkle proofs on a large wallet). Nulling it before the
|
||||||
|
// cancelled ConnectAndSync unwinds would discard that progress, forcing a
|
||||||
|
// full restart instead of a resume. Only tear down the dead socket here;
|
||||||
|
// ConnectAndSync's own cancellation handling persists the partial cache
|
||||||
|
// (PersistPartialTxCache) before it recreates the synchronizer — same
|
||||||
|
// sequencing already used for the "server changed mid-sync" case.
|
||||||
|
if (_client is { } deadClient)
|
||||||
|
{
|
||||||
|
_client = null;
|
||||||
|
try { await deadClient.DisposeAsync(); } catch { }
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await KeepAliveTickAsync();
|
||||||
|
}
|
||||||
|
|
||||||
// ---- wallet lifecycle ----
|
// ---- wallet lifecycle ----
|
||||||
|
|
||||||
[RelayCommand]
|
[RelayCommand]
|
||||||
|
|||||||
@@ -60,7 +60,7 @@
|
|||||||
<!-- ============ SETUP WIZARD (§15): one step at a time ============ -->
|
<!-- ============ SETUP WIZARD (§15): one step at a time ============ -->
|
||||||
<ScrollViewer Grid.Row="1" IsVisible="{Binding IsSetupVisible}">
|
<ScrollViewer Grid.Row="1" IsVisible="{Binding IsSetupVisible}">
|
||||||
<StackPanel MaxWidth="560" Margin="24,40" Spacing="18"
|
<StackPanel MaxWidth="560" Margin="24,40" Spacing="18"
|
||||||
HorizontalAlignment="Center">
|
HorizontalAlignment="Stretch">
|
||||||
<TextBlock Text="Palladium Wallet" FontSize="28" FontWeight="Bold"
|
<TextBlock Text="Palladium Wallet" FontSize="28" FontWeight="Bold"
|
||||||
HorizontalAlignment="Center"/>
|
HorizontalAlignment="Center"/>
|
||||||
|
|
||||||
@@ -1044,7 +1044,7 @@
|
|||||||
<Border Background="{DynamicResource OverlayCardBrush}"
|
<Border Background="{DynamicResource OverlayCardBrush}"
|
||||||
BorderBrush="{DynamicResource BorderSubtleBrush}" BorderThickness="1" CornerRadius="8"
|
BorderBrush="{DynamicResource BorderSubtleBrush}" BorderThickness="1" CornerRadius="8"
|
||||||
MaxWidth="360" Margin="16"
|
MaxWidth="360" Margin="16"
|
||||||
HorizontalAlignment="Center" VerticalAlignment="Center">
|
HorizontalAlignment="Stretch" VerticalAlignment="Center">
|
||||||
<StackPanel Margin="24" Spacing="14">
|
<StackPanel Margin="24" Spacing="14">
|
||||||
<TextBlock Text="{Binding Loc[addr.privkey.prompt.title]}"
|
<TextBlock Text="{Binding Loc[addr.privkey.prompt.title]}"
|
||||||
FontSize="16" FontWeight="Bold"/>
|
FontSize="16" FontWeight="Bold"/>
|
||||||
@@ -1385,7 +1385,7 @@
|
|||||||
<Border Background="{DynamicResource OverlayCardBrush}"
|
<Border Background="{DynamicResource OverlayCardBrush}"
|
||||||
BorderBrush="{DynamicResource BorderSubtleBrush}" BorderThickness="1" CornerRadius="8"
|
BorderBrush="{DynamicResource BorderSubtleBrush}" BorderThickness="1" CornerRadius="8"
|
||||||
MaxWidth="500" Margin="16"
|
MaxWidth="500" Margin="16"
|
||||||
HorizontalAlignment="Center" VerticalAlignment="Center">
|
HorizontalAlignment="Stretch" VerticalAlignment="Center">
|
||||||
<ScrollViewer MaxHeight="620">
|
<ScrollViewer MaxHeight="620">
|
||||||
<StackPanel Margin="24" Spacing="14">
|
<StackPanel Margin="24" Spacing="14">
|
||||||
<TextBlock Text="{Binding Loc[walletinfo.title]}"
|
<TextBlock Text="{Binding Loc[walletinfo.title]}"
|
||||||
@@ -1648,8 +1648,9 @@
|
|||||||
</ScrollViewer>
|
</ScrollViewer>
|
||||||
</TabItem>
|
</TabItem>
|
||||||
|
|
||||||
<!-- Tab: Donate -->
|
<!-- Tab: Donate (needs an open wallet to send from) -->
|
||||||
<TabItem Header="{Binding Loc[help.tab.donate]}">
|
<TabItem Header="{Binding Loc[help.tab.donate]}"
|
||||||
|
IsVisible="{Binding IsWalletOpen}">
|
||||||
<ScrollViewer VerticalScrollBarVisibility="Auto">
|
<ScrollViewer VerticalScrollBarVisibility="Auto">
|
||||||
<StackPanel Spacing="12" Margin="0,12,0,0">
|
<StackPanel Spacing="12" Margin="0,12,0,0">
|
||||||
<TextBlock Text="{Binding Loc[donate.desc]}" TextWrapping="Wrap"
|
<TextBlock Text="{Binding Loc[donate.desc]}" TextWrapping="Wrap"
|
||||||
|
|||||||
@@ -75,6 +75,14 @@ public sealed class WalletSynchronizer(IWalletAccount account, ElectrumClient cl
|
|||||||
|
|
||||||
private readonly ConcurrentDictionary<string, Transaction> _txCache = new();
|
private readonly ConcurrentDictionary<string, Transaction> _txCache = new();
|
||||||
|
|
||||||
|
// txids known confirmed (height > 0) as of download time, independent of whether their
|
||||||
|
// Merkle proof has been verified yet — lets ExportCaches persist raw tx bytes for a
|
||||||
|
// confirmed transaction interrupted before verification, instead of forcing a
|
||||||
|
// re-download on the next sync just because _verifiedAtHeight hasn't caught up. Every
|
||||||
|
// entry here has txHeights[txid] > 0 at the time it was recorded, i.e. server-confirmed;
|
||||||
|
// unconfirmed (mempool/RBF-able) transactions are deliberately never added.
|
||||||
|
private readonly ConcurrentDictionary<string, byte> _confirmedTxids = new();
|
||||||
|
|
||||||
// Concurrent: written incrementally by individual merkle-verification tasks as they
|
// Concurrent: written incrementally by individual merkle-verification tasks as they
|
||||||
// complete (§7.4 progressive verification), not just once after they all finish.
|
// complete (§7.4 progressive verification), not just once after they all finish.
|
||||||
private readonly ConcurrentDictionary<string, int> _verifiedAtHeight = new();
|
private readonly ConcurrentDictionary<string, int> _verifiedAtHeight = new();
|
||||||
@@ -116,7 +124,12 @@ public sealed class WalletSynchronizer(IWalletAccount account, ElectrumClient cl
|
|||||||
Dictionary<int, int>? anchoredUpTo = null)
|
Dictionary<int, int>? anchoredUpTo = null)
|
||||||
{
|
{
|
||||||
foreach (var (txid, hex) in rawTxHex)
|
foreach (var (txid, hex) in rawTxHex)
|
||||||
|
{
|
||||||
_txCache.TryAdd(txid, Transaction.Parse(hex, network));
|
_txCache.TryAdd(txid, Transaction.Parse(hex, network));
|
||||||
|
// ExportCaches only ever wrote confirmed transactions here (see its own
|
||||||
|
// filter), so every preloaded entry is safe to mark confirmed too.
|
||||||
|
_confirmedTxids.TryAdd(txid, 0);
|
||||||
|
}
|
||||||
foreach (var (txid, height) in verifiedAt)
|
foreach (var (txid, height) in verifiedAt)
|
||||||
if (!_verifiedAtHeight.ContainsKey(txid))
|
if (!_verifiedAtHeight.ContainsKey(txid))
|
||||||
_verifiedAtHeight[txid] = height;
|
_verifiedAtHeight[txid] = height;
|
||||||
@@ -147,7 +160,7 @@ public sealed class WalletSynchronizer(IWalletAccount account, ElectrumClient cl
|
|||||||
Dictionary<int, int> AnchoredUpTo)
|
Dictionary<int, int> AnchoredUpTo)
|
||||||
ExportCaches(Network network)
|
ExportCaches(Network network)
|
||||||
{
|
{
|
||||||
var rawHex = _verifiedAtHeight.Keys
|
var rawHex = _confirmedTxids.Keys
|
||||||
.Where(_txCache.ContainsKey)
|
.Where(_txCache.ContainsKey)
|
||||||
.ToDictionary(txid => txid, txid => _txCache[txid].ToHex());
|
.ToDictionary(txid => txid, txid => _txCache[txid].ToHex());
|
||||||
|
|
||||||
@@ -232,17 +245,30 @@ public sealed class WalletSynchronizer(IWalletAccount account, ElectrumClient cl
|
|||||||
&& (!_verifiedAtHeight.TryGetValue(kv.Key, out var h) || h != kv.Value))
|
&& (!_verifiedAtHeight.TryGetValue(kv.Key, out var h) || h != kv.Value))
|
||||||
.ToList();
|
.ToList();
|
||||||
|
|
||||||
|
// Total/already-cached counts (not just this session's downloads): on a sync resumed
|
||||||
|
// after an interruption, `missing` is often empty because everything was already
|
||||||
|
// fetched last time (see _confirmedTxids/PreloadCaches) — reporting against the total
|
||||||
|
// shows "n/n transactions" immediately instead of a misleading "0/0" before jumping
|
||||||
|
// straight to proof verification.
|
||||||
|
var totalTx = txHeights.Count;
|
||||||
|
var alreadyCached = totalTx - missing.Count;
|
||||||
|
|
||||||
|
string DownloadVerifyStatus(int downloaded, int verified) =>
|
||||||
|
$"transactions {downloaded}/{totalTx}, proofs {verified}/{toVerify.Count}…";
|
||||||
|
|
||||||
if (missing.Count > 0 || toVerify.Count > 0)
|
if (missing.Count > 0 || toVerify.Count > 0)
|
||||||
Progress?.Invoke($"downloading {missing.Count} txs, verifying {toVerify.Count} proofs…");
|
Progress?.Invoke(DownloadVerifyStatus(alreadyCached, 0));
|
||||||
|
|
||||||
var dlDone = 0;
|
var dlDone = 0;
|
||||||
await Task.WhenAll(missing.Select(txid => RetryOnBusyAsync(async () =>
|
await Task.WhenAll(missing.Select(txid => RetryOnBusyAsync(async () =>
|
||||||
{
|
{
|
||||||
var raw = await client.GetTransactionAsync(txid, ct);
|
var raw = await client.GetTransactionAsync(txid, ct);
|
||||||
_txCache[txid] = Transaction.Parse(raw, network);
|
_txCache[txid] = Transaction.Parse(raw, network);
|
||||||
|
if (txHeights[txid] > 0)
|
||||||
|
_confirmedTxids.TryAdd(txid, 0);
|
||||||
var n = Interlocked.Increment(ref dlDone);
|
var n = Interlocked.Increment(ref dlDone);
|
||||||
if (n % 50 == 0 || n == missing.Count)
|
if (n % 50 == 0 || n == missing.Count)
|
||||||
Progress?.Invoke($"tx {n}/{missing.Count}, proofs 0/{toVerify.Count}…");
|
Progress?.Invoke(DownloadVerifyStatus(alreadyCached + n, 0));
|
||||||
}, ct)));
|
}, ct)));
|
||||||
|
|
||||||
SyncResult BuildSnapshot() =>
|
SyncResult BuildSnapshot() =>
|
||||||
@@ -273,7 +299,7 @@ public sealed class WalletSynchronizer(IWalletAccount account, ElectrumClient cl
|
|||||||
_verifiedAtHeight[txid] = height;
|
_verifiedAtHeight[txid] = height;
|
||||||
var n = Interlocked.Increment(ref merkDone);
|
var n = Interlocked.Increment(ref merkDone);
|
||||||
if (n % 50 == 0 || n == toVerify.Count)
|
if (n % 50 == 0 || n == toVerify.Count)
|
||||||
Progress?.Invoke($"tx {missing.Count}/{missing.Count}, proofs {n}/{toVerify.Count}…");
|
Progress?.Invoke(DownloadVerifyStatus(totalTx, n));
|
||||||
if (n % PartialResultBatchSize == 0)
|
if (n % PartialResultBatchSize == 0)
|
||||||
PartialResult?.Invoke(BuildSnapshot());
|
PartialResult?.Invoke(BuildSnapshot());
|
||||||
}, ct)).ToList();
|
}, ct)).ToList();
|
||||||
|
|||||||
Reference in New Issue
Block a user