From ae4012adb7e469e6f799956b08839e5394a1fce0 Mon Sep 17 00:00:00 2001 From: DanS Date: Mon, 19 Oct 2026 22:05:11 -0500 Subject: [PATCH] feat(recovery): offer legacy-fund recovery when a degraded wallet can't shield its transparent funds MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 1 of automating legacy wallet.dat migration: a persistent, actionable banner that detects a wallet whose transparent funds are STUCK and offers the migrate-to-seed recovery flow (showSeedMigrationDialog). Corrected trigger (the important part): it fires ONLY when wallet_degraded_ is set — i.e. z_getnewaddress failed with "HD seed not found", so the wallet genuinely cannot create a shielded address to move its funds — AND there is a transparent balance to move. It is NOT driven by "the wallet has no mnemonic": a non-mnemonic but functional wallet (one that self-healed with a retrofit HD seed on load) has wallet_degraded_ == false, can still shield/spend, and is deliberately never flagged — matching the existing "legacy wallets are not nagged" policy. Full-node only. Implemented in the already-unit-tested node banner: new NodeBannerReason::LegacyFundsStuck + NodeBannerAction::RecoverLegacyFunds, gated on a new legacy_funds_stuck input (App computes it from wallet_degraded_ + transparentBalance). NoPeers still takes priority (can't recover before the node sees the chain); lite never shows it. Pure decision tests added: fires when stuck, never for a functional wallet, yields to NoPeers, suppressed on lite. Co-Authored-By: Claude Opus 4.8 --- src/app.cpp | 9 +++++++++ src/ui/node_status_banner.h | 18 ++++++++++++++++++ src/util/i18n.cpp | 3 +++ tests/test_phase4.cpp | 27 +++++++++++++++++++++++++++ 4 files changed, 57 insertions(+) diff --git a/src/app.cpp b/src/app.cpp index 73fdbe1..59037f1 100644 --- a/src/app.cpp +++ b/src/app.cpp @@ -2716,6 +2716,10 @@ void App::renderNodeStatusBanner() in.node_busy = busy.busy; in.node_busy_detail = busy.compact; // one line; the overlay and the quit prompt explain more in.auth_failed = rpc_auth_failed_; + // Corrected trigger: ONLY a degraded wallet (wallet_degraded_ — z_getnewaddress fails, can't shield) that + // actually holds transparent funds. A merely-non-mnemonic but functional wallet has wallet_degraded_ == + // false and is never flagged here. Full-node only (in.lite returns early regardless). + in.legacy_funds_stuck = !isLiteBuild() && wallet_degraded_ && state_.transparentBalance > 0.0001; const ui::NodeBannerState banner = ui::evaluateNodeStatusBanner(in); if (!banner.show) return; @@ -2754,6 +2758,8 @@ void App::renderNodeStatusBanner() title = busy.title.c_str(); icon = ICON_MD_HOURGLASS_EMPTY; break; case ui::NodeBannerReason::AuthFailed: title = TR("node_banner_auth_title"); icon = ICON_MD_LOCK; break; + case ui::NodeBannerReason::LegacyFundsStuck: + title = TR("node_banner_legacy_title"); icon = ICON_MD_KEY; break; case ui::NodeBannerReason::FullNodeOffline: default: title = TR("node_banner_offline_title"); icon = ICON_MD_CLOUD_OFF; break; @@ -2763,6 +2769,7 @@ void App::renderNodeStatusBanner() if (banner.action == ui::NodeBannerAction::Reconnect) actionLabel = TR("node_banner_reconnect"); else if (banner.action == ui::NodeBannerAction::RestartNode) actionLabel = TR("node_banner_restart"); else if (banner.action == ui::NodeBannerAction::OpenConfigFolder) actionLabel = TR("node_banner_open_config"); + else if (banner.action == ui::NodeBannerAction::RecoverLegacyFunds) actionLabel = TR("node_banner_recover"); // The NoPeers case deliberately carries no passthrough detail (connection_status is just // "Connected" there, which reads as a contradiction under the "no peers" headline). Show a @@ -2770,6 +2777,7 @@ void App::renderNodeStatusBanner() std::string detailText; if (banner.reason == ui::NodeBannerReason::NoPeers) detailText = TR("node_banner_no_peers_detail"); else if (banner.reason == ui::NodeBannerReason::AuthFailed) detailText = TR("node_banner_auth_detail"); + else if (banner.reason == ui::NodeBannerReason::LegacyFundsStuck) detailText = TR("node_banner_legacy_detail"); else detailText = banner.detail; const float padX = ui::Layout::spacingLg(); @@ -2859,6 +2867,7 @@ void App::renderNodeStatusBanner() if (m::TactileButton(actionLabel, ImVec2(btnW, btnH))) { if (banner.action == ui::NodeBannerAction::RestartNode) restartDaemon(); else if (banner.action == ui::NodeBannerAction::Reconnect) tryConnect(); + else if (banner.action == ui::NodeBannerAction::RecoverLegacyFunds) showSeedMigrationDialog(); else if (banner.action == ui::NodeBannerAction::OpenConfigFolder) { const std::string conf = rpc::Connection::getDefaultConfPath(); util::Platform::openFolder(std::filesystem::path(conf).parent_path().string(), diff --git a/src/ui/node_status_banner.h b/src/ui/node_status_banner.h index 926aa70..25c0f56 100644 --- a/src/ui/node_status_banner.h +++ b/src/ui/node_status_banner.h @@ -23,6 +23,7 @@ enum class NodeBannerAction { Reconnect, // full node: re-run the RPC connect state machine (App::tryConnect) RestartNode, // full node: the embedded daemon crashed & auto-restart gave up (App::restartDaemon) OpenConfigFolder,// external node: open the folder holding DRAGONX.conf so the user can fix the login + RecoverLegacyFunds, // a degraded legacy wallet with stuck transparent funds: open migrate-to-seed }; // Why the banner is up. App maps this to a translated headline; `detail` carries the live, @@ -35,6 +36,8 @@ enum class NodeBannerReason { NoPeers, // connected to the daemon but no peer-derived tip (0 peers) — stalled, stale NodeBusy, // the node is up but busy (rebuilding witnesses / catching up / not answering) AuthFailed, // the node rejected the wallet's RPC login (bad rpcuser/rpcpassword in the conf) + LegacyFundsStuck, // a DEGRADED legacy wallet (can't derive a z-address) holding transparent funds it + // therefore can't shield/spend — offer the migrate-to-seed recovery }; struct NodeBannerState { @@ -75,6 +78,11 @@ struct NodeBannerInputs { bool auth_failed = false; // the node rejected the wallet's RPC login (bad rpcuser/rpcpassword) + // The wallet opened DEGRADED (no usable HD seed, so z_getnewaddress / shielding fail) AND it holds + // transparent funds that are therefore stuck. App sets this ONLY from wallet_degraded_ + a transparent + // balance — never from merely-non-mnemonic status, which is a functional wallet and must not be flagged. + bool legacy_funds_stuck = false; + std::string connection_status; // human-readable status line (already translated) std::string daemon_last_error; // DaemonController::lastError() (may be empty) std::string lite_open_error; // lite: last wallet-open failure reason @@ -134,6 +142,16 @@ inline NodeBannerState evaluateNodeStatusBanner(const NodeBannerInputs& in) { ? NodeBannerAction::RestartNode : NodeBannerAction::None; s.detail.clear(); + return s; + } + // Connected and synced, but a degraded legacy wallet can't shield its transparent funds. Offer the + // recovery flow. Checked after NoPeers (can't recover until the node can see the chain anyway). + if (in.legacy_funds_stuck) { + s.show = true; + s.severity = NodeBannerSeverity::Warning; + s.reason = NodeBannerReason::LegacyFundsStuck; + s.action = NodeBannerAction::RecoverLegacyFunds; + s.detail.clear(); } return s; } diff --git a/src/util/i18n.cpp b/src/util/i18n.cpp index b24ecef..1f4ceab 100644 --- a/src/util/i18n.cpp +++ b/src/util/i18n.cpp @@ -1639,6 +1639,9 @@ void I18n::loadBuiltinEnglish() strings_["node_banner_open_config"] = "Open config folder"; strings_["node_banner_auth_title"] = "Wallet can't sign in to the node"; strings_["node_banner_auth_detail"] = "The node rejected the wallet's saved login. Restart the node, or open the config folder to fix the login in DRAGONX.conf."; + strings_["node_banner_legacy_title"] = "Your transparent funds can't be moved yet"; + strings_["node_banner_legacy_detail"] = "This wallet has no usable seed, so it can't create a shielded address to move its transparent funds. Recover them into a fresh seed-backed wallet."; + strings_["node_banner_recover"] = "Recover funds"; // Refresh-staleness badge (W6-2) on the Total Balance card. strings_["data_stale_prefix"] = "Updated"; strings_["data_stale_tooltip"] = diff --git a/tests/test_phase4.cpp b/tests/test_phase4.cpp index a37ad6b..d0ce1bd 100644 --- a/tests/test_phase4.cpp +++ b/tests/test_phase4.cpp @@ -3808,6 +3808,33 @@ void testNodeStatusBanner() NodeBannerInputs open; open.lite = true; open.connected = true; EXPECT_TRUE(!evaluateNodeStatusBanner(open).show); } + // Legacy-funds-stuck: a connected+synced degraded wallet with stuck transparent funds offers recovery. + { + NodeBannerInputs in; + in.connected = true; in.tip_known = true; in.blocks = 100; + in.legacy_funds_stuck = true; + NodeBannerState s = evaluateNodeStatusBanner(in); + EXPECT_TRUE(s.show); + EXPECT_TRUE(s.severity == NodeBannerSeverity::Warning); + EXPECT_TRUE(s.reason == NodeBannerReason::LegacyFundsStuck); + EXPECT_TRUE(s.action == NodeBannerAction::RecoverLegacyFunds); + } + // A functional wallet (legacy_funds_stuck false — e.g. non-mnemonic but can still shield) is NEVER flagged. + { + NodeBannerInputs in; in.connected = true; in.tip_known = true; in.blocks = 100; + EXPECT_TRUE(!evaluateNodeStatusBanner(in).show); + } + // NoPeers takes priority: can't recover before the node can see the chain. + { + NodeBannerInputs in; + in.connected = true; in.tip_known = false; in.blocks = 100; in.legacy_funds_stuck = true; + EXPECT_TRUE(evaluateNodeStatusBanner(in).reason == NodeBannerReason::NoPeers); + } + // Lite never shows it (the lite branch returns first; App also gates the input to full-node). + { + NodeBannerInputs in; in.lite = true; in.connected = true; in.legacy_funds_stuck = true; + EXPECT_TRUE(!evaluateNodeStatusBanner(in).show); + } } void testStalenessBadge()