feat(wallet): migrate-to-seed, Phase 1 — isolated seed-wallet creation

First, fund-safe step of "migrate a legacy wallet to a seed wallet": mint a
brand-new BIP39 mnemonic wallet in an ISOLATED throwaway datadir so the user
can later sweep funds into it. No funds move and the real wallet.dat / main
daemon are never touched — the isolated node runs concurrently on its own port.

- EmbeddedDaemon: one-shot isolated-datadir override (setNextStartOverride)
  consumed on the next start(); a skip-default-port-check for an isolated
  instance beside the main daemon; and a tcpPortInUse() probe. The datadir's
  basename must be the assetchain name (DRAGONX) and the daemon reads
  <datadir>/DRAGONX.conf itself, so no -conf is passed (both learned from
  live testing against the hd-transparent-keys daemon).
- SeedWalletCreator (src/daemon): picks a free port, writes a throwaway conf,
  starts an isolated dragonxd with -usemnemonic=1 -connect=0, waits for RPC,
  calls z_exportmnemonic + z_getnewaddress (the sweep target), stops it, and
  keeps/scrubs the temp datadir. Off-thread; the seed is wiped after use.
- App: beginCreateSeedWallet (background) -> pumpSeedMigration (main-thread
  handoff) -> renderSeedMigrationDialog (Intro -> Working -> Show seed ->
  Error), reusing the seed_display grid + clipboard auto-clear.
- Settings: "Migrate to seed…" button (full-node gated) + persisted pending
  migration state (dest address + temp datadir) for Phase 2 to adopt.

Validated end-to-end against the hd-transparent-keys daemon: the isolated
node comes up in ~4s and returns a 24-word mnemonic + a shielded z-address.
Requires that daemon (the bundled Jun-28 build lacks z_exportmnemonic). The
migration modal uses English literals for now (as renderBackupDialog does);
the whole flow gets translated once Phase 2 finalizes it. Phase 2 (sweep +
adopt) is next.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-07-07 12:53:36 -05:00
parent 403e145b30
commit 718cb82c13
12 changed files with 466 additions and 5 deletions

View File

@@ -719,6 +719,9 @@ 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).
pumpSeedMigration();
// Auto-lock check (only when connected + encrypted + unlocked)
if (state_.connected && state_.isUnlocked()) {
checkAutoLock();
@@ -1901,6 +1904,10 @@ void App::render()
renderSeedBackupDialog();
}
if (show_seed_migration_) {
renderSeedMigrationDialog();
}
// Security overlay dialogs (encrypt, decrypt, PIN) are rendered AFTER ImGui::End()
// to ensure they appear on top of all other content
@@ -3029,6 +3036,124 @@ void App::renderSeedBackupDialog()
ui::material::EndOverlayDialog();
}
void App::renderSeedMigrationDialog()
{
auto wipeSeed = [this]() {
if (!seed_migration_seed_.empty())
sodium_memzero(&seed_migration_seed_[0], seed_migration_seed_.size());
seed_migration_seed_.clear();
};
auto close = [this, &wipeSeed]() { wipeSeed(); show_seed_migration_ = false; };
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();
return;
}
ui::material::Type().text(ui::material::TypeStyle::H6, "Migrate to a seed wallet");
ImGui::Dummy(ImVec2(0, ui::Layout::spacingSm()));
switch (seed_migration_step_) {
case SeedMigrationStep::Intro:
ImGui::TextWrapped(
"This creates a brand-new wallet backed by a 24-word seed phrase, in an isolated node, "
"so you can move your funds into it. Your current wallet is NOT touched and NO funds "
"move in this step — you'll back up the new seed first, then sweep your funds into it "
"as a separate, confirmed step.");
ImGui::Spacing();
ImGui::Separator();
if (ui::material::StyledButton("Create seed wallet", ImVec2(180, 0)))
beginCreateSeedWallet();
ImGui::SameLine();
if (ui::material::StyledButton("Cancel", ImVec2(120, 0)))
close();
break;
case SeedMigrationStep::Working:
ImGui::TextWrapped("Creating your new seed wallet in an isolated node — this can take a "
"minute. Your main wallet keeps running.");
ImGui::Spacing();
if (!seed_migration_status_.empty())
ImGui::TextColored(ImGui::ColorConvertU32ToFloat4(ui::material::OnSurfaceMedium()), "%s", seed_migration_status_.c_str());
break;
case SeedMigrationStep::ShowSeed: {
ImGui::PushStyleColor(ImGuiCol_Text, ui::material::Error());
ImGui::TextWrapped("Write these 24 words down in order and store them offline. They are the "
"only backup of your new wallet — if you lose them, the funds you migrate "
"are gone forever.");
ImGui::PopStyleColor();
ImGui::Spacing();
ui::RenderSeedWordGrid(ui::SplitSeedWords(seed_migration_seed_));
ImGui::Spacing();
ImGui::TextColored(ImGui::ColorConvertU32ToFloat4(ui::material::OnSurfaceMedium()), "New wallet receive address:");
ImGui::TextWrapped("%s", seed_migration_dest_.c_str());
ImGui::Spacing();
if (ui::material::StyledButton("Copy seed", ImVec2(120, 0)))
copySecretToClipboard(seed_migration_seed_);
ImGui::SameLine();
if (ui::material::StyledButton("Save to file", ImVec2(130, 0))) {
std::string path = util::Platform::getConfigDir() + "/dragonx-seed-wallet-backup.txt";
std::string content = seed_migration_seed_ + "\nAddress: " + seed_migration_dest_ + "\n";
const bool ok = util::Platform::writeFileAtomically(path, content, /*restrict=*/true);
sodium_memzero(&content[0], content.size());
seed_migration_status_ = (ok ? "Saved to " : "Could not write ") + path;
}
if (!seed_migration_status_.empty()) {
ImGui::TextColored(ImVec4(0.3f, 0.8f, 0.3f, 1.0f), "%s", seed_migration_status_.c_str());
}
ImGui::Spacing();
ImGui::Checkbox("I've written down my seed phrase", &seed_migration_backed_up_);
ImGui::Spacing();
ImGui::PushStyleColor(ImGuiCol_Text, ui::material::OnSurfaceMedium());
ImGui::TextWrapped("Step 1 of 2 complete — your funds have NOT moved yet. Sweeping them into "
"this new wallet is the next step.");
ImGui::PopStyleColor();
ImGui::Separator();
if (!seed_migration_backed_up_) ImGui::BeginDisabled();
if (ui::material::StyledButton("Done", ImVec2(120, 0)))
close();
if (!seed_migration_backed_up_) ImGui::EndDisabled();
ImGui::SameLine();
if (ui::material::StyledButton("Discard migration", ImVec2(160, 0))) {
// Throw away the isolated new wallet + clear the pending state.
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_->save();
}
seed_migration_temp_dir_.clear();
seed_migration_dest_.clear();
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();
}
void App::renderAntivirusHelpDialog()
{
#ifdef _WIN32