feat(wallet): give new full-node wallets a BIP39 seed phrase + backup UI

New full-node wallets now get a real 24-word recovery phrase, and users can
reveal/back it up from Settings.

- Pass -usemnemonic=1 to dragonxd. The daemon reads it only inside
  GenerateNewSeed() when a wallet has no seed yet, so it is inert on existing
  wallets (safe to pass unconditionally) and makes every freshly-created
  wallet mnemonic-backed — its phrase is then exportable via z_exportmnemonic
  and portable to SDXLite/ObsidianDragonLite. (Existing/legacy wallets are
  unaffected and keep using the chat identity's z_exportkey fallback.)
- App::exportSeedPhrase() wraps z_exportmnemonic on the RPC worker with
  sodium_memzero wiping on every path; it flags the daemon's "not derived
  from a mnemonic" error as a legacy wallet rather than a failure.
- New "Back up seed phrase" modal (Settings -> Backup & Data, full-node
  gated): reveals the phrase in a numbered word grid, copy (45s clipboard
  auto-clear) + save-to-file (0600), wipes the secret on every close path
  incl. dismiss-mid-fetch. Shared ui/windows/seed_display.h grid helper.
- One-time nudge (settings flag seed_backup_reminded) toasts mnemonic-wallet
  users to back up their phrase; legacy wallets are not nagged.
- 15 new strings translated into all 8 languages (additive, 957->972 keys);
  CJK subset font rebuilt for the new glyphs.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-07-07 10:49:03 -05:00
parent 036199a011
commit 403e145b30
18 changed files with 394 additions and 2 deletions

View File

@@ -33,6 +33,7 @@
#include "ui/windows/network_tab.h"
#include "ui/windows/console_command_executor.h"
#include "ui/windows/explorer_tab.h"
#include "ui/windows/seed_display.h"
#include "ui/windows/market_tab.h"
#include "ui/windows/settings_window.h"
#include "ui/windows/about_dialog.h"
@@ -715,6 +716,9 @@ void App::update()
// it can act; compiled away when the feature is off (constexpr gate inside).
maybeProvisionChatIdentity();
// One-time reminder to back up the wallet's seed phrase (mnemonic wallets only).
maybeRemindSeedBackup();
// Auto-lock check (only when connected + encrypted + unlocked)
if (state_.connected && state_.isUnlocked()) {
checkAutoLock();
@@ -1893,6 +1897,10 @@ void App::render()
renderBackupDialog();
}
if (show_seed_backup_) {
renderSeedBackupDialog();
}
// Security overlay dialogs (encrypt, decrypt, PIN) are rendered AFTER ImGui::End()
// to ensure they appear on top of all other content
@@ -2921,7 +2929,103 @@ void App::renderBackupDialog()
backup_status_.clear();
s_backup_confirm_overwrite = false;
}
ui::material::EndOverlayDialog();
}
void App::renderSeedBackupDialog()
{
// Wipe the revealed phrase and reset the dialog's state.
auto closeAndWipe = [this]() {
if (!seed_backup_phrase_.empty())
sodium_memzero(&seed_backup_phrase_[0], seed_backup_phrase_.size());
seed_backup_phrase_.clear();
seed_backup_status_.clear();
seed_backup_fetch_started_ = false;
seed_backup_loading_ = false;
seed_backup_no_mnemonic_ = false;
show_seed_backup_ = false;
};
// Kick off the async z_exportmnemonic fetch once, and only while unlocked.
if (!seed_backup_fetch_started_ && !state_.isLocked()) {
seed_backup_fetch_started_ = true;
seed_backup_loading_ = true;
seed_backup_no_mnemonic_ = false;
seed_backup_status_.clear();
exportSeedPhrase([this](bool ok, bool noMnemonic, const std::string& phrase,
const std::string& error) {
if (!show_seed_backup_) return; // dialog closed while the fetch was in flight
seed_backup_loading_ = false;
if (ok) {
seed_backup_phrase_ = phrase; // working copy — wiped on close
} else if (noMnemonic) {
seed_backup_no_mnemonic_ = true;
} else {
seed_backup_status_ = error.empty() ? std::string(TR("seed_backup_load_failed")) : error;
}
});
}
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;
}
ui::material::Type().text(ui::material::TypeStyle::H6, TR("seed_backup_title"));
ImGui::Dummy(ImVec2(0, ui::Layout::spacingSm()));
if (state_.isLocked()) {
ImGui::TextWrapped("%s", TR("seed_backup_locked"));
} else if (seed_backup_loading_) {
ImGui::TextWrapped("%s", TR("seed_backup_loading"));
} else if (seed_backup_no_mnemonic_) {
ImGui::TextWrapped("%s", TR("seed_backup_none"));
} else if (!seed_backup_phrase_.empty()) {
ImGui::TextWrapped("%s", TR("seed_backup_intro"));
ImGui::Spacing();
ImGui::PushStyleColor(ImGuiCol_Text, ui::material::Error());
ImGui::TextWrapped("%s", TR("seed_backup_warning"));
ImGui::PopStyleColor();
ImGui::Spacing();
ui::RenderSeedWordGrid(ui::SplitSeedWords(seed_backup_phrase_));
ImGui::Spacing();
if (!seed_backup_status_.empty()) {
ImGui::TextColored(ImVec4(0.3f, 0.8f, 0.3f, 1.0f), "%s", seed_backup_status_.c_str());
ImGui::Spacing();
}
ImGui::Separator();
if (ui::material::StyledButton(TR("seed_backup_copy"), ImVec2(120, 0))) {
copySecretToClipboard(seed_backup_phrase_);
}
ImGui::SameLine();
if (ui::material::StyledButton(TR("seed_backup_save"), ImVec2(150, 0))) {
std::string path = util::Platform::getConfigDir() + "/dragonx-seed-backup.txt";
std::string content = seed_backup_phrase_ + "\n";
const bool ok = util::Platform::writeFileAtomically(path, content,
/*restrictPermissions=*/true);
sodium_memzero(&content[0], content.size());
seed_backup_status_ = (ok ? std::string(TR("seed_backup_saved"))
: std::string(TR("seed_backup_save_failed"))) + path;
}
ImGui::SameLine();
if (ui::material::StyledButton(TR("seed_backup_close"), ImVec2(120, 0))) {
closeAndWipe();
ui::material::EndOverlayDialog();
return;
}
ui::material::EndOverlayDialog();
return;
} else if (!seed_backup_status_.empty()) {
ImGui::TextColored(ImVec4(0.8f, 0.3f, 0.3f, 1.0f), "%s", seed_backup_status_.c_str());
}
ImGui::Spacing();
ImGui::Separator();
if (ui::material::StyledButton(TR("seed_backup_close"), ImVec2(120, 0))) {
closeAndWipe();
}
ui::material::EndOverlayDialog();
}

