feat(seed,console): persistent re-nudging seed backup; confirm destructive console commands
- 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 <noreply@anthropic.com>
This commit is contained in:
@@ -4654,6 +4654,12 @@ void App::renderSeedBackupDialog()
|
|||||||
seed_backup_loading_ = false;
|
seed_backup_loading_ = false;
|
||||||
if (ok) {
|
if (ok) {
|
||||||
seed_backup_phrase_ = phrase; // working copy — wiped on close
|
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) {
|
} else if (noMnemonic) {
|
||||||
seed_backup_no_mnemonic_ = true;
|
seed_backup_no_mnemonic_ = true;
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
@@ -1406,6 +1406,8 @@ private:
|
|||||||
bool seed_backup_loading_ = false;
|
bool seed_backup_loading_ = false;
|
||||||
bool seed_backup_no_mnemonic_ = false;
|
bool seed_backup_no_mnemonic_ = false;
|
||||||
bool seed_backup_reminder_in_flight_ = false; // guards the one-time backup nudge probe
|
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
|
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
|
bool large_wallet_checked_ = false; // gate: stat wallet.dat for the bloat nudge once per launch
|
||||||
|
|
||||||
|
|||||||
+20
-6
@@ -5833,7 +5833,8 @@ void App::maybeRemindSeedBackup()
|
|||||||
if (capture_mode_) return; // no live ops during a UI sweep
|
if (capture_mode_) return; // no live ops during a UI sweep
|
||||||
if (window_background_) return; // when someone can see it
|
if (window_background_) return; // when someone can see it
|
||||||
if (lite_wallet_) return; // lite has its own seed UX
|
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_.connected || !state_.encryption_state_known) return;
|
||||||
if (state_.isLocked()) return; // wait until unlocked to read it
|
if (state_.isLocked()) return; // wait until unlocked to read it
|
||||||
if (seed_backup_reminder_in_flight_) return;
|
if (seed_backup_reminder_in_flight_) return;
|
||||||
@@ -5844,16 +5845,29 @@ void App::maybeRemindSeedBackup()
|
|||||||
exportSeedPhrase([this](bool ok, bool noMnemonic, const std::string& /*phrase*/,
|
exportSeedPhrase([this](bool ok, bool noMnemonic, const std::string& /*phrase*/,
|
||||||
const std::string& /*error*/) {
|
const std::string& /*error*/) {
|
||||||
seed_backup_reminder_in_flight_ = false;
|
seed_backup_reminder_in_flight_ = false;
|
||||||
// Only mark "reminded" once we have a definitive answer, so a transient failure retries
|
if (ok) {
|
||||||
// next session rather than silently suppressing the reminder forever.
|
// The wallet HAS a mnemonic and the user hasn't backed it up yet. Don't mark "reminded"
|
||||||
if (ok || noMnemonic) {
|
// 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_->setSeedBackupReminded(true);
|
||||||
settings_->save();
|
settings_->save();
|
||||||
} else {
|
} 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);
|
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);
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1537,6 +1537,25 @@ void ConsoleTab::renderInput(ConsoleCommandExecutor& exec)
|
|||||||
focus_input_pending_ = false;
|
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<char>(std::tolower(c)); });
|
||||||
|
if (n == firstLower) return cat.commands[i].destructive;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
} // namespace
|
||||||
|
|
||||||
bool ConsoleTab::submitConsoleCommand(ConsoleCommandExecutor& exec, const std::string& cmd)
|
bool ConsoleTab::submitConsoleCommand(ConsoleCommandExecutor& exec, const std::string& cmd)
|
||||||
{
|
{
|
||||||
if (cmd.empty()) return false;
|
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
|
// 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.
|
// 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;
|
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); };
|
auto add = [this](const std::string& l, ConsoleChannel c) { addLine(l, c); };
|
||||||
if (first == "clear" || first == "cls") {
|
if (first == "clear" || first == "cls") {
|
||||||
// View-only clear — NEVER forwarded (the lite backend's `clear` wipes tx history).
|
// 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.
|
// 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"),
|
addLine(exec.hasRpcReference() ? TR("console_not_connected") : TR("console_not_connected_lite"),
|
||||||
ConsoleChannel::Error);
|
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 {
|
} else {
|
||||||
|
destructive_confirm_cmd_.clear();
|
||||||
exec.submit(cmd);
|
exec.submit(cmd);
|
||||||
}
|
}
|
||||||
return true;
|
return true;
|
||||||
@@ -2185,6 +2214,7 @@ void ConsoleTab::clear()
|
|||||||
wrap_cache_.reset();
|
wrap_cache_.reset();
|
||||||
vis_flat_valid_ = false;
|
vis_flat_valid_ = false;
|
||||||
stop_confirm_pending_ = false; // a pending 'stop' confirmation is cancelled by clearing
|
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);
|
addLine(TR("console_cleared"), ConsoleChannel::Info);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -161,6 +161,7 @@ private:
|
|||||||
int history_index_ = -1;
|
int history_index_ = -1;
|
||||||
char input_buffer_[4096] = {0};
|
char input_buffer_[4096] = {0};
|
||||||
bool stop_confirm_pending_ = false; // 'stop' typed once, awaiting a confirming second 'stop'
|
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)
|
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)
|
// (log-ingestion cursors + result queue moved to the ConsoleCommandExecutor)
|
||||||
|
|
||||||
|
|||||||
@@ -357,6 +357,7 @@ void I18n::loadBuiltinEnglish()
|
|||||||
strings_["seed_backup_save_failed"] = "Could not write ";
|
strings_["seed_backup_save_failed"] = "Could not write ";
|
||||||
strings_["seed_backup_close"] = "Close";
|
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"] = "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…";
|
strings_["seed_migrate_button"] = "Migrate to seed…";
|
||||||
// Multi-wallet: wallet-files list + switcher
|
// Multi-wallet: wallet-files list + switcher
|
||||||
strings_["wallets_button"] = "Wallets…";
|
strings_["wallets_button"] = "Wallets…";
|
||||||
@@ -1908,6 +1909,7 @@ void I18n::loadBuiltinEnglish()
|
|||||||
strings_["console_last_error"] = "Last error:";
|
strings_["console_last_error"] = "Last error:";
|
||||||
strings_["console_not_connected_lite"] = "Error: no wallet open";
|
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_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_capturing_output"] = "Capturing daemon output...";
|
||||||
strings_["console_clear"] = "Clear";
|
strings_["console_clear"] = "Clear";
|
||||||
strings_["console_clear_console"] = "Clear Console";
|
strings_["console_clear_console"] = "Clear Console";
|
||||||
|
|||||||
Reference in New Issue
Block a user