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>
195 lines
10 KiB
C++
195 lines
10 KiB
C++
#pragma once
|
|
|
|
#include <string>
|
|
|
|
// Persistent node-connectivity banner shown at the top of the content column when the wallet
|
|
// cannot reach its node. Distinct from the transient toast notifications: it stays visible for
|
|
// as long as the fault persists, so an offline wallet is never silently mistaken for a working
|
|
// one. The decision (whether to show, how severe, which action) is a pure function of a state
|
|
// snapshot so it can be unit-tested; App::renderNodeStatusBanner() feeds it the live state and
|
|
// draws the strip. See src/app.cpp.
|
|
namespace dragonx::ui {
|
|
|
|
// Visual weight. Warning (amber) = recoverable / a reconnect is offered; Error (red) = a hard
|
|
// fault the user must act on (the daemon gave up crashing, or a lite wallet failed to open).
|
|
enum class NodeBannerSeverity {
|
|
Warning,
|
|
Error,
|
|
};
|
|
|
|
// What the banner's action button does. App maps this to the concrete call.
|
|
enum class NodeBannerAction {
|
|
None, // no button — nothing the user can usefully do from here
|
|
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,
|
|
// already-human-readable status text (connection_status_ / daemon lastError / lite open error).
|
|
enum class NodeBannerReason {
|
|
None,
|
|
FullNodeOffline, // a reachable node was lost, or never came up; reconnect offered
|
|
DaemonCrashed, // the embedded daemon crashed repeatedly and auto-restart stopped
|
|
LiteOpenFailed, // lite build: the wallet failed to open
|
|
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 {
|
|
bool show = false;
|
|
NodeBannerSeverity severity = NodeBannerSeverity::Warning;
|
|
NodeBannerReason reason = NodeBannerReason::None;
|
|
NodeBannerAction action = NodeBannerAction::None;
|
|
std::string detail; // passthrough status/error text (may be empty)
|
|
};
|
|
|
|
// Snapshot of the connection state the banner reads. Plain values so the decision is testable
|
|
// without an App instance.
|
|
struct NodeBannerInputs {
|
|
bool lite = false; // lite build (no embedded daemon / RPC)
|
|
bool connected = false; // state_.connected — the master "online" flag
|
|
bool warming_up = false; // daemon reachable, RPC warmup (code -28)
|
|
bool daemon_initializing = false; // daemon launching / block index loading
|
|
bool connection_in_progress = false; // a connect attempt is actively running
|
|
|
|
// Peer connectivity: a full node connected to the daemon but with NO peer-derived tip
|
|
// (longestchain == 0 -> tip_known false) has no peers, so it silently stalls at a stale height
|
|
// while balance/sync look "done". `blocks` gates the check so we don't flag the brief window
|
|
// before the first chain-info refresh. (audit: no-peer-banner)
|
|
bool tip_known = true; // sync.tip_known — a peer-derived network tip is known
|
|
int blocks = 0; // sync.blocks — >0 once chain info has been fetched
|
|
|
|
// Full-node embedded-daemon crash signal.
|
|
bool using_embedded_daemon = false;
|
|
bool has_daemon_controller = false;
|
|
bool daemon_running = false;
|
|
int daemon_crash_count = 0;
|
|
|
|
// The node is running but busy with something it can't be interrupted out of (a witness rebuild, the
|
|
// startup replay) or too busy to answer RPC. Wallet data may be stale; a reconnect would only add
|
|
// requests, so the banner offers no action. node_busy_detail is the live, translated description.
|
|
bool node_busy = false;
|
|
std::string node_busy_detail;
|
|
|
|
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
|
|
};
|
|
|
|
// Auto-restart give-up threshold — mirrors the crash cap in app_network.cpp's connect loop.
|
|
inline constexpr int kNodeBannerCrashGiveUpCount = 3;
|
|
|
|
inline NodeBannerState evaluateNodeStatusBanner(const NodeBannerInputs& in) {
|
|
NodeBannerState s;
|
|
|
|
if (in.lite) {
|
|
// Lite has no daemon/RPC; "online" == wallet open. Only a genuine open failure is a
|
|
// fault worth a persistent banner (a not-yet-created wallet is handled by the normal
|
|
// "No wallet open" prompt, and leaves lite_open_error empty).
|
|
if (!in.connected && !in.lite_open_error.empty()) {
|
|
s.show = true;
|
|
s.severity = NodeBannerSeverity::Error;
|
|
s.reason = NodeBannerReason::LiteOpenFailed;
|
|
s.action = NodeBannerAction::None;
|
|
s.detail = in.lite_open_error;
|
|
}
|
|
return s;
|
|
}
|
|
|
|
// Full node, busy. Before the startup-phase returns below: the overlay sets daemon_initializing while
|
|
// the node is busy too, and the banner must still say why the wallet isn't updating. No action: a
|
|
// "Reconnect" would only queue more requests behind the busy node.
|
|
if (in.node_busy) {
|
|
s.show = true;
|
|
s.severity = NodeBannerSeverity::Warning;
|
|
s.reason = NodeBannerReason::NodeBusy;
|
|
s.action = NodeBannerAction::None;
|
|
s.detail = in.node_busy_detail;
|
|
return s;
|
|
}
|
|
|
|
// Full node. In an expected startup phase → the loading/warmup overlay owns the screen, so no
|
|
// banner. An active connect attempt likewise shows progress, not an error — don't flicker a
|
|
// banner over it. (These return even if `connected` has flipped early, matching prior behavior.)
|
|
if (in.warming_up || in.daemon_initializing) return s;
|
|
if (in.connection_in_progress) return s;
|
|
|
|
// Connected to the daemon (RPC works) but with no peer-derived tip → the node has no peers and
|
|
// cannot learn about new blocks; it silently stalls at a stale height while everything looks
|
|
// synced. Surface it as a recoverable warning. A plain reconnect won't help, but restarting the
|
|
// embedded daemon re-triggers peer discovery (it re-reads the -addnode seeds), so offer that when
|
|
// we own the node; an external node is the user's own to restart, so no button there. The detail
|
|
// is left empty: here connection_status is just "Connected", which would contradict the
|
|
// "no peers" headline — App renders a translated, actionable hint for this reason instead.
|
|
if (in.connected) {
|
|
if (!in.tip_known && in.blocks > 0) {
|
|
s.show = true;
|
|
s.severity = NodeBannerSeverity::Warning;
|
|
s.reason = NodeBannerReason::NoPeers;
|
|
s.action = (in.using_embedded_daemon && in.has_daemon_controller)
|
|
? 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;
|
|
}
|
|
|
|
// Genuinely offline. Distinguish "the embedded daemon crashed and we stopped retrying" (a
|
|
// hard fault needing a manual restart) from an ordinary lost/failed connection (retryable).
|
|
if (in.using_embedded_daemon && in.has_daemon_controller && !in.daemon_running &&
|
|
in.daemon_crash_count >= kNodeBannerCrashGiveUpCount) {
|
|
s.show = true;
|
|
s.severity = NodeBannerSeverity::Error;
|
|
s.reason = NodeBannerReason::DaemonCrashed;
|
|
s.action = NodeBannerAction::RestartNode;
|
|
s.detail = !in.daemon_last_error.empty() ? in.daemon_last_error : in.connection_status;
|
|
return s;
|
|
}
|
|
|
|
// The node rejected the wallet's RPC login — retrying won't help (the creds are wrong). Restarting
|
|
// the embedded node regenerates its cookie/creds; for an external node the user fixes DRAGONX.conf,
|
|
// so offer to open its folder. App renders a translated, actionable detail (connection_status here
|
|
// is the jargon "Auth failed…" line).
|
|
if (in.auth_failed) {
|
|
s.show = true;
|
|
s.severity = NodeBannerSeverity::Error;
|
|
s.reason = NodeBannerReason::AuthFailed;
|
|
s.action = (in.using_embedded_daemon && in.has_daemon_controller)
|
|
? NodeBannerAction::RestartNode
|
|
: NodeBannerAction::OpenConfigFolder;
|
|
s.detail.clear();
|
|
return s;
|
|
}
|
|
|
|
s.show = true;
|
|
s.severity = NodeBannerSeverity::Warning;
|
|
s.reason = NodeBannerReason::FullNodeOffline;
|
|
s.action = NodeBannerAction::Reconnect;
|
|
s.detail = in.connection_status;
|
|
return s;
|
|
}
|
|
|
|
} // namespace dragonx::ui
|