Compare commits
6
Commits
bbed21e820
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8ac4a05c44 | ||
|
|
0d541d0fe3 | ||
|
|
7057905d94 | ||
|
|
51f1af8786 | ||
|
|
4cd5fab736 | ||
|
|
94a474fe41 |
+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
|
||||
|
||||
+22
-10
@@ -72,10 +72,11 @@ Running without arguments shows an interactive menu — pick a single target or
|
||||
./docker/build.sh [TARGET] [--rebuild]
|
||||
|
||||
Targets:
|
||||
windows Win x64 single-file executable (native libs embedded)
|
||||
linux Linux x64 single-file binary (runs as-is, nothing to install)
|
||||
android Android APK (release-signed, prompts for keystore passwords)
|
||||
all All three targets
|
||||
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 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
|
||||
```
|
||||
|
||||
@@ -96,11 +98,12 @@ Examples:
|
||||
All artifacts land in `dist/` at the repository root. The version number is
|
||||
read automatically from `<Version>` in `src/App/PalladiumWallet.App.csproj`.
|
||||
|
||||
| Target | Path |
|
||||
|---------|--------------------------------------------------|
|
||||
| Windows | `dist/windows/PalladiumWallet-{ver}-win-x64.exe` |
|
||||
| Linux | `dist/linux/PalladiumWallet-{ver}-linux-x64` |
|
||||
| Android | `dist/android/PalladiumWallet-{ver}.apk` |
|
||||
| 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
|
||||
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),
|
||||
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
|
||||
|
||||
+32
-12
@@ -27,10 +27,11 @@ usage() {
|
||||
$(bold "Usage:") $(basename "$0") [TARGET] [OPTIONS]
|
||||
|
||||
$(bold "Targets:")
|
||||
windows Win x64 single-file executable → dist/windows/
|
||||
linux Linux x64 single-file binary → dist/linux/
|
||||
android Android APK (release-signed) → dist/android/
|
||||
all All three targets
|
||||
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 targets above
|
||||
|
||||
$(bold "Options:")
|
||||
--rebuild Force rebuild of Docker images (e.g. after Dockerfile change)
|
||||
@@ -50,9 +51,9 @@ TARGET=""
|
||||
|
||||
for arg in "$@"; do
|
||||
case "$arg" in
|
||||
windows|linux|android|all) TARGET="$arg" ;;
|
||||
--rebuild) REBUILD=true ;;
|
||||
-h|--help) usage; exit 0 ;;
|
||||
windows|linux|linux-arm64|android|all) TARGET="$arg" ;;
|
||||
--rebuild) REBUILD=true ;;
|
||||
-h|--help) usage; exit 0 ;;
|
||||
*) err "Unknown argument: $arg"; usage; exit 1 ;;
|
||||
esac
|
||||
done
|
||||
@@ -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
|
||||
|
||||
@@ -221,12 +239,14 @@ build_android() {
|
||||
START=$(date +%s)
|
||||
|
||||
case "$TARGET" in
|
||||
windows) build_windows ;;
|
||||
linux) build_linux ;;
|
||||
android) build_android ;;
|
||||
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;
|
||||
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 (_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,28 +162,102 @@ 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 the wallet is open and the last sync failed, retry automatically.
|
||||
if (_syncFailed && _account is not null)
|
||||
if (_client is { IsConnected: true } client)
|
||||
{
|
||||
// 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();
|
||||
return;
|
||||
}
|
||||
try { await _client.PingAsync(); }
|
||||
catch { }
|
||||
}
|
||||
else if (_autoReconnect)
|
||||
finally
|
||||
{
|
||||
ConnectionStatus = Loc.Tr("conn.reconnecting");
|
||||
await ConnectAndSync();
|
||||
_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 ----
|
||||
|
||||
[RelayCommand]
|
||||
|
||||
@@ -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