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

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

View File

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

View 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

View 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