feat(recovery): offer legacy-fund recovery when a degraded wallet can't shield its transparent funds

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 <noreply@anthropic.com>
This commit is contained in:
2026-10-19 22:05:11 -05:00
co-authored by Claude Opus 4.8
parent 6c2a277751
commit ae4012adb7
4 changed files with 57 additions and 0 deletions
+9
View File
@@ -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(),