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:
+9
-4
@@ -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"));
|
||||
}
|
||||
|
||||
@@ -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
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.";
|
||||
|
||||
Reference in New Issue
Block a user