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:
Binary file not shown.
@@ -1294,6 +1294,8 @@
|
||||
"sweep_to": "Gefegt an:",
|
||||
"sweep_toggle": "In meine Wallet fegen (Schlüssel nicht behalten)",
|
||||
"sweep_tx": "Transaktion:",
|
||||
"switch_corrupt_body": "Diese Wallet scheint beschädigt zu sein – der Knoten konnte sie nicht öffnen. Aus einem Backup wiederherstellen, neu erstellen oder eine Reparatur versuchen.",
|
||||
"switch_corrupt_repair": "Reparatur versuchen (Salvage)",
|
||||
"switch_progress_background": "Im Hintergrund fortsetzen",
|
||||
"switch_progress_default_wallet": "Standard-Wallet",
|
||||
"switch_progress_elapsed": "Vergangen",
|
||||
|
||||
@@ -1294,6 +1294,8 @@
|
||||
"sweep_to": "Barrido a:",
|
||||
"sweep_toggle": "Barrer a mi monedero (no conservar la clave)",
|
||||
"sweep_tx": "Transacción:",
|
||||
"switch_corrupt_body": "Esta cartera parece dañada: el nodo no pudo abrirla. Restáurala desde una copia de seguridad, vuelve a crearla o intenta repararla.",
|
||||
"switch_corrupt_repair": "Intentar reparar (salvage)",
|
||||
"switch_progress_background": "Continuar en segundo plano",
|
||||
"switch_progress_default_wallet": "Cartera predeterminada",
|
||||
"switch_progress_elapsed": "Transcurrido",
|
||||
|
||||
@@ -1294,6 +1294,8 @@
|
||||
"sweep_to": "Balayé vers :",
|
||||
"sweep_toggle": "Balayer vers mon portefeuille (ne pas conserver la clé)",
|
||||
"sweep_tx": "Transaction :",
|
||||
"switch_corrupt_body": "Ce portefeuille semble corrompu — le nœud n'a pas pu l'ouvrir. Restaurez-le depuis une sauvegarde, recréez-le ou tentez de le réparer.",
|
||||
"switch_corrupt_repair": "Tenter une réparation (salvage)",
|
||||
"switch_progress_background": "Continuer en arrière-plan",
|
||||
"switch_progress_default_wallet": "Portefeuille par défaut",
|
||||
"switch_progress_elapsed": "Écoulé",
|
||||
|
||||
@@ -1294,6 +1294,8 @@
|
||||
"sweep_to": "集約先:",
|
||||
"sweep_toggle": "ウォレットに集約(鍵は保持しない)",
|
||||
"sweep_tx": "取引:",
|
||||
"switch_corrupt_body": "このウォレットは破損しているようです。ノードが開けませんでした。バックアップから復元するか、作り直すか、修復を試してください。",
|
||||
"switch_corrupt_repair": "修復を試す(salvage)",
|
||||
"switch_progress_background": "バックグラウンドで続行",
|
||||
"switch_progress_default_wallet": "既定のウォレット",
|
||||
"switch_progress_elapsed": "経過",
|
||||
|
||||
@@ -1294,6 +1294,8 @@
|
||||
"sweep_to": "쓸어담은 주소:",
|
||||
"sweep_toggle": "내 지갑으로 쓸어담기 (키 보관 안 함)",
|
||||
"sweep_tx": "거래:",
|
||||
"switch_corrupt_body": "이 지갑이 손상된 것 같습니다. 노드가 열 수 없습니다. 백업에서 복원하거나 다시 만들거나 복구를 시도하세요.",
|
||||
"switch_corrupt_repair": "복구 시도(salvage)",
|
||||
"switch_progress_background": "백그라운드에서 계속",
|
||||
"switch_progress_default_wallet": "기본 지갑",
|
||||
"switch_progress_elapsed": "경과",
|
||||
|
||||
@@ -1294,6 +1294,8 @@
|
||||
"sweep_to": "Varrido para:",
|
||||
"sweep_toggle": "Varrer para minha carteira (não manter a chave)",
|
||||
"sweep_tx": "Transação:",
|
||||
"switch_corrupt_body": "Esta carteira parece corrompida — o nó não conseguiu abri-la. Restaure de um backup, recrie-a ou tente repará-la.",
|
||||
"switch_corrupt_repair": "Tentar reparar (salvage)",
|
||||
"switch_progress_background": "Continuar em segundo plano",
|
||||
"switch_progress_default_wallet": "Carteira padrão",
|
||||
"switch_progress_elapsed": "Decorrido",
|
||||
|
||||
@@ -1294,6 +1294,8 @@
|
||||
"sweep_to": "Переведено на:",
|
||||
"sweep_toggle": "Перевести в мой кошелёк (не сохранять ключ)",
|
||||
"sweep_tx": "Транзакция:",
|
||||
"switch_corrupt_body": "Похоже, этот кошелёк повреждён — узел не смог его открыть. Восстановите из резервной копии, создайте заново или попробуйте восстановить.",
|
||||
"switch_corrupt_repair": "Попробовать восстановить (salvage)",
|
||||
"switch_progress_background": "Продолжить в фоне",
|
||||
"switch_progress_default_wallet": "Кошелёк по умолчанию",
|
||||
"switch_progress_elapsed": "Прошло",
|
||||
|
||||
@@ -1294,6 +1294,8 @@
|
||||
"sweep_to": "归集到:",
|
||||
"sweep_toggle": "归集到我的钱包(不保留密钥)",
|
||||
"sweep_tx": "交易:",
|
||||
"switch_corrupt_body": "此钱包似乎已损坏——节点无法打开它。请从备份恢复、重新创建,或尝试修复。",
|
||||
"switch_corrupt_repair": "尝试修复(salvage)",
|
||||
"switch_progress_background": "在后台继续",
|
||||
"switch_progress_default_wallet": "默认钱包",
|
||||
"switch_progress_elapsed": "已用时",
|
||||
|
||||
@@ -3919,6 +3919,15 @@ void App::renderSwitchStopDaemonDialog()
|
||||
// rather than repeating the title.
|
||||
ui::material::DialogWarningHeader(wallet_switch_error_.c_str());
|
||||
ImGui::Dummy(ImVec2(0, ui::Layout::spacingMd()));
|
||||
// If the node reported the target wallet as corrupt, offer a one-click -salvagewallet repair
|
||||
// (retries the switch to that wallet with recovery enabled).
|
||||
if (switch_wallet_corrupt_.load()) {
|
||||
if (ui::material::TactileButton(TR("switch_corrupt_repair"), ImVec2(240.0f * dp, 0))) {
|
||||
const std::string target = wallet_switch_target_file_;
|
||||
switchToWallet(target, /*stopDaemonConfirmed=*/true, /*salvage=*/true); // reopens the progress modal
|
||||
}
|
||||
ImGui::SameLine();
|
||||
}
|
||||
if (ui::material::TactileButton(TR("close"), ImVec2(110.0f * dp, 0))) {
|
||||
wallet_switch_dialog_open_.store(false);
|
||||
wallet_switch_phase_.store(static_cast<int>(WalletSwitchPhase::None));
|
||||
|
||||
@@ -492,8 +492,8 @@ public:
|
||||
// (rescan only if it was never synced in this datadir). Per-wallet data follows automatically
|
||||
// via the identity-scoped caches (P1). No-op if that wallet is already active.
|
||||
// stopDaemonConfirmed: skip the "stop the running node?" confirmation (set true when re-entered from
|
||||
// that dialog). The confirmation only appears when the node to restart was ADOPTED (externally running).
|
||||
void switchToWallet(const std::string& walletFile, bool stopDaemonConfirmed = false);
|
||||
// that dialog). salvage: start the target node with -salvagewallet (repair a corrupt wallet).
|
||||
void switchToWallet(const std::string& walletFile, bool stopDaemonConfirmed = false, bool salvage = false);
|
||||
// Main-thread continuation: if a wallet switch's daemon failed to start, revert active_wallet_file.
|
||||
void processWalletSwitchRevert();
|
||||
|
||||
@@ -780,6 +780,10 @@ private:
|
||||
std::atomic<bool> wallet_switch_dialog_open_{false};
|
||||
std::string wallet_switch_error_;
|
||||
double wallet_switch_started_time_ = 0.0; // ImGui::GetTime() captured on first progress frame (elapsed)
|
||||
// Set when a failed switch's node output indicates the target wallet is CORRUPT — the Failed modal then
|
||||
// offers a one-click "-salvagewallet" repair, retrying the switch to wallet_switch_target_file_.
|
||||
std::atomic<bool> switch_wallet_corrupt_{false};
|
||||
std::string wallet_switch_target_file_; // the wallet we were switching TO (for a salvage retry)
|
||||
|
||||
// Set by the deleteBlockchainData worker (item count); the main loop surfaces a completion toast
|
||||
// and resets it to -1. Atomic because the worker thread writes it and the UI thread reads/clears it.
|
||||
|
||||
@@ -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()) {
|
||||
|
||||
@@ -121,6 +121,11 @@ void DaemonController::setZapOnNextStart(bool enabled)
|
||||
daemon_->setZapOnNextStart(enabled);
|
||||
}
|
||||
|
||||
void DaemonController::setSalvageOnNextStart(bool enabled)
|
||||
{
|
||||
daemon_->setSalvageOnNextStart(enabled);
|
||||
}
|
||||
|
||||
bool DaemonController::zapOnNextStart() const
|
||||
{
|
||||
return daemon_->zapOnNextStart();
|
||||
|
||||
@@ -107,6 +107,7 @@ public:
|
||||
bool rescanOnNextStart() const;
|
||||
void setZapOnNextStart(bool enabled);
|
||||
bool zapOnNextStart() const;
|
||||
void setSalvageOnNextStart(bool enabled);
|
||||
|
||||
static ShutdownDecision evaluateShutdownPolicy(bool hasDaemon,
|
||||
bool externalDaemonDetected,
|
||||
|
||||
@@ -524,9 +524,16 @@ bool EmbeddedDaemon::start(const std::string& binary_path)
|
||||
args.push_back("-wallet=" + wallet_file_);
|
||||
}
|
||||
|
||||
// Add wallet-repair flag if requested (one-shot). -zapwallettxes=2 wipes all wallet tx/note
|
||||
// records and rebuilds them from the chain; it implies -rescan, so don't also pass -rescan.
|
||||
if (zap_on_next_start_.exchange(false)) {
|
||||
// Add wallet-repair flag if requested (one-shot). Precedence: salvage > zap > rescan; each implies a
|
||||
// rescan in the daemon, so we don't stack them.
|
||||
if (salvage_on_next_start_.exchange(false)) {
|
||||
// -salvagewallet recovers readable keypairs from a corrupt wallet.dat; the daemon then implies -rescan.
|
||||
DEBUG_LOGF("[INFO] Adding -salvagewallet flag to recover a corrupt wallet\n");
|
||||
args.push_back("-salvagewallet");
|
||||
zap_on_next_start_.store(false);
|
||||
rescan_on_next_start_.store(false);
|
||||
} else if (zap_on_next_start_.exchange(false)) {
|
||||
// -zapwallettxes=2 wipes all wallet tx/note records and rebuilds them from the chain (implies -rescan).
|
||||
DEBUG_LOGF("[INFO] Adding -zapwallettxes=2 flag for wallet repair (zap & rebuild)\n");
|
||||
args.push_back("-zapwallettxes=2");
|
||||
rescan_on_next_start_.store(false); // implied by zap; avoid redundant -rescan
|
||||
|
||||
@@ -201,6 +201,11 @@ public:
|
||||
void setZapOnNextStart(bool v) { zap_on_next_start_ = v; }
|
||||
bool zapOnNextStart() const { return zap_on_next_start_.load(); }
|
||||
|
||||
// -salvagewallet: attempt to recover keys from a corrupt wallet.dat on startup (implies -rescan in
|
||||
// the daemon). One-shot, consumed on the next start. Used to repair a wallet a switch flagged corrupt.
|
||||
void setSalvageOnNextStart(bool v) { salvage_on_next_start_ = v; }
|
||||
bool salvageOnNextStart() const { return salvage_on_next_start_.load(); }
|
||||
|
||||
/**
|
||||
* @brief One-shot isolated-datadir override for the NEXT start(): run the daemon against a
|
||||
* different datadir (with its own DRAGONX.conf) plus the given extra args. Used by the
|
||||
@@ -274,6 +279,7 @@ private:
|
||||
std::atomic<int> crash_count_{0}; // consecutive crash counter
|
||||
std::atomic<bool> rescan_on_next_start_{false}; // -rescan flag for next start
|
||||
std::atomic<bool> zap_on_next_start_{false}; // -zapwallettxes=2 flag for next start
|
||||
std::atomic<bool> salvage_on_next_start_{false}; // -salvagewallet flag for next start
|
||||
std::string override_datadir_; // one-shot: -datadir for the next start
|
||||
std::vector<std::string> override_extra_args_; // one-shot: extra args for the next start
|
||||
bool skip_port_check_ = false; // isolated instance on a non-default port
|
||||
|
||||
@@ -1057,6 +1057,8 @@ void I18n::loadBuiltinEnglish()
|
||||
strings_["switch_progress_elapsed"] = "Elapsed";
|
||||
strings_["switch_progress_default_wallet"] = "Default wallet";
|
||||
strings_["switch_progress_external_wallet"] = "External wallet";
|
||||
strings_["switch_corrupt_body"] = "This wallet appears corrupt — the node couldn't open it. Restore it from a backup, re-create it, or try to repair it.";
|
||||
strings_["switch_corrupt_repair"] = "Try to repair (salvage)";
|
||||
|
||||
// Receive Tab
|
||||
strings_["receiving_addresses"] = "Your Receiving Addresses";
|
||||
|
||||
Reference in New Issue
Block a user