From 0cb1df3bfec8e04374442fe049ec24e317453644 Mon Sep 17 00:00:00 2001 From: DanS Date: Mon, 19 Oct 2026 15:22:17 -0500 Subject: [PATCH] fix(migrate): require a reorg-safe sweep depth + re-verify before adopt; make the wallet.dat swap crash-safe MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Resilience audit, migrate-to-seed (the sweep RPC is byte-identical — see below): - Adopt was enabled at >= 1 confirmation and never re-read state before stopping the daemon, so a 1-block reorg after the swap could un-mine the sweep and strand the funds in the .bak. Adopt now needs a reorg-safe depth (>= 10, matching the History "confirmed" threshold), and the button runs verifyThenAdoptSeedWallet(): a read-only gettransaction(confs) + z_gettotalbalance minconf 0 (includes unconfirmed, fixing the old minconf-1 "no funds" gate) immediately before the daemon stop, refusing while syncing or if any read fails. The remainder re-sweep still shows once mined (>=1). - The wallet.dat swap was rename(legacy->bak) then non-atomic copy(new->wallet.dat): a hard kill left wallet.dat missing or half-written. New order: probe the new file is a BDB wallet; stage a size-verified copy beside wallet.dat; COPY (never overwrite) the legacy wallet to the timestamped .bak and verify it; then ONE atomic fs::rename of the staged file over wallet.dat. At every instant wallet.dat is the whole old or whole new wallet, and a verified .bak exists before the replace. On any failure only our scratch files are removed; the user's wallet is untouched. Byte-identical: z_mergetoaddress (beginSweepToSeedWallet, ["ANY_TADDR","ANY_ZADDR"], amounts, fee, recipient, memo) and beginAdoptSeedWallet's disconnect/vault-removal/stop/rescan/restart logic are unchanged; only the adopt gating, two new read-only RPCs, presentation, and the file-move safety change. Pure tests: sweepConfsAreReorgSafe + adoptReverifyPasses. Co-Authored-By: Claude Opus 4.8 --- src/app.cpp | 13 ++-- src/app.h | 1 + src/app_network.cpp | 113 +++++++++++++++++++++++++++---- src/data/seed_migration_resume.h | 20 ++++++ src/util/i18n.cpp | 2 + tests/test_phase4.cpp | 18 +++++ 6 files changed, 150 insertions(+), 17 deletions(-) diff --git a/src/app.cpp b/src/app.cpp index 7662085..18da0a1 100644 --- a/src/app.cpp +++ b/src/app.cpp @@ -5043,7 +5043,7 @@ void App::renderSeedMigrationDialog() ImGui::Spacing(); if (!seed_migration_nofunds_confirmed_) ImGui::BeginDisabled(); if (ui::material::TactileButton(TR("mig_adopt_now"), ImVec2(190 * dp, 0))) - beginAdoptSeedWallet(); + verifyThenAdoptSeedWallet(); // fresh minconf-0, sync-aware balance check before the swap if (!seed_migration_nofunds_confirmed_) ImGui::EndDisabled(); ImGui::SameLine(); if (ui::material::TactileButton(TR("refresh"), ImVec2(110 * dp, 0))) @@ -5107,7 +5107,8 @@ void App::renderSeedMigrationDialog() case SeedMigrationStep::Confirming: { const double dust = DRAGONX_DEFAULT_FEE; // treat <= one fee as "empty" - const bool confirmed = seed_migration_sweep_confs_ >= 1; + const bool mined = seed_migration_sweep_confs_ >= 1; // remainder re-sweep stays offered once mined + const bool confirmed = dragonx::sweepConfsAreReorgSafe(seed_migration_sweep_confs_); // adopt needs reorg-safe depth const bool legacyEmpty = seed_migration_legacy_remaining_ >= 0.0 && seed_migration_legacy_remaining_ <= dust; ImGui::TextWrapped("%s", TR("mig_confirming")); @@ -5127,8 +5128,8 @@ void App::renderSeedMigrationDialog() ImGui::Spacing(); if (confirmed && legacyEmpty) { if (ui::material::TactileButton(TR("mig_adopt"), ImVec2(180 * dp, 0))) - beginAdoptSeedWallet(); - } else if (confirmed && seed_migration_legacy_remaining_ >= 0.0 && !legacyEmpty) { + verifyThenAdoptSeedWallet(); // re-reads confs + balance right before the daemon stop + } else if (mined && seed_migration_legacy_remaining_ >= 0.0 && !legacyEmpty) { // Only claim a remainder once the legacy balance was actually measured; while a sweep is // mined but the balance scan is still gated (node syncing), remaining stays at the -1.0 // sentinel, so fall through to the waiting state rather than offering a sweep on an unknown. @@ -5152,6 +5153,10 @@ void App::renderSeedMigrationDialog() if (ui::material::TactileButton(TR("cancel"), ImVec2(110 * dp, 0))) seed_migration_confirm_sweep_ = false; } + } else if (mined && legacyEmpty) { + // Mined but not yet reorg-safe deep: say so instead of the misleading "waiting to be mined". + char dbuf[200]; snprintf(dbuf, sizeof(dbuf), TR("mig_waiting_depth"), dragonx::kSweepAdoptMinConfs); + ImGui::TextWrapped("%s", dbuf); } else { ImGui::TextColored(kMedium, "%s", TR("mig_waiting_mine")); } diff --git a/src/app.h b/src/app.h index 06797a3..93af85a 100644 --- a/src/app.h +++ b/src/app.h @@ -1188,6 +1188,7 @@ private: void pollSweepStatus(bool forceBalance = false); // Confirming step: confs always; balance only when it can move the gate void locateSweepTxid(); // recover a lost sweep txid (send to dest) -> Confirming gate void beginAdoptSeedWallet(); // stop daemon -> swap wallet.dat -> restart with -rescan + void verifyThenAdoptSeedWallet(); // re-read sweep confs + minconf-0 balance, then beginAdoptSeedWallet() iff safe void provisionChatIdentityFromSecret(std::string secret); std::string chatReplyZaddr(); // a stable (persisted) wallet z-addr — chat IDENTITY (reply-to) // A spendable z-address that can actually PAY `need` (a fee, or a split's notes plus its fee), preferring diff --git a/src/app_network.cpp b/src/app_network.cpp index a9b0e25..9cb977e 100644 --- a/src/app_network.cpp +++ b/src/app_network.cpp @@ -41,6 +41,10 @@ #include "ui/windows/mining_pool_panel.h" // ui::resolveMiningUserAddress #include // sodium_memzero for wiping the fetched mnemonic #include +#ifndef _WIN32 +#include // open/fsync: flush the staged wallet + .bak before the atomic swap +#include +#endif #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 @@ -6571,6 +6575,57 @@ void App::locateSweepTxid() }); } +// Final gate before the swap: on the RPC worker, RE-READ the sweep tx's confirmations (when a sweep txid +// exists) and z_gettotalbalance with minconf 0 (includes unconfirmed, unlike the default minconf 1), then +// - only if both are reorg-safe, the node is not syncing and the legacy balance is ~0 - call +// beginAdoptSeedWallet() on the main thread, so the disconnect + daemon stop follow these reads at once. +// Read-only RPCs; any failure blocks adopt (nothing is stopped or swapped) and the user may retry. +void App::verifyThenAdoptSeedWallet() +{ + if (capture_mode_) { beginAdoptSeedWallet(); return; } // UI sweep: unchanged behaviour + if (seed_migration_confirm_in_flight_) return; + if (!rpc_ || !worker_ || !state_.connected) { + ui::Notifications::instance().warning(TR("mig_adopt_reverify_failed")); + return; + } + seed_migration_confirm_in_flight_ = true; + const std::string txid = seed_migration_sweep_txid_; + const bool needTx = !txid.empty(); + const bool syncing = effectivelySyncing(); // main-thread snapshot for the worker + worker_->post([this, txid, needTx, syncing]() -> rpc::RPCWorker::MainCb { + int confs = 0; double total = -1.0; bool ok = false; + rpc::RPCClient::TraceScope trace("Migrate / pre-adopt verify"); + try { + if (needTx) { + auto tx = rpc_->call("gettransaction", {txid}); + if (!tx.contains("confirmations") || !tx["confirmations"].is_number()) throw std::runtime_error("no confirmations"); + confs = tx["confirmations"].get(); + } + auto bal = rpc_->call("z_gettotalbalance", nlohmann::json::array({0})); // minconf 0 + if (bal.contains("total") && bal["total"].is_string()) { + total = std::stod(bal["total"].get()); + ok = true; + } + } catch (...) { ok = false; } + return [this, needTx, syncing, confs, total, ok]() { + seed_migration_confirm_in_flight_ = false; + if (ok) { + if (needTx) seed_migration_sweep_confs_ = confs; + seed_migration_legacy_remaining_ = total; + } + // Only act while still on a step that offers Adopt (the user may have closed/changed it). + const bool onAdoptStep = seed_migration_step_ == SeedMigrationStep::Confirming || + seed_migration_step_ == SeedMigrationStep::Sweep; + if (ok && onAdoptStep && + dragonx::adoptReverifyPasses(needTx, confs, total, syncing, DRAGONX_DEFAULT_FEE)) { + beginAdoptSeedWallet(); // unchanged: stop daemon -> swap -> restart with -rescan + } else if (onAdoptStep) { + ui::Notifications::instance().warning(TR("mig_adopt_reverify_failed"), 12.0f); + } + }; + }); +} + // 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. @@ -6632,21 +6687,53 @@ void App::beginAdoptSeedWallet() 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 if (!util::probeWalletFile(newWallet).isBerkeleyDB) { + err = "The new wallet file is not a valid wallet; 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; + // Crash-safe order: wallet.dat is only ever REPLACED by one atomic rename of a complete, + // size-verified staged copy, and only AFTER a complete size-verified .bak of the legacy + // wallet exists. A hard kill at any point leaves wallet.dat as either the whole old or the + // whole new wallet - never missing or half-written. Nothing of the user's is deleted. + const std::string staged = legacy + ".adopt-tmp"; + auto sameSize = [](const std::string& a, const std::string& b) { + std::error_code e1, e2; + const auto sa = fs::file_size(a, e1); + const auto sb = fs::file_size(b, e2); + return !e1 && !e2 && sa == sb && sa > 0; + }; +#ifndef _WIN32 + auto syncFile = [](const std::string& p) { // best-effort flush to disk + int fd = ::open(p.c_str(), O_RDONLY); + if (fd >= 0) { ::fsync(fd); ::close(fd); } + }; +#else + auto syncFile = [](const std::string&) {}; +#endif + bool backupCreated = false; + // (a) Stage a full copy of the new wallet beside wallet.dat (same dir => atomic rename). + fs::copy_file(newWallet, staged, fs::copy_options::overwrite_existing, ec); + if (ec || !sameSize(newWallet, staged)) { + err = "Could not stage the new wallet; nothing was changed."; + } else { + syncFile(staged); + // (b) COPY (not rename) the legacy wallet to the timestamped .bak and verify it. + if (fs::exists(legacy)) { + backupCreated = !fs::exists(backup); + fs::copy_file(legacy, backup, fs::copy_options::none, ec); // never overwrites an existing .bak + if (ec || !sameSize(legacy, backup)) err = "Could not back up your current wallet; nothing was changed."; + else syncFile(backup); } + // (c) Atomically replace wallet.dat with the staged wallet. + if (err.empty()) { + fs::rename(staged, legacy, ec); + if (ec) err = "Could not install the new wallet; your original wallet was left unchanged."; + else swapDone = true; + } + } + if (!err.empty()) { // failed before the replace: original untouched; drop only OUR scratch files + std::error_code e3; + fs::remove(staged, e3); + if (backupCreated) fs::remove(backup, e3); } } } diff --git a/src/data/seed_migration_resume.h b/src/data/seed_migration_resume.h index 9b29d77..7b6bc0e 100644 --- a/src/data/seed_migration_resume.h +++ b/src/data/seed_migration_resume.h @@ -42,4 +42,24 @@ inline MigrationResume decideSeedMigrationResume(bool pending, // wallet.dat on an unconfirmed sweep. (fund-safety: audit) inline bool sweepGateMayAdoptEmptyWallet(bool sweepSubmitted) { return !sweepSubmitted; } +// Reorg-safe depth before the adopt swap is enabled. Matches the History "confirmed" threshold +// (>= 10, data/transaction_history_store_policy.h): a 1-block reorg must not be able to un-mine the +// sweep after wallet.dat has been moved aside, which would strand the funds in the .bak. +inline constexpr int kSweepAdoptMinConfs = 10; +inline bool sweepConfsAreReorgSafe(int confs) { return confs >= kSweepAdoptMinConfs; } + +// Final pre-stop re-verification, run on fresh RPC reads immediately before the daemon is stopped. +// needSweepTx : a sweep txid exists, so its confirmations must be reorg-safe (false = genuinely +// empty wallet, no sweep ever submitted). +// confs : fresh gettransaction confirmations (negative = conflicted). +// balanceMinconf0 : fresh z_gettotalbalance with minconf 0 (INCLUDES unconfirmed), < 0 = unknown. +// syncing : the node is still catching up, so balance reads are not trustworthy. +inline bool adoptReverifyPasses(bool needSweepTx, int confs, double balanceMinconf0, + bool syncing, double dust) +{ + if (syncing) return false; + if (needSweepTx && !sweepConfsAreReorgSafe(confs)) return false; + return balanceMinconf0 >= 0.0 && balanceMinconf0 <= dust; +} + } // namespace dragonx diff --git a/src/util/i18n.cpp b/src/util/i18n.cpp index fea33e3..23dc491 100644 --- a/src/util/i18n.cpp +++ b/src/util/i18n.cpp @@ -521,6 +521,8 @@ void I18n::loadBuiltinEnglish() strings_["mig_sweep_confirm"] = "Send %.8f DRGX from your current wallet to the new seed wallet in a single transaction? This moves all of your funds."; strings_["mig_sweep_confirm_yes"] = "Yes, sweep funds"; strings_["mig_waiting_mine"] = "Waiting for the sweep transaction to be mined…"; + strings_["mig_waiting_depth"] = "Sweep mined. Waiting for %d confirmations before switching wallets, so a short chain reorganisation cannot undo it…"; + strings_["mig_adopt_reverify_failed"] = "Could not confirm it is safe to switch wallets yet (the node may still be syncing, the sweep is not deep enough, or funds remain). Nothing was changed - wait and try again."; strings_["mig_adopting"] = "Installing your new wallet and rescanning…"; strings_["mig_done"] = "Migration complete. Your wallet is now backed by your seed phrase."; strings_["mig_done_detail"] = "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."; diff --git a/tests/test_phase4.cpp b/tests/test_phase4.cpp index 3bfba3c..5f381cb 100644 --- a/tests/test_phase4.cpp +++ b/tests/test_phase4.cpp @@ -3846,6 +3846,24 @@ void testSeedMigrationResume() // ambiguous (the tx may be unconfirmed in the mempool), so direct adopt must be blocked. EXPECT_TRUE(dragonx::sweepGateMayAdoptEmptyWallet(/*sweepSubmitted=*/false)); // empty wallet -> ok EXPECT_FALSE(dragonx::sweepGateMayAdoptEmptyWallet(/*sweepSubmitted=*/true)); // in-flight -> blocked + + // FIX E: reorg-safe adopt depth + the final pre-stop re-verification verdict. + EXPECT_EQ(dragonx::kSweepAdoptMinConfs, 10); + EXPECT_FALSE(dragonx::sweepConfsAreReorgSafe(-1)); + EXPECT_FALSE(dragonx::sweepConfsAreReorgSafe(0)); + EXPECT_FALSE(dragonx::sweepConfsAreReorgSafe(1)); + EXPECT_FALSE(dragonx::sweepConfsAreReorgSafe(9)); + EXPECT_TRUE(dragonx::sweepConfsAreReorgSafe(10)); + const double kDust = 0.0001; + EXPECT_TRUE(dragonx::adoptReverifyPasses(true, 10, 0.0, false, kDust)); // deep + empty + synced + EXPECT_FALSE(dragonx::adoptReverifyPasses(true, 9, 0.0, false, kDust)); // 1-block-reorg window + EXPECT_FALSE(dragonx::adoptReverifyPasses(true, -1, 0.0, false, kDust)); // conflicted + EXPECT_FALSE(dragonx::adoptReverifyPasses(true, 50, 0.0, true, kDust)); // syncing -> balance untrusted + EXPECT_FALSE(dragonx::adoptReverifyPasses(true, 50, 5.0, false, kDust)); // funds remain / unconfirmed + EXPECT_FALSE(dragonx::adoptReverifyPasses(true, 50, -1.0, false, kDust)); // balance unknown + EXPECT_TRUE(dragonx::adoptReverifyPasses(false, 0, 0.0, false, kDust)); // genuinely empty, no sweep + EXPECT_FALSE(dragonx::adoptReverifyPasses(false, 0, 2.0, false, kDust)); // no-funds path but unconfirmed funds + EXPECT_FALSE(dragonx::adoptReverifyPasses(false, 0, 0.0, true, kDust)); // no-funds path while syncing } void testLoggerFileSink()