feat(lite): guided seed backup on wallet creation

Creating a wallet was one-click and silent — it never showed the seed, relying on
the user to later find Settings -> Show seed, which is an easy-to-miss fund-loss
risk. Replace the first-run prompt with a 3-step guided flow mirroring the upstream
SilentDragonXLite wizard:

  1. Welcome (Create / Restore / Later) — unchanged entry.
  2. Reveal: after createWallet, read the seed back via exportSeed and show all 24
     words (numbered grid) + the birthday, with a strong "only way to restore"
     warning, plus Copy. ("Skip" leaves the wallet created, seed backable later.)
  3. Verify: tap the words in order (shuffled chips) to confirm the backup before
     finishing; out-of-order taps are rejected with a hint.

The seed is held only for the wizard and securely wiped (sodium_memzero) on finish.
Builds clean for full-node, lite, and Windows cross-compile.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-08 11:12:56 -05:00
parent 788d71a549
commit 8c51b092f8

View File

@@ -88,6 +88,7 @@
#include <cstdio>
#include <ctime>
#include <algorithm>
#include <random>
#include <map>
#include <set>
#include <unordered_set>
@@ -1922,54 +1923,193 @@ void App::reloadThemeImages(const std::string& bgPath, const std::string& logoPa
void App::renderLiteFirstRunPrompt()
{
// Lite-only: a brief welcome shown when no wallet file exists yet (full-node uses the wizard,
// which is skipped in lite). Routes to the existing create/restore + Backup flows in Settings.
// Lite-only guided onboarding (full-node uses its own wizard). Welcome -> (on create) reveal
// the new seed + birthday with backup warnings -> verify the user wrote it down -> done.
if (!lite_wallet_ || lite_firstrun_dismissed_) return;
if (lite_wallet_->walletOpen() || lite_wallet_->walletExists()) return;
// Wizard state (persists across frames). The seed is SECRET — wiped in finish().
static int step = 0; // 0 welcome, 1 reveal seed, 2 verify
static std::string seed; // full seed phrase
static unsigned long long birthday = 0;
static std::vector<std::string> words; // correct order
static std::vector<std::pair<std::string, bool>> chips; // shuffled (word, consumed)
static int progress = 0; // # words confirmed in order
static double wrongFlashUntil = 0.0; // brief "not the next word" hint
// The welcome page is only relevant before any wallet exists; once create has started the
// reveal/verify (step>0), keep showing it through to completion even though the wallet is open.
if (step == 0 && (lite_wallet_->walletOpen() || lite_wallet_->walletExists())) return;
if (!ImGui::IsPopupOpen("##LiteFirstRun")) ImGui::OpenPopup("##LiteFirstRun");
ImGuiViewport* vp = ImGui::GetMainViewport();
ImGui::SetNextWindowPos(ImVec2(vp->Pos.x + vp->Size.x * 0.5f, vp->Pos.y + vp->Size.y * 0.5f),
ImGuiCond_Appearing, ImVec2(0.5f, 0.5f));
auto finish = [&]() {
wallet::secureWipeLiteSecret(seed);
words.clear();
chips.clear();
progress = 0;
step = 0;
lite_firstrun_dismissed_ = true;
ImGui::CloseCurrentPopup();
};
auto splitWords = [](const std::string& s) {
std::vector<std::string> out;
std::string w;
for (char c : s) {
if (std::isspace(static_cast<unsigned char>(c))) { if (!w.empty()) { out.push_back(w); w.clear(); } }
else w.push_back(c);
}
if (!w.empty()) out.push_back(w);
return out;
};
if (ImGui::BeginPopupModal("##LiteFirstRun", nullptr,
ImGuiWindowFlags_AlwaysAutoResize | ImGuiWindowFlags_NoTitleBar)) {
ImGui::PushFont(ui::material::Type().subtitle1());
ImGui::TextUnformatted(TR("lite_welcome_title"));
ImGui::PopFont();
ImGui::Spacing();
ImGui::PushTextWrapPos(ImGui::GetCursorPosX() + 360.0f);
ImGui::TextUnformatted(TR("lite_welcome_msg"));
ImGui::PopTextWrapPos();
ImGui::Spacing();
ImGui::Spacing();
const float btnW = 170.0f;
if (ImGui::Button(TR("lite_welcome_create"), ImVec2(btnW, 0))) {
// One-click create (unencrypted by default); the user backs up the seed next via the
// Settings → Backup & keys flow, where the seed can be revealed and copied.
const auto result = lite_wallet_->createWallet(wallet::LiteWalletCreateRequest{});
if (result.walletReady) {
ui::Notifications::instance().success(TR("lite_welcome_created"), 8.0f);
setCurrentPage(ui::NavPage::Settings);
} else {
ui::Notifications::instance().warning(TR("lite_welcome_create_failed"));
if (step == 0) {
// ── Welcome ──────────────────────────────────────────────────────────
ImGui::PushFont(ui::material::Type().subtitle1());
ImGui::TextUnformatted(TR("lite_welcome_title"));
ImGui::PopFont();
ImGui::Spacing();
ImGui::PushTextWrapPos(ImGui::GetCursorPosX() + 360.0f);
ImGui::TextUnformatted(TR("lite_welcome_msg"));
ImGui::PopTextWrapPos();
ImGui::Spacing(); ImGui::Spacing();
if (ImGui::Button(TR("lite_welcome_create"), ImVec2(btnW, 0))) {
const auto result = lite_wallet_->createWallet(wallet::LiteWalletCreateRequest{});
if (result.walletReady) {
// Read the freshly generated seed back so we can show + verify it.
auto s = lite_wallet_->exportSeed();
if (s.ok && !s.seedPhrase.empty()) {
seed = s.seedPhrase;
birthday = s.birthday;
wallet::secureWipeLiteSecret(s.seedPhrase);
words = splitWords(seed);
step = 1;
} else {
// Created, but couldn't reveal the seed now — fall back to Settings backup.
ui::Notifications::instance().success(TR("lite_welcome_created"), 8.0f);
setCurrentPage(ui::NavPage::Settings);
finish();
}
} else {
ui::Notifications::instance().warning(TR("lite_welcome_create_failed"));
}
}
ImGui::SameLine();
if (ImGui::Button(TR("lite_welcome_restore"), ImVec2(btnW, 0))) {
ui::Notifications::instance().info(TR("lite_welcome_restore_hint"), 8.0f);
setCurrentPage(ui::NavPage::Settings);
finish();
}
ImGui::Spacing();
if (ImGui::Button(TR("lite_welcome_later"),
ImVec2(btnW * 2 + ImGui::GetStyle().ItemSpacing.x, 0))) {
finish();
}
} else if (step == 1) {
// ── Reveal the seed + birthday with backup warnings ─────────────────────
ImGui::PushFont(ui::material::Type().subtitle1());
ImGui::TextUnformatted("Back up your seed phrase");
ImGui::PopFont();
ImGui::Spacing();
ImGui::PushTextWrapPos(ImGui::GetCursorPosX() + 380.0f);
ImGui::PushStyleColor(ImGuiCol_Text, ui::material::Error());
ImGui::TextUnformatted("These 24 words are the ONLY way to restore your wallet. "
"Write them down in order, store them offline, and never share "
"them. If you lose them, your funds are gone forever.");
ImGui::PopStyleColor();
ImGui::PopTextWrapPos();
ImGui::Spacing();
// Numbered word grid (4 columns).
for (size_t i = 0; i < words.size(); ++i) {
char cell[96];
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) * 130.0f);
}
ImGui::Spacing();
char bday[80];
snprintf(bday, sizeof(bday), "Birthday (block height): %llu — back this up too.", birthday);
ImGui::PushStyleColor(ImGuiCol_Text, ui::material::OnSurfaceMedium());
ImGui::TextUnformatted(bday);
ImGui::PopStyleColor();
ImGui::Spacing(); ImGui::Spacing();
if (ImGui::Button("I've written it down", ImVec2(btnW, 0))) {
chips.clear();
for (const auto& w : words) chips.emplace_back(w, false);
std::mt19937 rng{std::random_device{}()};
std::shuffle(chips.begin(), chips.end(), rng);
progress = 0;
step = 2;
}
ImGui::SameLine();
if (ImGui::Button("Copy", ImVec2(80, 0))) ImGui::SetClipboardText(seed.c_str());
ImGui::SameLine();
if (ImGui::Button("Skip", ImVec2(80, 0))) {
ui::Notifications::instance().success(TR("lite_welcome_created"), 6.0f);
finish();
}
} else { // step == 2
// ── Verify: tap the words in order ──────────────────────────────────────
ImGui::PushFont(ui::material::Type().subtitle1());
ImGui::TextUnformatted("Confirm your backup");
ImGui::PopFont();
ImGui::Spacing();
ImGui::PushTextWrapPos(ImGui::GetCursorPosX() + 380.0f);
ImGui::TextUnformatted("Tap the words in the correct order to confirm you saved them.");
ImGui::PopTextWrapPos();
ImGui::Spacing();
ImGui::TextDisabled("Progress: %d / %d", progress, (int)words.size());
if (ImGui::GetTime() < wrongFlashUntil) {
ImGui::SameLine();
ImGui::PushStyleColor(ImGuiCol_Text, ui::material::Error());
ImGui::TextUnformatted(" — that's not the next word");
ImGui::PopStyleColor();
}
ImGui::Spacing();
for (size_t i = 0; i < chips.size(); ++i) {
ImGui::PushID((int)i);
if (chips[i].second) {
ImGui::BeginDisabled();
ImGui::Button(chips[i].first.c_str(), ImVec2(125, 0));
ImGui::EndDisabled();
} else if (ImGui::Button(chips[i].first.c_str(), ImVec2(125, 0))) {
if (progress < (int)words.size() && chips[i].first == words[progress]) {
chips[i].second = true; // correct next word
++progress;
} else {
wrongFlashUntil = ImGui::GetTime() + 1.5; // out of order
}
}
ImGui::PopID();
if ((i % 4) != 3 && i + 1 < chips.size()) ImGui::SameLine();
}
ImGui::Spacing(); ImGui::Spacing();
const bool verified = progress == (int)words.size();
if (!verified) ImGui::BeginDisabled();
if (ImGui::Button("Done", ImVec2(btnW, 0))) {
ui::Notifications::instance().success("Wallet created and backed up.", 6.0f);
finish();
}
if (!verified) ImGui::EndDisabled();
ImGui::SameLine();
if (ImGui::Button("Back", ImVec2(80, 0))) step = 1;
ImGui::SameLine();
if (ImGui::Button("Skip", ImVec2(80, 0))) {
ui::Notifications::instance().success(TR("lite_welcome_created"), 6.0f);
finish();
}
lite_firstrun_dismissed_ = true;
ImGui::CloseCurrentPopup();
}
ImGui::SameLine();
if (ImGui::Button(TR("lite_welcome_restore"), ImVec2(btnW, 0))) {
ui::Notifications::instance().info(TR("lite_welcome_restore_hint"), 8.0f);
setCurrentPage(ui::NavPage::Settings);
lite_firstrun_dismissed_ = true;
ImGui::CloseCurrentPopup();
}
ImGui::Spacing();
if (ImGui::Button(TR("lite_welcome_later"),
ImVec2(btnW * 2 + ImGui::GetStyle().ItemSpacing.x, 0))) {
lite_firstrun_dismissed_ = true;
ImGui::CloseCurrentPopup();
}
ImGui::EndPopup();
}