View File

@@ -295,6 +295,13 @@ public:
// Wallet backup
void backupWallet(const std::string& destination, std::function<void(bool, const std::string&)> callback);
// Export the wallet's BIP39 seed phrase (z_exportmnemonic). The callback receives
// (ok, noMnemonic, phrase, error): ok+phrase on success; noMnemonic=true when the
// wallet's seed is not mnemonic-derived (legacy wallet). Full-node only; the phrase
// is a secret and is wiped after the callback returns.
void exportSeedPhrase(std::function<void(bool ok, bool noMnemonic,
const std::string& phrase,
const std::string& error)> callback);
// Transaction operations
void sendTransaction(const std::string& from, const std::string& to,
@@ -344,6 +351,7 @@ public:
void showImportKeyDialog() { show_import_key_ = true; }
void showExportKeyDialog() { show_export_key_ = true; }
void showBackupDialog() { show_backup_ = true; }
void showSeedBackupDialog() { show_seed_backup_ = true; }
void showAboutDialog() { show_about_ = true; }
// Legacy tab compat — maps int to NavPage
@@ -599,6 +607,9 @@ private:
// variants); derives via deriveChatIdentityFromSecret and wipes the secret. No-op when the
// feature is off, already provisioned, in flight, or unavailable.
void maybeProvisionChatIdentity();
// 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();
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
@@ -656,6 +667,16 @@ private:
bool show_import_key_ = false;
bool show_export_key_ = false;
bool show_backup_ = false;
bool show_seed_backup_ = false;
// Seed-phrase backup dialog state. seed_backup_phrase_ holds a SECRET (the revealed
// mnemonic) and is wiped with sodium_memzero when the dialog closes.
std::string seed_backup_phrase_;
std::string seed_backup_status_;
bool seed_backup_fetch_started_ = false;
bool seed_backup_loading_ = false;
bool seed_backup_no_mnemonic_ = false;
bool seed_backup_reminder_in_flight_ = false; // guards the one-time backup nudge probe
// Embedded daemon state
bool use_embedded_daemon_ = wallet::supportsEmbeddedDaemon(wallet::currentWalletCapabilities());
@@ -918,6 +939,7 @@ private:
void renderImportKeyDialog();
void renderExportKeyDialog();
void renderBackupDialog();
void renderSeedBackupDialog(); // full-node "Back up seed phrase" modal (z_exportmnemonic)
void renderFirstRunWizard();
void renderLockScreen();
void renderEncryptWalletDialog();

View File

@@ -2863,6 +2863,69 @@ void App::importPrivateKey(const std::string& rawKey, std::function<void(bool, c
});
}
void App::exportSeedPhrase(std::function<void(bool, bool, const std::string&, const std::string&)> callback)
{
if (!state_.connected || !rpc_ || !worker_) {
if (callback) callback(false, false, std::string(), "Not connected to the daemon.");
return;
}
worker_->post([this, callback]() -> rpc::RPCWorker::MainCb {
std::string phrase; // SECRET — wiped after the callback runs
std::string err;
bool ok = false;
bool noMnemonic = false;
rpc::RPCClient::TraceScope trace("Settings / Export seed phrase");
try {
auto response = rpc_->call("z_exportmnemonic");
if (response.contains("mnemonic") && response["mnemonic"].is_string()) {
auto& m = response["mnemonic"].get_ref<std::string&>();
phrase = m;
if (!m.empty()) sodium_memzero(&m[0], m.size()); // scrub the json's own copy
ok = !phrase.empty();
} else {
err = "The daemon returned no seed phrase.";
}
} catch (const rpc::RpcError& e) {
err = e.what();
// The daemon errors with this specific message when the wallet's seed is not
// mnemonic-derived (a legacy wallet). Any other error (e.g. locked) stays generic.
if (err.find("not derived from a mnemonic") != std::string::npos)
noMnemonic = true;
} catch (const std::exception& e) {
err = e.what();
}
return [callback, ok, noMnemonic, phrase = std::move(phrase), err]() mutable {
if (callback) callback(ok, noMnemonic, phrase, err);
if (!phrase.empty()) sodium_memzero(&phrase[0], phrase.size());
};
});
}
// One-time nudge to back up the seed phrase on a mnemonic-backed full-node wallet. Fires at
// most one z_exportmnemonic probe per install; the phrase is not retained (the probe only
// learns whether a mnemonic exists). Legacy (non-mnemonic) wallets are not nagged.
void App::maybeRemindSeedBackup()
{
if (lite_wallet_) return; // lite has its own seed UX
if (!settings_ || settings_->getSeedBackupReminded()) return;
if (!state_.connected || !state_.encryption_state_known) return;
if (state_.isLocked()) return; // wait until unlocked to read it
if (seed_backup_reminder_in_flight_) return;
seed_backup_reminder_in_flight_ = true;
exportSeedPhrase([this](bool ok, bool noMnemonic, const std::string& /*phrase*/,
const std::string& /*error*/) {
seed_backup_reminder_in_flight_ = false;
// Only mark "reminded" once we have a definitive answer, so a transient failure retries
// next session rather than silently suppressing the reminder forever.
if (ok || noMnemonic) {
settings_->setSeedBackupReminded(true);
settings_->save();
}
if (ok)
ui::Notifications::instance().info(TR("seed_backup_reminder"), 12.0f);
});
}
void App::backupWallet(const std::string& destination, std::function<void(bool, const std::string&)> callback)
{
if (!state_.connected || !rpc_) {

View File

@@ -199,6 +199,7 @@ bool Settings::load(const std::string& path)
}
}
loadScalar(j, "wizard_completed", wizard_completed_);
loadScalar(j, "seed_backup_reminded", seed_backup_reminded_);
loadScalar(j, "auto_lock_timeout", auto_lock_timeout_);
loadScalar(j, "unlock_duration", unlock_duration_);
loadScalar(j, "pin_enabled", pin_enabled_);
@@ -432,6 +433,7 @@ bool Settings::save(const std::string& path)
j["address_meta"] = meta_obj;
}
j["wizard_completed"] = wizard_completed_;
j["seed_backup_reminded"] = seed_backup_reminded_;
j["auto_lock_timeout"] = auto_lock_timeout_;
j["unlock_duration"] = unlock_duration_;
j["pin_enabled"] = pin_enabled_;

