From 90e02b1ddd7ac4df70d20acf842b7cd2d5cf38cb Mon Sep 17 00:00:00 2001 From: DanS Date: Mon, 31 Aug 2026 22:48:46 -0500 Subject: [PATCH] feat(ui): surface daemon DEGRADED mode + v1.3.0 auto-shield status MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- src/app.h | 13 +++++++ src/app_network.cpp | 52 ++++++++++++++++++++------- src/daemon/daemon_startup_diagnosis.h | 10 ++++++ src/ui/pages/settings_page.cpp | 12 +++++++ src/util/i18n.cpp | 2 ++ 5 files changed, 76 insertions(+), 13 deletions(-) diff --git a/src/app.h b/src/app.h index fe1fd7c..0a34982 100644 --- a/src/app.h +++ b/src/app.h @@ -163,6 +163,13 @@ public: bool supportsEmbeddedDaemon() const { return wallet::supportsEmbeddedDaemon(walletCapabilities()); } bool supportsFullNodeLifecycleActions() const { return wallet::supportsFullNodeLifecycleActions(walletCapabilities()); } + // Daemon (v1.3.0+) coinbase auto-shield status, from z_autoshieldstatus. "Not probed" / all-false on + // pre-1.3.0 daemons (no such RPC) — callers treat that as "the wallet handles auto-shield itself". + bool daemonAutoShieldProbed() const { return daemon_autoshield_probed_; } + bool daemonAutoShieldActive() const { return daemon_autoshield_active_; } + const std::string& daemonAutoShieldAddress() const { return daemon_autoshield_address_; } + const std::string& daemonAutoShieldDisabledReason() const { return daemon_autoshield_disabled_reason_; } + // W7 QoL: a plaintext support-diagnostics snapshot (version, variant, daemon/RPC/wallet/log state) // for the "Copy diagnostics" action. Contains no secrets. std::string buildDiagnosticsReport(); @@ -958,6 +965,8 @@ private: // so a possibly-incomplete salvaged wallet isn't mistaken for fund loss. Warned once per session. bool wallet_auto_recovered_ = false; // a salvage happened this session bool wallet_auto_recovered_warned_ = false; // guard: only surface it once per session + bool wallet_degraded_ = false; // v1.3.0+ opened the wallet in DEGRADED mode (no new HD keys) + bool wallet_degraded_warned_ = false; // guard: surface the degraded-mode notice once per session bool show_wallet_recovered_dialog_ = false; // auto-shown warning dialog // Complementary on-disk safety net for a salvage we DIDN'T witness this launch (happened on a prior // run, or under an external daemon whose startup output we never captured): if the active wallet loads @@ -1248,6 +1257,9 @@ private: bool daemon_autoshield_probed_ = false; bool daemon_autoshield_active_ = false; std::atomic daemon_autoshield_probe_inflight_{false}; + std::string daemon_autoshield_address_; // z_autoshieldstatus fields (O1); empty on old daemons + std::string daemon_autoshield_disabled_reason_; // daemon's reason auto-shield is off (e.g. seed not recoverable) + bool daemon_autoshield_seed_recoverable_ = false; // P4: Incremental transaction cache int last_tx_block_height_ = -1; // block height at last full tx fetch @@ -1440,6 +1452,7 @@ private: void renderWalletRecoveredDialog(); // warn that the node auto-recovered/salvaged wallet.dat void renderEmptyWalletWarningDialog();// warn that the active wallet is empty while a sibling holds funds void detectWalletAutoRecovery(); // scan daemon output for a salvage; fire the warning once/session + void detectWalletDegraded(); // scan daemon output for a DEGRADED-mode open; warn once/session void restoreOriginalWallet(); // swap the wallet..bak back over the salvaged copy + restart void pumpWalletRestore(); // main-thread: surface the restore/rebuild op's result void rebuildWalletDatabase(); // rebuild a BDB-inconsistent wallet into a loadable one (helper) diff --git a/src/app_network.cpp b/src/app_network.cpp index 0c82961..06fda63 100644 --- a/src/app_network.cpp +++ b/src/app_network.cpp @@ -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(); - } 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(); + if (st.contains("autoshieldaddress")) addr = st["autoshieldaddress"].get(); + if (st.contains("disabled_reason")) reason = st["disabled_reason"].get(); + if (st.contains("seed_recoverable")) seedRecoverable = st["seed_recoverable"].get(); + } + } 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; diff --git a/src/daemon/daemon_startup_diagnosis.h b/src/daemon/daemon_startup_diagnosis.h index 72ce57e..325e2bb 100644 --- a/src/daemon/daemon_startup_diagnosis.h +++ b/src/daemon/daemon_startup_diagnosis.h @@ -49,6 +49,16 @@ inline bool walletAutoRecovered(const std::string& out) && out.find(".bak") != std::string::npos); // Recover moved wallet.dat aside } +// True when dragonxd (v1.3.0+) opened the wallet in DEGRADED mode: a wallet.dat that lost its hdchain +// record (e.g. an old `-salvagewallet` output) now OPENS — existing keys stay intact and spendable — +// instead of aborting, but the daemon can no longer derive NEW HD keys, so z_getnewaddress / +// z_shieldcoinbase / a t->z z_sendmany fail with "HD seed not found". The only signal is a startup log +// line; pre-1.3.0 daemons never emit it, so this classifier is naturally a no-op against them. +inline bool walletOpenedDegraded(const std::string& out) +{ + return out.find("Wallet opened in DEGRADED mode") != std::string::npos; +} + // If `name` is a daemon salvage backup "wallet..bak", return its timestamp; else -1. inline long long parseWalletSalvageBakTs(const std::string& name) { diff --git a/src/ui/pages/settings_page.cpp b/src/ui/pages/settings_page.cpp index bee107d..b1f36e4 100644 --- a/src/ui/pages/settings_page.cpp +++ b/src/ui/pages/settings_page.cpp @@ -1186,6 +1186,18 @@ void RenderSettingsPage(App* app) { if (ImGui::IsItemHovered()) material::Tooltip("%s", TR("tt_save_ztx")); CB(TrId("auto_shield", "auto_shld"), &s_settingsState.auto_shield); if (ImGui::IsItemHovered()) material::Tooltip("%s", TR("tt_auto_shield")); + // O1: when a v1.3.0+ node auto-shields coinbase itself, show its real state — the checkbox + // above only governs the wallet's own fallback shielder (which defers to the node). Nothing + // renders on pre-1.3.0 daemons (never probed), so their behaviour is unchanged. + if (app && app->daemonAutoShieldProbed()) { + if (app->daemonAutoShieldActive()) { + ImGui::TextColored(ImVec4(0.40f, 0.78f, 0.40f, 1.0f), " %s", TR("autoshield_by_node")); + if (!app->daemonAutoShieldAddress().empty()) + ImGui::TextDisabled(" %s", app->daemonAutoShieldAddress().c_str()); + } else if (!app->daemonAutoShieldDisabledReason().empty()) { + ImGui::TextDisabled(" %s", app->daemonAutoShieldDisabledReason().c_str()); + } + } CB(TrId("use_tor", "tor"), &s_settingsState.use_tor); if (ImGui::IsItemHovered()) material::Tooltip("%s", TR("tt_tor")); if (showDaemonOptions) { diff --git a/src/util/i18n.cpp b/src/util/i18n.cpp index d1c9351..00b7e6f 100644 --- a/src/util/i18n.cpp +++ b/src/util/i18n.cpp @@ -1250,6 +1250,8 @@ void I18n::loadBuiltinEnglish() strings_["wallet_recovered_restore"] = "Restore the original file instead"; strings_["wallet_recovered_restore_sub"] = "Puts your largest untouched backup back in place, verbatim, then re-scans — slightly faster, but only as complete as that one file was. Your current file is kept as a dated backup either way."; strings_["wallet_recovered_notify"] = "Your wallet file needed a repair — your original was safely backed up. Open the app to review your options."; + strings_["wallet_degraded_notify"] = "Your wallet opened in reduced-function mode: existing funds are safe and spendable, but creating new addresses and shielding are disabled. Back up your seed phrase and restore it to fully repair the wallet."; + strings_["autoshield_by_node"] = "Auto-shield is handled by your node"; // In-dialog recovery lifecycle (Offer → Working → Done/Failed) + disclosures. strings_["wallet_recovery_working_label"] = "Working"; strings_["wallet_recovery_done"] = "Done";