From 718cb82c13c920ae957603208a6a6c97ab061a99 Mon Sep 17 00:00:00 2001 From: DanS Date: Tue, 7 Jul 2026 12:53:36 -0500 Subject: [PATCH] =?UTF-8?q?feat(wallet):=20migrate-to-seed,=20Phase=201=20?= =?UTF-8?q?=E2=80=94=20isolated=20seed-wallet=20creation?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 /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) --- CMakeLists.txt | 2 + src/app.cpp | 125 ++++++++++++++++++++++++++ src/app.h | 24 +++++ src/app_network.cpp | 70 +++++++++++++++ src/config/settings.cpp | 6 ++ src/config/settings.h | 12 +++ src/daemon/embedded_daemon.cpp | 25 +++++- src/daemon/embedded_daemon.h | 26 ++++++ src/daemon/seed_wallet_creator.cpp | 137 +++++++++++++++++++++++++++++ src/daemon/seed_wallet_creator.h | 32 +++++++ src/ui/pages/settings_page.cpp | 10 ++- src/util/i18n.cpp | 2 + 12 files changed, 466 insertions(+), 5 deletions(-) create mode 100644 src/daemon/seed_wallet_creator.cpp create mode 100644 src/daemon/seed_wallet_creator.h diff --git a/CMakeLists.txt b/CMakeLists.txt index cd2ae32..233c888 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -503,6 +503,7 @@ set(APP_SOURCES src/util/texture_loader.cpp src/util/noise_texture.cpp src/daemon/embedded_daemon.cpp + src/daemon/seed_wallet_creator.cpp src/daemon/daemon_controller.cpp src/daemon/lifecycle_adapters.cpp src/daemon/xmrig_manager.cpp @@ -634,6 +635,7 @@ set(APP_HEADERS src/util/payment_uri.h src/util/secure_vault.h src/daemon/embedded_daemon.h + src/daemon/seed_wallet_creator.h src/daemon/daemon_controller.h src/daemon/lifecycle_adapters.h src/daemon/xmrig_manager.h diff --git a/src/app.cpp b/src/app.cpp index 8e8fdb8..e2c1829 100644 --- a/src/app.cpp +++ b/src/app.cpp @@ -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 diff --git a/src/app.h b/src/app.h index 4a327f1..2b9ad04 100644 --- a/src/app.h +++ b/src/app.h @@ -9,6 +9,7 @@ #include #include #include +#include #include #include #include @@ -352,6 +353,7 @@ public: void showExportKeyDialog() { show_export_key_ = true; } void showBackupDialog() { show_backup_ = true; } void showSeedBackupDialog() { show_seed_backup_ = true; } + void showSeedMigrationDialog() { show_seed_migration_ = true; seed_migration_step_ = SeedMigrationStep::Intro; } void showAboutDialog() { show_about_ = true; } // Legacy tab compat — maps int to NavPage @@ -610,6 +612,10 @@ private: // One-time nudge: on a full-node wallet that has a mnemonic, remind the user (once per // install) to back up their seed phrase. Cheap early-outs keep it idle until it can act. void maybeRemindSeedBackup(); + + // 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 pumpSeedMigration(); // main thread: pick up background progress/result each frame void provisionChatIdentityFromSecret(std::string secret); std::string chatReplyZaddr(); // a stable (persisted) wallet z-addr for chat std::string generateChatLocalId(const char* prefix, int numBytes) const; // unique echo id / cid @@ -678,6 +684,23 @@ private: bool seed_backup_no_mnemonic_ = false; bool seed_backup_reminder_in_flight_ = false; // guards the one-time backup nudge probe + // --- Seed-wallet migration (Phase 1: isolated seed-wallet creation) --- + enum class SeedMigrationStep { Intro, Working, ShowSeed, Error }; + bool show_seed_migration_ = false; + SeedMigrationStep seed_migration_step_ = SeedMigrationStep::Intro; + bool seed_migration_in_flight_ = false; // main-thread guard while the bg task runs + 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_temp_dir_; // temp datadir holding the new wallet (kept) + bool seed_migration_backed_up_ = false; // "I've written it down" confirmation + std::string seed_migration_status_; // main-thread display (progress / error text) + // Cross-thread handoff from the background create task (guarded by seed_migration_mutex_). + std::mutex seed_migration_mutex_; + std::string seed_migration_progress_; // latest progress line + bool seed_migration_done_ = false; // result ready to consume + bool seed_migration_ok_ = false; + std::string seed_migration_r_seed_, seed_migration_r_dest_, seed_migration_r_tmp_, seed_migration_r_err_; + // Embedded daemon state bool use_embedded_daemon_ = wallet::supportsEmbeddedDaemon(wallet::currentWalletCapabilities()); std::string daemon_status_; @@ -940,6 +963,7 @@ private: void renderExportKeyDialog(); void renderBackupDialog(); void renderSeedBackupDialog(); // full-node "Back up seed phrase" modal (z_exportmnemonic) + void renderSeedMigrationDialog(); // "Migrate to a seed wallet" guided modal (Phase 1: create) void renderFirstRunWizard(); void renderLockScreen(); void renderEncryptWalletDialog(); diff --git a/src/app_network.cpp b/src/app_network.cpp index 89096bf..3873309 100644 --- a/src/app_network.cpp +++ b/src/app_network.cpp @@ -41,6 +41,7 @@ #include "wallet/lite_wallet_state_mapper.h" // LiteWalletAppRefreshModel for lite chat harvest #include "daemon/daemon_controller.h" #include "daemon/embedded_daemon.h" +#include "daemon/seed_wallet_creator.h" #include "daemon/xmrig_manager.h" #include "util/pool_registry.h" #include "ui/notifications.h" @@ -2926,6 +2927,75 @@ void App::maybeRemindSeedBackup() }); } +void App::beginCreateSeedWallet() +{ + if (seed_migration_in_flight_) return; + seed_migration_in_flight_ = true; + seed_migration_step_ = SeedMigrationStep::Working; + { + std::lock_guard lk(seed_migration_mutex_); + seed_migration_done_ = false; + seed_migration_progress_ = "Starting…"; + } + // Runs the isolated create on a background thread; the main thread picks up progress + the + // result in pumpSeedMigration(). No funds move and the real wallet is never touched. + async_tasks_.submit("Create seed wallet", [this](const util::AsyncTaskManager::Token&) { + auto res = daemon::SeedWalletCreator::create(/*keepDatadir=*/true, + [this](const std::string& msg) { + std::lock_guard lk(seed_migration_mutex_); + seed_migration_progress_ = msg; + }); + std::lock_guard lk(seed_migration_mutex_); + seed_migration_ok_ = res.ok; + seed_migration_r_seed_ = res.seedPhrase; + seed_migration_r_dest_ = res.destAddress; + seed_migration_r_tmp_ = res.tempDatadir; + seed_migration_r_err_ = res.error; + seed_migration_done_ = true; + if (!res.seedPhrase.empty()) sodium_memzero(&res.seedPhrase[0], res.seedPhrase.size()); + }); +} + +void App::pumpSeedMigration() +{ + if (!seed_migration_in_flight_) return; + bool done = false, ok = false; + std::string seed, dest, tmp, err; + { + std::lock_guard lk(seed_migration_mutex_); + seed_migration_status_ = seed_migration_progress_; // live progress for the Working step + done = seed_migration_done_; + if (done) { + ok = seed_migration_ok_; + seed = std::move(seed_migration_r_seed_); + dest = std::move(seed_migration_r_dest_); + tmp = std::move(seed_migration_r_tmp_); + err = std::move(seed_migration_r_err_); + seed_migration_done_ = false; + seed_migration_r_seed_.clear(); + } + } + if (!done) return; + seed_migration_in_flight_ = false; + if (ok) { + seed_migration_seed_ = std::move(seed); + seed_migration_dest_ = std::move(dest); + seed_migration_temp_dir_ = std::move(tmp); + seed_migration_backed_up_ = false; + seed_migration_step_ = SeedMigrationStep::ShowSeed; + // Persist the pending migration so a later sweep/adopt step can find the new wallet. + if (settings_) { + settings_->setSeedMigrationPending(true); + settings_->setSeedMigrationDest(seed_migration_dest_); + settings_->setSeedMigrationTempDir(seed_migration_temp_dir_); + settings_->save(); + } + } else { + seed_migration_status_ = err.empty() ? std::string("Could not create the seed wallet.") : err; + seed_migration_step_ = SeedMigrationStep::Error; + } +} + void App::backupWallet(const std::string& destination, std::function callback) { if (!state_.connected || !rpc_) { diff --git a/src/config/settings.cpp b/src/config/settings.cpp index 9dfc2de..48b0e0a 100644 --- a/src/config/settings.cpp +++ b/src/config/settings.cpp @@ -200,6 +200,9 @@ bool Settings::load(const std::string& path) } loadScalar(j, "wizard_completed", wizard_completed_); loadScalar(j, "seed_backup_reminded", seed_backup_reminded_); + loadScalar(j, "seed_migration_pending", seed_migration_pending_); + loadScalar(j, "seed_migration_dest", seed_migration_dest_); + loadScalar(j, "seed_migration_temp_dir", seed_migration_temp_dir_); loadScalar(j, "auto_lock_timeout", auto_lock_timeout_); loadScalar(j, "unlock_duration", unlock_duration_); loadScalar(j, "pin_enabled", pin_enabled_); @@ -434,6 +437,9 @@ bool Settings::save(const std::string& path) } j["wizard_completed"] = wizard_completed_; j["seed_backup_reminded"] = seed_backup_reminded_; + j["seed_migration_pending"] = seed_migration_pending_; + j["seed_migration_dest"] = seed_migration_dest_; + j["seed_migration_temp_dir"] = seed_migration_temp_dir_; j["auto_lock_timeout"] = auto_lock_timeout_; j["unlock_duration"] = unlock_duration_; j["pin_enabled"] = pin_enabled_; diff --git a/src/config/settings.h b/src/config/settings.h index ed8b367..78cf496 100644 --- a/src/config/settings.h +++ b/src/config/settings.h @@ -250,6 +250,15 @@ public: bool getSeedBackupReminded() const { return seed_backup_reminded_; } void setSeedBackupReminded(bool v) { seed_backup_reminded_ = v; } + // Pending "migrate to a seed wallet" state (Phase 1 created the wallet; a later sweep/adopt + // step consumes it). dest = the new wallet's sweep-target z-address; tempDir = its datadir. + bool getSeedMigrationPending() const { return seed_migration_pending_; } + void setSeedMigrationPending(bool v) { seed_migration_pending_ = v; } + std::string getSeedMigrationDest() const { return seed_migration_dest_; } + void setSeedMigrationDest(const std::string& v) { seed_migration_dest_ = v; } + std::string getSeedMigrationTempDir() const { return seed_migration_temp_dir_; } + void setSeedMigrationTempDir(const std::string& v) { seed_migration_temp_dir_ = v; } + // Security — auto-lock timeout (seconds; 0 = disabled) int getAutoLockTimeout() const { return auto_lock_timeout_; } void setAutoLockTimeout(int seconds) { auto_lock_timeout_ = seconds; } @@ -449,6 +458,9 @@ private: std::map address_meta_; bool wizard_completed_ = false; bool seed_backup_reminded_ = false; + bool seed_migration_pending_ = false; + std::string seed_migration_dest_; + std::string seed_migration_temp_dir_; int auto_lock_timeout_ = 900; // 15 minutes int unlock_duration_ = 600; // 10 minutes bool pin_enabled_ = false; diff --git a/src/daemon/embedded_daemon.cpp b/src/daemon/embedded_daemon.cpp index af37011..2779bd3 100644 --- a/src/daemon/embedded_daemon.cpp +++ b/src/daemon/embedded_daemon.cpp @@ -448,9 +448,10 @@ bool EmbeddedDaemon::start(const std::string& binary_path) return true; } - // Check if something is already listening on the RPC port + // Check if something is already listening on the RPC port. An isolated instance (migrate-to- + // seed) runs on its own non-default port alongside the main daemon, so it skips this bail. int rpc_port = std::atoi(DRAGONX_DEFAULT_RPC_PORT); - if (isPortInUse(rpc_port)) { + if (!skip_port_check_ && isPortInUse(rpc_port)) { std::string owner = getPortOwnerInfo(rpc_port); VERBOSE_LOGF("[INFO] Port %d is already in use by %s — external daemon detected, will connect to it.\\n", rpc_port, owner.c_str()); external_daemon_detected_ = true; @@ -498,7 +499,20 @@ bool EmbeddedDaemon::start(const std::string& binary_path) DEBUG_LOGF("[INFO] Adding -rescan flag for blockchain rescan\n"); args.push_back("-rescan"); } - + + // One-shot isolated-datadir override (migrate-to-seed flow): run this start against a + // throwaway datadir, plus any extra args (e.g. -connect=0). Consumed here so later starts + // revert to the normal datadir. The datadir's basename MUST be the assetchain name (DRAGONX) + // or the daemon mis-resolves its conf/port; it reads /DRAGONX.conf automatically, so + // no -conf is passed (an explicit -conf confuses the Komodo/Hush path resolution). + if (!override_datadir_.empty()) { + DEBUG_LOGF("[INFO] Isolated start override: -datadir=%s\n", override_datadir_.c_str()); + args.push_back("-datadir=" + override_datadir_); + } + for (const auto& a : override_extra_args_) args.push_back(a); + override_datadir_.clear(); + override_extra_args_.clear(); + if (!startProcess(daemon_path, args)) { DEBUG_LOGF("[ERROR] Failed to start dragonxd process: %s\\n", last_error_.c_str()); setState(State::Error, "Failed to start dragonxd process"); @@ -1220,5 +1234,10 @@ bool EmbeddedDaemon::isRpcPortInUse() return isPortInUse(port); } +bool EmbeddedDaemon::tcpPortInUse(int port) +{ + return isPortInUse(port); +} + } // namespace daemon } // namespace dragonx diff --git a/src/daemon/embedded_daemon.h b/src/daemon/embedded_daemon.h index 8604a69..bd4d038 100644 --- a/src/daemon/embedded_daemon.h +++ b/src/daemon/embedded_daemon.h @@ -191,6 +191,29 @@ public: void setZapOnNextStart(bool v) { zap_on_next_start_ = v; } bool zapOnNextStart() const { return zap_on_next_start_.load(); } + /** + * @brief One-shot isolated-datadir override for the NEXT start(): run the daemon against a + * different datadir (with its own DRAGONX.conf) plus the given extra args. Used by the + * "migrate to a seed wallet" flow to mint a fresh mnemonic wallet in a throwaway datadir + * without touching the real one. Consumed on the next start(); later starts are normal. + * The caller must serialize this with start() (no concurrent starts). + */ + void setNextStartOverride(const std::string& datadir, std::vector extraArgs) { + override_datadir_ = datadir; + override_extra_args_ = std::move(extraArgs); + } + void clearNextStartOverride() { override_datadir_.clear(); override_extra_args_.clear(); } + + /** + * @brief Skip the "default RPC port already in use → external daemon" bail in start(). + * Set true only for an isolated instance running on its OWN (non-default) port + * alongside the main daemon (migrate-to-seed flow). + */ + void setSkipPortCheck(bool v) { skip_port_check_ = v; } + + /** @brief Is an arbitrary TCP port currently in use on localhost? (used to pick a free port) */ + static bool tcpPortInUse(int port); + /** Get number of consecutive daemon crashes (resets on successful start or manual reset) */ int getCrashCount() const { return crash_count_.load(); } /** Reset crash counter (call on successful connection or manual restart) */ @@ -231,6 +254,9 @@ private: std::atomic crash_count_{0}; // consecutive crash counter std::atomic rescan_on_next_start_{false}; // -rescan flag for next start std::atomic zap_on_next_start_{false}; // -zapwallettxes=2 flag for next start + std::string override_datadir_; // one-shot: -datadir for the next start + std::vector override_extra_args_; // one-shot: extra args for the next start + bool skip_port_check_ = false; // isolated instance on a non-default port }; } // namespace daemon diff --git a/src/daemon/seed_wallet_creator.cpp b/src/daemon/seed_wallet_creator.cpp new file mode 100644 index 0000000..53dd0f8 --- /dev/null +++ b/src/daemon/seed_wallet_creator.cpp @@ -0,0 +1,137 @@ +#include "daemon/seed_wallet_creator.h" + +#include +#include +#include + +#include + +#include "daemon/embedded_daemon.h" +#include "rpc/rpc_client.h" +#include "util/platform.h" + +namespace fs = std::filesystem; + +namespace dragonx { +namespace daemon { + +namespace { + +// Random alphanumeric token for the isolated node's throwaway RPC credentials (libsodium CSPRNG; +// sodium_init() has already run at app startup for the chat crypto). +std::string randomToken(int n) +{ + static const char cs[] = + "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"; + std::string s; + s.reserve(n); + for (int i = 0; i < n; ++i) + s.push_back(cs[randombytes_uniform(sizeof(cs) - 1)]); + return s; +} + +// A free localhost port for the isolated node — just above the default so it never collides with +// the main daemon (which keeps running on the default port throughout). +int pickFreePort() +{ + for (int p = 21770; p < 21900; ++p) + if (!EmbeddedDaemon::tcpPortInUse(p)) + return p; + return 0; +} + +} // namespace + +SeedWalletResult SeedWalletCreator::create(bool keepDatadir, + const std::function& progress) +{ + auto report = [&](const std::string& m) { if (progress) progress(m); }; + SeedWalletResult r; + std::error_code ec; + + // 1. Isolated throwaway datadir. The Komodo/Hush daemon requires the datadir's basename to be + // the assetchain name (DRAGONX) — mirroring ~/.hush/DRAGONX — or it mis-resolves its conf and + // RPC port. So the wallet lives in /DRAGONX; `base` is the migration root we clean up. + const std::string base = util::Platform::getConfigDir() + "/seed-migrate"; + const std::string dataDir = base + "/DRAGONX"; + fs::remove_all(base, ec); + fs::create_directories(dataDir, ec); + if (ec) { r.error = "Could not create the temporary wallet directory."; return r; } + + // 2. Free port + fresh throwaway RPC credentials for the isolated node. + const int port = pickFreePort(); + if (port <= 0) { r.error = "No free local port for the isolated node."; return r; } + const std::string user = randomToken(16); + const std::string pass = randomToken(32); + + // 3. Minimal conf for the isolated node (own creds/port; -tls=only comes from the chain args, + // so the RPC client below connects with TLS just like the main daemon does). + const std::string conf = "rpcuser=" + user + "\n" + "rpcpassword=" + pass + "\n" + "rpcport=" + std::to_string(port) + "\n" + "server=1\n"; + if (!util::Platform::writeFileAtomically(dataDir + "/DRAGONX.conf", conf, + /*restrictPermissions=*/true)) { + r.error = "Could not write the isolated node config."; + fs::remove_all(base, ec); + return r; + } + + // 4. Start the isolated daemon: fresh mnemonic wallet (-usemnemonic=1), no network/sync. + report("Starting an isolated node…"); + EmbeddedDaemon temp; + temp.setSkipPortCheck(true); // runs on `port`, beside the main daemon on the default port + temp.setNextStartOverride(dataDir, {"-usemnemonic=1", "-connect=0", "-listen=0", + "-maxconnections=0"}); + if (!temp.start("")) { + r.error = "Could not start the isolated node: " + temp.getLastError(); + fs::remove_all(base, ec); + return r; + } + + // 5. Connect to it, retrying until the RPC is responsive and past warmup. + report("Creating your new seed wallet…"); + rpc::RPCClient cli; + bool ready = false; + for (int i = 0; i < 90 && !ready; ++i) { + if (cli.connect("127.0.0.1", std::to_string(port), user, pass, /*useTls=*/true)) { + try { cli.call("getinfo"); ready = true; } // succeeds only once past warmup (-28) + catch (...) { cli.disconnect(); } + } + if (!ready) std::this_thread::sleep_for(std::chrono::seconds(1)); + } + if (!ready) { + r.error = "The isolated node did not become ready in time."; + temp.stop(20000); + fs::remove_all(base, ec); + return r; + } + + // 6. Export the new seed phrase + a fresh shielded receive address (the future sweep target). + try { + auto m = cli.call("z_exportmnemonic"); + if (m.contains("mnemonic") && m["mnemonic"].is_string()) + r.seedPhrase = m["mnemonic"].get(); + r.destAddress = cli.call("z_getnewaddress").get(); + r.ok = !r.seedPhrase.empty() && !r.destAddress.empty(); + if (!r.ok) r.error = "The isolated node returned an empty seed or address."; + } catch (const std::exception& e) { + r.error = std::string("Seed export failed: ") + e.what(); + } + + // 7. Stop the isolated node (graceful; it flushes its tiny empty chain quickly). + cli.disconnect(); + temp.stop(20000); + + // 8. Keep the temp wallet for a later sweep/adopt step, or scrub it. tempDatadir is the + // migration root `base`; the new wallet.dat lives in /DRAGONX. + r.tempDatadir = base; + if (!keepDatadir || !r.ok) { + fs::remove_all(base, ec); + r.tempDatadir.clear(); + } + return r; +} + +} // namespace daemon +} // namespace dragonx diff --git a/src/daemon/seed_wallet_creator.h b/src/daemon/seed_wallet_creator.h new file mode 100644 index 0000000..d49f1ae --- /dev/null +++ b/src/daemon/seed_wallet_creator.h @@ -0,0 +1,32 @@ +#pragma once + +#include +#include + +namespace dragonx { +namespace daemon { + +struct SeedWalletResult { + bool ok = false; + std::string seedPhrase; // SECRET — the caller must wipe it after use + std::string destAddress; // new shielded z-address (the Phase 2 sweep target) + std::string tempDatadir; // datadir holding the new wallet.dat (kept iff keepDatadir + ok) + std::string error; +}; + +// Mint a fresh BIP39 mnemonic wallet in an ISOLATED throwaway datadir by running a second +// dragonxd on its own port with no network, export its seed + a new z-address, then stop it. +// +// This is the safe first step of "migrate to a seed wallet": it moves no funds and never touches +// the main daemon or the real wallet.dat. Blocking — call it on a background thread. +// +// keepDatadir: keep the temp datadir + its wallet.dat so a later sweep/adopt step can use it, or +// delete it immediately (used when only the seed itself is wanted). +class SeedWalletCreator { +public: + static SeedWalletResult create(bool keepDatadir, + const std::function& progress = {}); +}; + +} // namespace daemon +} // namespace dragonx diff --git a/src/ui/pages/settings_page.cpp b/src/ui/pages/settings_page.cpp index b2d6fdc..898d610 100644 --- a/src/ui/pages/settings_page.cpp +++ b/src/ui/pages/settings_page.cpp @@ -1304,10 +1304,11 @@ void RenderSettingsPage(App* app) { naturalW += ImGui::CalcTextSize(r1[i]).x + btnPadX; float wizW = showFullNodeLifecycleActions ? ImGui::CalcTextSize(wizLabel).x + btnPadX : 0.0f; float bsW = showFullNodeLifecycleActions ? ImGui::CalcTextSize(bsLabel).x + btnPadX : 0.0f; - // Full-node-only "Seed phrase" button trails the Backup button (see below). + // Full-node-only "Seed phrase" + "Migrate to seed" buttons trail the Backup button. float seedW = showFullNodeLifecycleActions ? ImGui::CalcTextSize(TR("seed_backup_button")).x + btnPadX : 0.0f; + float migrateW = showFullNodeLifecycleActions ? ImGui::CalcTextSize(TR("seed_migrate_button")).x + btnPadX : 0.0f; float totalW = naturalW + sp * 5; - if (showFullNodeLifecycleActions) totalW += wizW + bsW + seedW + sp * 3; + if (showFullNodeLifecycleActions) totalW += wizW + bsW + seedW + migrateW + sp * 4; float scale = (totalW > contentW) ? contentW / totalW : 1.0f; if (scale < 1.0f) ImGui::SetWindowFontScale(scale); @@ -1334,6 +1335,11 @@ void RenderSettingsPage(App* app) { if (TactileButton(TR("seed_backup_button"), ImVec2(0, 0), btnFont)) app->showSeedBackupDialog(); if (ImGui::IsItemHovered()) material::Tooltip("%s", TR("tt_seed_backup")); + // Migrate to a new seed-phrase wallet (create in isolation, then sweep funds). + ImGui::SameLine(0, scaledSp); + if (TactileButton(TR("seed_migrate_button"), ImVec2(0, 0), btnFont)) + app->showSeedMigrationDialog(); + if (ImGui::IsItemHovered()) material::Tooltip("%s", TR("tt_seed_migrate")); } ImGui::SameLine(0, scaledSp); if (TactileButton(r1[4], ImVec2(0, 0), btnFont)) diff --git a/src/util/i18n.cpp b/src/util/i18n.cpp index 2ccc3ef..16caf60 100644 --- a/src/util/i18n.cpp +++ b/src/util/i18n.cpp @@ -249,6 +249,8 @@ void I18n::loadBuiltinEnglish() strings_["seed_backup_save_failed"] = "Could not write "; strings_["seed_backup_close"] = "Close"; strings_["seed_backup_reminder"] = "Your wallet has a 24-word recovery seed phrase. Back it up now in Settings → Node & Security."; + strings_["seed_migrate_button"] = "Migrate to seed…"; + strings_["tt_seed_migrate"] = "Create a new seed-phrase wallet and move your funds into it"; strings_["contacts_search_placeholder"] = "Search contacts..."; strings_["contacts_search_no_match"] = "No matching contacts"; strings_["address_book_confirm_delete"] = "Confirm delete?";