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
parent ef8ceeaf9a
commit 90e02b1ddd
5 changed files with 76 additions and 13 deletions

View File

@@ -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<bool> 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.<ts>.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)

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;

View File

@@ -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.<unixtime>.bak", return its timestamp; else -1.
inline long long parseWalletSalvageBakTs(const std::string& name)
{

View File

@@ -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) {

View File

@@ -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";