feat(fullnode): add "Repair Wallet" (-zapwallettxes=2) to Settings

When a note's stored record is corrupt or its tx isn't in the canonical chain,
z_sendmany fails to build a valid sapling spend proof even after a full -rescan,
because a plain rescan replays witnesses but keeps the existing tx/note records.
The zcashd repair for this is -zapwallettxes=2, which deletes all wallet tx/note
records and rebuilds them from the chain (keys/addresses preserved).

Adds a RepairWallet lifecycle operation that mirrors the existing -rescan plumbing
(one-shot zapOnNextStart flag on the embedded daemon; -zapwallettxes=2 implies and
supersedes -rescan), an App::repairWallet() that reuses the rescan status UI (so the
status bar + warmup-end completion detection apply), and a confirmed "Repair Wallet"
button + dialog in Settings → node maintenance (embedded daemon only).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-17 07:42:10 -05:00
parent 6ff80354df
commit 37c8287a12
8 changed files with 129 additions and 2 deletions

View File

@@ -2989,6 +2989,47 @@ void App::rescanBlockchain()
}); });
} }
void App::repairWallet()
{
if (!supportsFullNodeLifecycleActions()) {
ui::Notifications::instance().warning("Full-node lifecycle actions are unavailable in lite build");
return;
}
auto decision = daemon::DaemonController::evaluateLifecycleOperation(
daemon::DaemonController::LifecycleOperation::RepairWallet,
isUsingEmbeddedDaemon(), daemon_controller_ != nullptr, isEmbeddedDaemonRunning());
if (!decision.allowed) {
ui::Notifications::instance().warning(decision.warning);
return;
}
DEBUG_LOGF("[App] Starting wallet repair (-zapwallettxes=2) - stopping daemon first\n");
ui::Notifications::instance().info("Restarting daemon with -zapwallettxes=2 (wallet repair)...");
// -zapwallettxes=2 deletes and rebuilds every wallet tx/note record, then rescans the whole
// chain — so reuse the rescan status UI (status bar + warmup-end completion detection). Same
// confirmed-active gating as rescan: the first poll may still reach the pre-restart daemon.
state_.sync.rescanning = true;
rescan_confirmed_active_ = false;
state_.sync.rescan_progress = 0.0f;
state_.sync.rescan_status = decision.status;
transactions_dirty_ = true;
last_tx_block_height_ = -1;
invalidateShieldedHistoryScanProgress(true);
// Set the zap flag BEFORE stopping so it's ready when we restart
daemon_controller_->prepareLifecycleOperation(decision, settings_.get());
DEBUG_LOGF("[App] Wallet-repair flag set, zapOnNextStart=%d\n", daemon_controller_->zapOnNextStart() ? 1 : 0);
async_tasks_.submit(decision.taskName, [this, decision](const util::AsyncTaskManager::Token& token) {
DEBUG_LOGF("[App] Stopping daemon for wallet repair...\n");
AppDaemonLifecycleRuntime runtime(*this);
daemon::AsyncLifecycleTaskContext context(token, shutting_down_);
daemon_controller_->executeLifecycleOperation(decision, runtime, context);
});
}
void App::deleteBlockchainData() void App::deleteBlockchainData()
{ {
if (!supportsFullNodeLifecycleActions()) { if (!supportsFullNodeLifecycleActions()) {

View File

@@ -306,6 +306,7 @@ public:
bool isUsingEmbeddedDaemon() const { return supportsEmbeddedDaemon() && use_embedded_daemon_; } bool isUsingEmbeddedDaemon() const { return supportsEmbeddedDaemon() && use_embedded_daemon_; }
void setUseEmbeddedDaemon(bool use) { use_embedded_daemon_ = use && supportsEmbeddedDaemon(); } void setUseEmbeddedDaemon(bool use) { use_embedded_daemon_ = use && supportsEmbeddedDaemon(); }
void rescanBlockchain(); // restart daemon with -rescan flag void rescanBlockchain(); // restart daemon with -rescan flag
void repairWallet(); // restart daemon with -zapwallettxes=2 (wipe & rebuild wallet tx records)
void deleteBlockchainData(); // stop daemon, delete chain data, restart fresh void deleteBlockchainData(); // stop daemon, delete chain data, restart fresh
bool stopDaemonForBootstrap(); // stop daemon + disconnect for bootstrap, returns true if was running bool stopDaemonForBootstrap(); // stop daemon + disconnect for bootstrap, returns true if was running
bool isBootstrapDownloading() const { return bootstrap_downloading_; } bool isBootstrapDownloading() const { return bootstrap_downloading_; }

View File

@@ -96,12 +96,23 @@ bool DaemonController::rescanOnNextStart() const
return daemon_->rescanOnNextStart(); return daemon_->rescanOnNextStart();
} }
void DaemonController::setZapOnNextStart(bool enabled)
{
daemon_->setZapOnNextStart(enabled);
}
bool DaemonController::zapOnNextStart() const
{
return daemon_->zapOnNextStart();
}
void DaemonController::prepareLifecycleOperation(const LifecycleDecision& decision, void DaemonController::prepareLifecycleOperation(const LifecycleDecision& decision,
const config::Settings* settings) const config::Settings* settings)
{ {
if (settings) syncSettings(settings); if (settings) syncSettings(settings);
if (decision.resetCrashCount) resetCrashCount(); if (decision.resetCrashCount) resetCrashCount();
if (decision.setRescanOnNextStart) setRescanOnNextStart(true); if (decision.setRescanOnNextStart) setRescanOnNextStart(true);
if (decision.setZapOnNextStart) setZapOnNextStart(true);
} }
DaemonController::ShutdownDecision DaemonController::shutdownDecision( DaemonController::ShutdownDecision DaemonController::shutdownDecision(

View File

@@ -31,6 +31,7 @@ public:
enum class LifecycleOperation { enum class LifecycleOperation {
ManualRestart, ManualRestart,
Rescan, Rescan,
RepairWallet, // restart with -zapwallettxes=2 (wipe & rebuild wallet tx records)
DeleteBlockchainData, DeleteBlockchainData,
BootstrapStop BootstrapStop
}; };
@@ -46,6 +47,7 @@ public:
bool setRescanOnNextStart = false; bool setRescanOnNextStart = false;
bool disconnectRpc = false; bool disconnectRpc = false;
int restartDelayMs = 0; int restartDelayMs = 0;
bool setZapOnNextStart = false;
}; };
class LifecycleTaskContext { class LifecycleTaskContext {
@@ -102,6 +104,8 @@ public:
void resetCrashCount(); void resetCrashCount();
void setRescanOnNextStart(bool enabled); void setRescanOnNextStart(bool enabled);
bool rescanOnNextStart() const; bool rescanOnNextStart() const;
void setZapOnNextStart(bool enabled);
bool zapOnNextStart() const;
static ShutdownDecision evaluateShutdownPolicy(bool hasDaemon, static ShutdownDecision evaluateShutdownPolicy(bool hasDaemon,
bool externalDaemonDetected, bool externalDaemonDetected,
@@ -141,6 +145,13 @@ public:
} }
return {operation, true, daemonRunning, "rescan-blockchain", "Starting rescan...", "", return {operation, true, daemonRunning, "rescan-blockchain", "Starting rescan...", "",
false, true, false, 3000}; false, true, false, 3000};
case LifecycleOperation::RepairWallet:
if (!usingEmbeddedDaemon || !hasDaemon) {
return {operation, false, daemonRunning, "", "",
"Wallet repair requires embedded daemon. Restart your daemon with -zapwallettxes=2 manually."};
}
return {operation, true, daemonRunning, "repair-wallet", "Repairing wallet...", "",
false, false, false, 3000, true};
case LifecycleOperation::DeleteBlockchainData: case LifecycleOperation::DeleteBlockchainData:
if (!usingEmbeddedDaemon || !hasDaemon) { if (!usingEmbeddedDaemon || !hasDaemon) {
return {operation, false, daemonRunning, "", "", return {operation, false, daemonRunning, "", "",
@@ -190,6 +201,7 @@ public:
} }
break; break;
case LifecycleOperation::Rescan: case LifecycleOperation::Rescan:
case LifecycleOperation::RepairWallet:
case LifecycleOperation::DeleteBlockchainData: case LifecycleOperation::DeleteBlockchainData:
runtime.stopDaemonWithPolicy(); runtime.stopDaemonWithPolicy();
result.stopped = true; result.stopped = true;
@@ -206,6 +218,7 @@ public:
} }
if (decision.operation == LifecycleOperation::Rescan || if (decision.operation == LifecycleOperation::Rescan ||
decision.operation == LifecycleOperation::RepairWallet ||
decision.operation == LifecycleOperation::DeleteBlockchainData) { decision.operation == LifecycleOperation::DeleteBlockchainData) {
runtime.resetOutputOffset(); runtime.resetOutputOffset();
} }

View File

@@ -482,8 +482,14 @@ bool EmbeddedDaemon::start(const std::string& binary_path)
args.push_back("-maxconnections=" + std::to_string(max_connections_)); args.push_back("-maxconnections=" + std::to_string(max_connections_));
} }
// Add -rescan flag if requested (one-shot) // Add wallet-repair flag if requested (one-shot). -zapwallettxes=2 wipes all wallet tx/note
if (rescan_on_next_start_.exchange(false)) { // records and rebuilds them from the chain; it implies -rescan, so don't also pass -rescan.
if (zap_on_next_start_.exchange(false)) {
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
} else if (rescan_on_next_start_.exchange(false)) {
// Add -rescan flag if requested (one-shot)
DEBUG_LOGF("[INFO] Adding -rescan flag for blockchain rescan\n"); DEBUG_LOGF("[INFO] Adding -rescan flag for blockchain rescan\n");
args.push_back("-rescan"); args.push_back("-rescan");
} }

View File

@@ -183,6 +183,14 @@ public:
void setRescanOnNextStart(bool v) { rescan_on_next_start_ = v; } void setRescanOnNextStart(bool v) { rescan_on_next_start_ = v; }
bool rescanOnNextStart() const { return rescan_on_next_start_.load(); } bool rescanOnNextStart() const { return rescan_on_next_start_.load(); }
/**
* @brief Request a wallet repair (-zapwallettxes=2) on the next daemon start. This deletes all
* wallet transaction/note records and rebuilds them from the chain (keys are kept); the
* daemon implicitly rescans afterwards. One-shot, like the rescan flag.
*/
void setZapOnNextStart(bool v) { zap_on_next_start_ = v; }
bool zapOnNextStart() const { return zap_on_next_start_.load(); }
/** Get number of consecutive daemon crashes (resets on successful start or manual reset) */ /** Get number of consecutive daemon crashes (resets on successful start or manual reset) */
int getCrashCount() const { return crash_count_.load(); } int getCrashCount() const { return crash_count_.load(); }
/** Reset crash counter (call on successful connection or manual restart) */ /** Reset crash counter (call on successful connection or manual restart) */
@@ -222,6 +230,7 @@ private:
int max_connections_ = 0; // 0 = daemon default int max_connections_ = 0; // 0 = daemon default
std::atomic<int> crash_count_{0}; // consecutive crash counter 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> rescan_on_next_start_{false}; // -rescan flag for next start
std::atomic<bool> zap_on_next_start_{false}; // -zapwallettxes=2 flag for next start
}; };
} // namespace daemon } // namespace daemon

