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:
@@ -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
|
||||
|
||||
125
src/app.cpp
125
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
|
||||
|
||||
24
src/app.h
24
src/app.h
@@ -9,6 +9,7 @@
|
||||
#include <functional>
|
||||
#include <thread>
|
||||
#include <atomic>
|
||||
#include <mutex>
|
||||
#include <chrono>
|
||||
#include <unordered_map>
|
||||
#include <unordered_set>
|
||||
@@ -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();
|
||||
|
||||
@@ -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<std::mutex> 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<std::mutex> lk(seed_migration_mutex_);
|
||||
seed_migration_progress_ = msg;
|
||||
});
|
||||
std::lock_guard<std::mutex> 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<std::mutex> 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<void(bool, const std::string&)> callback)
|
||||
{
|
||||
if (!state_.connected || !rpc_) {
|
||||
|
||||
@@ -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_;
|
||||
|
||||
@@ -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<std::string, AddressMeta> 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;
|
||||
|
||||
@@ -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 <datadir>/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
|
||||
|
||||
@@ -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<std::string> 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<int> crash_count_{0}; // consecutive crash counter
|
||||
std::atomic<bool> rescan_on_next_start_{false}; // -rescan flag for next start
|
||||
std::atomic<bool> zap_on_next_start_{false}; // -zapwallettxes=2 flag for next start
|
||||
std::string override_datadir_; // one-shot: -datadir for the next start
|
||||
std::vector<std::string> 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
|
||||
|
||||
137
src/daemon/seed_wallet_creator.cpp
Normal file
137
src/daemon/seed_wallet_creator.cpp
Normal file
@@ -0,0 +1,137 @@
|
||||
#include "daemon/seed_wallet_creator.h"
|
||||
|
||||
#include <chrono>
|
||||
#include <filesystem>
|
||||
#include <thread>
|
||||
|
||||
#include <sodium.h>
|
||||
|
||||
#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<void(const std::string&)>& 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 <base>/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<std::string>();
|
||||
r.destAddress = cli.call("z_getnewaddress").get<std::string>();
|
||||
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 <base>/DRAGONX.
|
||||
r.tempDatadir = base;
|
||||
if (!keepDatadir || !r.ok) {
|
||||
fs::remove_all(base, ec);
|
||||
r.tempDatadir.clear();
|
||||
}
|
||||
return r;
|
||||
}
|
||||
|
||||
} // namespace daemon
|
||||
} // namespace dragonx
|
||||
32
src/daemon/seed_wallet_creator.h
Normal file
32
src/daemon/seed_wallet_creator.h
Normal file
@@ -0,0 +1,32 @@
|
||||
#pragma once
|
||||
|
||||
#include <functional>
|
||||
#include <string>
|
||||
|
||||
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<void(const std::string&)>& progress = {});
|
||||
};
|
||||
|
||||
} // namespace daemon
|
||||
} // namespace dragonx
|
||||
@@ -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))
|
||||
|
||||
@@ -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?";
|
||||
|
||||
Reference in New Issue
Block a user