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:
166
src/app.cpp
166
src/app.cpp
@@ -119,7 +119,14 @@ App::App()
|
||||
// deterministic across launches.
|
||||
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 {
|
||||
// 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).
|
||||
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();
|
||||
// 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)
|
||||
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)) {
|
||||
// Dismissed (Esc / outside click / X) — scrub the revealed secret.
|
||||
if (seed_backup_fetch_started_) closeAndWipe();
|
||||
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"));
|
||||
ImGui::Dummy(ImVec2(0, ui::Layout::spacingSm()));
|
||||
@@ -3045,13 +3066,20 @@ void App::renderSeedMigrationDialog()
|
||||
};
|
||||
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)) {
|
||||
// Block dismiss while the background create is running (it can't be cancelled cleanly);
|
||||
// otherwise a dismiss wipes the revealed seed.
|
||||
if (seed_migration_in_flight_) show_seed_migration_ = true;
|
||||
else wipeSeed();
|
||||
if (!busy) wipeSeed();
|
||||
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");
|
||||
ImGui::Dummy(ImVec2(0, ui::Layout::spacingSm()));
|
||||
@@ -3114,8 +3142,11 @@ void App::renderSeedMigrationDialog()
|
||||
ImGui::PopStyleColor();
|
||||
ImGui::Separator();
|
||||
if (!seed_migration_backed_up_) ImGui::BeginDisabled();
|
||||
if (ui::material::StyledButton("Done", ImVec2(120, 0)))
|
||||
close();
|
||||
if (ui::material::StyledButton("Continue to sweep", ImVec2(170, 0))) {
|
||||
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();
|
||||
ImGui::SameLine();
|
||||
if (ui::material::StyledButton("Discard migration", ImVec2(160, 0))) {
|
||||
@@ -3137,21 +3168,125 @@ void App::renderSeedMigrationDialog()
|
||||
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:
|
||||
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("Back", ImVec2(120, 0)))
|
||||
seed_migration_step_ = SeedMigrationStep::Intro;
|
||||
ImGui::SameLine();
|
||||
if (ui::material::StyledButton("Close", ImVec2(120, 0)))
|
||||
close();
|
||||
break;
|
||||
}
|
||||
|
||||
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()
|
||||
@@ -3700,7 +3835,12 @@ void App::beginShutdown()
|
||||
shutdown_timer_ = 0.0f;
|
||||
shutdown_start_time_ = std::chrono::steady_clock::now();
|
||||
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
|
||||
// already in flight so the later join() doesn't wait out a request timeout.
|
||||
// The actual thread join + rpc disconnect happen in shutdown() after
|
||||
|
||||
Reference in New Issue
Block a user