feat(wallet): detect a corrupt target wallet on switch + offer -salvagewallet repair

When a switch fails, the failure modal now distinguishes a CORRUPT target wallet
from other failures and offers a one-click repair:
- The switch worker watermarks the node's captured console output before the
  start and, if the node dies in init, scans this start's output for a corruption
  signature ("Failed to rename … .bak" / "salvage failed" / "wallet.dat corrupt" /
  "Error loading wallet") → sets switch_wallet_corrupt_.
- The Failed modal then shows an accurate "this wallet appears corrupt" message
  (instead of the generic "Couldn't open that wallet") plus a "Try to repair
  (salvage)" button that retries the switch with the target node started under
  -salvagewallet (recovers readable keypairs; implies -rescan).
- EmbeddedDaemon::setSalvageOnNextStart (one-shot, precedence salvage > zap >
  rescan) + controller forwarder; switchToWallet gains a salvage arg.

Salvage operates only on the corrupt target (never the good wallet, no fund
movement). Adversarially verified: output isolation, one-shot lifecycle, state
handling, re-entrancy, no success-path regression.

i18n (EN + 8 languages) + 2 new CJK glyphs baked into the subset font.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-07-15 23:40:33 -05:00
parent faf77de9ce
commit d9911a9bc9
17 changed files with 92 additions and 14 deletions

View File

