fix(migrate): require a reorg-safe sweep depth + re-verify before adopt; make the wallet.dat swap crash-safe

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 <noreply@anthropic.com>
This commit is contained in:
2026-10-19 15:22:17 -05:00
co-authored by Claude Opus 4.8
parent 6f369376ec
commit 0cb1df3bfe
6 changed files with 150 additions and 17 deletions
+9 -4
View File
@@ -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"));
}
+1
View File
@@ -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
+100 -13
View File
@@ -41,6 +41,10 @@
#include "ui/windows/mining_pool_panel.h" // ui::resolveMiningUserAddress
#include <sodium.h> // sodium_memzero for wiping the fetched mnemonic
#include <cctype>
#ifndef _WIN32
#include <fcntl.h> // open/fsync: flush the staged wallet + .bak before the atomic swap
#include <unistd.h>
#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<int>();
}
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<std::string>());
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);
}
}
}
+20
View File
@@ -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
+2
View File
@@ -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.";
+18
View File
@@ -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()