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

@@ -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