diff --git a/src/app.cpp b/src/app.cpp index 5dcb7cd..a2c88b5 100644 --- a/src/app.cpp +++ b/src/app.cpp @@ -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() { if (!supportsFullNodeLifecycleActions()) { diff --git a/src/app.h b/src/app.h index f552324..b88bdbd 100644 --- a/src/app.h +++ b/src/app.h @@ -306,6 +306,7 @@ public: bool isUsingEmbeddedDaemon() const { return supportsEmbeddedDaemon() && use_embedded_daemon_; } void setUseEmbeddedDaemon(bool use) { use_embedded_daemon_ = use && supportsEmbeddedDaemon(); } 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 bool stopDaemonForBootstrap(); // stop daemon + disconnect for bootstrap, returns true if was running bool isBootstrapDownloading() const { return bootstrap_downloading_; } diff --git a/src/daemon/daemon_controller.cpp b/src/daemon/daemon_controller.cpp index 405d168..183ae79 100644 --- a/src/daemon/daemon_controller.cpp +++ b/src/daemon/daemon_controller.cpp @@ -96,12 +96,23 @@ bool DaemonController::rescanOnNextStart() const return daemon_->rescanOnNextStart(); } +void DaemonController::setZapOnNextStart(bool enabled) +{ + daemon_->setZapOnNextStart(enabled); +} + +bool DaemonController::zapOnNextStart() const +{ + return daemon_->zapOnNextStart(); +} + void DaemonController::prepareLifecycleOperation(const LifecycleDecision& decision, const config::Settings* settings) { if (settings) syncSettings(settings); if (decision.resetCrashCount) resetCrashCount(); if (decision.setRescanOnNextStart) setRescanOnNextStart(true); + if (decision.setZapOnNextStart) setZapOnNextStart(true); } DaemonController::ShutdownDecision DaemonController::shutdownDecision( diff --git a/src/daemon/daemon_controller.h b/src/daemon/daemon_controller.h index cfe4e99..0f78348 100644 --- a/src/daemon/daemon_controller.h +++ b/src/daemon/daemon_controller.h @@ -31,6 +31,7 @@ public: enum class LifecycleOperation { ManualRestart, Rescan, + RepairWallet, // restart with -zapwallettxes=2 (wipe & rebuild wallet tx records) DeleteBlockchainData, BootstrapStop }; @@ -46,6 +47,7 @@ public: bool setRescanOnNextStart = false; bool disconnectRpc = false; int restartDelayMs = 0; + bool setZapOnNextStart = false; }; class LifecycleTaskContext { @@ -102,6 +104,8 @@ public: void resetCrashCount(); void setRescanOnNextStart(bool enabled); bool rescanOnNextStart() const; + void setZapOnNextStart(bool enabled); + bool zapOnNextStart() const; static ShutdownDecision evaluateShutdownPolicy(bool hasDaemon, bool externalDaemonDetected, @@ -141,6 +145,13 @@ public: } return {operation, true, daemonRunning, "rescan-blockchain", "Starting rescan...", "", 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: if (!usingEmbeddedDaemon || !hasDaemon) { return {operation, false, daemonRunning, "", "", @@ -190,6 +201,7 @@ public: } break; case LifecycleOperation::Rescan: + case LifecycleOperation::RepairWallet: case LifecycleOperation::DeleteBlockchainData: runtime.stopDaemonWithPolicy(); result.stopped = true; @@ -206,6 +218,7 @@ public: } if (decision.operation == LifecycleOperation::Rescan || + decision.operation == LifecycleOperation::RepairWallet || decision.operation == LifecycleOperation::DeleteBlockchainData) { runtime.resetOutputOffset(); } diff --git a/src/daemon/embedded_daemon.cpp b/src/daemon/embedded_daemon.cpp index b2d08d3..ba21684 100644 --- a/src/daemon/embedded_daemon.cpp +++ b/src/daemon/embedded_daemon.cpp @@ -482,8 +482,14 @@ bool EmbeddedDaemon::start(const std::string& binary_path) args.push_back("-maxconnections=" + std::to_string(max_connections_)); } - // Add -rescan flag if requested (one-shot) - if (rescan_on_next_start_.exchange(false)) { + // 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)) { + 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"); args.push_back("-rescan"); } diff --git a/src/daemon/embedded_daemon.h b/src/daemon/embedded_daemon.h index f0cc2e2..8604a69 100644 --- a/src/daemon/embedded_daemon.h +++ b/src/daemon/embedded_daemon.h @@ -183,6 +183,14 @@ public: void setRescanOnNextStart(bool v) { rescan_on_next_start_ = v; } 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) */ int getCrashCount() const { return crash_count_.load(); } /** Reset crash counter (call on successful connection or manual restart) */ @@ -222,6 +230,7 @@ private: int max_connections_ = 0; // 0 = daemon default std::atomic crash_count_{0}; // consecutive crash counter std::atomic rescan_on_next_start_{false}; // -rescan flag for next start + std::atomic zap_on_next_start_{false}; // -zapwallettxes=2 flag for next start }; } // namespace daemon diff --git a/src/ui/pages/settings_page.cpp b/src/ui/pages/settings_page.cpp index 6dee018..0be1267 100644 --- a/src/ui/pages/settings_page.cpp +++ b/src/ui/pages/settings_page.cpp @@ -146,6 +146,7 @@ struct SettingsPageState { bool confirm_clear_ztx = false; bool confirm_delete_blockchain = false; bool confirm_rescan = false; + bool confirm_repair_wallet = false; bool confirm_restart_daemon = false; bool confirm_lite_redownload = false; effects::ScrollFadeShader fade_shader; @@ -2044,6 +2045,16 @@ void RenderSettingsPage(App* app) { } if (ImGui::IsItemHovered(ImGuiHoveredFlags_AllowWhenDisabled)) ImGui::SetTooltip("%s", TR("tt_delete_blockchain")); 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) if (s_settingsState.confirm_restart_daemon) { if (BeginOverlayDialog(TR("confirm_restart_daemon_title"), &s_settingsState.confirm_restart_daemon, 500.0f, 0.94f)) { diff --git a/src/util/i18n.cpp b/src/util/i18n.cpp index e386bb1..1fbdc13 100644 --- a/src/util/i18n.cpp +++ b/src/util/i18n.cpp @@ -418,6 +418,11 @@ void I18n::loadBuiltinEnglish() 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_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_msg"] = "This stops and restarts the daemon to apply the changed options. The wallet will briefly disconnect and reconnect."; strings_["lite_maintenance"] = "Maintenance";