View File

@@ -246,6 +246,10 @@ public:
bool getWizardCompleted() const { return wizard_completed_; }
void setWizardCompleted(bool v) { wizard_completed_ = v; }
// Whether the one-time "back up your seed phrase" reminder has already been shown.
bool getSeedBackupReminded() const { return seed_backup_reminded_; }
void setSeedBackupReminded(bool v) { seed_backup_reminded_ = v; }
// Security — auto-lock timeout (seconds; 0 = disabled)
int getAutoLockTimeout() const { return auto_lock_timeout_; }
void setAutoLockTimeout(int seconds) { auto_lock_timeout_ = seconds; }
@@ -444,6 +448,7 @@ private:
std::set<std::string> favorite_addresses_;
std::map<std::string, AddressMeta> address_meta_;
bool wizard_completed_ = false;
bool seed_backup_reminded_ = false;
int auto_lock_timeout_ = 900; // 15 minutes
int unlock_duration_ = 600; // 10 minutes
bool pin_enabled_ = false;

View File

@@ -212,6 +212,11 @@ std::vector<std::string> EmbeddedDaemon::getChainParams()
"-addnode=node4.dragonx.is",
"-experimentalfeatures",
"-developerencryptwallet",
// Create fresh wallets from a BIP39 mnemonic so their 24-word phrase can be
// exported (z_exportmnemonic) and is portable to SDXLite/ObsidianDragonLite.
// The daemon reads this ONLY inside GenerateNewSeed() when a wallet has no seed
// yet, so it is inert on existing wallets — safe to pass unconditionally.
"-usemnemonic=1",
dbcache_arg
};
}

