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:
41
src/app.cpp
41
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()
|
void App::deleteBlockchainData()
|
||||||
{
|
{
|
||||||
if (!supportsFullNodeLifecycleActions()) {
|
if (!supportsFullNodeLifecycleActions()) {
|
||||||
|
|||||||
@@ -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_; }
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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");
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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)) {
|
||||||
|
|||||||
@@ -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";
|
||||||
|
|||||||
Reference in New Issue
Block a user