View File

@@ -146,6 +146,7 @@ struct SettingsPageState {
bool confirm_clear_ztx = false; bool confirm_clear_ztx = false;
bool confirm_delete_blockchain = false; bool confirm_delete_blockchain = false;
bool confirm_rescan = false; bool confirm_rescan = false;
bool confirm_repair_wallet = false;
bool confirm_restart_daemon = false; bool confirm_restart_daemon = false;
bool confirm_lite_redownload = false; bool confirm_lite_redownload = false;
effects::ScrollFadeShader fade_shader; effects::ScrollFadeShader fade_shader;
@@ -2044,6 +2045,16 @@ void RenderSettingsPage(App* app) {
} }
if (ImGui::IsItemHovered(ImGuiHoveredFlags_AllowWhenDisabled)) ImGui::SetTooltip("%s", TR("tt_delete_blockchain")); if (ImGui::IsItemHovered(ImGuiHoveredFlags_AllowWhenDisabled)) ImGui::SetTooltip("%s", TR("tt_delete_blockchain"));
ImGui::EndDisabled(); ImGui::EndDisabled();
// Repair wallet (-zapwallettxes=2): wipe & rebuild wallet tx/note records from
// the chain (keys kept). Fixes notes that fail to spend after a rescan.
ImGui::SetCursorScreenPos(ImVec2(leftX, ImGui::GetCursorScreenPos().y + Layout::spacingSm()));
ImGui::BeginDisabled(!app->isUsingEmbeddedDaemon());
if (TactileButton(TR("repair_wallet"), ImVec2(0, 0), btnFont)) {
s_settingsState.confirm_repair_wallet = true;
}
if (ImGui::IsItemHovered(ImGuiHoveredFlags_AllowWhenDisabled)) ImGui::SetTooltip("%s", TR("tt_repair_wallet"));
ImGui::EndDisabled();
} }
} }
@@ -2647,6 +2658,36 @@ void RenderSettingsPage(App* app) {
} }
} }
// Confirm: repair wallet (-zapwallettxes=2 — wipe & rebuild wallet tx records, then rescan)
if (s_settingsState.confirm_repair_wallet) {
if (BeginOverlayDialog(TR("confirm_repair_wallet_title"), &s_settingsState.confirm_repair_wallet, 500.0f, 0.94f)) {
ImGui::PushFont(Type().iconLarge());
ImGui::TextColored(ImVec4(1.0f, 0.8f, 0.2f, 1.0f), ICON_MD_WARNING);
ImGui::PopFont();
ImGui::SameLine();
ImGui::TextColored(ImVec4(1.0f, 0.8f, 0.2f, 1.0f), "%s", TR("warning"));
ImGui::Spacing();
ImGui::TextWrapped("%s", TR("confirm_repair_wallet_msg"));
ImGui::Spacing();
ImGui::TextColored(ImVec4(0.3f, 0.8f, 0.3f, 1.0f), "%s", TR("confirm_repair_wallet_safe"));
ImGui::Spacing();
ImGui::Separator();
ImGui::Spacing();
float btnW = (ImGui::GetContentRegionAvail().x - ImGui::GetStyle().ItemSpacing.x) * 0.5f;
if (ImGui::Button(TrId("cancel", "repair_wallet_cancel").c_str(), ImVec2(btnW, 40))) {
s_settingsState.confirm_repair_wallet = false;
}
ImGui::SameLine();
if (ImGui::Button(TrId("repair_wallet", "repair_wallet_confirm").c_str(), ImVec2(btnW, 40))) {
app->repairWallet();
s_settingsState.confirm_repair_wallet = false;
}
EndOverlayDialog();
}
}
// Confirm: restart daemon (briefly drops the connection to apply changed options) // Confirm: restart daemon (briefly drops the connection to apply changed options)
if (s_settingsState.confirm_restart_daemon) { if (s_settingsState.confirm_restart_daemon) {
if (BeginOverlayDialog(TR("confirm_restart_daemon_title"), &s_settingsState.confirm_restart_daemon, 500.0f, 0.94f)) { if (BeginOverlayDialog(TR("confirm_restart_daemon_title"), &s_settingsState.confirm_restart_daemon, 500.0f, 0.94f)) {

View File

@@ -418,6 +418,11 @@ void I18n::loadBuiltinEnglish()
strings_["confirm_rescan_title"] = "Rescan Blockchain"; strings_["confirm_rescan_title"] = "Rescan Blockchain";
strings_["confirm_rescan_msg"] = "This restarts the daemon and re-scans the entire blockchain for your wallet's transactions. It can take a long time and the wallet stays offline until it finishes."; strings_["confirm_rescan_msg"] = "This restarts the daemon and re-scans the entire blockchain for your wallet's transactions. It can take a long time and the wallet stays offline until it finishes.";
strings_["confirm_rescan_safe"] = "Your wallet.dat and blockchain data are not deleted — only re-scanned."; strings_["confirm_rescan_safe"] = "Your wallet.dat and blockchain data are not deleted — only re-scanned.";
strings_["repair_wallet"] = "Repair Wallet";
strings_["tt_repair_wallet"] = "Wipe and rebuild the wallet's transaction records from the blockchain (fixes notes that fail to send after a rescan)";
strings_["confirm_repair_wallet_title"] = "Repair Wallet";
strings_["confirm_repair_wallet_msg"] = "This restarts the daemon with -zapwallettxes=2: it deletes all of the wallet's transaction and note records, then rebuilds them from the blockchain. Use this when transactions fail to build (\"Invalid sapling spend proof\" / \"shielded requirements not met\") even after a full rescan. It takes a long time and the wallet stays offline until it finishes.";
strings_["confirm_repair_wallet_safe"] = "Your keys, addresses and balance are preserved — only the cached transaction records are rebuilt.";
strings_["confirm_restart_daemon_title"] = "Restart Daemon"; strings_["confirm_restart_daemon_title"] = "Restart Daemon";
strings_["confirm_restart_daemon_msg"] = "This stops and restarts the daemon to apply the changed options. The wallet will briefly disconnect and reconnect."; strings_["confirm_restart_daemon_msg"] = "This stops and restarts the daemon to apply the changed options. The wallet will briefly disconnect and reconnect.";
strings_["lite_maintenance"] = "Maintenance"; strings_["lite_maintenance"] = "Maintenance";