feat(ui): surface daemon DEGRADED mode + v1.3.0 auto-shield status

Rec3 — v1.3.0+ opens a wallet that lost its hdchain in DEGRADED mode (existing funds spendable,
but no new HD-key derivation) instead of aborting. Add a daemon-log classifier
(walletOpenedDegraded) + detectWalletDegraded(), warned once per session. Pre-1.3.0 daemons
never emit that line, so it's a no-op there.

O1 — probe z_autoshieldstatus once per connection (now decoupled from our own toggle/balance) and,
in Settings, show whether the node handles auto-shield itself (+ its destination, or the daemon's
disabled_reason). The checkbox now governs only the wallet's fallback shielder, which defers to the
node. Nothing renders on pre-1.3.0 daemons (no such RPC), so behaviour is unchanged.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-08-31 22:48:46 -05:00
co-authored by Claude Opus 4.8
parent ef8ceeaf9a
commit 90e02b1ddd
5 changed files with 76 additions and 13 deletions
+39 -13
View File
@@ -249,6 +249,21 @@ void App::detectWalletAutoRecovery()
VERBOSE_LOGF("[recovery] Daemon auto-recovered/salvaged wallet.dat — surfacing the recovery dialog\n");
}
// v1.3.0+ opens a wallet that lost its hdchain in DEGRADED mode (funds spendable) instead of aborting,
// but can't derive NEW HD keys — z_getnewaddress / z_shieldcoinbase / t->z z_sendmany fail with "HD seed
// not found". Only a startup log line signals it, so scan the captured output and warn once. Pre-1.3.0
// daemons never emit it, so this is a no-op there (backwards compatible).
void App::detectWalletDegraded()
{
if (wallet_degraded_warned_) return;
if (!isUsingEmbeddedDaemon() || !daemon_controller_ || !daemon_controller_->daemon()) return;
if (!daemon::walletOpenedDegraded(daemon_controller_->daemon()->getOutput())) return;
wallet_degraded_ = true;
wallet_degraded_warned_ = true;
ui::Notifications::instance().warning(TR("wallet_degraded_notify"), 30.0f);
VERBOSE_LOGF("[recovery] Daemon opened wallet in DEGRADED mode — new-key derivation disabled\n");
}
void App::tryConnect()
{
// Lite builds have no full node / RPC daemon, so never run the RPC connection state machine
@@ -259,6 +274,7 @@ void App::tryConnect()
// Catch a startup wallet salvage as soon as it appears in the node's output — independent of whether
// the node ever finishes starting or connects (skip only while an orchestrated swap is mid-flight).
if (!daemon_restarting_) detectWalletAutoRecovery();
if (!daemon_restarting_) detectWalletDegraded();
if (connection_in_progress_) return;
@@ -1746,29 +1762,39 @@ void App::refreshCoreData()
}
}
// Auto-shield transparent funds if enabled. A v1.3.0+ daemon auto-shields coinbase
// itself; probe z_autoshieldstatus once (while synced, so the daemon is past warmup) and
// defer to it when it's active — otherwise the wallet and the daemon race for the same
// coinbase UTXOs and split funds across different z-addresses. A pre-1.3.0 daemon lacks
// the RPC, so the probe fails closed (active=false) and the wallet keeps shielding.
const bool autoShieldEligible = result.balanceOk && settings_ && settings_->getAutoShield() &&
state_.spendableTransparentBalance > 0.0001 && !state_.sync.syncing;
if (autoShieldEligible && !daemon_autoshield_probed_ && worker_ &&
// Probe the daemon's auto-shield status once per connection — independent of our own
// toggle/balance — so both the defer-gate below and the Settings UI (O1) can read it.
// Only when synced (daemon past warmup). Fail-closed: a pre-1.3.0 daemon lacks the RPC,
// so the probe leaves active=false and the wallet keeps shielding client-side.
if (result.balanceOk && !state_.sync.syncing && !daemon_autoshield_probed_ && worker_ &&
!daemon_autoshield_probe_inflight_.exchange(true)) {
worker_->post([this]() -> rpc::RPCWorker::MainCb {
bool active = false;
bool active = false, seedRecoverable = false;
std::string addr, reason;
try {
auto st = rpc_->call("z_autoshieldstatus", json::array());
if (st.is_object() && st.contains("autoshield"))
active = st["autoshield"].get<bool>();
} catch (...) { active = false; } // pre-1.3.0 daemon: method not found
return [this, active]() {
if (st.is_object()) {
if (st.contains("autoshield")) active = st["autoshield"].get<bool>();
if (st.contains("autoshieldaddress")) addr = st["autoshieldaddress"].get<std::string>();
if (st.contains("disabled_reason")) reason = st["disabled_reason"].get<std::string>();
if (st.contains("seed_recoverable")) seedRecoverable = st["seed_recoverable"].get<bool>();
}
} catch (...) {} // pre-1.3.0 daemon: no such method — leave defaults (inactive)
return [this, active, addr, reason, seedRecoverable]() {
daemon_autoshield_active_ = active;
daemon_autoshield_address_ = addr;
daemon_autoshield_disabled_reason_ = reason;
daemon_autoshield_seed_recoverable_ = seedRecoverable;
daemon_autoshield_probed_ = true;
daemon_autoshield_probe_inflight_ = false;
};
});
}
// Auto-shield transparent funds — but defer to the daemon's own coinbase auto-shielder
// (v1.3.0+) when it's active, so we don't double-shield and split funds across z-addrs.
const bool autoShieldEligible = result.balanceOk && settings_ && settings_->getAutoShield() &&
state_.spendableTransparentBalance > 0.0001 && !state_.sync.syncing;
if (autoShieldEligible && daemon_autoshield_probed_ && !daemon_autoshield_active_ &&
!auto_shield_pending_.exchange(true)) {
std::string targetZAddr;