feat(lite): async wallet creation with server failover
Mirror the async-open path for wallet creation. beginOpenExisting() and beginCreateWallet() now both delegate to beginAsyncLifecycle(bool create), which runs the backend init on a detached thread and walks the failover server list (preferred server first, then all usable defaults), reporting the preferred server's error on total failure. The first-run wizard's Create button drives this through a non-blocking "creating" poll state so the UI no longer freezes while the backend contacts a (possibly flaky) lightwalletd. The created seed response is securely wiped immediately and read back via exportSeed for the reveal/verify steps. Safe because litelib_initialize_new contacts the server before writing any wallet file and LightClient::new errors if a wallet already exists, so a failed candidate leaves no partial state. Tests: fake backend's initialize_new now honors the dead/warmup server substrings; testLiteWalletControllerOpenFailover gains a create-failover case (preferred dead, fallback good -> walletOpen). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
61
src/app.cpp
61
src/app.cpp
@@ -1935,10 +1935,12 @@ void App::renderLiteFirstRunPrompt()
|
|||||||
static std::vector<std::pair<std::string, bool>> chips; // shuffled (word, consumed)
|
static std::vector<std::pair<std::string, bool>> chips; // shuffled (word, consumed)
|
||||||
static int progress = 0; // # words confirmed in order
|
static int progress = 0; // # words confirmed in order
|
||||||
static double wrongFlashUntil = 0.0; // brief "not the next word" hint
|
static double wrongFlashUntil = 0.0; // brief "not the next word" hint
|
||||||
|
static bool creating = false; // async create (with failover) in flight
|
||||||
|
|
||||||
// The welcome page is only relevant before any wallet exists; once create has started the
|
// The welcome page is only relevant before any wallet exists; once create has started
|
||||||
// reveal/verify (step>0), keep showing it through to completion even though the wallet is open.
|
// (creating) or reached reveal/verify (step>0), keep showing it through to completion even
|
||||||
if (step == 0 && (lite_wallet_->walletOpen() || lite_wallet_->walletExists())) return;
|
// though the wallet becomes open.
|
||||||
|
if (step == 0 && !creating && (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();
|
||||||
@@ -1951,6 +1953,7 @@ void App::renderLiteFirstRunPrompt()
|
|||||||
chips.clear();
|
chips.clear();
|
||||||
progress = 0;
|
progress = 0;
|
||||||
step = 0;
|
step = 0;
|
||||||
|
creating = false;
|
||||||
lite_firstrun_dismissed_ = true;
|
lite_firstrun_dismissed_ = true;
|
||||||
ImGui::CloseCurrentPopup();
|
ImGui::CloseCurrentPopup();
|
||||||
};
|
};
|
||||||
@@ -1980,37 +1983,51 @@ void App::renderLiteFirstRunPrompt()
|
|||||||
ImGui::PopTextWrapPos();
|
ImGui::PopTextWrapPos();
|
||||||
ImGui::Spacing(); ImGui::Spacing();
|
ImGui::Spacing(); ImGui::Spacing();
|
||||||
|
|
||||||
if (ImGui::Button(TR("lite_welcome_create"), ImVec2(btnW, 0))) {
|
if (creating) {
|
||||||
const auto result = lite_wallet_->createWallet(wallet::LiteWalletCreateRequest{});
|
// Async create (with server failover) is in flight — driven to completion by
|
||||||
if (result.walletReady) {
|
// App::update()'s pumpAsyncOpen(). Poll the controller for the outcome.
|
||||||
// Read the freshly generated seed back so we can show + verify it.
|
ImGui::TextUnformatted("Creating your wallet\xE2\x80\xA6");
|
||||||
auto s = lite_wallet_->exportSeed();
|
if (lite_wallet_->walletOpen()) {
|
||||||
|
auto s = lite_wallet_->exportSeed(); // read the new seed back (local, fast)
|
||||||
if (s.ok && !s.seedPhrase.empty()) {
|
if (s.ok && !s.seedPhrase.empty()) {
|
||||||
seed = s.seedPhrase;
|
seed = s.seedPhrase;
|
||||||
birthday = s.birthday;
|
birthday = s.birthday;
|
||||||
wallet::secureWipeLiteSecret(s.seedPhrase);
|
wallet::secureWipeLiteSecret(s.seedPhrase);
|
||||||
words = splitWords(seed);
|
words = splitWords(seed);
|
||||||
|
creating = false;
|
||||||
step = 1;
|
step = 1;
|
||||||
} else {
|
} else {
|
||||||
// Created, but couldn't reveal the seed now — fall back to Settings backup.
|
|
||||||
ui::Notifications::instance().success(TR("lite_welcome_created"), 8.0f);
|
ui::Notifications::instance().success(TR("lite_welcome_created"), 8.0f);
|
||||||
setCurrentPage(ui::NavPage::Settings);
|
setCurrentPage(ui::NavPage::Settings);
|
||||||
finish();
|
finish();
|
||||||
}
|
}
|
||||||
} else {
|
} else if (!lite_wallet_->openInProgress() &&
|
||||||
ui::Notifications::instance().warning(TR("lite_welcome_create_failed"));
|
!lite_wallet_->lastOpenError().empty()) {
|
||||||
|
ui::Notifications::instance().warning(
|
||||||
|
std::string("Create failed: ") + lite_wallet_->lastOpenError());
|
||||||
|
creating = false; // back to the buttons so the user can retry
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if (ImGui::Button(TR("lite_welcome_create"), ImVec2(btnW, 0))) {
|
||||||
|
// Async create with the same server failover as open (no UI freeze; a dead
|
||||||
|
// server falls through to the next). On success the wizard reveals the seed.
|
||||||
|
if (lite_wallet_->beginCreateWallet()) {
|
||||||
|
creating = true;
|
||||||
|
} 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();
|
||||||
}
|
}
|
||||||
}
|
|
||||||
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) {
|
} else if (step == 1) {
|
||||||
// ── Reveal the seed + birthday with backup warnings ─────────────────────
|
// ── Reveal the seed + birthday with backup warnings ─────────────────────
|
||||||
|
|||||||
@@ -320,15 +320,19 @@ std::vector<std::string> LiteWalletController::failoverServerUrls() const
|
|||||||
return urls;
|
return urls;
|
||||||
}
|
}
|
||||||
|
|
||||||
bool LiteWalletController::beginOpenExisting()
|
bool LiteWalletController::beginOpenExisting() { return beginAsyncLifecycle(/*create=*/false); }
|
||||||
|
bool LiteWalletController::beginCreateWallet() { return beginAsyncLifecycle(/*create=*/true); }
|
||||||
|
|
||||||
|
bool LiteWalletController::beginAsyncLifecycle(bool create)
|
||||||
{
|
{
|
||||||
if (walletOpen_.load() || openRunning_->load()) return false;
|
if (walletOpen_.load() || openRunning_->load()) return false;
|
||||||
|
const char* verb = create ? "Create" : "Open";
|
||||||
if (lifecycle_.availability() != LiteWalletLifecycleAvailability::Ready) {
|
if (lifecycle_.availability() != LiteWalletLifecycleAvailability::Ready) {
|
||||||
const std::string& reason = lifecycle_.status().message;
|
const std::string& reason = lifecycle_.status().message;
|
||||||
status_ = WalletBackendStatus{WalletBackendState::Error,
|
status_ = WalletBackendStatus{WalletBackendState::Error,
|
||||||
reason.empty() ? "lite wallet is not available" : reason, {}, {}, 0.0};
|
reason.empty() ? "lite wallet is not available" : reason, {}, {}, 0.0};
|
||||||
lastOpenError_ = status_.message;
|
lastOpenError_ = status_.message;
|
||||||
liteLog("Open blocked: " + lastOpenError_);
|
liteLog(std::string(verb) + " blocked: " + lastOpenError_);
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
auto servers = failoverServerUrls();
|
auto servers = failoverServerUrls();
|
||||||
@@ -336,7 +340,7 @@ bool LiteWalletController::beginOpenExisting()
|
|||||||
status_ = WalletBackendStatus{WalletBackendState::Error,
|
status_ = WalletBackendStatus{WalletBackendState::Error,
|
||||||
"no usable lite servers are configured", {}, {}, 0.0};
|
"no usable lite servers are configured", {}, {}, 0.0};
|
||||||
lastOpenError_ = status_.message;
|
lastOpenError_ = status_.message;
|
||||||
liteLog("Open blocked: " + lastOpenError_);
|
liteLog(std::string(verb) + " blocked: " + lastOpenError_);
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -346,24 +350,31 @@ bool LiteWalletController::beginOpenExisting()
|
|||||||
openResult_->reset();
|
openResult_->reset();
|
||||||
}
|
}
|
||||||
openRunning_->store(true);
|
openRunning_->store(true);
|
||||||
status_ = WalletBackendStatus{WalletBackendState::Connecting, "opening wallet", {}, {}, 0.0};
|
status_ = WalletBackendStatus{WalletBackendState::Connecting,
|
||||||
liteLog("Opening wallet — trying " + std::to_string(servers.size()) + " server(s)");
|
create ? "creating wallet" : "opening wallet", {}, {}, 0.0};
|
||||||
|
liteLog(std::string(create ? "Creating wallet — trying " : "Opening wallet — trying ") +
|
||||||
|
std::to_string(servers.size()) + " server(s)");
|
||||||
|
|
||||||
// Capture only shared refs + value copies (never `this`) so the thread can safely outlive us.
|
// Capture only shared refs + value copies (never `this`) so the thread can safely outlive us.
|
||||||
auto bridge = bridge_;
|
auto bridge = bridge_;
|
||||||
auto running = openRunning_;
|
auto running = openRunning_;
|
||||||
auto resultMutex = openResultMutex_;
|
auto resultMutex = openResultMutex_;
|
||||||
auto resultSlot = openResult_;
|
auto resultSlot = openResult_;
|
||||||
openThread_ = std::thread([bridge, servers, running, resultMutex, resultSlot]() {
|
openThread_ = std::thread([bridge, servers, running, resultMutex, resultSlot, create]() {
|
||||||
OpenOutcome outcome;
|
OpenOutcome outcome;
|
||||||
std::string preferredError; // error from the FIRST (preferred) server — the actionable one
|
std::string preferredError; // error from the FIRST (preferred) server — the actionable one
|
||||||
for (const auto& url : servers) {
|
for (const auto& url : servers) {
|
||||||
if (!bridge) break;
|
if (!bridge) break;
|
||||||
liteLog(" connecting to " + url + " ...");
|
liteLog(" connecting to " + url + " ...");
|
||||||
// initialize_existing loads the wallet file but contacts the server to start the
|
// create: initialize_new generates a NEW seed+wallet, but contacts the server FIRST,
|
||||||
// light client; ok && non-empty value == ready (mirrors the lifecycle's success test).
|
// so an unreachable server fails before writing any file — safe to try the next.
|
||||||
const auto call = bridge->initializeExisting(/*dangerous=*/false, url);
|
// open: initialize_existing just loads the file. ok && non-empty value == ready.
|
||||||
if (call.ok && !call.value.empty()) {
|
auto call = create ? bridge->initializeNew(/*dangerous=*/false, url)
|
||||||
|
: bridge->initializeExisting(/*dangerous=*/false, url);
|
||||||
|
const bool ready = call.ok && !call.value.empty();
|
||||||
|
// create's response IS the secret seed — wipe our copy; it's read back via exportSeed().
|
||||||
|
if (create) secureWipeLiteSecret(call.value);
|
||||||
|
if (ready) {
|
||||||
liteLog(" " + url + ": connected");
|
liteLog(" " + url + ": connected");
|
||||||
outcome.ok = true;
|
outcome.ok = true;
|
||||||
outcome.serverUrl = url;
|
outcome.serverUrl = url;
|
||||||
@@ -405,7 +416,7 @@ void LiteWalletController::pumpAsyncOpen()
|
|||||||
lastOpenError_.clear();
|
lastOpenError_.clear();
|
||||||
lastOpenWarming_ = false;
|
lastOpenWarming_ = false;
|
||||||
status_ = WalletBackendStatus{WalletBackendState::Ready, "wallet open", {}, {}, 0.0};
|
status_ = WalletBackendStatus{WalletBackendState::Ready, "wallet open", {}, {}, 0.0};
|
||||||
liteLog("Wallet opened via " + outcome.serverUrl);
|
liteLog("Wallet ready via " + outcome.serverUrl);
|
||||||
if (persist_) persist_();
|
if (persist_) persist_();
|
||||||
startSync(); // begin background sync on the backend
|
startSync(); // begin background sync on the backend
|
||||||
startWorker(); // begin periodic refresh -> WalletState
|
startWorker(); // begin periodic refresh -> WalletState
|
||||||
|
|||||||
@@ -168,8 +168,14 @@ public:
|
|||||||
// already open, an open is already in progress, no wallet exists, or the rollout gate blocks
|
// already open, an open is already in progress, no wallet exists, or the rollout gate blocks
|
||||||
// lite. On all-servers-fail, status()/lastOpenError() report the reason.
|
// lite. On all-servers-fail, status()/lastOpenError() report the reason.
|
||||||
bool beginOpenExisting();
|
bool beginOpenExisting();
|
||||||
// Finalize a completed async open on the calling (main) thread: flips walletOpen()/status and
|
// Asynchronously CREATE a new wallet (new seed) with the same server failover as open:
|
||||||
// starts sync, or records the failure. Cheap no-op when nothing is pending. Call each tick.
|
// initialize_new contacts the server before writing any file, so an unreachable server fails
|
||||||
|
// cleanly and the next is tried. On success the seed lives in the wallet — read it back with
|
||||||
|
// exportSeed(). Non-blocking; finalized by pumpAsyncOpen(). Returns false if a wallet is open,
|
||||||
|
// an open/create is in progress, the rollout gate blocks lite, or no usable server exists.
|
||||||
|
bool beginCreateWallet();
|
||||||
|
// Finalize a completed async open/create on the calling (main) thread: flips walletOpen()/status
|
||||||
|
// and starts sync, or records the failure. Cheap no-op when nothing is pending. Call each tick.
|
||||||
void pumpAsyncOpen();
|
void pumpAsyncOpen();
|
||||||
bool openInProgress() const { return openRunning_ && openRunning_->load(); }
|
bool openInProgress() const { return openRunning_ && openRunning_->load(); }
|
||||||
const std::string& lastOpenError() const { return lastOpenError_; }
|
const std::string& lastOpenError() const { return lastOpenError_; }
|
||||||
@@ -294,6 +300,8 @@ private:
|
|||||||
// the detached thread captures only shared_ptrs + value copies, never `this`, so it can
|
// the detached thread captures only shared_ptrs + value copies, never `this`, so it can
|
||||||
// safely outlive the controller). pumpAsyncOpen() finalizes the result on the main thread.
|
// safely outlive the controller). pumpAsyncOpen() finalizes the result on the main thread.
|
||||||
std::vector<std::string> failoverServerUrls() const;
|
std::vector<std::string> failoverServerUrls() const;
|
||||||
|
// Shared async open/create-with-failover core (create=true uses initialize_new).
|
||||||
|
bool beginAsyncLifecycle(bool create);
|
||||||
struct OpenOutcome { bool ok = false; bool warming = false; std::string serverUrl; std::string error; };
|
struct OpenOutcome { bool ok = false; bool warming = false; std::string serverUrl; std::string error; };
|
||||||
std::thread openThread_;
|
std::thread openThread_;
|
||||||
std::shared_ptr<std::atomic<bool>> openRunning_ = std::make_shared<std::atomic<bool>>(false);
|
std::shared_ptr<std::atomic<bool>> openRunning_ = std::make_shared<std::atomic<bool>>(false);
|
||||||
|
|||||||
@@ -64,8 +64,20 @@ inline bool liteFakeWalletExists(const char*) { return g_liteFakeWalletExists; }
|
|||||||
// Match the real litelib_* return shapes so tests exercise the production walletReady path:
|
// Match the real litelib_* return shapes so tests exercise the production walletReady path:
|
||||||
// create/restore return a seed object ({"seed":..,"birthday":..}); open returns the bare
|
// create/restore return a seed object ({"seed":..,"birthday":..}); open returns the bare
|
||||||
// string "OK" (NOT JSON) — see litelib_initialize_existing.
|
// string "OK" (NOT JSON) — see litelib_initialize_existing.
|
||||||
inline char* liteFakeInitNew(bool, const char*)
|
inline char* liteFakeInitNew(bool, const char* server)
|
||||||
{
|
{
|
||||||
|
// Honor the dead/warmup substrings so create-with-failover can be exercised (mirrors how the
|
||||||
|
// real initialize_new contacts the server first and fails before writing a wallet).
|
||||||
|
if (server) {
|
||||||
|
const std::string s(server);
|
||||||
|
if (!g_liteFakeWarmupServerSubstr.empty() &&
|
||||||
|
s.find(g_liteFakeWarmupServerSubstr) != std::string::npos)
|
||||||
|
return liteFakeDup("Error: grpc-message: \"error requesting block: -28: "
|
||||||
|
"Activating best chain...\"");
|
||||||
|
if (!g_liteFakeDeadServerSubstr.empty() &&
|
||||||
|
s.find(g_liteFakeDeadServerSubstr) != std::string::npos)
|
||||||
|
return liteFakeDup("Error: could not connect to server");
|
||||||
|
}
|
||||||
return liteFakeDup("{\"seed\":\"fake seed phrase words\",\"birthday\":0}");
|
return liteFakeDup("{\"seed\":\"fake seed phrase words\",\"birthday\":0}");
|
||||||
}
|
}
|
||||||
inline char* liteFakeInitFromPhrase(bool, const char*, const char*,
|
inline char* liteFakeInitFromPhrase(bool, const char*, const char*,
|
||||||
|
|||||||
@@ -3578,7 +3578,7 @@ void testLiteWalletControllerOpenFailover()
|
|||||||
bool sawConnecting = false, sawOpened = false;
|
bool sawConnecting = false, sawOpened = false;
|
||||||
for (const auto& l : log) {
|
for (const auto& l : log) {
|
||||||
if (l.find("connecting to") != std::string::npos) sawConnecting = true;
|
if (l.find("connecting to") != std::string::npos) sawConnecting = true;
|
||||||
if (l.find("Wallet opened via") != std::string::npos) sawOpened = true;
|
if (l.find("Wallet ready via") != std::string::npos) sawOpened = true;
|
||||||
}
|
}
|
||||||
EXPECT_TRUE(sawConnecting);
|
EXPECT_TRUE(sawConnecting);
|
||||||
EXPECT_TRUE(sawOpened);
|
EXPECT_TRUE(sawOpened);
|
||||||
@@ -3616,6 +3616,23 @@ void testLiteWalletControllerOpenFailover()
|
|||||||
EXPECT_TRUE(controller.lastOpenWasWarmup());
|
EXPECT_TRUE(controller.lastOpenWasWarmup());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Create also fails over: preferred dead, fallback good -> a new wallet is created via the
|
||||||
|
// fallback (no existing wallet beforehand).
|
||||||
|
{
|
||||||
|
dragonx::test::resetLiteFakeCounters();
|
||||||
|
dragonx::test::g_liteFakeWalletExists = false; // creating fresh
|
||||||
|
dragonx::test::g_liteFakeDeadServerSubstr = "dead.example";
|
||||||
|
LiteWalletController controller(liteCaps, conn,
|
||||||
|
LiteClientBridge::fromApi(dragonx::test::makeFakeLiteApi()));
|
||||||
|
EXPECT_FALSE(controller.walletOpen());
|
||||||
|
EXPECT_TRUE(controller.beginCreateWallet());
|
||||||
|
for (int i = 0; i < 400 && controller.openInProgress(); ++i)
|
||||||
|
std::this_thread::sleep_for(std::chrono::milliseconds(5));
|
||||||
|
controller.pumpAsyncOpen();
|
||||||
|
EXPECT_TRUE(controller.walletOpen());
|
||||||
|
EXPECT_TRUE(controller.lastOpenError().empty());
|
||||||
|
}
|
||||||
|
|
||||||
dragonx::test::g_liteFakeWalletExists = false;
|
dragonx::test::g_liteFakeWalletExists = false;
|
||||||
dragonx::test::g_liteFakeDeadServerSubstr.clear();
|
dragonx::test::g_liteFakeDeadServerSubstr.clear();
|
||||||
dragonx::test::g_liteFakeWarmupServerSubstr.clear();
|
dragonx::test::g_liteFakeWarmupServerSubstr.clear();
|
||||||
|
|||||||
Reference in New Issue
Block a user