From e66f2b6c8ca7466ad056025c30be8be260901882 Mon Sep 17 00:00:00 2001 From: DanS Date: Tue, 7 Jul 2026 15:04:40 -0500 Subject: [PATCH] =?UTF-8?q?feat(wallet):=20migrate-to-seed,=20Phase=202=20?= =?UTF-8?q?=E2=80=94=20sweep=20+=20adopt=20(fund-safety=20gated)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Second half of "migrate a legacy wallet to a seed wallet": sweep all funds into the new seed wallet, then adopt it as the primary wallet. - Sweep (beginSweepToSeedWallet): z_mergetoaddress ["ANY_TADDR","ANY_ZADDR"] -> the new wallet's z-address, at the default fee, limits 0,0. The opid is tracked via the existing send-callback pipeline; the txid is persisted. - Confirming gate (fund-safety): after the sweep, pollSweepStatus() polls the tx confirmations + legacy balance. "Make this my wallet" only unlocks once the sweep is MINED (>= 1 conf) AND the old wallet is empty — so wallet.dat is never swapped while the funds could still bounce back or a remainder is left. A too-big-for-one-tx remainder offers a "Sweep remaining" re-sweep. - Adopt (beginAdoptSeedWallet, background): stop daemon -> move legacy wallet.dat to a timestamped .bak (never deleted; restored on failure) -> install the new wallet -> restart with -rescan. Exception-safe; daemon_restarting_ gates tryConnect + is always cleared; skips the restart while shutting down; beginShutdown joins the task first. - Resume: a pending migration reopens at Sweep, or at Confirming if the sweep txid is already recorded (re-derived from the chain) — never re-sweeps blind. - Secret hygiene: the seed is wiped leaving ShowSeed, on any modal dismiss (detected after BeginOverlayDialog), and in App::~App() (same fix applied to the seed-backup dialog). Built both variants; tests + hygiene pass. Two rounds of parallel adversarial review (12 findings fixed, then a clean re-verify) gate this fund-moving code. The migration modal uses English literals (as renderBackupDialog does); it gets translated once the flow is finalized. Live sweep test against a funded wallet is the next step. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/app.cpp | 166 ++++++++++++++++++++++++--- src/app.h | 27 ++++- src/app_network.cpp | 246 ++++++++++++++++++++++++++++++++++++++++ src/config/settings.cpp | 2 + src/config/settings.h | 5 + 5 files changed, 427 insertions(+), 19 deletions(-) diff --git a/src/app.cpp b/src/app.cpp index e2c1829..d56a979 100644 --- a/src/app.cpp +++ b/src/app.cpp @@ -119,7 +119,14 @@ App::App() // deterministic across launches. balance_rng_.seed(std::random_device{}()); } -App::~App() = default; +App::~App() +{ + // Scrub any seed/phrase secret still resident (e.g. app quit with a backup/migration modal open). + if (!seed_migration_seed_.empty()) + sodium_memzero(&seed_migration_seed_[0], seed_migration_seed_.size()); + if (!seed_backup_phrase_.empty()) + sodium_memzero(&seed_backup_phrase_[0], seed_backup_phrase_.size()); +} namespace { // How often auto-balance re-evaluates the pool while active. Long, because switching @@ -719,8 +726,16 @@ void App::update() // One-time reminder to back up the wallet's seed phrase (mnemonic wallets only). maybeRemindSeedBackup(); - // Pick up progress/result from a running seed-wallet migration (Phase 1: isolated create). + // Pick up progress/result from a running seed-wallet migration (create/sweep/adopt). pumpSeedMigration(); + // While confirming the sweep, poll the tx confirmations + legacy balance every ~5s. + if (show_seed_migration_ && seed_migration_step_ == SeedMigrationStep::Confirming) { + seed_migration_poll_timer_ -= ImGui::GetIO().DeltaTime; + if (seed_migration_poll_timer_ <= 0.0f) { + seed_migration_poll_timer_ = 5.0f; + pollSweepStatus(); + } + } // Auto-lock check (only when connected + encrypted + unlocked) if (state_.connected && state_.isUnlocked()) { @@ -2975,10 +2990,16 @@ void App::renderSeedBackupDialog() } if (!ui::material::BeginOverlayDialog(TR("seed_backup_title"), &show_seed_backup_, 540.0f, 0.94f)) { - // Dismissed (Esc / outside click / X) — scrub the revealed secret. if (seed_backup_fetch_started_) closeAndWipe(); return; } + // BeginOverlayDialog flips show_seed_backup_ to false on an Esc / outside-click / X dismiss but + // still returns true this frame — catch that here so the revealed secret is always scrubbed. + if (!show_seed_backup_) { + closeAndWipe(); + ui::material::EndOverlayDialog(); + return; + } ui::material::Type().text(ui::material::TypeStyle::H6, TR("seed_backup_title")); ImGui::Dummy(ImVec2(0, ui::Layout::spacingSm())); @@ -3045,13 +3066,20 @@ void App::renderSeedMigrationDialog() }; auto close = [this, &wipeSeed]() { wipeSeed(); show_seed_migration_ = false; }; + // A background op (create/sweep/adopt) can't be cancelled cleanly, so the modal must not be + // dismissed mid-op. + const bool busy = seed_migration_in_flight_ + || seed_migration_step_ == SeedMigrationStep::Sweeping + || seed_migration_step_ == SeedMigrationStep::Adopting; + if (!ui::material::BeginOverlayDialog("Migrate to a seed wallet", &show_seed_migration_, 580.0f, 0.94f)) { - // Block dismiss while the background create is running (it can't be cancelled cleanly); - // otherwise a dismiss wipes the revealed seed. - if (seed_migration_in_flight_) show_seed_migration_ = true; - else wipeSeed(); + if (!busy) wipeSeed(); return; } + // BeginOverlayDialog flips show_seed_migration_ to false on an Esc / outside-click / X dismiss + // (but still returns true this frame). Veto the dismiss while busy; otherwise it's handled by + // the post-EndOverlayDialog wipe below. + if (!show_seed_migration_ && busy) show_seed_migration_ = true; ui::material::Type().text(ui::material::TypeStyle::H6, "Migrate to a seed wallet"); ImGui::Dummy(ImVec2(0, ui::Layout::spacingSm())); @@ -3114,8 +3142,11 @@ void App::renderSeedMigrationDialog() ImGui::PopStyleColor(); ImGui::Separator(); if (!seed_migration_backed_up_) ImGui::BeginDisabled(); - if (ui::material::StyledButton("Done", ImVec2(120, 0))) - close(); + if (ui::material::StyledButton("Continue to sweep", ImVec2(170, 0))) { + wipeSeed(); // the sweep/adopt steps only use the address, never the seed itself + seed_migration_step_ = SeedMigrationStep::Sweep; + refreshSeedMigrationBalance(); + } if (!seed_migration_backed_up_) ImGui::EndDisabled(); ImGui::SameLine(); if (ui::material::StyledButton("Discard migration", ImVec2(160, 0))) { @@ -3137,21 +3168,125 @@ void App::renderSeedMigrationDialog() break; } + case SeedMigrationStep::Sweep: + ImGui::TextWrapped("Now sweep all funds from your current wallet into the new seed wallet. " + "This sends a single transaction to the new wallet's address; your seed " + "phrase (already backed up) controls the funds from here on."); + ImGui::Spacing(); + ImGui::TextColored(ImGui::ColorConvertU32ToFloat4(ui::material::OnSurfaceMedium()), + "Current wallet balance: %.8f DRGX (minus a small fee)", seed_migration_balance_); + ImGui::TextWrapped("To: %s", seed_migration_dest_.c_str()); + if (state_.isLocked()) { + ImGui::Spacing(); + ImGui::PushStyleColor(ImGuiCol_Text, ui::material::Error()); + ImGui::TextWrapped("Unlock your wallet first to spend from it."); + ImGui::PopStyleColor(); + } + ImGui::Spacing(); + ImGui::Separator(); + { + const bool canSweep = !state_.isLocked() && seed_migration_balance_ > 0.0; + if (!canSweep) ImGui::BeginDisabled(); + if (ui::material::StyledButton("Sweep all funds", ImVec2(170, 0))) + beginSweepToSeedWallet(); + if (!canSweep) ImGui::EndDisabled(); + } + ImGui::SameLine(); + if (ui::material::StyledButton("Refresh", ImVec2(110, 0))) + refreshSeedMigrationBalance(); + ImGui::SameLine(); + if (ui::material::StyledButton("Later", ImVec2(100, 0))) + close(); // pending state persists; resumes here next time + break; + + case SeedMigrationStep::Sweeping: + ImGui::TextWrapped("Sweeping your funds into the new wallet…"); + ImGui::Spacing(); + if (!seed_migration_status_.empty()) + ImGui::TextColored(ImGui::ColorConvertU32ToFloat4(ui::material::OnSurfaceMedium()), + "%s", seed_migration_status_.c_str()); + break; + + case SeedMigrationStep::Confirming: { + const ImVec4 medium = ImGui::ColorConvertU32ToFloat4(ui::material::OnSurfaceMedium()); + const double dust = DRAGONX_DEFAULT_FEE; // treat <= one fee as "empty" + const bool confirmed = seed_migration_sweep_confs_ >= 1; + const bool legacyEmpty = seed_migration_legacy_remaining_ >= 0.0 && + seed_migration_legacy_remaining_ <= dust; + ImGui::TextWrapped("Waiting for the sweep to confirm on-chain before switching wallets — " + "this guarantees your funds have actually moved."); + ImGui::Spacing(); + ImGui::TextColored(medium, "Sweep confirmations: %d", seed_migration_sweep_confs_); + if (!seed_migration_sweep_txid_.empty()) + ImGui::TextColored(medium, "txid: %s", seed_migration_sweep_txid_.c_str()); + if (seed_migration_legacy_remaining_ >= 0.0) + ImGui::TextColored(medium, "Remaining in old wallet: %.8f DRGX", + seed_migration_legacy_remaining_); + ImGui::Spacing(); + ImGui::Separator(); + if (confirmed && legacyEmpty) { + if (ui::material::StyledButton("Make this my wallet", ImVec2(180, 0))) + beginAdoptSeedWallet(); + } else if (confirmed && !legacyEmpty) { + ImGui::PushStyleColor(ImGuiCol_Text, ui::material::Error()); + ImGui::TextWrapped("Some funds are still in your old wallet (too many inputs for one " + "transaction) — sweep the remainder before switching."); + ImGui::PopStyleColor(); + if (ui::material::StyledButton("Sweep remaining", ImVec2(180, 0))) + beginSweepToSeedWallet(); + } else { + ImGui::TextColored(medium, "Waiting for the sweep transaction to be mined…"); + } + ImGui::SameLine(); + if (ui::material::StyledButton("Refresh", ImVec2(110, 0))) + pollSweepStatus(); + ImGui::SameLine(); + if (ui::material::StyledButton("Later", ImVec2(100, 0))) + close(); // pending state + txid persist; resumes here next time + break; + } + + case SeedMigrationStep::Adopting: + ImGui::TextWrapped("Installing your new wallet and rescanning…"); + ImGui::Spacing(); + if (!seed_migration_status_.empty()) + ImGui::TextColored(ImGui::ColorConvertU32ToFloat4(ui::material::OnSurfaceMedium()), + "%s", seed_migration_status_.c_str()); + break; + + case SeedMigrationStep::Done: + ImGui::PushStyleColor(ImGuiCol_Text, ImVec4(0.3f, 0.8f, 0.3f, 1.0f)); + ImGui::TextWrapped("Migration complete. Your wallet is now backed by your seed phrase."); + ImGui::PopStyleColor(); + ImGui::Spacing(); + ImGui::TextWrapped("The daemon is rescanning to show your funds — this can take a few " + "minutes. Your previous wallet was saved as a .bak in the data folder."); + if (!seed_migration_status_.empty()) { // a non-fatal warning (e.g. the daemon didn't restart) + ImGui::Spacing(); + ImGui::PushStyleColor(ImGuiCol_Text, ui::material::Error()); + ImGui::TextWrapped("%s", seed_migration_status_.c_str()); + ImGui::PopStyleColor(); + } + ImGui::Spacing(); + ImGui::Separator(); + if (ui::material::StyledButton("Done", ImVec2(120, 0))) + close(); + break; + case SeedMigrationStep::Error: ImGui::PushStyleColor(ImGuiCol_Text, ui::material::Error()); ImGui::TextWrapped("%s", seed_migration_status_.c_str()); ImGui::PopStyleColor(); ImGui::Spacing(); ImGui::Separator(); - if (ui::material::StyledButton("Back", ImVec2(120, 0))) - seed_migration_step_ = SeedMigrationStep::Intro; - ImGui::SameLine(); if (ui::material::StyledButton("Close", ImVec2(120, 0))) close(); break; } ui::material::EndOverlayDialog(); + // A real dismiss (Esc/outside/X) or a button close() leaves the flag false — scrub the seed. + if (!show_seed_migration_) wipeSeed(); } void App::renderAntivirusHelpDialog() @@ -3700,7 +3835,12 @@ void App::beginShutdown() shutdown_timer_ = 0.0f; shutdown_start_time_ = std::chrono::steady_clock::now(); async_tasks_.cancelAll(); - + // The seed-migration adopt task drives daemon stop/start and swaps wallet.dat; let it finish + // before shutdown touches the daemon, so the two never race. shutting_down_ is already set, so + // the adopt task will NOT restart the daemon (it checks shutting_down_ before startEmbeddedDaemon). + if (async_tasks_.isRunning("Adopt seed wallet")) + async_tasks_.join("Adopt seed wallet"); + // Signal the RPC worker to stop accepting new tasks (non-blocking), and abort any call // already in flight so the later join() doesn't wait out a request timeout. // The actual thread join + rpc disconnect happen in shutdown() after diff --git a/src/app.h b/src/app.h index 2b9ad04..3909e22 100644 --- a/src/app.h +++ b/src/app.h @@ -353,7 +353,7 @@ public: void showExportKeyDialog() { show_export_key_ = true; } void showBackupDialog() { show_backup_ = true; } void showSeedBackupDialog() { show_seed_backup_ = true; } - void showSeedMigrationDialog() { show_seed_migration_ = true; seed_migration_step_ = SeedMigrationStep::Intro; } + void showSeedMigrationDialog(); // opens the migration modal (resumes a pending one at Sweep) void showAboutDialog() { show_about_ = true; } // Legacy tab compat — maps int to NavPage @@ -616,6 +616,11 @@ private: // Seed-wallet migration (Phase 1: create a new mnemonic wallet in isolation, no funds moved). void beginCreateSeedWallet(); // starts the isolated create on a background thread void pumpSeedMigration(); // main thread: pick up background progress/result each frame + // Phase 2: sweep all legacy funds into the new wallet, then adopt it as the primary wallet. + void refreshSeedMigrationBalance(); // query the legacy total (shown on the Sweep step) + void beginSweepToSeedWallet(); // z_mergetoaddress ["ANY_TADDR","ANY_ZADDR"] -> dest + void pollSweepStatus(); // Confirming step: poll sweep confirmations + legacy balance + void beginAdoptSeedWallet(); // stop daemon -> swap wallet.dat -> restart with -rescan void provisionChatIdentityFromSecret(std::string secret); std::string chatReplyZaddr(); // a stable (persisted) wallet z-addr for chat std::string generateChatLocalId(const char* prefix, int numBytes) const; // unique echo id / cid @@ -684,22 +689,32 @@ private: bool seed_backup_no_mnemonic_ = false; bool seed_backup_reminder_in_flight_ = false; // guards the one-time backup nudge probe - // --- Seed-wallet migration (Phase 1: isolated seed-wallet creation) --- - enum class SeedMigrationStep { Intro, Working, ShowSeed, Error }; + // --- Seed-wallet migration (Phase 1: create; Phase 2: sweep + adopt) --- + enum class SeedMigrationStep { Intro, Working, ShowSeed, Sweep, Sweeping, Confirming, Adopting, Done, Error }; bool show_seed_migration_ = false; SeedMigrationStep seed_migration_step_ = SeedMigrationStep::Intro; - bool seed_migration_in_flight_ = false; // main-thread guard while the bg task runs + bool seed_migration_in_flight_ = false; // main-thread guard while the bg create task runs std::string seed_migration_seed_; // SECRET — the revealed phrase, wiped on close std::string seed_migration_dest_; // new shielded z-address (Phase 2 sweep target) - std::string seed_migration_temp_dir_; // temp datadir holding the new wallet (kept) + std::string seed_migration_temp_dir_; // temp datadir root holding the new wallet (kept) bool seed_migration_backed_up_ = false; // "I've written it down" confirmation std::string seed_migration_status_; // main-thread display (progress / error text) + double seed_migration_balance_ = 0.0; // legacy total to sweep (shown on the Sweep step) + std::string seed_migration_sweep_txid_; // the z_mergetoaddress sweep transaction id + int seed_migration_sweep_confs_ = 0; // confirmations of the sweep tx (adopt gate: >= 1) + double seed_migration_legacy_remaining_ = -1.0; // legacy balance after sweep (-1 = unknown) + float seed_migration_poll_timer_ = 0.0f; // throttles the Confirming-step poll + bool seed_migration_confirm_in_flight_ = false; // guards the confirm poll // Cross-thread handoff from the background create task (guarded by seed_migration_mutex_). std::mutex seed_migration_mutex_; std::string seed_migration_progress_; // latest progress line - bool seed_migration_done_ = false; // result ready to consume + bool seed_migration_done_ = false; // create result ready to consume bool seed_migration_ok_ = false; std::string seed_migration_r_seed_, seed_migration_r_dest_, seed_migration_r_tmp_, seed_migration_r_err_; + // Cross-thread handoff from the background adopt task (guarded by seed_migration_mutex_). + bool seed_migration_adopt_done_ = false; + bool seed_migration_adopt_ok_ = false; + std::string seed_migration_adopt_err_; // Embedded daemon state bool use_embedded_daemon_ = wallet::supportsEmbeddedDaemon(wallet::currentWalletCapabilities()); diff --git a/src/app_network.cpp b/src/app_network.cpp index 3873309..6833cf5 100644 --- a/src/app_network.cpp +++ b/src/app_network.cpp @@ -39,6 +39,7 @@ #include "config/settings.h" #include "wallet/lite_wallet_controller.h" // lite send/new-address routing #include "wallet/lite_wallet_state_mapper.h" // LiteWalletAppRefreshModel for lite chat harvest +#include "config/version.h" #include "daemon/daemon_controller.h" #include "daemon/embedded_daemon.h" #include "daemon/seed_wallet_creator.h" @@ -60,6 +61,8 @@ #include #include #include +#include +#include #include #include @@ -210,6 +213,10 @@ void App::tryConnect() if (connection_in_progress_) return; + // Don't fight an in-progress restart/adopt orchestration: while it stops the daemon, swaps + // wallet.dat (seed migration), and restarts, tryConnect must NOT start the daemon underneath it. + if (daemon_restarting_) return; + static int connect_attempt = 0; ++connect_attempt; @@ -2956,8 +2963,247 @@ void App::beginCreateSeedWallet() }); } +void App::showSeedMigrationDialog() +{ + show_seed_migration_ = true; + // Resume a pending migration. If a sweep was already submitted (txid persisted), resume at the + // confirm/adopt stage — re-derived from the chain — rather than sweeping again; otherwise start + // at the Sweep step. With no pending migration, start fresh at the intro. + if (settings_ && settings_->getSeedMigrationPending() && !settings_->getSeedMigrationDest().empty()) { + seed_migration_dest_ = settings_->getSeedMigrationDest(); + seed_migration_temp_dir_ = settings_->getSeedMigrationTempDir(); + seed_migration_sweep_txid_ = settings_->getSeedMigrationSweepTxid(); + if (!seed_migration_sweep_txid_.empty()) { + seed_migration_sweep_confs_ = 0; + seed_migration_legacy_remaining_ = -1.0; + seed_migration_poll_timer_ = 0.0f; // poll immediately + seed_migration_step_ = SeedMigrationStep::Confirming; + } else { + seed_migration_step_ = SeedMigrationStep::Sweep; + refreshSeedMigrationBalance(); + } + } else { + seed_migration_step_ = SeedMigrationStep::Intro; + } +} + +void App::refreshSeedMigrationBalance() +{ + if (!rpc_ || !worker_ || !state_.connected) return; + worker_->post([this]() -> rpc::RPCWorker::MainCb { + double total = 0.0; bool ok = false; + rpc::RPCClient::TraceScope trace("Migrate / balance"); + try { + auto res = rpc_->call("z_gettotalbalance"); + if (res.contains("total") && res["total"].is_string()) + total = std::stod(res["total"].get()); + ok = true; + } catch (...) {} + return [this, total, ok]() { if (ok) seed_migration_balance_ = total; }; + }); +} + +// Phase 2 step 1: sweep ALL legacy funds (transparent + shielded) into the new seed wallet's +// address. z_mergetoaddress with 0,0 limits merges as many inputs as fit in one transaction; a +// huge-input wallet may leave a small remainder in the (preserved) legacy backup. +void App::beginSweepToSeedWallet() +{ + if (!rpc_ || !worker_ || !state_.connected) { + seed_migration_status_ = "Not connected to the daemon."; + seed_migration_step_ = SeedMigrationStep::Error; return; + } + if (seed_migration_dest_.empty()) { + seed_migration_status_ = "No destination address for the sweep."; + seed_migration_step_ = SeedMigrationStep::Error; return; + } + seed_migration_step_ = SeedMigrationStep::Sweeping; + seed_migration_status_ = "Building the sweep transaction…"; + const std::string dest = seed_migration_dest_; + worker_->post([this, dest]() -> rpc::RPCWorker::MainCb { + std::string opid, err; + rpc::RPCClient::TraceScope trace("Migrate / sweep"); + try { + json fromAddrs = json::array({"ANY_TADDR", "ANY_ZADDR"}); + auto res = rpc_->call("z_mergetoaddress", {fromAddrs, dest, DRAGONX_DEFAULT_FEE, 0, 0}); + opid = res.value("opid", std::string()); + if (opid.empty()) err = "The daemon returned no operation id for the sweep."; + } catch (const std::exception& e) { err = e.what(); } + return [this, opid, err]() { + if (!err.empty() || opid.empty()) { + seed_migration_status_ = err.empty() ? "The sweep failed to start." : err; + seed_migration_step_ = SeedMigrationStep::Error; + return; + } + pending_send_callbacks_[opid] = [this](bool ok, const std::string& result) { + if (ok) { + seed_migration_sweep_txid_ = result; + // Persist the txid so a restart resumes at the confirm/adopt stage and never + // re-sweeps from scratch. The Confirming step gates adopt on this tx being mined + // (>= 1 confirmation) AND the legacy balance dropping to ~0. + if (settings_) { settings_->setSeedMigrationSweepTxid(result); settings_->save(); } + seed_migration_sweep_confs_ = 0; + seed_migration_legacy_remaining_ = -1.0; + seed_migration_poll_timer_ = 0.0f; + seed_migration_status_.clear(); + seed_migration_step_ = SeedMigrationStep::Confirming; + } else { + seed_migration_status_ = result.empty() ? "The sweep transaction failed." : result; + seed_migration_step_ = SeedMigrationStep::Error; + } + }; + trackOperation(opid); + seed_migration_status_ = "Waiting for the sweep transaction to be accepted…"; + }; + }); +} + +// Confirming step: poll the sweep tx's confirmations + the legacy wallet's remaining balance. The +// adopt step is gated on the tx being mined (confs >= 1) AND the legacy balance being ~0, so we +// never swap wallet.dat while the funds could still bounce back (dropped/reorged tx) or while a +// remainder is left behind (a sweep too big for one transaction). +void App::pollSweepStatus() +{ + if (!rpc_ || !worker_ || !state_.connected) return; + if (seed_migration_confirm_in_flight_ || seed_migration_sweep_txid_.empty()) return; + seed_migration_confirm_in_flight_ = true; + const std::string txid = seed_migration_sweep_txid_; + worker_->post([this, txid]() -> rpc::RPCWorker::MainCb { + int confs = 0; double remaining = -1.0; bool ok = false; + rpc::RPCClient::TraceScope trace("Migrate / confirm"); + try { + auto tx = rpc_->call("gettransaction", {txid}); + if (tx.contains("confirmations") && tx["confirmations"].is_number()) + confs = tx["confirmations"].get(); + auto bal = rpc_->call("z_gettotalbalance"); + if (bal.contains("total") && bal["total"].is_string()) + remaining = std::stod(bal["total"].get()); + ok = true; + } catch (...) {} + return [this, confs, remaining, ok]() { + seed_migration_confirm_in_flight_ = false; + if (ok) { + seed_migration_sweep_confs_ = confs; + seed_migration_legacy_remaining_ = remaining; + } + }; + }); +} + +// Phase 2 step 2: adopt the new seed wallet as the primary wallet. Stop the daemon, move the +// legacy wallet.dat aside to a timestamped backup (NEVER delete it), copy the new wallet in, and +// restart with -rescan so the new keys pick up the swept funds. Runs on a background thread. +void App::beginAdoptSeedWallet() +{ + seed_migration_step_ = SeedMigrationStep::Adopting; + seed_migration_status_ = "Restarting with your new wallet — this rescan can take a while…"; + { std::lock_guard lk(seed_migration_mutex_); seed_migration_adopt_done_ = false; } + // Gate the main-loop reconnect so tryConnect can't start the daemon while we swap wallet.dat. + daemon_restarting_ = true; + if (rpc_ && rpc_->isConnected()) rpc_->disconnect(); + onDisconnected("Adopting seed wallet"); + const std::string base = seed_migration_temp_dir_; + async_tasks_.submit("Adopt seed wallet", [this, base](const util::AsyncTaskManager::Token&) { + namespace fs = std::filesystem; + std::string err; // fatal (swap did not happen; migration incomplete) + std::string warn; // non-fatal (swap done but the daemon did not restart) + bool swapDone = false; + try { + std::error_code ec; + // 1. Stop the main daemon and wait for it to fully exit. + stopEmbeddedDaemon(); + for (int i = 0; i < 60 && isEmbeddedDaemonRunning(); ++i) + std::this_thread::sleep_for(std::chrono::milliseconds(500)); + if (isEmbeddedDaemonRunning()) { + err = "The daemon did not stop in time; your wallet was not changed."; + } else { + // 2. Swap wallet.dat. Move the legacy one aside to a timestamped backup (NEVER + // delete), then copy the new seed wallet in. On any failure, restore the legacy. + const std::string datadir = util::Platform::getDragonXDataDir(); + const std::string legacy = datadir + "/wallet.dat"; + const std::string newWallet = base + "/DRAGONX/wallet.dat"; + std::time_t t = std::time(nullptr); + std::tm tmv{}; localtime_r(&t, &tmv); // thread-safe (the UI thread also uses localtime) + char ts[32]; std::strftime(ts, sizeof(ts), "%Y%m%d-%H%M%S", &tmv); + const std::string backup = legacy + ".legacy-migrated-" + ts + ".bak"; + if (!fs::exists(newWallet)) { + err = "The new wallet file is missing; your wallet was not changed."; + } else { + bool movedLegacy = false; + if (fs::exists(legacy)) { + fs::rename(legacy, backup, ec); + if (ec) err = "Could not back up your current wallet; nothing was changed."; + else movedLegacy = true; + } + if (err.empty()) { + fs::copy_file(newWallet, legacy, fs::copy_options::overwrite_existing, ec); + if (ec) { + if (movedLegacy) { std::error_code ec2; fs::rename(backup, legacy, ec2); } + err = "Could not install the new wallet; your original wallet was restored."; + } else { + swapDone = true; + } + } + } + } + + // 3. Rescan on next start (only if the swap happened) and bring the daemon back up — + // unless we're quitting, in which case don't resurrect it. + if (swapDone && daemon_controller_) daemon_controller_->setRescanOnNextStart(true); + if (!shutting_down_) { + if (!startEmbeddedDaemon() && swapDone) + warn = "Your wallet was swapped, but the daemon did not restart — start it from Settings."; + } + } catch (const std::exception& e) { + err = std::string("Migration failed: ") + e.what(); + } catch (...) { + err = "Migration failed due to an unexpected error."; + } + daemon_restarting_ = false; // ALWAYS re-arm the reconnect gate, even on a throw + + std::lock_guard lk(seed_migration_mutex_); + // Migration counts as done once the swap succeeded (funds already moved + wallet installed); + // a restart hiccup is only a warning. If the swap never happened, it's a full error and the + // pending state is kept so the user can retry. + seed_migration_adopt_ok_ = swapDone; + seed_migration_adopt_err_ = swapDone ? warn : err; + seed_migration_adopt_done_ = true; + }); +} + void App::pumpSeedMigration() { + // --- Adopt handoff (Phase 2, background daemon swap) --- + { + bool done = false, ok = false; std::string err; + { + std::lock_guard lk(seed_migration_mutex_); + if (seed_migration_adopt_done_) { + done = true; ok = seed_migration_adopt_ok_; err = seed_migration_adopt_err_; + seed_migration_adopt_done_ = false; + } + } + if (done) { + if (ok) { + if (!seed_migration_temp_dir_.empty()) { + std::error_code ec; std::filesystem::remove_all(seed_migration_temp_dir_, ec); + } + if (settings_) { + settings_->setSeedMigrationPending(false); + settings_->setSeedMigrationDest(""); + settings_->setSeedMigrationTempDir(""); + settings_->setSeedMigrationSweepTxid(""); + settings_->save(); + } + seed_migration_status_ = err; // a non-empty warning here (e.g. restart hiccup) is shown on Done + seed_migration_step_ = SeedMigrationStep::Done; + } else { + seed_migration_status_ = err.empty() ? "Adopting the new wallet failed." : err; + seed_migration_step_ = SeedMigrationStep::Error; + } + } + } + + // --- Create handoff (Phase 1, background isolated create) --- if (!seed_migration_in_flight_) return; bool done = false, ok = false; std::string seed, dest, tmp, err; diff --git a/src/config/settings.cpp b/src/config/settings.cpp index 48b0e0a..488b5f4 100644 --- a/src/config/settings.cpp +++ b/src/config/settings.cpp @@ -203,6 +203,7 @@ bool Settings::load(const std::string& path) loadScalar(j, "seed_migration_pending", seed_migration_pending_); loadScalar(j, "seed_migration_dest", seed_migration_dest_); loadScalar(j, "seed_migration_temp_dir", seed_migration_temp_dir_); + loadScalar(j, "seed_migration_sweep_txid", seed_migration_sweep_txid_); loadScalar(j, "auto_lock_timeout", auto_lock_timeout_); loadScalar(j, "unlock_duration", unlock_duration_); loadScalar(j, "pin_enabled", pin_enabled_); @@ -440,6 +441,7 @@ bool Settings::save(const std::string& path) j["seed_migration_pending"] = seed_migration_pending_; j["seed_migration_dest"] = seed_migration_dest_; j["seed_migration_temp_dir"] = seed_migration_temp_dir_; + j["seed_migration_sweep_txid"] = seed_migration_sweep_txid_; j["auto_lock_timeout"] = auto_lock_timeout_; j["unlock_duration"] = unlock_duration_; j["pin_enabled"] = pin_enabled_; diff --git a/src/config/settings.h b/src/config/settings.h index 78cf496..f4002cc 100644 --- a/src/config/settings.h +++ b/src/config/settings.h @@ -258,6 +258,10 @@ public: void setSeedMigrationDest(const std::string& v) { seed_migration_dest_ = v; } std::string getSeedMigrationTempDir() const { return seed_migration_temp_dir_; } void setSeedMigrationTempDir(const std::string& v) { seed_migration_temp_dir_ = v; } + // The sweep transaction id, persisted once the sweep is submitted — non-empty means the + // migration is past the sweep, so a resume goes to the confirm/adopt stage (not sweep again). + std::string getSeedMigrationSweepTxid() const { return seed_migration_sweep_txid_; } + void setSeedMigrationSweepTxid(const std::string& v) { seed_migration_sweep_txid_ = v; } // Security — auto-lock timeout (seconds; 0 = disabled) int getAutoLockTimeout() const { return auto_lock_timeout_; } @@ -461,6 +465,7 @@ private: bool seed_migration_pending_ = false; std::string seed_migration_dest_; std::string seed_migration_temp_dir_; + std::string seed_migration_sweep_txid_; int auto_lock_timeout_ = 900; // 15 minutes int unlock_duration_ = 600; // 10 minutes bool pin_enabled_ = false;