From 643ac7daef6527854d3e312f8cef7775d2d5118e Mon Sep 17 00:00:00 2001 From: DanS Date: Sat, 17 Oct 2026 16:46:09 -0500 Subject: [PATCH] feat(seed,console): persistent re-nudging seed backup; confirm destructive console commands MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Seed backup: the one-time 12s toast that marked itself "reminded" the instant it fired (so a funded, unbacked wallet was never nudged again) is replaced by an actionable alert with a "Back up now" link that opens the backup dialog and persists in the bell panel. "Reminded" now means the user actually viewed the phrase (set from the backup dialog), so the nudge re-fires each session until they do, and escalates to a warning once a balance exists. - Console: fund-moving / key-revealing commands (dumpprivkey, z_exportkey, z_sendmany, z_mergetoaddress, …) now require a confirming re-entry when typed or PASTED, honoring the command reference's `destructive` flag that was previously enforced only by the reference dialog's Run button. (Output masking of key dumps in the scrollback is still a follow-up.) Co-Authored-By: Claude Opus 4.8 --- src/app.cpp | 6 ++++++ src/app.h | 2 ++ src/app_network.cpp | 26 ++++++++++++++++++++------ src/ui/windows/console_tab.cpp | 30 ++++++++++++++++++++++++++++++ src/ui/windows/console_tab.h | 1 + src/util/i18n.cpp | 2 ++ 6 files changed, 61 insertions(+), 6 deletions(-) diff --git a/src/app.cpp b/src/app.cpp index 6dfdbf3..098a427 100644 --- a/src/app.cpp +++ b/src/app.cpp @@ -4654,6 +4654,12 @@ void App::renderSeedBackupDialog() seed_backup_loading_ = false; if (ok) { seed_backup_phrase_ = phrase; // working copy — wiped on close + // The user is now looking at their seed via the dedicated backup flow — treat that as + // backed up, so the periodic nudge (maybeRemindSeedBackup) stops for good. + if (settings_ && !settings_->getSeedBackupReminded()) { + settings_->setSeedBackupReminded(true); + settings_->save(); + } } else if (noMnemonic) { seed_backup_no_mnemonic_ = true; } else { diff --git a/src/app.h b/src/app.h index 8083024..5c13184 100644 --- a/src/app.h +++ b/src/app.h @@ -1406,6 +1406,8 @@ private: bool seed_backup_loading_ = false; bool seed_backup_no_mnemonic_ = false; bool seed_backup_reminder_in_flight_ = false; // guards the one-time backup nudge probe + bool seed_backup_nudged_this_session_ = false; // nudged (or definitively classified) once this session; + // unlike the persisted "reminded" flag it re-nudges next run std::chrono::steady_clock::time_point seed_backup_reminder_retry_at_{}; // after an inconclusive probe bool large_wallet_checked_ = false; // gate: stat wallet.dat for the bloat nudge once per launch diff --git a/src/app_network.cpp b/src/app_network.cpp index 99b01c4..c9b5ad7 100644 --- a/src/app_network.cpp +++ b/src/app_network.cpp @@ -5833,7 +5833,8 @@ void App::maybeRemindSeedBackup() if (capture_mode_) return; // no live ops during a UI sweep if (window_background_) return; // when someone can see it if (lite_wallet_) return; // lite has its own seed UX - if (!settings_ || settings_->getSeedBackupReminded()) return; + if (!settings_ || settings_->getSeedBackupReminded()) return; // already backed up (saw the phrase) + if (seed_backup_nudged_this_session_) return; // already nudged/classified this run if (!state_.connected || !state_.encryption_state_known) return; if (state_.isLocked()) return; // wait until unlocked to read it if (seed_backup_reminder_in_flight_) return; @@ -5844,16 +5845,29 @@ void App::maybeRemindSeedBackup() exportSeedPhrase([this](bool ok, bool noMnemonic, const std::string& /*phrase*/, const std::string& /*error*/) { seed_backup_reminder_in_flight_ = false; - // Only mark "reminded" once we have a definitive answer, so a transient failure retries - // next session rather than silently suppressing the reminder forever. - if (ok || noMnemonic) { + if (ok) { + // The wallet HAS a mnemonic and the user hasn't backed it up yet. Don't mark "reminded" + // here — that permanent flag now means "the user has seen the phrase" (set from the backup + // dialog), so leaving it clear re-nudges next session until they actually do. Nudge once + // per session as an ACTIONABLE alert (survives in the bell panel with a "Back up now" link), + // not a 12s toast that's gone before it's read. Escalate to a warning once funds exist. + seed_backup_nudged_this_session_ = true; + const bool hasFunds = state_.totalBalance > 0.0; + ui::Notifications::instance().action( + TR("seed_backup_reminder"), + hasFunds ? ui::NotificationType::Warning : ui::NotificationType::Info, + [this]() { showSeedBackupDialog(); }, + TR("seed_backup_reminder_action"), + hasFunds ? 15.0f : 12.0f); + } else if (noMnemonic) { + // A legacy (non-mnemonic) wallet has nothing to back up this way — suppress for good. + seed_backup_nudged_this_session_ = true; settings_->setSeedBackupReminded(true); settings_->save(); } else { + // Inconclusive (busy/old daemon) — retry later this session rather than suppressing forever. seed_backup_reminder_retry_at_ = std::chrono::steady_clock::now() + std::chrono::minutes(5); } - if (ok) - ui::Notifications::instance().info(TR("seed_backup_reminder"), 12.0f); }); } diff --git a/src/ui/windows/console_tab.cpp b/src/ui/windows/console_tab.cpp index 217a9d2..bc65c1e 100644 --- a/src/ui/windows/console_tab.cpp +++ b/src/ui/windows/console_tab.cpp @@ -1537,6 +1537,25 @@ void ConsoleTab::renderInput(ConsoleCommandExecutor& exec) focus_input_pending_ = false; } +namespace { +// Is `firstLower` (a lowercased command name) flagged destructive in the command reference? The +// `destructive` flag was previously consulted only by the reference dialog's Run button; this lets the +// typed/pasted path honor it too. Scans the full-node or lite command set per the active executor. +bool IsDestructiveConsoleCommand(const std::string& firstLower, bool fullNode) +{ + const auto& cats = fullNode ? consoleCommandCategories() : liteConsoleCommandCategories(); + for (const auto& cat : cats) { + for (int i = 0; i < cat.count; ++i) { + std::string n = cat.commands[i].name ? cat.commands[i].name : ""; + std::transform(n.begin(), n.end(), n.begin(), + [](unsigned char c) { return static_cast(std::tolower(c)); }); + if (n == firstLower) return cat.commands[i].destructive; + } + } + return false; +} +} // namespace + bool ConsoleTab::submitConsoleCommand(ConsoleCommandExecutor& exec, const std::string& cmd) { if (cmd.empty()) return false; @@ -1561,6 +1580,8 @@ bool ConsoleTab::submitConsoleCommand(ConsoleCommandExecutor& exec, const std::s // pending state. Member (not a function-local static) so clear() can also cancel it — otherwise a // toolbar/context-menu clear between the two 'stop's would leave a stale arm and skip the warning. if (first != "stop") stop_confirm_pending_ = false; + // A pending destructive-command confirmation is armed on the EXACT line; any different input cancels it. + if (destructive_confirm_cmd_ != cmd) destructive_confirm_cmd_.clear(); auto add = [this](const std::string& l, ConsoleChannel c) { addLine(l, c); }; if (first == "clear" || first == "cls") { // View-only clear — NEVER forwarded (the lite backend's `clear` wipes tx history). @@ -1586,7 +1607,15 @@ bool ConsoleTab::submitConsoleCommand(ConsoleCommandExecutor& exec, const std::s // Full node connects to a daemon; the lite backend opens a wallet — word the error per variant. addLine(exec.hasRpcReference() ? TR("console_not_connected") : TR("console_not_connected_lite"), ConsoleChannel::Error); + } else if (IsDestructiveConsoleCommand(first, exec.hasRpcReference()) && + destructive_confirm_cmd_ != cmd) { + // Fund-moving / key-revealing commands (dumpprivkey, z_sendmany, z_mergetoaddress, …) get the + // same two-step confirm as 'stop' when typed or PASTED directly — previously the destructive + // flag was honored only by the reference dialog, so a pasted command ran instantly. + destructive_confirm_cmd_ = cmd; + addLine(TR("console_destructive_confirm"), ConsoleChannel::Warning); } else { + destructive_confirm_cmd_.clear(); exec.submit(cmd); } return true; @@ -2185,6 +2214,7 @@ void ConsoleTab::clear() wrap_cache_.reset(); vis_flat_valid_ = false; stop_confirm_pending_ = false; // a pending 'stop' confirmation is cancelled by clearing + destructive_confirm_cmd_.clear(); // likewise cancel a pending destructive-command confirmation addLine(TR("console_cleared"), ConsoleChannel::Info); } diff --git a/src/ui/windows/console_tab.h b/src/ui/windows/console_tab.h index c4dbf7d..6a02515 100644 --- a/src/ui/windows/console_tab.h +++ b/src/ui/windows/console_tab.h @@ -161,6 +161,7 @@ private: int history_index_ = -1; char input_buffer_[4096] = {0}; bool stop_confirm_pending_ = false; // 'stop' typed once, awaiting a confirming second 'stop' + std::string destructive_confirm_cmd_; // a destructive command typed once, awaiting an identical re-entry bool focus_input_pending_ = false; // one-shot: focus the command input next render (tab-open auto-focus) // (log-ingestion cursors + result queue moved to the ConsoleCommandExecutor) diff --git a/src/util/i18n.cpp b/src/util/i18n.cpp index a1c1e6d..556504d 100644 --- a/src/util/i18n.cpp +++ b/src/util/i18n.cpp @@ -357,6 +357,7 @@ void I18n::loadBuiltinEnglish() strings_["seed_backup_save_failed"] = "Could not write "; strings_["seed_backup_close"] = "Close"; strings_["seed_backup_reminder"] = "Your wallet has a 24-word recovery seed phrase. Back it up now in Settings → Node & Security."; + strings_["seed_backup_reminder_action"] = "Back up now"; strings_["seed_migrate_button"] = "Migrate to seed…"; // Multi-wallet: wallet-files list + switcher strings_["wallets_button"] = "Wallets…"; @@ -1908,6 +1909,7 @@ void I18n::loadBuiltinEnglish() strings_["console_last_error"] = "Last error:"; strings_["console_not_connected_lite"] = "Error: no wallet open"; strings_["console_stop_confirm_node"] = "'stop' will shut down the node and disconnect the wallet. Type 'stop' again to confirm."; + strings_["console_destructive_confirm"] = "This command can move funds or reveal secret keys. Never run a command someone else gave you. Run the exact same command again to confirm."; strings_["console_capturing_output"] = "Capturing daemon output..."; strings_["console_clear"] = "Clear"; strings_["console_clear_console"] = "Clear Console";