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:
216
src/app.cpp
216
src/app.cpp
@@ -88,6 +88,7 @@
|
|||||||
#include <cstdio>
|
#include <cstdio>
|
||||||
#include <ctime>
|
#include <ctime>
|
||||||
#include <algorithm>
|
#include <algorithm>
|
||||||
|
#include <random>
|
||||||
#include <map>
|
#include <map>
|
||||||
#include <set>
|
#include <set>
|
||||||
#include <unordered_set>
|
#include <unordered_set>
|
||||||
@@ -1922,54 +1923,193 @@ void App::reloadThemeImages(const std::string& bgPath, const std::string& logoPa
|
|||||||
|
|
||||||
void App::renderLiteFirstRunPrompt()
|
void App::renderLiteFirstRunPrompt()
|
||||||
{
|
{
|
||||||
// Lite-only: a brief welcome shown when no wallet file exists yet (full-node uses the wizard,
|
// Lite-only guided onboarding (full-node uses its own wizard). Welcome -> (on create) reveal
|
||||||
// which is skipped in lite). Routes to the existing create/restore + Backup flows in Settings.
|
// the new seed + birthday with backup warnings -> verify the user wrote it down -> done.
|
||||||
if (!lite_wallet_ || lite_firstrun_dismissed_) return;
|
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");
|
if (!ImGui::IsPopupOpen("##LiteFirstRun")) ImGui::OpenPopup("##LiteFirstRun");
|
||||||
|
|
||||||
ImGuiViewport* vp = ImGui::GetMainViewport();
|
ImGuiViewport* vp = ImGui::GetMainViewport();
|
||||||
ImGui::SetNextWindowPos(ImVec2(vp->Pos.x + vp->Size.x * 0.5f, vp->Pos.y + vp->Size.y * 0.5f),
|
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));
|
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,
|
if (ImGui::BeginPopupModal("##LiteFirstRun", nullptr,
|
||||||
ImGuiWindowFlags_AlwaysAutoResize | ImGuiWindowFlags_NoTitleBar)) {
|
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;
|
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
|
if (step == 0) {
|
||||||
// Settings → Backup & keys flow, where the seed can be revealed and copied.
|
// ── Welcome ──────────────────────────────────────────────────────────
|
||||||
const auto result = lite_wallet_->createWallet(wallet::LiteWalletCreateRequest{});
|
ImGui::PushFont(ui::material::Type().subtitle1());
|
||||||
if (result.walletReady) {
|
ImGui::TextUnformatted(TR("lite_welcome_title"));
|
||||||
ui::Notifications::instance().success(TR("lite_welcome_created"), 8.0f);
|
ImGui::PopFont();
|
||||||
setCurrentPage(ui::NavPage::Settings);
|
ImGui::Spacing();
|
||||||
} else {
|
ImGui::PushTextWrapPos(ImGui::GetCursorPosX() + 360.0f);
|
||||||
ui::Notifications::instance().warning(TR("lite_welcome_create_failed"));
|
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();
|
ImGui::EndPopup();
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user