6 Commits
Author SHA1 Message Date
davide 8ac4a05c44 feat(docker): add linux-arm64 reproducible build target
Cross-published via the existing x64 desktop image (self-contained
publish doesn't need ARM hardware), for Raspberry Pi and other
64-bit ARM boards.
2026-07-26 11:05:16 +02:00
davide 0d541d0fe3 chore(release): bump version to 1.1.0
Fills in the CHANGELOG.md entry for the watch-only address-only import
mode, progressive Merkle verification, transaction size-limit fix, and
Android sync-reconnect fixes since 1.0.0, and bumps <Version>/versionCode
across the App and Android head csproj files ahead of the tag.
2026-07-19 17:59:56 +02:00
davide 7057905d94 fix(ui): hide Donate tab in Help until a wallet is open
The tab requires an open wallet to send from, so showing it before
one is loaded led to a dead end. Gate it behind IsWalletOpen, same as
the other wallet-only UI.
2026-07-19 17:46:34 +02:00
davide 51f1af8786 fix(ui): stop wizard/overlay TextBoxes from resizing on focus
Width-capped containers (wizard steps, private-key prompt, wallet info
overlay) used HorizontalAlignment="Center" with MaxWidth, which sizes
the panel from its content's DesiredSize. Since PlaceholderText only
contributes to that measurement while a TextBox is empty and
unfocused, clicking into an empty field shrank the whole panel.

Switch to HorizontalAlignment="Stretch": Avalonia's ArrangeCore sizes
Stretch from the available arrange rect (clamped by MaxWidth) instead
of DesiredSize, and centers the result exactly like Center alignment
does when MaxWidth caps it — so the box stays a fixed width regardless
of focus/placeholder state, on both desktop and Android.
2026-07-19 17:44:01 +02:00
davide 4cd5fab736 fix(sync): bound keep-alive ping and resume a sync stuck on lock/unlock
Three issues surfaced by testing the previous lock/unlock reconnect fix
on Android with a large wallet:

- The keep-alive ping had no timeout, so a "half-open" TCP connection
  (remote end gone with no FIN/RST ever delivered, the common outcome
  of Doze/mobile-radio suspend after a longer lock) left it awaiting a
  response that never arrives — the failure path that tears the dead
  client down was never reached. Bound the ping to 8s and added a
  re-entrancy guard so overlapping 20s ticks can't pile up concurrent
  reconnect attempts while one is stuck.

- CheckConnectionOnResumeAsync bailed out whenever a sync was already
  in progress, so a lock/unlock during an active sync (which has no
  per-request timeout of its own) left it hung indefinitely instead of
  recovering. It now cancels the stuck sync and tears down the dead
  client, marking the interruption as self-inflicted (_resumeRecovering)
  so the UI shows "reconnecting" and restarts immediately instead of a
  transient error message.

- WalletSynchronizer.ExportCaches persisted raw transaction bytes only
  for already-verified transactions, discarding anything downloaded but
  not yet through Merkle-proof verification when a sync was interrupted
  mid-way — forcing a full re-download of a large wallet's transactions
  on every resume instead of resuming straight into proof verification.
  Track confirmed txids at download time (_confirmedTxids), independent
  of verification status, and export/preload against that instead.

Also unified the three sync progress messages onto one template
reporting against the sync's total transaction/proof counts rather than
this session's download count, so a resumed sync immediately shows
"transactions n/n" instead of a misleading "0/0" before jumping into
proof verification.
2026-07-19 17:43:31 +02:00
davide 94a474fe41 fix(sync): recover connection after Android screen lock/unlock
TcpClient.Connected only reflects the last known socket state, so a
connection killed silently while the phone was locked (Doze, mobile
radio suspend, NAT timeout) still reported IsConnected == true. The
keep-alive ping's failure was swallowed by an empty catch, so the
stale "connected" state never cleared and sync kept retrying on a
dead socket instead of reconnecting.

Treat a failed keep-alive ping as a disconnect (tear down the client
and reconnect), and add OnPause/OnResume to the Android activity to
force an immediate health check on resume instead of waiting for the
20s timer, which may itself be suspended during Doze.
2026-07-19 16:01:18 +02:00
11 changed files with 343 additions and 50 deletions
+116
View File
@@ -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
+22 -10
View File
@@ -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
View File
@@ -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
+22
View File
@@ -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
+5
View File
@@ -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.
+1 -1
View File
@@ -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>
+22 -6
View File
@@ -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.
+85 -10
View File
@@ -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]
+6 -5
View File
@@ -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"
+30 -4
View File
@@ -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();