feat(lite): async + failover for Settings-page create/open/restore

The Settings page drove the controller's synchronous createWallet/openWallet/
restoreWallet, which blocks the UI thread on the (often flaky) lightwalletd and
gives up after the first server. Add a generic async lifecycle path that mirrors
the async-open failover but carries the full request (passphrase, restore seed/
birthday/account/overwrite):

  - beginCreateWalletAsync / beginOpenWalletAsync / beginRestoreWalletAsync run
    on a detached thread that builds its OWN local LiteWalletLifecycleService
    from captured value copies + the shared bridge (never `this`, so it can
    safely outlive the controller). Each request type's serverUrl override field
    feeds the failover: try the preferred server, then every other usable
    default; stop on the first ready wallet or a structural block; keep the
    preferred server's error on total failure. The request's secrets are wiped
    once the attempt finishes.
  - pumpLifecycleResult() finalizes on the main thread (flip walletOpen, persist,
    start sync) and caches the result for the UI; wired into App::update next to
    pumpAsyncOpen(). beginAsyncLifecycle() now also yields to an in-flight
    lifecycle request so the auto-open loop can't race it on the same bridge.
  - settings_page kicks off the async op, disables the button while in flight,
    and polls the cached result each frame for the status/summary.

Tests: testLiteWalletControllerAsyncLifecycleFailover covers async create (with
passphrase) and restore failing over preferred->fallback, plus all-servers-down.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-08 11:42:47 -05:00
parent 320c659689
commit 6f9123f651
5 changed files with 315 additions and 15 deletions

View File

