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:
164
src/app.cpp
164
src/app.cpp
@@ -119,7 +119,14 @@ App::App()
|
|||||||
// deterministic across launches.
|
// deterministic across launches.
|
||||||
balance_rng_.seed(std::random_device{}());
|
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 {
|
namespace {
|
||||||
// How often auto-balance re-evaluates the pool while active. Long, because switching
|
// 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).
|
// One-time reminder to back up the wallet's seed phrase (mnemonic wallets only).
|
||||||
maybeRemindSeedBackup();
|
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();
|
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)
|
// Auto-lock check (only when connected + encrypted + unlocked)
|
||||||
if (state_.connected && state_.isUnlocked()) {
|
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)) {
|
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();
|
if (seed_backup_fetch_started_) closeAndWipe();
|
||||||
return;
|
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"));
|
ui::material::Type().text(ui::material::TypeStyle::H6, TR("seed_backup_title"));
|
||||||
ImGui::Dummy(ImVec2(0, ui::Layout::spacingSm()));
|
ImGui::Dummy(ImVec2(0, ui::Layout::spacingSm()));
|
||||||
@@ -3045,13 +3066,20 @@ void App::renderSeedMigrationDialog()
|
|||||||
};
|
};
|
||||||
auto close = [this, &wipeSeed]() { wipeSeed(); show_seed_migration_ = false; };
|
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)) {
|
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);
|
if (!busy) wipeSeed();
|
||||||
// otherwise a dismiss wipes the revealed seed.
|
|
||||||
if (seed_migration_in_flight_) show_seed_migration_ = true;
|
|
||||||
else wipeSeed();
|
|
||||||
return;
|
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");
|
ui::material::Type().text(ui::material::TypeStyle::H6, "Migrate to a seed wallet");
|
||||||
ImGui::Dummy(ImVec2(0, ui::Layout::spacingSm()));
|
ImGui::Dummy(ImVec2(0, ui::Layout::spacingSm()));
|
||||||
@@ -3114,8 +3142,11 @@ void App::renderSeedMigrationDialog()
|
|||||||
ImGui::PopStyleColor();
|
ImGui::PopStyleColor();
|
||||||
ImGui::Separator();
|
ImGui::Separator();
|
||||||
if (!seed_migration_backed_up_) ImGui::BeginDisabled();
|
if (!seed_migration_backed_up_) ImGui::BeginDisabled();
|
||||||
if (ui::material::StyledButton("Done", ImVec2(120, 0)))
|
if (ui::material::StyledButton("Continue to sweep", ImVec2(170, 0))) {
|
||||||
close();
|
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();
|
if (!seed_migration_backed_up_) ImGui::EndDisabled();
|
||||||
ImGui::SameLine();
|
ImGui::SameLine();
|
||||||
if (ui::material::StyledButton("Discard migration", ImVec2(160, 0))) {
|
if (ui::material::StyledButton("Discard migration", ImVec2(160, 0))) {
|
||||||
@@ -3137,21 +3168,125 @@ void App::renderSeedMigrationDialog()
|
|||||||
break;
|
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:
|
case SeedMigrationStep::Error:
|
||||||
ImGui::PushStyleColor(ImGuiCol_Text, ui::material::Error());
|
ImGui::PushStyleColor(ImGuiCol_Text, ui::material::Error());
|
||||||
ImGui::TextWrapped("%s", seed_migration_status_.c_str());
|
ImGui::TextWrapped("%s", seed_migration_status_.c_str());
|
||||||
ImGui::PopStyleColor();
|
ImGui::PopStyleColor();
|
||||||
ImGui::Spacing();
|
ImGui::Spacing();
|
||||||
ImGui::Separator();
|
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)))
|
if (ui::material::StyledButton("Close", ImVec2(120, 0)))
|
||||||
close();
|
close();
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
ui::material::EndOverlayDialog();
|
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()
|
void App::renderAntivirusHelpDialog()
|
||||||
@@ -3700,6 +3835,11 @@ void App::beginShutdown()
|
|||||||
shutdown_timer_ = 0.0f;
|
shutdown_timer_ = 0.0f;
|
||||||
shutdown_start_time_ = std::chrono::steady_clock::now();
|
shutdown_start_time_ = std::chrono::steady_clock::now();
|
||||||
async_tasks_.cancelAll();
|
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
|
// 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.
|
// already in flight so the later join() doesn't wait out a request timeout.
|
||||||
|
|||||||
27
src/app.h
27
src/app.h
@@ -353,7 +353,7 @@ public:
|
|||||||
void showExportKeyDialog() { show_export_key_ = true; }
|
void showExportKeyDialog() { show_export_key_ = true; }
|
||||||
void showBackupDialog() { show_backup_ = true; }
|
void showBackupDialog() { show_backup_ = true; }
|
||||||
void showSeedBackupDialog() { show_seed_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; }
|
void showAboutDialog() { show_about_ = true; }
|
||||||
|
|
||||||
// Legacy tab compat — maps int to NavPage
|
// 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).
|
// 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 beginCreateSeedWallet(); // starts the isolated create on a background thread
|
||||||
void pumpSeedMigration(); // main thread: pick up background progress/result each frame
|
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);
|
void provisionChatIdentityFromSecret(std::string secret);
|
||||||
std::string chatReplyZaddr(); // a stable (persisted) wallet z-addr for chat
|
std::string chatReplyZaddr(); // a stable (persisted) wallet z-addr for chat
|
||||||
std::string generateChatLocalId(const char* prefix, int numBytes) const; // unique echo id / cid
|
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_no_mnemonic_ = false;
|
||||||
bool seed_backup_reminder_in_flight_ = false; // guards the one-time backup nudge probe
|
bool seed_backup_reminder_in_flight_ = false; // guards the one-time backup nudge probe
|
||||||
|
|
||||||
// --- Seed-wallet migration (Phase 1: isolated seed-wallet creation) ---
|
// --- Seed-wallet migration (Phase 1: create; Phase 2: sweep + adopt) ---
|
||||||
enum class SeedMigrationStep { Intro, Working, ShowSeed, Error };
|
enum class SeedMigrationStep { Intro, Working, ShowSeed, Sweep, Sweeping, Confirming, Adopting, Done, Error };
|
||||||
bool show_seed_migration_ = false;
|
bool show_seed_migration_ = false;
|
||||||
SeedMigrationStep seed_migration_step_ = SeedMigrationStep::Intro;
|
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_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_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
|
bool seed_migration_backed_up_ = false; // "I've written it down" confirmation
|
||||||
std::string seed_migration_status_; // main-thread display (progress / error text)
|
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_).
|
// Cross-thread handoff from the background create task (guarded by seed_migration_mutex_).
|
||||||
std::mutex seed_migration_mutex_;
|
std::mutex seed_migration_mutex_;
|
||||||
std::string seed_migration_progress_; // latest progress line
|
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;
|
bool seed_migration_ok_ = false;
|
||||||
std::string seed_migration_r_seed_, seed_migration_r_dest_, seed_migration_r_tmp_, seed_migration_r_err_;
|
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
|
// Embedded daemon state
|
||||||
bool use_embedded_daemon_ = wallet::supportsEmbeddedDaemon(wallet::currentWalletCapabilities());
|
bool use_embedded_daemon_ = wallet::supportsEmbeddedDaemon(wallet::currentWalletCapabilities());
|
||||||
|
|||||||
@@ -39,6 +39,7 @@
|
|||||||
#include "config/settings.h"
|
#include "config/settings.h"
|
||||||
#include "wallet/lite_wallet_controller.h" // lite send/new-address routing
|
#include "wallet/lite_wallet_controller.h" // lite send/new-address routing
|
||||||
#include "wallet/lite_wallet_state_mapper.h" // LiteWalletAppRefreshModel for lite chat harvest
|
#include "wallet/lite_wallet_state_mapper.h" // LiteWalletAppRefreshModel for lite chat harvest
|
||||||
|
#include "config/version.h"
|
||||||
#include "daemon/daemon_controller.h"
|
#include "daemon/daemon_controller.h"
|
||||||
#include "daemon/embedded_daemon.h"
|
#include "daemon/embedded_daemon.h"
|
||||||
#include "daemon/seed_wallet_creator.h"
|
#include "daemon/seed_wallet_creator.h"
|
||||||
@@ -60,6 +61,8 @@
|
|||||||
#include <cmath>
|
#include <cmath>
|
||||||
#include <ctime>
|
#include <ctime>
|
||||||
#include <filesystem>
|
#include <filesystem>
|
||||||
|
#include <thread>
|
||||||
|
#include <chrono>
|
||||||
#include <fstream>
|
#include <fstream>
|
||||||
#include <utility>
|
#include <utility>
|
||||||
|
|
||||||
@@ -210,6 +213,10 @@ void App::tryConnect()
|
|||||||
|
|
||||||
if (connection_in_progress_) return;
|
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;
|
static int connect_attempt = 0;
|
||||||
++connect_attempt;
|
++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()
|
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;
|
if (!seed_migration_in_flight_) return;
|
||||||
bool done = false, ok = false;
|
bool done = false, ok = false;
|
||||||
std::string seed, dest, tmp, err;
|
std::string seed, dest, tmp, err;
|
||||||
|
|||||||
@@ -203,6 +203,7 @@ bool Settings::load(const std::string& path)
|
|||||||
loadScalar(j, "seed_migration_pending", seed_migration_pending_);
|
loadScalar(j, "seed_migration_pending", seed_migration_pending_);
|
||||||
loadScalar(j, "seed_migration_dest", seed_migration_dest_);
|
loadScalar(j, "seed_migration_dest", seed_migration_dest_);
|
||||||
loadScalar(j, "seed_migration_temp_dir", seed_migration_temp_dir_);
|
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, "auto_lock_timeout", auto_lock_timeout_);
|
||||||
loadScalar(j, "unlock_duration", unlock_duration_);
|
loadScalar(j, "unlock_duration", unlock_duration_);
|
||||||
loadScalar(j, "pin_enabled", pin_enabled_);
|
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_pending"] = seed_migration_pending_;
|
||||||
j["seed_migration_dest"] = seed_migration_dest_;
|
j["seed_migration_dest"] = seed_migration_dest_;
|
||||||
j["seed_migration_temp_dir"] = seed_migration_temp_dir_;
|
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["auto_lock_timeout"] = auto_lock_timeout_;
|
||||||
j["unlock_duration"] = unlock_duration_;
|
j["unlock_duration"] = unlock_duration_;
|
||||||
j["pin_enabled"] = pin_enabled_;
|
j["pin_enabled"] = pin_enabled_;
|
||||||
|
|||||||
@@ -258,6 +258,10 @@ public:
|
|||||||
void setSeedMigrationDest(const std::string& v) { seed_migration_dest_ = v; }
|
void setSeedMigrationDest(const std::string& v) { seed_migration_dest_ = v; }
|
||||||
std::string getSeedMigrationTempDir() const { return seed_migration_temp_dir_; }
|
std::string getSeedMigrationTempDir() const { return seed_migration_temp_dir_; }
|
||||||
void setSeedMigrationTempDir(const std::string& v) { seed_migration_temp_dir_ = v; }
|
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)
|
// Security — auto-lock timeout (seconds; 0 = disabled)
|
||||||
int getAutoLockTimeout() const { return auto_lock_timeout_; }
|
int getAutoLockTimeout() const { return auto_lock_timeout_; }
|
||||||
@@ -461,6 +465,7 @@ private:
|
|||||||
bool seed_migration_pending_ = false;
|
bool seed_migration_pending_ = false;
|
||||||
std::string seed_migration_dest_;
|
std::string seed_migration_dest_;
|
||||||
std::string seed_migration_temp_dir_;
|
std::string seed_migration_temp_dir_;
|
||||||
|
std::string seed_migration_sweep_txid_;
|
||||||
int auto_lock_timeout_ = 900; // 15 minutes
|
int auto_lock_timeout_ = 900; // 15 minutes
|
||||||
int unlock_duration_ = 600; // 10 minutes
|
int unlock_duration_ = 600; // 10 minutes
|
||||||
bool pin_enabled_ = false;
|
bool pin_enabled_ = false;
|
||||||
|
|||||||
Reference in New Issue
Block a user