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:
2026-06-08 11:29:59 -05:00
parent 6ff1fda870
commit 320c659689
5 changed files with 102 additions and 37 deletions

View File

@@ -320,15 +320,19 @@ std::vector<std::string> LiteWalletController::failoverServerUrls() const
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;
const char* verb = create ? "Create" : "Open";
if (lifecycle_.availability() != LiteWalletLifecycleAvailability::Ready) {
const std::string& reason = lifecycle_.status().message;
status_ = WalletBackendStatus{WalletBackendState::Error,
reason.empty() ? "lite wallet is not available" : reason, {}, {}, 0.0};
lastOpenError_ = status_.message;
liteLog("Open blocked: " + lastOpenError_);
liteLog(std::string(verb) + " blocked: " + lastOpenError_);
return false;
}
auto servers = failoverServerUrls();
@@ -336,7 +340,7 @@ bool LiteWalletController::beginOpenExisting()
status_ = WalletBackendStatus{WalletBackendState::Error,
"no usable lite servers are configured", {}, {}, 0.0};
lastOpenError_ = status_.message;
liteLog("Open blocked: " + lastOpenError_);
liteLog(std::string(verb) + " blocked: " + lastOpenError_);
return false;
}
@@ -346,24 +350,31 @@ bool LiteWalletController::beginOpenExisting()
openResult_->reset();
}
openRunning_->store(true);
status_ = WalletBackendStatus{WalletBackendState::Connecting, "opening wallet", {}, {}, 0.0};
liteLog("Opening wallet — trying " + std::to_string(servers.size()) + " server(s)");
status_ = WalletBackendStatus{WalletBackendState::Connecting,
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.
auto bridge = bridge_;
auto running = openRunning_;
auto resultMutex = openResultMutex_;
auto resultSlot = openResult_;
openThread_ = std::thread([bridge, servers, running, resultMutex, resultSlot]() {
openThread_ = std::thread([bridge, servers, running, resultMutex, resultSlot, create]() {
OpenOutcome outcome;
std::string preferredError; // error from the FIRST (preferred) server — the actionable one
for (const auto& url : servers) {
if (!bridge) break;
liteLog(" connecting to " + url + " ...");
// initialize_existing loads the wallet file but contacts the server to start the
// light client; ok && non-empty value == ready (mirrors the lifecycle's success test).
const auto call = bridge->initializeExisting(/*dangerous=*/false, url);
if (call.ok && !call.value.empty()) {
// create: initialize_new generates a NEW seed+wallet, but contacts the server FIRST,
// so an unreachable server fails before writing any file — safe to try the next.
// open: initialize_existing just loads the file. ok && non-empty value == ready.
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");
outcome.ok = true;
outcome.serverUrl = url;
@@ -405,7 +416,7 @@ void LiteWalletController::pumpAsyncOpen()
lastOpenError_.clear();
lastOpenWarming_ = false;
status_ = WalletBackendStatus{WalletBackendState::Ready, "wallet open", {}, {}, 0.0};
liteLog("Wallet opened via " + outcome.serverUrl);
liteLog("Wallet ready via " + outcome.serverUrl);
if (persist_) persist_();
startSync(); // begin background sync on the backend
startWorker(); // begin periodic refresh -> WalletState

View File

@@ -168,8 +168,14 @@ public:
// 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.
bool beginOpenExisting();
// Finalize a completed async open 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.
// Asynchronously CREATE a new wallet (new seed) with the same server failover as open:
// 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();
bool openInProgress() const { return openRunning_ && openRunning_->load(); }
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
// safely outlive the controller). pumpAsyncOpen() finalizes the result on the main thread.
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; };
std::thread openThread_;
std::shared_ptr<std::atomic<bool>> openRunning_ = std::make_shared<std::atomic<bool>>(false);