@@ -486,6 +486,8 @@ void App::update()
if (lite_wallet_) {
// Finalize any completed async open on the main thread (flips walletOpen / surfaces failure).
lite_wallet_->pumpAsyncOpen();
// Likewise finalize any completed async lifecycle request (Settings-page create/open/restore).
lite_wallet_->pumpLifecycleResult();
// Auto-open an existing wallet asynchronously with server failover (initialize_existing
// needs no passphrase — it just loads the file and contacts the server). Running it off

View File

@@ -115,6 +115,9 @@ struct SettingsPageState {
bool lite_restore_overwrite = false;
std::string lite_lifecycle_status;
std::string lite_lifecycle_summary;
// True while an async create/open/restore is in flight (Settings drives the controller's
// async lifecycle path so a flaky server never freezes the UI; the result is polled each frame).
bool lite_lifecycle_pending = false;
// Backup & keys (only populated for an open lite wallet). lite_export_secret holds the
// revealed seed/private-keys backup and is SECRET: securely wiped on hide / new export /
// import. lite_import_key is the import input buffer (wiped right after submission).
@@ -231,30 +234,34 @@ static void evaluateLiteLifecycleRequestFromPageState(App* app) {
}
}
// When a linked lite backend is present, execute the operation for real through the
// App-owned controller. Otherwise fall back to the validation-only adapter.
// When a linked lite backend is present, execute the operation for real through the App-owned
// controller — ASYNCHRONOUSLY, with server failover, so an unreachable/flaky server never
// freezes the UI. The request (with its secrets) is moved into the controller; the page polls
// the outcome each frame in renderLiteLifecyclePending() below. (Secret wiping of the page char
// buffers + the moved-from request copies is handled by liteSecretScrubber at function exit.)
if (auto* lite = app->liteWallet()) {
wallet::LiteWalletLifecycleResult result;
bool started = false;
switch (input.request.operation) {
case wallet::LiteWalletLifecycleOperation::CreateNew:
result = lite->createWallet(input.request.createRequest);
started = lite->beginCreateWalletAsync(std::move(input.request.createRequest));
break;
case wallet::LiteWalletLifecycleOperation::OpenExisting:
result = lite->openWallet(input.request.openRequest);
started = lite->beginOpenWalletAsync(std::move(input.request.openRequest));
break;
case wallet::LiteWalletLifecycleOperation::RestoreFromSeed:
result = lite->restoreWallet(input.request.restoreRequest);
started = lite->beginRestoreWalletAsync(std::move(input.request.restoreRequest));
break;
}
// (Secret wiping is handled unconditionally by liteSecretScrubber at function exit.)
s_settingsState.lite_lifecycle_summary = result.bridgeResponseRedacted;
if (result.walletReady) {
s_settingsState.lite_lifecycle_status = "Wallet ready";
if (started) {
s_settingsState.lite_lifecycle_pending = true;
s_settingsState.lite_lifecycle_status = "Working…";
s_settingsState.lite_lifecycle_summary.clear();
} else {
s_settingsState.lite_lifecycle_status = result.error.empty()
? result.status.message
: result.error;
// Rejected before any thread launched (wallet already open, an attempt in flight, or no
// usable server). The controller's status carries the reason.
s_settingsState.lite_lifecycle_status = lite->status().message.empty()
? "Could not start the operation"
: lite->status().message;
Notifications::instance().warning(s_settingsState.lite_lifecycle_status);
}
return;
@@ -1600,10 +1607,36 @@ void RenderSettingsPage(App* app) {
sizeof(s_settingsState.lite_lifecycle_passphrase),
ImGuiInputTextFlags_Password);
// Poll a completed async create/open/restore (driven by the App-owned
// controller; finalized on the main thread by App::update's
// pumpLifecycleResult()). While in flight the button is disabled.
if (s_settingsState.lite_lifecycle_pending) {
if (auto* lite = app->liteWallet()) {
if (!lite->lifecycleRequestInProgress()) {
s_settingsState.lite_lifecycle_pending = false;
const auto& result = lite->lastLifecycleResult();
s_settingsState.lite_lifecycle_summary = result.bridgeResponseRedacted;
if (result.walletReady) {
s_settingsState.lite_lifecycle_status = "Wallet ready";
} else {
s_settingsState.lite_lifecycle_status = result.error.empty()
? result.status.message
: result.error;
Notifications::instance().warning(s_settingsState.lite_lifecycle_status);
}
}
} else {
s_settingsState.lite_lifecycle_pending = false; // backend went away
}
}
ImGui::SetCursorScreenPos(ImVec2(leftX, ImGui::GetCursorScreenPos().y));
const bool liteLifecycleBusy = s_settingsState.lite_lifecycle_pending;
if (liteLifecycleBusy) ImGui::BeginDisabled();
if (TactileButton("Validate##LiteLifecycleValidate", ImVec2(0, 0), S.resolveFont("button"))) {
evaluateLiteLifecycleRequestFromPageState(app);
}
if (liteLifecycleBusy) ImGui::EndDisabled();
if (!s_settingsState.lite_lifecycle_status.empty()) {
ImGui::SameLine(0, Layout::spacingSm());

View File

@@ -242,6 +242,8 @@ LiteWalletController::LiteWalletController(WalletCapabilities capabilities,
: bridge_(std::make_shared<LiteClientBridge>(std::move(bridge))),
chainName_(connectionSettings.chainName),
connectionSettings_(connectionSettings),
capabilities_(capabilities),
lifecycleOptions_{options.allowBridgeCalls, options.rolloutBlocked, options.rolloutMessage},
lifecycle_(capabilities, connectionSettings, bridge_.get(),
LiteWalletLifecycleOptions{options.allowBridgeCalls,
options.rolloutBlocked, options.rolloutMessage}),
@@ -273,6 +275,9 @@ LiteWalletController::~LiteWalletController()
// The async-open failover thread captures only shared refs (bridge + running flag + result
// slot), never `this`, so detaching is safe if it's still trying servers at shutdown.
if (openThread_.joinable()) openThread_.detach();
// The async full-lifecycle thread builds its own local lifecycle service from captured value
// copies + the shared bridge (never `this`), so detaching is likewise safe.
if (lifecycleThread_.joinable()) lifecycleThread_.detach();
}
std::unique_ptr<LiteWalletController> LiteWalletController::createLinked(
@@ -325,7 +330,8 @@ bool LiteWalletController::beginCreateWallet() { return beginAsyncLifecycle(/*cr
bool LiteWalletController::beginAsyncLifecycle(bool create)
{
if (walletOpen_.load() || openRunning_->load()) return false;
// Don't race a Settings-page async lifecycle request (create/open/restore) on the same bridge.
if (walletOpen_.load() || openRunning_->load() || lifecycleRunning_->load()) return false;
const char* verb = create ? "Create" : "Open";
if (lifecycle_.availability() != LiteWalletLifecycleAvailability::Ready) {
const std::string& reason = lifecycle_.status().message;
@@ -429,6 +435,153 @@ void LiteWalletController::pumpAsyncOpen()
}
}
bool LiteWalletController::beginCreateWalletAsync(LiteWalletCreateRequest request)
{
auto req = std::make_shared<LiteWalletCreateRequest>(std::move(request));
return beginLifecycleRequestAsync(
"Create",
[req](LiteWalletLifecycleService& svc, const std::string& url) {
req->serverUrl = url;
return svc.createWallet(*req);
},
[req]() { secureWipeLiteSecret(req->passphrase); });
}
bool LiteWalletController::beginOpenWalletAsync(LiteWalletOpenRequest request)
{
auto req = std::make_shared<LiteWalletOpenRequest>(std::move(request));
return beginLifecycleRequestAsync(
"Open",
[req](LiteWalletLifecycleService& svc, const std::string& url) {
req->serverUrl = url;
return svc.openWallet(*req);
},
[req]() { secureWipeLiteSecret(req->passphrase); });
}
bool LiteWalletController::beginRestoreWalletAsync(LiteWalletRestoreRequest request)
{
auto req = std::make_shared<LiteWalletRestoreRequest>(std::move(request));
return beginLifecycleRequestAsync(
"Restore",
[req](LiteWalletLifecycleService& svc, const std::string& url) {
req->serverUrl = url;
return svc.restoreWallet(*req);
},
[req]() {
secureWipeLiteSecret(req->seedPhrase);
secureWipeLiteSecret(req->passphrase);
});
}
bool LiteWalletController::beginLifecycleRequestAsync(
const char* verb,
std::function<LiteWalletLifecycleResult(LiteWalletLifecycleService&, const std::string&)> exec,
std::function<void()> wipeSecrets)
{
// Reject (and still wipe the caller's secrets) if a wallet is already open or any async
// open/lifecycle attempt is in flight.
if (walletOpen_.load() || lifecycleRunning_->load() || openRunning_->load()) {
if (wipeSecrets) wipeSecrets();
return false;
}
auto servers = failoverServerUrls();
if (servers.empty()) {
if (wipeSecrets) wipeSecrets();
status_ = WalletBackendStatus{WalletBackendState::Error,
"no usable lite servers are configured", {}, {}, 0.0};
lastOpenError_ = status_.message;
liteLog(std::string(verb) + " blocked: " + lastOpenError_);
return false;
}
if (lifecycleThread_.joinable()) lifecycleThread_.join(); // a prior attempt has fully finished
{
std::lock_guard<std::mutex> lk(*lifecycleResultMutex_);
lifecycleResult_->reset();
}
lifecycleRunning_->store(true);
status_ = WalletBackendStatus{WalletBackendState::Connecting,
std::string(verb) + " wallet…", {}, {}, 0.0};
liteLog(std::string(verb) + " wallet — trying " + std::to_string(servers.size()) + " server(s)");
// Capture only value copies + the shared bridge (never `this`): the thread builds its own
// local lifecycle service, so it can safely outlive the controller (mirrors the open thread).
auto bridge = bridge_;
auto caps = capabilities_;
auto conn = connectionSettings_;
auto opts = lifecycleOptions_;
auto running = lifecycleRunning_;
auto resultMutex = lifecycleResultMutex_;
auto resultSlot = lifecycleResult_;
lifecycleThread_ = std::thread(
[bridge, caps, conn, opts, servers, exec, wipeSecrets, running, resultMutex, resultSlot]() {
LiteWalletLifecycleService localLifecycle(caps, conn, bridge.get(), opts);
LiteWalletLifecycleResult chosen;
bool have = false;
for (const auto& url : servers) {
if (!bridge) break;
liteLog(" connecting to " + url + " ...");
LiteWalletLifecycleResult r = exec(localLifecycle, url);
if (r.walletReady) {
liteLog(" " + url + ": ready");
chosen = std::move(r);
have = true;
break;
}
// A non-attempted result is a structural block (availability / validation): the same
// for every server, so stop and surface it rather than retrying pointlessly.
if (!r.attempted) {
chosen = std::move(r);
have = true;
break;
}
liteLog(" " + url + ": " + (r.error.empty() ? r.status.message : r.error));
// Keep the PREFERRED (first) server's failure — the actionable one for the user —
// rather than whichever fallback happened to be tried last.
if (!have) {
chosen = std::move(r);
have = true;
}
}
if (!have) {
chosen.error = "could not reach any lite server";
chosen.status = WalletBackendStatus{WalletBackendState::Error, chosen.error, {}, {}, 0.0};
}
if (wipeSecrets) wipeSecrets(); // wipe the request's secrets once the attempt is done
{
std::lock_guard<std::mutex> lk(*resultMutex);
*resultSlot = std::move(chosen);
}
running->store(false);
});
return true;
}
void LiteWalletController::pumpLifecycleResult()
{
LiteWalletLifecycleResult out;
{
std::lock_guard<std::mutex> lk(*lifecycleResultMutex_);
if (!lifecycleResult_->has_value()) return; // nothing finished since last pump
out = std::move(lifecycleResult_->value());
lifecycleResult_->reset();
}
if (lifecycleThread_.joinable()) lifecycleThread_.join(); // producer set its result, then exits
// Finalize on the main thread: flip walletOpen()/status, persist, start sync/worker on success;
// log the failure otherwise (shared with the synchronous lifecycle path).
onLifecycleResult(out);
if (out.walletReady) {
lastOpenError_.clear();
lastOpenWarming_ = false;
} else {
lastOpenError_ = out.error.empty() ? out.status.message : out.error;
lastOpenWarming_ = liteOpenErrorIsWarmup(lastOpenError_);
}
lastLifecycleResult_ = std::move(out);
}
void LiteWalletController::startSync()
{
if (syncLaunched_.exchange(true)) return;

View File

@@ -178,6 +178,25 @@ public:
// and starts sync, or records the failure. Cheap no-op when nothing is pending. Call each tick.
void pumpAsyncOpen();
bool openInProgress() const { return openRunning_ && openRunning_->load(); }
// Asynchronous FULL lifecycle (create / open / restore WITH passphrase + restore params) with
// the same server failover as beginOpenExisting(). Unlike beginCreateWallet() (plain new seed,
// no passphrase), these carry the complete request so the Settings page can create with
// encryption, open an encrypted wallet, or restore from a seed without freezing the UI on a
// flaky server. Non-blocking; finalized by pumpLifecycleResult() on the main thread. The
// request is taken by value and its secret fields are securely wiped once the attempt finishes.
// Returns false if a wallet is open, a lifecycle/open is already in progress, or no usable
// server exists (the request's secrets are still wiped on that path).
bool beginCreateWalletAsync(LiteWalletCreateRequest request);
bool beginOpenWalletAsync(LiteWalletOpenRequest request);
bool beginRestoreWalletAsync(LiteWalletRestoreRequest request);
bool lifecycleRequestInProgress() const { return lifecycleRunning_ && lifecycleRunning_->load(); }
// Finalize a completed async lifecycle request on the main thread (flip walletOpen()/status,
// persist, start sync) and cache the result for the UI. Cheap no-op when nothing is pending.
// Call each tick alongside pumpAsyncOpen().
void pumpLifecycleResult();
// The most recent finalized async-lifecycle result (create/open/restore), for UI display.
const LiteWalletLifecycleResult& lastLifecycleResult() const { return lastLifecycleResult_; }
const std::string& lastOpenError() const { return lastOpenError_; }
// True if the last failed open hit a server that was merely warming up (JSON-RPC -28 /
// "Activating best chain"): the server is healthy and will be ready shortly, so the caller
@@ -273,6 +292,8 @@ private:
std::shared_ptr<LiteClientBridge> bridge_;
std::string chainName_; // backend chain id (for walletExists); from connection settings
LiteConnectionSettings connectionSettings_; // kept for the failover candidate-server list
WalletCapabilities capabilities_; // for thread-local lifecycle services (async path)
LiteWalletLifecycleOptions lifecycleOptions_; // ditto (allowBridgeCalls / rollout gate)
LiteWalletLifecycleService lifecycle_;
LiteWalletGateway gateway_;
LiteSyncService sync_;
@@ -311,6 +332,21 @@ private:
std::string lastOpenError_; // main-thread only
bool lastOpenWarming_ = false; // last failed open hit a warming-up (-28) server
// Asynchronous FULL lifecycle (create/open/restore with passphrase + restore params). Same
// shared-lifetime discipline as the open thread: the detached thread builds its OWN local
// LiteWalletLifecycleService from captured value copies + the shared bridge (never `this`), so
// it can safely outlive the controller. pumpLifecycleResult() finalizes on the main thread.
bool beginLifecycleRequestAsync(
const char* verb,
std::function<LiteWalletLifecycleResult(LiteWalletLifecycleService&, const std::string&)> exec,
std::function<void()> wipeSecrets);
std::thread lifecycleThread_;
std::shared_ptr<std::atomic<bool>> lifecycleRunning_ = std::make_shared<std::atomic<bool>>(false);
std::shared_ptr<std::mutex> lifecycleResultMutex_ = std::make_shared<std::mutex>();
std::shared_ptr<std::optional<LiteWalletLifecycleResult>> lifecycleResult_ =
std::make_shared<std::optional<LiteWalletLifecycleResult>>();
LiteWalletLifecycleResult lastLifecycleResult_; // main-thread only; last finalized request
// Joinable background refresh worker (fast iterations: syncstatus, plus data once synced).
std::thread worker_;
std::atomic<bool> running_{false};

View File

@@ -3638,6 +3638,81 @@ void testLiteWalletControllerOpenFailover()
dragonx::test::g_liteFakeWarmupServerSubstr.clear();
}
// Async FULL lifecycle (Settings-page create/open/restore WITH passphrase/restore params) also
// fails over: the request runs off the UI thread against the preferred server, then the other
// usable defaults, finalized by pumpLifecycleResult() on the main thread.
void testLiteWalletControllerAsyncLifecycleFailover()
{
using namespace dragonx::wallet;
const auto liteCaps = makeWalletCapabilities(WalletBuildKind::Lite, false, true);
LiteConnectionSettings conn;
conn.chainName = "main";
conn.servers = {
LiteServerEndpoint{"https://dead.example", "Dead", true},
LiteServerEndpoint{"https://good.example", "Good", true},
};
conn.selectionMode = LiteServerSelectionMode::Sticky;
conn.stickyServerUrl = "https://dead.example"; // preferred server is the dead one
const auto drain = [](LiteWalletController& c) {
for (int i = 0; i < 400 && c.lifecycleRequestInProgress(); ++i)
std::this_thread::sleep_for(std::chrono::milliseconds(5));
c.pumpLifecycleResult();
};
// Async create with a passphrase: preferred dead, fallback good -> wallet created via fallback.
{
dragonx::test::resetLiteFakeCounters();
dragonx::test::g_liteFakeWalletExists = false;
dragonx::test::g_liteFakeDeadServerSubstr = "dead.example";
LiteWalletController controller(liteCaps, conn,
LiteClientBridge::fromApi(dragonx::test::makeFakeLiteApi()));
LiteWalletCreateRequest req;
req.passphrase = "hunter2";
EXPECT_TRUE(controller.beginCreateWalletAsync(req));
drain(controller);
EXPECT_TRUE(controller.walletOpen());
EXPECT_TRUE(controller.lastLifecycleResult().walletReady);
}
// Async restore from seed: preferred dead, fallback good -> wallet restored via fallback.
{
dragonx::test::resetLiteFakeCounters();
dragonx::test::g_liteFakeWalletExists = false;
dragonx::test::g_liteFakeDeadServerSubstr = "dead.example";
LiteWalletController controller(liteCaps, conn,
LiteClientBridge::fromApi(dragonx::test::makeFakeLiteApi()));
LiteWalletRestoreRequest req;
req.seedPhrase = "abandon abandon abandon abandon abandon abandon abandon abandon "
"abandon abandon abandon abandon abandon abandon abandon abandon "
"abandon abandon abandon abandon abandon abandon abandon art";
req.birthday = 0;
EXPECT_TRUE(controller.beginRestoreWalletAsync(req));
drain(controller);
EXPECT_TRUE(controller.walletOpen());
EXPECT_TRUE(controller.lastLifecycleResult().walletReady);
}
// All servers dead -> async lifecycle fails, wallet stays closed, reason surfaced.
{
dragonx::test::resetLiteFakeCounters();
dragonx::test::g_liteFakeWalletExists = false;
dragonx::test::g_liteFakeDeadServerSubstr = "example"; // both servers fail
LiteWalletController controller(liteCaps, conn,
LiteClientBridge::fromApi(dragonx::test::makeFakeLiteApi()));
EXPECT_TRUE(controller.beginCreateWalletAsync(LiteWalletCreateRequest{}));
drain(controller);
EXPECT_FALSE(controller.walletOpen());
EXPECT_FALSE(controller.lastLifecycleResult().walletReady);
EXPECT_TRUE(!controller.lastOpenError().empty());
}
dragonx::test::g_liteFakeWalletExists = false;
dragonx::test::g_liteFakeDeadServerSubstr.clear();
dragonx::test::g_liteFakeWarmupServerSubstr.clear();
}
// M2: a parsed lite refresh bundle maps through to the app's WalletState (the last hop
// the Balance/Receive/Transactions tabs read), with zatoshi->DRGX conversion, z/t address
// split, transaction typing, confirmations, and sync progress.
@@ -4520,6 +4595,7 @@ int main()
testLiteBackendInjectableFakeBridge();
testLiteWalletControllerLifecycle();
testLiteWalletControllerOpenFailover();
testLiteWalletControllerAsyncLifecycleFailover();
testLiteWalletControllerM4();
testLiteWalletControllerM5Persistence();
testLiteWalletControllerEncryption();