feat(wallet): migrate-to-seed, Phase 2 — sweep + adopt (fund-safety gated)

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) <noreply@anthropic.com>
This commit is contained in:
2026-07-07 15:04:40 -05:00
parent 7ee20bb497
commit e66f2b6c8c
5 changed files with 427 additions and 19 deletions

View File

@@ -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 <cmath>
#include <ctime>
#include <filesystem>
#include <thread>
#include <chrono>
#include <fstream>
#include <utility>
@@ -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<std::string>());
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<int>();
auto bal = rpc_->call("z_gettotalbalance");
if (bal.contains("total") && bal["total"].is_string())
remaining = std::stod(bal["total"].get<std::string>());
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<std::mutex> 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<std::mutex> 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<std::mutex> 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;