View File

@@ -1304,8 +1304,10 @@ 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).
float seedW = showFullNodeLifecycleActions ? ImGui::CalcTextSize(TR("seed_backup_button")).x + btnPadX : 0.0f;
float totalW = naturalW + sp * 5;
if (showFullNodeLifecycleActions) totalW += wizW + bsW + sp * 2;
if (showFullNodeLifecycleActions) totalW += wizW + bsW + seedW + sp * 3;
float scale = (totalW > contentW) ? contentW / totalW : 1.0f;
if (scale < 1.0f) ImGui::SetWindowFontScale(scale);
@@ -1326,6 +1328,13 @@ void RenderSettingsPage(App* app) {
if (TactileButton(r1[3], ImVec2(0, 0), btnFont))
app->showBackupDialog();
if (ImGui::IsItemHovered()) material::Tooltip("%s", t1[3]);
if (showFullNodeLifecycleActions) {
// Back up the wallet's BIP39 seed phrase (full-node mnemonic wallets).
ImGui::SameLine(0, scaledSp);
if (TactileButton(TR("seed_backup_button"), ImVec2(0, 0), btnFont))
app->showSeedBackupDialog();
if (ImGui::IsItemHovered()) material::Tooltip("%s", TR("tt_seed_backup"));
}
ImGui::SameLine(0, scaledSp);
if (TactileButton(r1[4], ImVec2(0, 0), btnFont))
ExportTransactionsDialog::show();

View File

@@ -0,0 +1,46 @@
#pragma once
// Shared helpers for rendering a BIP39 seed phrase as a numbered word grid and for
// splitting a whitespace-separated phrase into words. Used by the full-node seed-phrase
// backup dialog and the "migrate to a seed wallet" flow so both present the phrase the
// same way the lite welcome wizard does.
#include <cstdio>
#include <string>
#include <vector>
#include "imgui.h"
namespace dragonx {
namespace ui {
// Split a whitespace-separated seed phrase into its individual words.
inline std::vector<std::string> SplitSeedWords(const std::string& phrase)
{
std::vector<std::string> words;
std::string cur;
for (char c : phrase) {
if (c == ' ' || c == '\t' || c == '\n' || c == '\r') {
if (!cur.empty()) { words.push_back(cur); cur.clear(); }
} else {
cur.push_back(c);
}
}
if (!cur.empty()) words.push_back(cur);
return words;
}
// Render the words as a numbered 4-column grid (matches the lite welcome wizard layout).
inline void RenderSeedWordGrid(const std::vector<std::string>& words, float colSpacingPx = 130.0f)
{
for (size_t i = 0; i < words.size(); ++i) {
char cell[96];
std::snprintf(cell, sizeof(cell), "%2zu. %s", i + 1, words[i].c_str());
ImGui::TextUnformatted(cell);
if ((i % 4) != 3 && i + 1 < words.size())
ImGui::SameLine(((i % 4) + 1) * colSpacingPx);
}
}
} // namespace ui
} // namespace dragonx

View File

@@ -233,6 +233,22 @@ void I18n::loadBuiltinEnglish()
strings_["chat_toast_compose_failed"] = "Could not compose the message (too long?).";
strings_["chat_toast_request_compose_failed"] = "Could not compose the contact request (invalid address / text?).";
strings_["chat_toast_request_queued"] = "Contact request queued.";
// Seed-phrase backup (full-node)
strings_["seed_backup_button"] = "Seed phrase";
strings_["tt_seed_backup"] = "Show and back up your wallet's 24-word recovery seed phrase";
strings_["seed_backup_title"] = "Back up seed phrase";
strings_["seed_backup_intro"] = "These words are the master backup of your wallet. Anyone who has them controls your funds.";
strings_["seed_backup_warning"] = "Write them down in order, store them offline, and never share or photograph them. If you lose them, your funds cannot be recovered.";
strings_["seed_backup_loading"] = "Loading your seed phrase…";
strings_["seed_backup_locked"] = "Unlock your wallet to reveal its seed phrase.";
strings_["seed_backup_none"] = "This wallet predates seed phrases, so it has no 24-word phrase. Back it up with \"Backup wallet\" or by exporting your private keys instead.";
strings_["seed_backup_load_failed"] = "Could not load the seed phrase.";
strings_["seed_backup_copy"] = "Copy";
strings_["seed_backup_save"] = "Save to file…";
strings_["seed_backup_saved"] = "Saved to ";
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_["contacts_search_placeholder"] = "Search contacts...";
strings_["contacts_search_no_match"] = "No matching contacts";
strings_["address_book_confirm_delete"] = "Confirm delete?";