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
|
||||
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
|
||||
|
||||
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.
|
||||
- **Security**: seed and private keys encrypted on disk (AES-GCM, PBKDF2-SHA512), never in plaintext in logs or on the wire; every server response is validated with Merkle proofs + checkpoints.
|
||||
- **HD wallet** (BIP39/BIP32), SegWit/wrapped/legacy addresses, watch-only from xpub.
|
||||
- **PSBT-centric**: signing flows go through PSBT (offline / air-gapped / multisig).
|
||||
- **HD wallet** (BIP39/BIP32), SegWit/wrapped/legacy addresses, watch-only from xpub or from plain addresses (no key material at all).
|
||||
- **PSBT-centric**: signing flows go through PSBT (offline / air-gapped); watch-only wallets export an unsigned PSBT for offline signing. Multisig script kinds are defined in the network profile but not yet implemented (planned, see `Core/Crypto/DerivationPaths.cs`).
|
||||
- **Multi-network**: mainnet, testnet, regtest.
|
||||
- **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
|
||||
|
||||
@@ -331,6 +331,9 @@ existing AVD, so create one first (step 3). Point it at the emulator binary:
|
||||
|
||||
## User guide (quick)
|
||||
|
||||
A condensed overview follows; for the complete, exhaustive walkthrough (every screen, every
|
||||
validation rule, troubleshooting) see [USERGUIDE.md](USERGUIDE.md).
|
||||
|
||||
### First launch
|
||||
1. On first launch (desktop), choose **where to store data** (wallet, configuration, certificates) — the default path or a folder of your choice. On Android this step is skipped: data lives in the app's private sandbox.
|
||||
2. Create a new wallet, restore from seed, or open one of the wallets already in your data folder.
|
||||
@@ -362,13 +365,18 @@ existing AVD, so create one first (step 3). Point it at the emulator binary:
|
||||
# Wallet
|
||||
dotnet run --project src/Cli -- create [--words 12|24] [--kind segwit|wrapped|legacy] [--net mainnet|testnet|regtest] [--password P]
|
||||
dotnet run --project src/Cli -- restore "<mnemonic>" [...]
|
||||
dotnet run --project src/Cli -- 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
|
||||
dotnet run --project src/Cli -- sync [--server host[:port]] [--ssl]
|
||||
dotnet run --project src/Cli -- send --to ADDRESS (--amount X | --all) [--feerate sat/vB] [--broadcast]
|
||||
dotnet run --project src/Cli -- servers [--discover]
|
||||
```
|
||||
The 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 RBF/CPFP UI (RBF flag is set on all transactions, but fee bumping is not exposed)
|
||||
- No Lightning Network support
|
||||
- No multisig (M-of-N) wallets: the network profile defines multisig SLIP-132 header
|
||||
variants, but derivation for them is not implemented — attempting to use one throws
|
||||
rather than silently producing an insecure/incorrect wallet
|
||||
|
||||
+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
|
||||
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
|
||||
@@ -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. |
|
||||
| Update prompt at startup (*"Update available"*) | A newer GitHub release exists (checked once at startup, silently skipped offline). | *Download* opens the release page; *Dismiss* continues. Never enter your seed into anything but the wallet itself. |
|
||||
| Android: update apk refuses to install | Signature mismatch between builds. | Back up the seed **before** uninstalling; see [3.2](#32-android). |
|
||||
| First sync is slow / server busy errors | Server throttling; the wallet retries automatically (up to 8 attempts, growing back-off). | Wait; progress is cached, so restarting resumes rather than repeats. |
|
||||
| First sync is slow / server busy errors | Server throttling (automatic retry, up to 8 attempts) and/or a large transaction history — sync time scales with transaction count, not balance, worse on mobile. | Wait; progress is cached, so restarting resumes rather than repeats. See [12.3](#123-what-synchronization-actually-does). Do not use this wallet for mining payouts (many small transactions). |
|
||||
|
||||
---
|
||||
|
||||
|
||||
+15
-3
@@ -74,8 +74,9 @@ Running without arguments shows an interactive menu — pick a single target or
|
||||
Targets:
|
||||
windows Win x64 single-file executable (native libs embedded)
|
||||
linux Linux x64 single-file binary (runs as-is, nothing to install)
|
||||
linux-arm64 Linux ARM64 single-file binary (runs as-is, nothing to install)
|
||||
android Android APK (release-signed, prompts for keystore passwords)
|
||||
all All three targets
|
||||
all All targets above
|
||||
|
||||
Options:
|
||||
--rebuild Force rebuild of the Docker images (needed after editing a Dockerfile)
|
||||
@@ -86,6 +87,7 @@ Examples:
|
||||
```bash
|
||||
./docker/build.sh all # build everything
|
||||
./docker/build.sh windows # Windows only
|
||||
./docker/build.sh linux-arm64 # Linux ARM64 only
|
||||
./docker/build.sh android --rebuild # Android, rebuilding the image first
|
||||
```
|
||||
|
||||
@@ -97,9 +99,10 @@ All artifacts land in `dist/` at the repository root. The version number is
|
||||
read automatically from `<Version>` in `src/App/PalladiumWallet.App.csproj`.
|
||||
|
||||
| Target | Path |
|
||||
|---------|--------------------------------------------------|
|
||||
|-------------|-----------------------------------------------------------|
|
||||
| Windows | `dist/windows/PalladiumWallet-{ver}-win-x64.exe` |
|
||||
| Linux | `dist/linux/PalladiumWallet-{ver}-linux-x64` |
|
||||
| Linux ARM64 | `dist/linux-arm64/PalladiumWallet-{ver}-linux-arm64` |
|
||||
| Android | `dist/android/PalladiumWallet-{ver}.apk` |
|
||||
|
||||
**Windows** — a single self-contained `.exe` (runtime and native libraries
|
||||
@@ -119,6 +122,15 @@ effectively all of them); no .NET or other packages to install. If you
|
||||
transfer it through a channel that strips permissions (e.g. a web download),
|
||||
restore the execute bit with `chmod +x`.
|
||||
|
||||
**Linux ARM64** — same as above, cross-published for `aarch64` (e.g.
|
||||
Raspberry Pi 4/5, ARM-based SBCs/laptops running a 64-bit distro). The build
|
||||
runs on an x64 Docker host — .NET's self-contained publish cross-targets
|
||||
`linux-arm64` without needing ARM hardware. Run it the same way:
|
||||
|
||||
```bash
|
||||
./PalladiumWallet-{ver}-linux-arm64
|
||||
```
|
||||
|
||||
**Android** — a release-signed APK for sideloading: transfer it to the phone
|
||||
and open it (enable "install from unknown sources" if prompted), or install
|
||||
via `adb install dist/android/PalladiumWallet-*.apk`. Supports Android 6.0+
|
||||
@@ -138,7 +150,7 @@ via `adb install dist/android/PalladiumWallet-*.apk`. Supports Android 6.0+
|
||||
|
||||
| Image | Dockerfile | Used for | Size |
|
||||
|---------------------|----------------------|-----------------|---------|
|
||||
| `plm-build-desktop` | `Dockerfile.desktop` | windows + linux | ~1.5 GB |
|
||||
| `plm-build-desktop` | `Dockerfile.desktop` | windows + linux + linux-arm64 | ~1.5 GB |
|
||||
| `plm-build-android` | `Dockerfile.android` | android | ~5 GB |
|
||||
|
||||
Images are built automatically the first time a target needs them and reused
|
||||
|
||||
+24
-4
@@ -29,8 +29,9 @@ $(bold "Usage:") $(basename "$0") [TARGET] [OPTIONS]
|
||||
$(bold "Targets:")
|
||||
windows Win x64 single-file executable → dist/windows/
|
||||
linux Linux x64 single-file binary → dist/linux/
|
||||
linux-arm64 Linux ARM64 single-file binary → dist/linux-arm64/
|
||||
android Android APK (release-signed) → dist/android/
|
||||
all All three targets
|
||||
all All targets above
|
||||
|
||||
$(bold "Options:")
|
||||
--rebuild Force rebuild of Docker images (e.g. after Dockerfile change)
|
||||
@@ -50,7 +51,7 @@ TARGET=""
|
||||
|
||||
for arg in "$@"; do
|
||||
case "$arg" in
|
||||
windows|linux|android|all) TARGET="$arg" ;;
|
||||
windows|linux|linux-arm64|android|all) TARGET="$arg" ;;
|
||||
--rebuild) REBUILD=true ;;
|
||||
-h|--help) usage; exit 0 ;;
|
||||
*) err "Unknown argument: $arg"; usage; exit 1 ;;
|
||||
@@ -62,10 +63,10 @@ if [[ -z "$TARGET" ]]; then
|
||||
bold "PalladiumWallet — reproducible build"
|
||||
echo ""
|
||||
PS3="Select target: "
|
||||
options=("windows" "linux" "android" "all" "quit")
|
||||
options=("windows" "linux" "linux-arm64" "android" "all" "quit")
|
||||
select opt in "${options[@]}"; do
|
||||
case "$opt" in
|
||||
windows|linux|android|all) TARGET="$opt"; break ;;
|
||||
windows|linux|linux-arm64|android|all) TARGET="$opt"; break ;;
|
||||
quit) echo "Aborted."; exit 0 ;;
|
||||
*) echo "Invalid choice, try again." ;;
|
||||
esac
|
||||
@@ -172,6 +173,23 @@ build_linux() {
|
||||
ok "Linux → dist/linux/PalladiumWallet-${VERSION}-linux-x64"
|
||||
}
|
||||
|
||||
build_linux_arm64() {
|
||||
ensure_desktop_image
|
||||
info "Building Linux ARM64 …"
|
||||
run_build "$IMAGE_DESKTOP" \
|
||||
"dotnet publish src/App.Desktop \
|
||||
-r linux-arm64 \
|
||||
-c Release \
|
||||
-p:PublishSingleFile=true \
|
||||
-p:IncludeNativeLibrariesForSelfExtract=true \
|
||||
--self-contained \
|
||||
-o /tmp/linux-arm64-out
|
||||
install -m 755 /tmp/linux-arm64-out/PalladiumWallet \
|
||||
\"/output/PalladiumWallet-${VERSION}-linux-arm64\"" \
|
||||
"${DIST_DIR}/linux-arm64"
|
||||
ok "Linux ARM64 → dist/linux-arm64/PalladiumWallet-${VERSION}-linux-arm64"
|
||||
}
|
||||
|
||||
build_android() {
|
||||
ensure_android_image
|
||||
|
||||
@@ -223,10 +241,12 @@ START=$(date +%s)
|
||||
case "$TARGET" in
|
||||
windows) build_windows ;;
|
||||
linux) build_linux ;;
|
||||
linux-arm64) build_linux_arm64 ;;
|
||||
android) build_android ;;
|
||||
all)
|
||||
build_windows
|
||||
build_linux
|
||||
build_linux_arm64
|
||||
build_android
|
||||
;;
|
||||
esac
|
||||
|
||||
@@ -4,6 +4,7 @@ using Android.Content;
|
||||
using Android.Content.PM;
|
||||
using Android.OS;
|
||||
using Avalonia.Android;
|
||||
using AvaloniaApp = PalladiumWallet.App.App;
|
||||
|
||||
namespace PalladiumWallet.Mobile;
|
||||
|
||||
@@ -17,6 +18,7 @@ public class MainActivity : AvaloniaMainActivity
|
||||
internal const int ScanRequestCode = 9001;
|
||||
internal static TaskCompletionSource<string?>? ScanTcs;
|
||||
internal static MainActivity? Current;
|
||||
private bool _wasPaused;
|
||||
|
||||
protected override void OnCreate(Bundle? savedInstanceState)
|
||||
{
|
||||
@@ -24,6 +26,26 @@ public class MainActivity : AvaloniaMainActivity
|
||||
Current = this;
|
||||
}
|
||||
|
||||
protected override void OnPause()
|
||||
{
|
||||
base.OnPause();
|
||||
_wasPaused = true;
|
||||
}
|
||||
|
||||
protected override void OnResume()
|
||||
{
|
||||
base.OnResume();
|
||||
if (!_wasPaused) return;
|
||||
_wasPaused = false;
|
||||
// The TCP socket can die silently while the screen was off/locked (Doze,
|
||||
// mobile radio suspend, NAT timeout) without the app ever observing the
|
||||
// failure. Force an immediate health check instead of waiting for the next
|
||||
// 20s keep-alive tick, which may itself have been suspended for longer than
|
||||
// the lock.
|
||||
if (AvaloniaApp.MainViewModel is { } vm)
|
||||
_ = vm.CheckConnectionOnResumeAsync();
|
||||
}
|
||||
|
||||
protected override void OnActivityResult(int requestCode, Result resultCode, Intent? data)
|
||||
{
|
||||
base.OnActivityResult(requestCode, resultCode, data);
|
||||
|
||||
@@ -8,8 +8,8 @@
|
||||
<Nullable>enable</Nullable>
|
||||
<ApplicationId>io.github.davide3011.palladiumwallet</ApplicationId>
|
||||
<!-- ApplicationVersion = versionCode (intero), ApplicationDisplayVersion = versionName -->
|
||||
<ApplicationVersion>3</ApplicationVersion>
|
||||
<ApplicationDisplayVersion>1.0.0</ApplicationDisplayVersion>
|
||||
<ApplicationVersion>4</ApplicationVersion>
|
||||
<ApplicationDisplayVersion>1.1.0</ApplicationDisplayVersion>
|
||||
<AndroidPackageFormat>apk</AndroidPackageFormat>
|
||||
<!-- Includi le assembly .NET DENTRO l'apk: senza, in Debug si usa il Fast
|
||||
Deployment (assembly spinte via adb da `dotnet run`) e un apk installato
|
||||
|
||||
@@ -8,6 +8,10 @@ namespace PalladiumWallet.App;
|
||||
|
||||
public partial class App : Application
|
||||
{
|
||||
/// <summary>Set once the single ViewModel is created; lets platform heads (e.g. the
|
||||
/// Android activity) reach it for lifecycle events without a second instance.</summary>
|
||||
public static MainWindowViewModel? MainViewModel { get; private set; }
|
||||
|
||||
public override void Initialize()
|
||||
{
|
||||
AvaloniaXamlLoader.Load(this);
|
||||
@@ -16,6 +20,7 @@ public partial class App : Application
|
||||
public override void OnFrameworkInitializationCompleted()
|
||||
{
|
||||
var vm = new MainWindowViewModel();
|
||||
MainViewModel = vm;
|
||||
|
||||
// Desktop (Windows/Linux): classic window. Mobile (Android): single
|
||||
// view. Same shared UI (MainView) and same ViewModel.
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<!-- Versione dell'applicazione: unico punto da modificare. Compare nel
|
||||
titolo della finestra ed è incisa nei binari pubblicati. -->
|
||||
<Version>1.0.0</Version>
|
||||
<Version>1.1.0</Version>
|
||||
<Nullable>enable</Nullable>
|
||||
<AvaloniaUseCompiledBindingsByDefault>true</AvaloniaUseCompiledBindingsByDefault>
|
||||
</PropertyGroup>
|
||||
|
||||
@@ -314,11 +314,13 @@ public partial class MainWindowViewModel
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
// Intentional cancellation due to server change request — not an error.
|
||||
// Intentional cancellation: a server change request, or CheckConnectionOnResumeAsync
|
||||
// recovering a sync stuck on a socket that died while the app was suspended
|
||||
// (_resumeRecovering) — neither is an error the user needs to see as one.
|
||||
cancelled = true;
|
||||
IsConnected = false;
|
||||
ConnectionStatus = Loc.Tr("conn.none");
|
||||
ConnectionStatusShort = Loc.Tr("conn.none");
|
||||
ConnectionStatus = _resumeRecovering ? Loc.Tr("conn.reconnecting") : Loc.Tr("conn.none");
|
||||
ConnectionStatusShort = _resumeRecovering ? Loc.Tr("conn.reconnecting") : Loc.Tr("conn.none");
|
||||
StatusMessage = "";
|
||||
}
|
||||
catch (CertificatePinMismatchException ex)
|
||||
@@ -331,9 +333,22 @@ public partial class MainWindowViewModel
|
||||
catch (Exception ex)
|
||||
{
|
||||
IsConnected = _client?.IsConnected == true;
|
||||
if (_resumeRecovering && !IsConnected)
|
||||
{
|
||||
// We tore the connection down ourselves (see CheckConnectionOnResumeAsync);
|
||||
// whether that surfaces here as a cancellation or as a transport exception
|
||||
// is a race, not a real failure — show "reconnecting" and restart right away
|
||||
// instead of a scary error message and a wait for the next keep-alive tick.
|
||||
cancelled = true;
|
||||
ConnectionStatus = Loc.Tr("conn.reconnecting");
|
||||
ConnectionStatusShort = Loc.Tr("conn.reconnecting");
|
||||
}
|
||||
else
|
||||
{
|
||||
ConnectionStatus = IsConnected ? ConnectionStatus : Loc.Tr("conn.none");
|
||||
ConnectionStatusShort = IsConnected ? Loc.Tr("conn.connectedto") : Loc.Tr("conn.none");
|
||||
StatusMessage = $"{Loc.Tr("msg.error")}: {DescribeError(ex)}";
|
||||
}
|
||||
if (_account is not null)
|
||||
{
|
||||
_syncFailed = true;
|
||||
@@ -345,6 +360,7 @@ public partial class MainWindowViewModel
|
||||
finally
|
||||
{
|
||||
IsSyncing = false;
|
||||
_resumeRecovering = false;
|
||||
}
|
||||
|
||||
// If cancelled due to a server change, restart immediately with the new server.
|
||||
|
||||
@@ -77,6 +77,7 @@ public partial class MainWindowViewModel : ViewModelBase
|
||||
// ---- keep-alive ----
|
||||
private bool _autoReconnect;
|
||||
private bool _syncFailed;
|
||||
private bool _resumeRecovering;
|
||||
private readonly DispatcherTimer _keepAliveTimer;
|
||||
|
||||
// ---- server UI sync ----
|
||||
@@ -161,11 +162,19 @@ public partial class MainWindowViewModel : ViewModelBase
|
||||
_ = CheckForUpdatesAsync();
|
||||
}
|
||||
|
||||
private bool _keepAliveRunning;
|
||||
|
||||
private async System.Threading.Tasks.Task KeepAliveTickAsync()
|
||||
{
|
||||
if (IsSyncing)
|
||||
// Re-entrancy guard: without it, a ping stuck on a half-open socket (see
|
||||
// below) would let every subsequent 20s tick pile up another concurrent
|
||||
// ping/reconnect attempt on top of it.
|
||||
if (IsSyncing || _keepAliveRunning)
|
||||
return;
|
||||
if (_client is { IsConnected: true })
|
||||
_keepAliveRunning = true;
|
||||
try
|
||||
{
|
||||
if (_client is { IsConnected: true } client)
|
||||
{
|
||||
// If the wallet is open and the last sync failed, retry automatically.
|
||||
if (_syncFailed && _account is not null)
|
||||
@@ -173,8 +182,30 @@ public partial class MainWindowViewModel : ViewModelBase
|
||||
await ConnectAndSync();
|
||||
return;
|
||||
}
|
||||
try { await _client.PingAsync(); }
|
||||
catch { }
|
||||
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)
|
||||
{
|
||||
@@ -182,6 +213,50 @@ public partial class MainWindowViewModel : ViewModelBase
|
||||
await ConnectAndSync();
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
_keepAliveRunning = false;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Forces an immediate connection health check, bypassing the 20s timer.
|
||||
/// Called when the app resumes from background/lock screen, since the socket may
|
||||
/// have died silently while suspended and the UI would otherwise show a stale
|
||||
/// "connected" state until the next scheduled tick (or forever, if it never fires
|
||||
/// during Doze).</summary>
|
||||
public async System.Threading.Tasks.Task CheckConnectionOnResumeAsync()
|
||||
{
|
||||
if (IsSyncing)
|
||||
{
|
||||
// A sync in progress when the phone locked may be stuck awaiting a
|
||||
// response on a socket that died silently while suspended (same
|
||||
// half-open-TCP scenario as the keep-alive ping, but sync requests have
|
||||
// no timeout of their own). Cancel it and tear the client down instead
|
||||
// of leaving it hung forever. _resumeRecovering tells ConnectAndSync's
|
||||
// error handling that this interruption is self-inflicted and expected,
|
||||
// so it shows "reconnecting" and restarts immediately instead of
|
||||
// flashing a scary error message and waiting for the next keep-alive tick.
|
||||
_resumeRecovering = true;
|
||||
ConnectionStatus = Loc.Tr("conn.reconnecting");
|
||||
ConnectionStatusShort = Loc.Tr("conn.reconnecting");
|
||||
_syncCts.Cancel();
|
||||
// Deliberately not DisconnectAsync() here: it also nulls _synchronizer,
|
||||
// which still holds whatever this sync already downloaded/verified (e.g.
|
||||
// thousands of Merkle proofs on a large wallet). Nulling it before the
|
||||
// cancelled ConnectAndSync unwinds would discard that progress, forcing a
|
||||
// full restart instead of a resume. Only tear down the dead socket here;
|
||||
// ConnectAndSync's own cancellation handling persists the partial cache
|
||||
// (PersistPartialTxCache) before it recreates the synchronizer — same
|
||||
// sequencing already used for the "server changed mid-sync" case.
|
||||
if (_client is { } deadClient)
|
||||
{
|
||||
_client = null;
|
||||
try { await deadClient.DisposeAsync(); } catch { }
|
||||
}
|
||||
return;
|
||||
}
|
||||
await KeepAliveTickAsync();
|
||||
}
|
||||
|
||||
// ---- wallet lifecycle ----
|
||||
|
||||
|
||||
@@ -60,7 +60,7 @@
|
||||
<!-- ============ SETUP WIZARD (§15): one step at a time ============ -->
|
||||
<ScrollViewer Grid.Row="1" IsVisible="{Binding IsSetupVisible}">
|
||||
<StackPanel MaxWidth="560" Margin="24,40" Spacing="18"
|
||||
HorizontalAlignment="Center">
|
||||
HorizontalAlignment="Stretch">
|
||||
<TextBlock Text="Palladium Wallet" FontSize="28" FontWeight="Bold"
|
||||
HorizontalAlignment="Center"/>
|
||||
|
||||
@@ -1044,7 +1044,7 @@
|
||||
<Border Background="{DynamicResource OverlayCardBrush}"
|
||||
BorderBrush="{DynamicResource BorderSubtleBrush}" BorderThickness="1" CornerRadius="8"
|
||||
MaxWidth="360" Margin="16"
|
||||
HorizontalAlignment="Center" VerticalAlignment="Center">
|
||||
HorizontalAlignment="Stretch" VerticalAlignment="Center">
|
||||
<StackPanel Margin="24" Spacing="14">
|
||||
<TextBlock Text="{Binding Loc[addr.privkey.prompt.title]}"
|
||||
FontSize="16" FontWeight="Bold"/>
|
||||
@@ -1385,7 +1385,7 @@
|
||||
<Border Background="{DynamicResource OverlayCardBrush}"
|
||||
BorderBrush="{DynamicResource BorderSubtleBrush}" BorderThickness="1" CornerRadius="8"
|
||||
MaxWidth="500" Margin="16"
|
||||
HorizontalAlignment="Center" VerticalAlignment="Center">
|
||||
HorizontalAlignment="Stretch" VerticalAlignment="Center">
|
||||
<ScrollViewer MaxHeight="620">
|
||||
<StackPanel Margin="24" Spacing="14">
|
||||
<TextBlock Text="{Binding Loc[walletinfo.title]}"
|
||||
@@ -1648,8 +1648,9 @@
|
||||
</ScrollViewer>
|
||||
</TabItem>
|
||||
|
||||
<!-- Tab: Donate -->
|
||||
<TabItem Header="{Binding Loc[help.tab.donate]}">
|
||||
<!-- Tab: Donate (needs an open wallet to send from) -->
|
||||
<TabItem Header="{Binding Loc[help.tab.donate]}"
|
||||
IsVisible="{Binding IsWalletOpen}">
|
||||
<ScrollViewer VerticalScrollBarVisibility="Auto">
|
||||
<StackPanel Spacing="12" Margin="0,12,0,0">
|
||||
<TextBlock Text="{Binding Loc[donate.desc]}" TextWrapping="Wrap"
|
||||
|
||||
@@ -75,6 +75,14 @@ public sealed class WalletSynchronizer(IWalletAccount account, ElectrumClient cl
|
||||
|
||||
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
|
||||
// complete (§7.4 progressive verification), not just once after they all finish.
|
||||
private readonly ConcurrentDictionary<string, int> _verifiedAtHeight = new();
|
||||
@@ -116,7 +124,12 @@ public sealed class WalletSynchronizer(IWalletAccount account, ElectrumClient cl
|
||||
Dictionary<int, int>? anchoredUpTo = null)
|
||||
{
|
||||
foreach (var (txid, hex) in rawTxHex)
|
||||
{
|
||||
_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)
|
||||
if (!_verifiedAtHeight.ContainsKey(txid))
|
||||
_verifiedAtHeight[txid] = height;
|
||||
@@ -147,7 +160,7 @@ public sealed class WalletSynchronizer(IWalletAccount account, ElectrumClient cl
|
||||
Dictionary<int, int> AnchoredUpTo)
|
||||
ExportCaches(Network network)
|
||||
{
|
||||
var rawHex = _verifiedAtHeight.Keys
|
||||
var rawHex = _confirmedTxids.Keys
|
||||
.Where(_txCache.ContainsKey)
|
||||
.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))
|
||||
.ToList();
|
||||
|
||||
// Total/already-cached counts (not just this session's downloads): on a sync resumed
|
||||
// after an interruption, `missing` is often empty because everything was already
|
||||
// fetched last time (see _confirmedTxids/PreloadCaches) — reporting against the total
|
||||
// shows "n/n transactions" immediately instead of a misleading "0/0" before jumping
|
||||
// straight to proof verification.
|
||||
var totalTx = txHeights.Count;
|
||||
var alreadyCached = totalTx - missing.Count;
|
||||
|
||||
string DownloadVerifyStatus(int downloaded, int verified) =>
|
||||
$"transactions {downloaded}/{totalTx}, proofs {verified}/{toVerify.Count}…";
|
||||
|
||||
if (missing.Count > 0 || toVerify.Count > 0)
|
||||
Progress?.Invoke($"downloading {missing.Count} txs, verifying {toVerify.Count} proofs…");
|
||||
Progress?.Invoke(DownloadVerifyStatus(alreadyCached, 0));
|
||||
|
||||
var dlDone = 0;
|
||||
await Task.WhenAll(missing.Select(txid => RetryOnBusyAsync(async () =>
|
||||
{
|
||||
var raw = await client.GetTransactionAsync(txid, ct);
|
||||
_txCache[txid] = Transaction.Parse(raw, network);
|
||||
if (txHeights[txid] > 0)
|
||||
_confirmedTxids.TryAdd(txid, 0);
|
||||
var n = Interlocked.Increment(ref dlDone);
|
||||
if (n % 50 == 0 || n == missing.Count)
|
||||
Progress?.Invoke($"tx {n}/{missing.Count}, proofs 0/{toVerify.Count}…");
|
||||
Progress?.Invoke(DownloadVerifyStatus(alreadyCached + n, 0));
|
||||
}, ct)));
|
||||
|
||||
SyncResult BuildSnapshot() =>
|
||||
@@ -273,7 +299,7 @@ public sealed class WalletSynchronizer(IWalletAccount account, ElectrumClient cl
|
||||
_verifiedAtHeight[txid] = height;
|
||||
var n = Interlocked.Increment(ref merkDone);
|
||||
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)
|
||||
PartialResult?.Invoke(BuildSnapshot());
|
||||
}, ct)).ToList();
|
||||
|
||||
Reference in New Issue
Block a user