@@ -189,6 +189,17 @@ static WarmupText translateWarmup(const std::string& raw)
return {raw.c_str(), ""};
}
// A node told to open a wallet it can't prints one of these to its console: the BDB corrupt-wallet
// recovery ("Failed to rename … .bak", "wallet.dat corrupt, salvage failed") or a generic load error.
// Used to offer a -salvagewallet repair when a switch fails because the target wallet is corrupt.
static bool walletOutputLooksCorrupt(const std::string& out)
{
return out.find("Failed to rename") != std::string::npos
|| out.find("salvage failed") != std::string::npos
|| out.find("wallet.dat corrupt") != std::string::npos
|| out.find("Error loading wallet") != std::string::npos;
}
// Phrases dragonxd prints to its console while initializing, in the order translateWarmup()
// understands them. The most recent matching console line tells us which stage the node is in
// even when the RPC probe just times out (no -28 reply to read).
@@ -1029,7 +1040,7 @@ void App::updateWalletIndexForActiveWallet(bool markOpened)
if (wallet_index_.upsert(e)) wallet_index_.save();
}
void App::switchToWallet(const std::string& walletFile, bool stopDaemonConfirmed)
void App::switchToWallet(const std::string& walletFile, bool stopDaemonConfirmed, bool salvage)
{
if (!supportsFullNodeLifecycleActions()) {
ui::Notifications::instance().warning("Full-node lifecycle actions are unavailable in lite build");
@@ -1079,8 +1090,10 @@ void App::switchToWallet(const std::string& walletFile, bool stopDaemonConfirmed
const std::string prevWallet = settings_->getActiveWalletFile(); // for revert if the new one fails
wallet_switch_prev_file_ = prevWallet;
wallet_switch_target_file_ = walletFile; // remembered for a "salvage" retry from the failure modal
wallet_switch_failed_.store(false);
switch_stop_failed_.store(false); // reason latch for the revert message; a stale one must not leak
switch_wallet_corrupt_.store(false);
wallet_switch_pending_confirm_.store(true); // cleared on a successful connect (onConnected)
settings_->setActiveWalletFile(walletFile);
settings_->save();
@@ -1117,8 +1130,12 @@ void App::switchToWallet(const std::string& walletFile, bool stopDaemonConfirmed
wallet_switch_phase_.store(static_cast<int>(WalletSwitchPhase::Stopping));
wallet_switch_dialog_open_.store(true);
async_tasks_.submit("Switch wallet", [this, needRescan](const util::AsyncTaskManager::Token&) {
async_tasks_.submit("Switch wallet", [this, needRescan, salvage](const util::AsyncTaskManager::Token&) {
bool ok = false;
// Watermark the node's captured console output so we scan only THIS start's lines for a
// corruption signature (below), not stale output from a previous daemon.
std::size_t out_off = 0;
if (daemon_controller_) daemon_controller_->outputSince(out_off);
try {
// Stop the node so it releases the datadir .lock + RPC port before we relaunch. For an adopted
// (external) daemon this sends a graceful RPC "stop" — stopEmbeddedDaemon()'s policy would leave
@@ -1137,7 +1154,8 @@ void App::switchToWallet(const std::string& walletFile, bool stopDaemonConfirmed
// as owned (stop/isRunning/exit behave normally for the next switch and app exit).
wallet_switch_phase_.store(static_cast<int>(WalletSwitchPhase::Starting));
if (daemon_controller_) daemon_controller_->clearExternalDaemonDetected();
if (needRescan && daemon_controller_) daemon_controller_->setRescanOnNextStart(true);
if (salvage && daemon_controller_) daemon_controller_->setSalvageOnNextStart(true); // repair a corrupt wallet
else if (needRescan && daemon_controller_) daemon_controller_->setRescanOnNextStart(true); // (salvage implies rescan)
// Start ONCE. Do NOT retry-spawn: a second start while the first is still shutting down leaves
// two dragonxd holding wallet.dat against each other (BDB "Failed to rename … Error"). The
// stopDaemonForWalletSwitch() wait already ensured the old node's process is gone, so a valid
@@ -1149,6 +1167,13 @@ void App::switchToWallet(const std::string& walletFile, bool stopDaemonConfirmed
std::this_thread::sleep_for(std::chrono::milliseconds(1500));
if (!isEmbeddedDaemonRunning()) ok = false;
}
// If it died in init, scan this start's captured output for a corrupt-wallet signature so the
// failure modal can offer a repair. Give the dying node a moment to flush its final lines.
if (!ok && !shutting_down_ && daemon_controller_) {
for (int i = 0; i < 12 && !shutting_down_; ++i) std::this_thread::sleep_for(std::chrono::milliseconds(100));
if (walletOutputLooksCorrupt(daemon_controller_->outputSince(out_off)))
switch_wallet_corrupt_.store(true);
}
// Process is up and staying up — now waiting for RPC to answer (onConnected closes the modal).
if (ok) wallet_switch_phase_.store(static_cast<int>(WalletSwitchPhase::Reconnecting));
}
@@ -1181,12 +1206,15 @@ void App::processWalletSwitchRevert()
}
// Clear the crash-wedge so the (good) previous wallet's daemon is allowed to start again.
if (daemon_controller_) daemon_controller_->resetCrashCount();
// Accurate reason: "port wouldn't free" (the running node kept the connection) vs. a genuinely bad
// target wallet. The former is the adopted-external-daemon case; the latter a daemon that exited in init.
const std::string reason = switch_stop_failed_.exchange(false)
? std::string("Couldn't switch wallets — the running node didn't release its connection in time. "
"It's still on the previous wallet.")
: std::string("Couldn't open that wallet — reverted to the previous one.");
// Accurate reason: "port wouldn't free" (the running node kept the connection); "wallet is corrupt"
// (the node's recovery couldn't open it); else a generic bad-wallet failure.
const std::string reason =
switch_stop_failed_.exchange(false)
? std::string("Couldn't switch wallets — the running node didn't release its connection in time. "
"It's still on the previous wallet.")
: switch_wallet_corrupt_.load()
? std::string(TR("switch_corrupt_body"))
: std::string("Couldn't open that wallet — reverted to the previous one.");
// Surface it in the progress modal if it's still up (keep it open on Failed until the user closes);
// otherwise (the user chose "continue in background") fall back to a toast.
if (wallet_switch_dialog_open_.load()) {