diff --git a/src/app.cpp b/src/app.cpp index f644c56..ac2acc4 100644 --- a/src/app.cpp +++ b/src/app.cpp @@ -751,6 +751,22 @@ bool sendErrorNeedsRescan(const std::string& raw) } } // namespace +void App::retireLiteWallet() +{ + if (!lite_wallet_) return; + lite_wallet_->retire("lite wallet reconnecting"); // non-blocking (see LiteWalletController::retire) + // Destroy off the UI thread: ~LiteWalletController joins the poll worker, which may be inside a backend call that + // does not return for minutes (no connect/request deadline). Nobody waits for this thread; it owns the controller + // outright and touches nothing else of App. On quit the process _Exit()s, so it is never joined. + auto old = std::move(lite_wallet_); + try { + std::thread([c = std::move(old)]() mutable { c.reset(); }).detach(); + } catch (...) { + // No thread: the controller was already destroyed inside the failed std::thread constructor, i.e. on this + // thread — the pre-fix blocking behaviour, but no crash. + } +} + void App::rebuildLiteWallet(bool force) { if (!supportsLiteBackend() || !settings_) return; @@ -758,15 +774,33 @@ void App::rebuildLiteWallet(bool force) // mid-sync), the new server selection is already persisted to settings and will take effect // the next time the controller is built (next launch, or before another wallet is opened). // Rebuilding would discard the open wallet and its in-flight, uninterruptible sync — which is - // exactly what the Network tab's apply-immediately server switch wants (force=true). Replacing - // lite_wallet_ destroys the old controller; its destructor detaches the uninterruptible sync - // thread (which keeps the shared bridge alive) and only joins the short poll worker, so this - // does not block. The app's auto-open loop then reopens the wallet against the new server. + // exactly what the Network tab's apply-immediately server switch wants (force=true). The old + // controller is retired (retireLiteWallet) and destroyed on a background thread: its destructor + // joins the poll worker, which can sit for minutes inside a backend call against a dead server, + // so it must never run on the UI thread. The app's auto-open loop then reopens the wallet + // against the new server. if (!force && lite_wallet_ && lite_wallet_->walletOpen()) return; + // Never retire a controller with a wallet op (new address / import / encrypt / decrypt) in flight: litelib_execute + // always runs on the GLOBAL client, so the op would still finish — and save — on the old client while the new + // client owns the same wallet file, after its UI callback was told it failed. Defer the switch (update() retries + // every tick) until the op has been delivered, but at most kLiteServerSwitchMaxDeferSeconds so an op stuck behind + // a dead server can't pin the user to it. + const bool opInFlight = lite_wallet_ && lite_wallet_->walletOpInProgress(); + const auto nowTp = std::chrono::steady_clock::now(); + if (opInFlight && !lite_rebuild_pending_) lite_rebuild_deferred_at_ = nowTp; + const double deferredFor = opInFlight + ? std::chrono::duration(nowTp - lite_rebuild_deferred_at_).count() : 0.0; + if (!wallet::liteServerSwitchMayProceed(opInFlight, deferredFor)) { + lite_rebuild_pending_ = true; + return; + } + lite_rebuild_pending_ = false; + const auto liteConn = wallet::liteConnectionSettingsFromAppSettings(*settings_); wallet::liteLog(std::string(force ? "Lite controller rebuilt" : "Lite controller built") + " (preferred server: " + liteConn.stickyServerUrl + ")"); + retireLiteWallet(); // no-op on the first build lite_wallet_ = wallet::LiteWalletController::createLinked( walletCapabilities(), liteConn, resolveLiteRolloutDecision(*settings_)); lite_wallet_->setPersistCallback([this]() { settings_->save(); }); @@ -865,6 +899,11 @@ void App::update() lite_wallet_->pumpAsyncOpen(); // Likewise finalize any completed async lifecycle request (Settings-page create/open/restore). lite_wallet_->pumpLifecycleResult(); + // Deliver finished wallet ops (new address / import / encrypt / decrypt) to their UI callbacks. + lite_wallet_->pumpWalletOps(); + // A server switch deferred behind an in-flight wallet op: retry it. This may replace lite_wallet_; the rest of + // this block then runs on the new controller, exactly as on the tick after a Network-tab switch. + if (lite_rebuild_pending_) rebuildLiteWallet(/*force=*/true); // 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 @@ -886,7 +925,8 @@ void App::update() // shows progress for it: keep frames coming while any of it is in flight. if (lite_wallet_->openInProgress() || lite_wallet_->lifecycleRequestInProgress() || lite_wallet_->scanInProgress() || lite_wallet_->broadcastInProgress() || - lite_wallet_->consoleCommandInProgress()) { + lite_wallet_->consoleCommandInProgress() || lite_wallet_->walletOpInProgress() || + lite_rebuild_pending_) { ui::material::RequestProgressFrame(); } @@ -7357,6 +7397,10 @@ void App::renderLoadingOverlay(float contentH) void App::shutdown() { + // Lite: let an in-flight wallet op (e.g. Encrypt's save) finish. Bounded: the window is already hidden and + // main.cpp has a 15 s watchdog. The backend writes tmp + rename, so being cut off never corrupts the wallet. + if (lite_wallet_) lite_wallet_->waitWalletOpsIdle(std::chrono::milliseconds(2500)); + // Full node: persist whatever a refresh changed since beginShutdown() (everything, on the direct-exit path), // then drain + join the history writer. Bounded like the settings flush: on a wedged disk the in-flight write // is left running (one autocommit INSERT in WAL mode: a cut-off write keeps the previous snapshot) and _Exit @@ -7503,7 +7547,8 @@ void App::verifyDebugCredential(const std::string& secret, std::functionhasPendingResults()) || (fast_worker_ && fast_worker_->hasPendingResults()) - || (http_worker_ && http_worker_->hasPendingResults()); + || (http_worker_ && http_worker_->hasPendingResults()) // g3: market-data GETs + || (lite_wallet_ && lite_wallet_->hasWalletOpCompletions()); // g1: lite wallet-op lane } std::string App::transactionSendProgressText() const diff --git a/src/app.h b/src/app.h index 3c15c0e..e9c8fb6 100644 --- a/src/app.h +++ b/src/app.h @@ -768,9 +768,9 @@ public: void verifyDebugCredential(const std::string& secret, std::function cb); /// @brief Something finished on a background thread and waits for update() to apply it on the main thread - /// (the RPC worker lanes, the HTTP lane). main.cpp's idle loop polls this every <= 200 ms (hasImmediateWork) and - /// renders a frame when it is true, so the result shows without input, even while the window is hidden. A new - /// lane whose results are applied in update() adds its term HERE. + /// (the RPC worker lanes, the HTTP lane, lite wallet ops). main.cpp's idle loop polls this every <= 200 ms + /// (hasImmediateWork) and renders a frame when it is true, so the result shows without input, even while the + /// window is hidden. A new lane whose results are applied in update() adds its term HERE. bool hasPendingBackgroundResults() const; bool hasTransactionSendProgress() const { return send_progress_active_ || send_submissions_in_flight_ > 0 || !pending_opids_.empty(); } std::string transactionSendProgressText() const; @@ -927,6 +927,13 @@ private: // Reason an existing lite wallet failed to auto-open (e.g. server unreachable). Surfaced in // the UI so a stuck "disconnected" state isn't silent; cleared once a wallet opens. std::string lite_open_error_; + // A forced lite rebuild (Network-tab server switch) deferred while a wallet op is in flight; update() retries it + // (rebuildLiteWallet). deferred_at bounds the wait (wallet::kLiteServerSwitchMaxDeferSeconds). + bool lite_rebuild_pending_ = false; + std::chrono::steady_clock::time_point lite_rebuild_deferred_at_{}; + // Retire the current lite controller without blocking the UI thread (fail its pending op callbacks, stop its + // worker, shut its client down) and destroy it on a detached thread. lite_wallet_ is null afterwards. + void retireLiteWallet(); // HushChat (experimental; gated by DRAGONX_ENABLE_CHAT — inert when OFF). App owns the chat // service so the transaction-refresh harvest can decrypt incoming memos into threaded messages. diff --git a/src/app_network.cpp b/src/app_network.cpp index e27381b..67a45b0 100644 --- a/src/app_network.cpp +++ b/src/app_network.cpp @@ -3688,22 +3688,30 @@ void App::createNewTAddress(std::function callback) void App::createNewAddress(bool shielded, std::function callback) { const char* typeStr = shielded ? "shielded" : "transparent"; - auto& targetList = shielded ? state_.z_addresses : state_.t_addresses; - // Lite build: derive locally via the controller (fast, no network). The backend auto-saves - // new addresses; the next lite refresh lists it with a balance. + // Lite build: the key is derived locally, but `new` ends in the backend's do_save(), which waits on its sync_lock + // (held for a whole scan and by every refresh fetch). It runs on the controller's wallet-op lane and the result is + // applied here on the main thread. The callback fires exactly once: inline if no wallet is open, or with "" if the + // controller is retired first. if (lite_wallet_) { - const auto result = lite_wallet_->newAddress(shielded); - if (result.ok) { - AddressInfo info; - info.address = result.address; - info.type = typeStr; - info.balance = 0.0; - targetList.push_back(info); - state_.addresses.push_back(info); - address_list_dirty_ = true; - } - if (callback) callback(result.ok ? result.address : std::string()); + lite_wallet_->newAddressAsync(shielded, + [this, shielded, typeStr, callback](const wallet::LiteNewAddressResult& result) { + if (result.ok) { + // A refresh applied between the backend call and this delivery may already list it. + const bool known = std::any_of(state_.addresses.begin(), state_.addresses.end(), + [&](const AddressInfo& a) { return a.address == result.address; }); + if (!known) { + AddressInfo info; + info.address = result.address; + info.type = typeStr; + info.balance = 0.0; + (shielded ? state_.z_addresses : state_.t_addresses).push_back(info); + state_.addresses.push_back(info); + address_list_dirty_ = true; + } + } + if (callback) callback(result.ok ? result.address : std::string()); + }); return; } diff --git a/src/ui/pages/settings_page.cpp b/src/ui/pages/settings_page.cpp index a173d27..4afc3f2 100644 --- a/src/ui/pages/settings_page.cpp +++ b/src/ui/pages/settings_page.cpp @@ -1902,6 +1902,11 @@ void RenderSettingsPage(App* app) { ImGuiInputTextFlags_Password); if (ImGui::IsItemHovered()) material::Tooltip("%s", TR("tt_lite_import_key")); ImGui::SetCursorScreenPos(ImVec2(leftX, ImGui::GetCursorScreenPos().y)); + // Import / Encrypt / Remove encryption end in a backend save that waits on the scan lock (held for a + // whole scan): they run on the controller's wallet-op lane and are unavailable while a scan runs. + const bool liteScanning = app->liteWallet()->scanInProgress(); + const bool liteOpBusy = app->liteWallet()->walletOpInProgress(); + ImGui::BeginDisabled(liteScanning || liteOpBusy); if (TactileButton(TrId("lite_import", "LiteImportKeyBtn").c_str(), ImVec2(0, 0), S.resolveFont("button"))) { std::string liteKey(s_settingsState.lite_import_key); while (!liteKey.empty() && (liteKey.front()==' '||liteKey.front()=='\t'||liteKey.front()=='\n'||liteKey.front()=='\r')) liteKey.erase(liteKey.begin()); @@ -1909,13 +1914,18 @@ void RenderSettingsPage(App* app) { if (liteKey.empty()) { s_settingsState.lite_backup_status = TR("grpa_enter_private_key_to_import"); } else { - const auto r = app->liteWallet()->importKey(liteKey); + s_settingsState.lite_backup_status = TR("lite_working"); + app->liteWallet()->importKeyAsync(liteKey, [](const wallet::LiteImportResult& r) { + s_settingsState.lite_backup_status = + r.ok ? std::string(TR("lite_key_imported")) : r.error; + }); sodium_memzero(s_settingsState.lite_import_key, sizeof(s_settingsState.lite_import_key)); - s_settingsState.lite_backup_status = - r.ok ? TR("lite_key_imported") : r.error; } + wallet::secureWipeLiteSecret(liteKey); // the trimmed copy (was never wiped before) } - if (ImGui::IsItemHovered()) material::Tooltip("%s", TR("tt_lite_import_key_btn")); + ImGui::EndDisabled(); + if (ImGui::IsItemHovered(ImGuiHoveredFlags_AllowWhenDisabled)) + material::Tooltip("%s", liteScanning ? TR("send_tooltip_syncing") : TR("tt_lite_import_key_btn")); if (!s_settingsState.lite_backup_status.empty()) { ImGui::SetCursorScreenPos(ImVec2(leftX, ImGui::GetCursorScreenPos().y)); @@ -1939,12 +1949,19 @@ void RenderSettingsPage(App* app) { sizeof(s_settingsState.lite_enc_pass), ImGuiInputTextFlags_Password); if (ImGui::IsItemHovered()) material::Tooltip("%s", TR("tt_lite_encrypt_pass")); ImGui::SetCursorScreenPos(ImVec2(leftX, ImGui::GetCursorScreenPos().y)); + ImGui::BeginDisabled(liteScanning || app->liteWallet()->walletOpInProgress()); if (TactileButton(TrId("lite_encrypt_wallet", "LiteEncrypt").c_str(), ImVec2(0, 0), S.resolveFont("button"))) { - const auto r = app->liteWallet()->encryptWallet(s_settingsState.lite_enc_pass); + s_settingsState.lite_encryption_status = TR("lite_working"); + app->liteWallet()->encryptWalletAsync(s_settingsState.lite_enc_pass, + [](const wallet::LiteEncryptionResult& r) { + s_settingsState.lite_encryption_status = + r.ok ? std::string(TR("lite_wallet_encrypted")) : r.error; + }); sodium_memzero(s_settingsState.lite_enc_pass, sizeof(s_settingsState.lite_enc_pass)); - s_settingsState.lite_encryption_status = r.ok ? TR("lite_wallet_encrypted") : r.error; } - if (ImGui::IsItemHovered()) material::Tooltip("%s", TR("tt_lite_encrypt")); + ImGui::EndDisabled(); + if (ImGui::IsItemHovered(ImGuiHoveredFlags_AllowWhenDisabled)) + material::Tooltip("%s", liteScanning ? TR("send_tooltip_syncing") : TR("tt_lite_encrypt")); } else { if (wstate.isLocked()) { ImGui::SetCursorScreenPos(ImVec2(leftX, ImGui::GetCursorScreenPos().y)); @@ -1980,12 +1997,19 @@ void RenderSettingsPage(App* app) { sizeof(s_settingsState.lite_dec_pass), ImGuiInputTextFlags_Password); if (ImGui::IsItemHovered()) material::Tooltip("%s", TR("tt_lite_decrypt_pass")); ImGui::SetCursorScreenPos(ImVec2(leftX, ImGui::GetCursorScreenPos().y)); + ImGui::BeginDisabled(liteScanning || app->liteWallet()->walletOpInProgress()); if (TactileButton(TrId("lite_remove_encryption", "LiteDecrypt").c_str(), ImVec2(0, 0), S.resolveFont("button"))) { - const auto r = app->liteWallet()->decryptWallet(s_settingsState.lite_dec_pass); + s_settingsState.lite_encryption_status = TR("lite_working"); + app->liteWallet()->decryptWalletAsync(s_settingsState.lite_dec_pass, + [](const wallet::LiteEncryptionResult& r) { + s_settingsState.lite_encryption_status = + r.ok ? std::string(TR("lite_encryption_removed")) : r.error; + }); sodium_memzero(s_settingsState.lite_dec_pass, sizeof(s_settingsState.lite_dec_pass)); - s_settingsState.lite_encryption_status = r.ok ? TR("lite_encryption_removed") : r.error; } - if (ImGui::IsItemHovered()) material::Tooltip("%s", TR("tt_lite_remove_encrypt")); + ImGui::EndDisabled(); + if (ImGui::IsItemHovered(ImGuiHoveredFlags_AllowWhenDisabled)) + material::Tooltip("%s", liteScanning ? TR("send_tooltip_syncing") : TR("tt_lite_remove_encrypt")); } if (!s_settingsState.lite_encryption_status.empty()) { ImGui::SetCursorScreenPos(ImVec2(leftX, ImGui::GetCursorScreenPos().y)); @@ -2000,7 +2024,8 @@ void RenderSettingsPage(App* app) { const bool scanning = app->liteWallet()->scanInProgress(); ImGui::SetCursorScreenPos(ImVec2(leftX, ImGui::GetCursorScreenPos().y)); - ImGui::BeginDisabled(scanning); + // Not while a wallet op is queued: its save would wait out the whole re-scan. + ImGui::BeginDisabled(scanning || app->liteWallet()->walletOpInProgress()); if (material::ActionButton("##literedl", TR("lite_redownload_blocks"), ICON_MD_CLOUD_DOWNLOAD, material::ActionTier::Secondary)) { s_settingsState.confirm_lite_redownload = true; } diff --git a/src/ui/windows/receive_tab.cpp b/src/ui/windows/receive_tab.cpp index eb71e88..dcd5d29 100644 --- a/src/ui/windows/receive_tab.cpp +++ b/src/ui/windows/receive_tab.cpp @@ -17,6 +17,7 @@ #include "../../util/text_format.h" #include "../../config/version.h" #include "../../data/wallet_state.h" +#include "../../wallet/lite_wallet_controller.h" #include "../../ui/widgets/qr_code.h" #include "../sidebar.h" #include "../layout.h" @@ -313,9 +314,11 @@ static void RenderAddressDropdown(App* app, float width) { } ImGui::EndDisabled(); - // New address button on same line + // New address button on same line. Lite: the backend's new-address save waits out a running scan, so the button + // is unavailable until the scan ends (it used to freeze the window that long). ImGui::SameLine(0, Layout::spacingSm()); - ImGui::BeginDisabled(!app->isConnected() || s_generating_address); + const bool liteScanning = app->liteWallet() && app->liteWallet()->scanInProgress(); + ImGui::BeginDisabled(!app->isConnected() || s_generating_address || liteScanning); if (s_generating_address) { char genLabel[64]; snprintf(genLabel, sizeof(genLabel), "%s%s##recv", TR("generating"), material::LoadingDots()); @@ -352,6 +355,8 @@ static void RenderAddressDropdown(App* app, float width) { } } ImGui::EndDisabled(); + if (liteScanning && ImGui::IsItemHovered(ImGuiHoveredFlags_AllowWhenDisabled)) + material::Tooltip("%s", TR("send_tooltip_syncing")); } void SweepSelectReceiveAddress(App* app, const char* address) { @@ -386,7 +391,8 @@ static void RenderMiningAddressNotice(App* app, const WalletState& state, float bestZ = static_cast(i); } } - ImGui::BeginDisabled(bestZ < 0 && (!app->isConnected() || s_generating_address)); + const bool liteScanningZ = app->liteWallet() && app->liteWallet()->scanInProgress(); + ImGui::BeginDisabled(bestZ < 0 && (!app->isConnected() || s_generating_address || liteScanningZ)); if (TactileButton(TrId("receive_use_shielded", "mining").c_str(), ImVec2(0, 0), schema::UI().resolveFont("button"))) { if (bestZ >= 0) { s_selected_address_idx = bestZ; diff --git a/src/wallet/lite_wallet_controller.cpp b/src/wallet/lite_wallet_controller.cpp index f5fa3e9..d61a813 100644 --- a/src/wallet/lite_wallet_controller.cpp +++ b/src/wallet/lite_wallet_controller.cpp @@ -121,6 +121,123 @@ LiteBroadcastResult doShield(LiteClientBridge& bridge, const std::string& option if (result.ok) persistAfterBroadcast(bridge); // shield does not auto-save either return result; } + +// --- Blocking cores of the wallet ops (no `this`, so they also run on the wallet-op lane) ------------------------ +LiteEncryptionResult parseEncryptionOpResponse(const LiteBridgeStringResult& bridgeCall); // defined further down + +// Heap copy of a secret, wiped when the last reference goes (after the backend call, or when a queued job is dropped +// unrun). Copy, never move: moving a short (SSO) std::string leaves its bytes behind in the source. +std::shared_ptr makeWipingSecretCopy(const std::string& secret) +{ + return std::shared_ptr(new std::string(secret), + [](std::string* s) { secureWipeLiteSecret(*s); delete s; }); +} + +// A lane op whose controller was retired (server switch) runs no further backend command: litelib_execute always runs +// on the GLOBAL client, which is then the NEXT server's. `retired` is null for the synchronous (main-thread) callers. +constexpr const char* kLiteOpRetiredError = "lite wallet reconnecting"; +bool liteOpRetired(const std::atomic* retired) { return retired && retired->load(); } + +LiteNewAddressResult newAddressCore(LiteClientBridge& bridge, bool shielded) +{ + LiteNewAddressResult out; + // Backend address-type tokens: "zs" (shielded) / "R" (transparent) (do_new_address). + const auto result = bridge.execute("new", shielded ? "zs" : "R"); + if (!result.ok) { + out.error = result.error.empty() ? "new address generation failed" : result.error; + return out; + } + // Response is a JSON array with the new address, e.g. ["zs1..."]. + try { + const auto parsed = nlohmann::json::parse(result.value); + if (parsed.is_array() && !parsed.empty() && parsed[0].is_string()) { + out.address = parsed[0].get(); + } else if (parsed.is_string()) { + out.address = parsed.get(); + } + } catch (...) { + // fall through to the error below + } + out.ok = !out.address.empty(); + if (!out.ok) out.error = "could not parse new address response"; + return out; +} + +// `key` must be non-empty; it is wiped before returning. +LiteImportResult importKeyCore(LiteClientBridge& bridge, std::string& key, const std::atomic* retired = nullptr) +{ + // Transparent WIFs begin with U/5/K/L (TImportCommand); shielded keys begin with + // "secret-..." / viewing keys "zxview...", so this prefix check usually won't collide. + // NB: the lite/SDXL backend's viewing key is an *extended full* viewing key ("zxviews…", + // hrp_sapling_viewing_key), which is genuinely correct here — do NOT "harmonize" this with the + // full node, whose z_importviewingkey takes an *incoming* viewing key ("zivks…") instead. The + // two variants accept different viewing-key forms; a VK is not portable between them. Regardless, + // the two-command fallback below means a mis-guessed prefix never rejects an otherwise-valid key. + const char first = key[0]; + const bool transparentFirst = (first == 'U' || first == '5' || first == 'K' || first == 'L'); + + auto runImport = [&](const char* command) -> LiteImportResult { + LiteImportResult r; + const auto result = bridge.execute(command, key); + if (!result.ok) { // do_import_* failures come back "Error:"-prefixed (bridge -> ok=false) + r.error = result.error.empty() ? "key import failed" : result.error; + return r; + } + try { + const auto j = nlohmann::json::parse(result.value); + if (j.is_object() && j.contains("error")) { + r.error = extractJsonError(j); + return r; + } + } catch (...) { + // A non-JSON success payload is acceptable; fall through. + } + r.detail = result.value; + r.ok = true; + return r; + }; + + LiteImportResult out = runImport(transparentFirst ? "timport" : "import"); + if (!out.ok && liteOpRetired(retired)) { + out.error = kLiteOpRetiredError; // the fallback would import the key on the NEXT server's client + } else if (!out.ok) { + // The single-char heuristic can mis-route (e.g. testnet/regtest WIFs). Try the other + // command before giving up — the wrong command rejects the key (each validates the + // encoding), it never imports it as the wrong type. + LiteImportResult alt = runImport(transparentFirst ? "import" : "timport"); + if (alt.ok) out = alt; + } + if (!out.ok) liteLog("Key import failed: " + out.error); // error text only — never the key + secureWipeLiteSecret(key); // wipe our copy after both attempts + return out; +} + +// encrypt (encrypt=true) or remove encryption, then save, as ONE unit; wipes `passphrase` (must be non-empty). +LiteEncryptionResult encryptionChangeCore(LiteClientBridge& bridge, std::string& passphrase, bool encrypt, + const std::atomic* retired = nullptr) +{ + LiteEncryptionResult out = + parseEncryptionOpResponse(bridge.execute(encrypt ? "encrypt" : "decrypt", passphrase)); + secureWipeLiteSecret(passphrase); + if (out.ok && liteOpRetired(retired)) { + // Retired mid-op: the save would run on the NEXT server's client. The file on disk keeps its old state. + out.ok = false; + out.error = std::string(encrypt ? "wallet encrypted" : "wallet decrypted") + " in memory but not saved: " + + kLiteOpRetiredError; + } else if (out.ok) { + // Persist the now-(un)encrypted wallet. If the save fails, do NOT report success — the on-disk wallet would + // still be in its old state, contradicting what the user was told. The backend's `save` reports failure as + // {"result":"error","error":..} (no "Error:" prefix), so check the reply, not just the bridge status. + const auto saved = parseEncryptionOpResponse(bridge.execute("save", "")); + if (!saved.ok) { + out.ok = false; + out.error = std::string(encrypt ? "wallet encrypted" : "wallet decrypted") + + " in memory but saving to disk failed" + + (saved.error.empty() ? std::string() : (": " + saved.error)); + } + } + return out; +} } // namespace void secureWipeLiteSecret(std::string& secret) @@ -275,7 +392,10 @@ LiteWalletController::~LiteWalletController() // by the backend (sync/send/shield already save inline). Guarded by syncDone_ and no in-flight // broadcast so we never block shutdown waiting on the wallet lock held by an uninterruptible // scan or an in-progress send proving. - if (walletOpen_.load() && syncDone_->load() && !broadcastInProgress() && bridge_) { + // A retired controller (server switch) never saves: it is destroyed on a background thread while the NEW client + // may already own the wallet file, and two LightClients saving at once both write wallet.dat.tmp. What this flush + // persisted (refresh-scanned blocks, mempool txs) is chain-derived; the new client re-scans it. + if (!retired_.load() && walletOpen_.load() && syncDone_->load() && !broadcastInProgress() && bridge_) { bridge_->execute("save", ""); } // The sync thread may be blocked in an uninterruptible full scan; detach it. It holds @@ -307,16 +427,19 @@ std::unique_ptr LiteWalletController::createLinked( LiteWalletControllerOptions{true, !rollout.allowed, rollout.message}); } -void LiteWalletController::onLifecycleResult(const LiteWalletLifecycleResult& result) +void LiteWalletController::onLifecycleResult(const LiteWalletLifecycleResult& result, bool deferBackground) { status_ = result.status; const std::string op = liteWalletLifecycleOperationName(result.operation); if (result.walletReady) { liteLog(op + ": wallet ready"); walletOpen_ = true; + connectedServerUrl_ = result.plan.server.url; if (persist_) persist_(); - startSync(); // begin background sync on the backend - startWorker(); // begin periodic refresh -> WalletState (via takeRefreshedModel) + if (!deferBackground) { + startSync(); // begin background sync on the backend + startWorker(); // begin periodic refresh -> WalletState (via takeRefreshedModel) + } } else if (result.attempted) { liteLog(op + " failed: " + (result.error.empty() ? result.status.message : result.error)); @@ -438,6 +561,7 @@ void LiteWalletController::pumpAsyncOpen() lastOpenWarming_ = false; status_ = WalletBackendStatus{WalletBackendState::Ready, "wallet open", {}, {}, 0.0}; liteLog("Wallet ready via " + outcome.serverUrl); + connectedServerUrl_ = outcome.serverUrl; if (persist_) persist_(); startSync(); // begin background sync on the backend startWorker(); // begin periodic refresh -> WalletState @@ -604,11 +728,15 @@ void LiteWalletController::pumpLifecycleResult() // 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); + onLifecycleResult(out, /*deferBackground=*/true); if (out.walletReady) { - // Wallet is now open (walletOpen_ set by onLifecycleResult) — apply the stashed passphrase: - // encrypt a freshly created/restored wallet, or unlock an existing encrypted one (C1/M1). + // Wallet is now open (walletOpen_ set by onLifecycleResult) — apply the stashed passphrase BEFORE the scan + // starts: encrypt a freshly created/restored wallet, or unlock an existing encrypted one (C1/M1). The save in + // encrypt waits on the backend sync_lock, which a running scan holds for its whole duration (C24); with no scan + // yet it is a millisecond save of a small, unscanned wallet, and the scan then runs on the encrypted file. applyPendingLifecycleSecurity(); + startSync(); // begin background sync on the backend + startWorker(); // begin periodic refresh -> WalletState (via takeRefreshedModel) lastOpenError_.clear(); lastOpenWarming_ = false; } else { @@ -679,6 +807,8 @@ void LiteWalletController::startSync() bool LiteWalletController::startRescan() { if (!walletOpen_.load()) return false; + // A queued wallet op's save would wait out the whole re-scan (sync_lock): don't start one under it. + if (!pendingOps_.empty()) return false; // Refuse if a sync/rescan is already running (would race two scans on the backend wallet lock). if (!syncDone_ || !syncDone_->load()) return false; @@ -710,6 +840,11 @@ bool LiteWalletController::startRescan() } std::optional LiteWalletController::refreshModel() +{ + return refreshModelImpl(/*honorStop=*/false, nullptr); // tests / direct callers: never cancelled +} + +std::optional LiteWalletController::refreshModelImpl(bool honorStop, bool* syncReadFailed) { if (!walletOpen_.load()) return std::nullopt; @@ -756,7 +891,13 @@ std::optional LiteWalletController::refreshModel() request.haveSyncStatus = true; request.syncStatus = syncResult.syncStatus; } + if (honorStop) { // background worker only: stop between backend commands, don't hammer a dead server + request.keepGoing = [this]() { return running_.load(); }; + request.stopSyncReadsOnFailure = true; + } const auto refreshResult = gateway_.refresh(request); + if (refreshResult.cancelled) return std::nullopt; // controller is stopping: discard the partial model + if (syncReadFailed) *syncReadFailed = refreshResult.syncReadFailed; if (refreshResult.bundle.successfulCommandCount == 0 && !request.haveSyncStatus) { return std::nullopt; } @@ -769,7 +910,7 @@ std::optional LiteWalletController::refreshModel() // returns just {"syncing":"false"}, so the mapped walletHeight is 0 and the status bar showed // "blocks: 0" when fully synced. Query the wallet's last-scanned height (a fast local read) and // surface it as the synced height/tip so the block count is correct at rest. - if (bridge_) { + if (bridge_ && (!honorStop || running_.load())) { const auto h = bridge_->execute("height", ""); if (h.ok) { const auto parsed = parseLiteHeightResponse(h.value); @@ -787,31 +928,12 @@ std::optional LiteWalletController::refreshModel() LiteNewAddressResult LiteWalletController::newAddress(bool shielded) { - LiteNewAddressResult out; if (!walletOpen_.load() || !bridge_) { + LiteNewAddressResult out; out.error = "no wallet is open"; return out; } - // Backend address-type tokens: "zs" (shielded) / "R" (transparent) (do_new_address). - const auto result = bridge_->execute("new", shielded ? "zs" : "R"); - if (!result.ok) { - out.error = result.error.empty() ? "new address generation failed" : result.error; - return out; - } - // Response is a JSON array with the new address, e.g. ["zs1..."]. - try { - const auto parsed = nlohmann::json::parse(result.value); - if (parsed.is_array() && !parsed.empty() && parsed[0].is_string()) { - out.address = parsed[0].get(); - } else if (parsed.is_string()) { - out.address = parsed.get(); - } - } catch (...) { - // fall through to the error below - } - out.ok = !out.address.empty(); - if (!out.ok) out.error = "could not parse new address response"; - return out; + return newAddressCore(*bridge_, shielded); } LiteBroadcastResult LiteWalletController::sendTransactionBlocking(const LiteSendRequest& request) @@ -964,48 +1086,7 @@ LiteImportResult LiteWalletController::importKey(std::string spendingOrViewingKe out.error = "no key provided"; return out; } - // Transparent WIFs begin with U/5/K/L (TImportCommand); shielded keys begin with - // "secret-..." / viewing keys "zxview...", so this prefix check usually won't collide. - // NB: the lite/SDXL backend's viewing key is an *extended full* viewing key ("zxviews…", - // hrp_sapling_viewing_key), which is genuinely correct here — do NOT "harmonize" this with the - // full node, whose z_importviewingkey takes an *incoming* viewing key ("zivks…") instead. The - // two variants accept different viewing-key forms; a VK is not portable between them. Regardless, - // the two-command fallback below means a mis-guessed prefix never rejects an otherwise-valid key. - const char first = spendingOrViewingKey[0]; - const bool transparentFirst = (first == 'U' || first == '5' || first == 'K' || first == 'L'); - - auto runImport = [&](const char* command) -> LiteImportResult { - LiteImportResult r; - const auto result = bridge_->execute(command, spendingOrViewingKey); - if (!result.ok) { // do_import_* failures come back "Error:"-prefixed (bridge -> ok=false) - r.error = result.error.empty() ? "key import failed" : result.error; - return r; - } - try { - const auto j = nlohmann::json::parse(result.value); - if (j.is_object() && j.contains("error")) { - r.error = extractJsonError(j); - return r; - } - } catch (...) { - // A non-JSON success payload is acceptable; fall through. - } - r.detail = result.value; - r.ok = true; - return r; - }; - - out = runImport(transparentFirst ? "timport" : "import"); - if (!out.ok) { - // The single-char heuristic can mis-route (e.g. testnet/regtest WIFs). Try the other - // command before giving up — the wrong command rejects the key (each validates the - // encoding), it never imports it as the wrong type. - LiteImportResult alt = runImport(transparentFirst ? "import" : "timport"); - if (alt.ok) out = alt; - } - if (!out.ok) liteLog("Key import failed: " + out.error); // error text only — never the key - secureWipeLiteSecret(spendingOrViewingKey); // wipe our copy after both attempts - return out; + return importKeyCore(*bridge_, spendingOrViewingKey); // wipes the key } LiteExportResult LiteWalletController::exportPrivateKeys(const std::string& optionalAddress) @@ -1078,6 +1159,117 @@ bool LiteWalletController::saveWallet() return bridge_->execute("save", "").ok; } +template +bool LiteWalletController::submitWalletOp(std::function core, + std::function onDone, + std::function makeFailure) +{ + if (retired_.load() || !walletOpen_.load() || !bridge_) { + if (onDone) onDone(makeFailure(retired_.load() ? "lite wallet reconnecting" : "no wallet is open")); + return false; + } + const std::uint64_t id = nextOpId_++; + pendingOps_[id] = [onDone, makeFailure](const std::string& why) { + if (onDone) onDone(makeFailure(why)); + }; + // The job runs on the lane thread and never dereferences `this`; only its completion does, and only + // pumpWalletOps() runs completions (main thread, controller alive). A throwing core still yields a completion, + // so the UI callback can never be left pending. + const bool queued = opLane_.post( + [this, id, core = std::move(core), onDone, makeFailure, + retired = opsRetired_]() -> util::SerialJobLane::Completion { + R result; + if (retired->load()) { + result = makeFailure(kLiteOpRetiredError); // dequeued just before retire(): never start it + } else { + try { result = core(); } catch (...) { result = makeFailure("internal error"); } + } + return [this, id, onDone, result = std::move(result)]() { + if (pendingOps_.erase(id) == 0) return; // already failed by failPendingWalletOps(): exactly once + if (onDone) onDone(result); + }; + }); + if (!queued) { // lane abandoned or no thread could be started: fail now, exactly once + auto it = pendingOps_.find(id); + if (it != pendingOps_.end()) { + auto fail = std::move(it->second); + pendingOps_.erase(it); + if (fail) fail("could not start the wallet operation"); + } + return false; + } + return true; +} + +bool LiteWalletController::newAddressAsync(bool shielded, LiteNewAddressCallback onDone) +{ + auto bridge = bridge_; + return submitWalletOp( + [bridge, shielded]() { return newAddressCore(*bridge, shielded); }, + std::move(onDone), + [](const std::string& why) { LiteNewAddressResult r; r.error = why; return r; }); +} + +bool LiteWalletController::importKeyAsync(std::string spendingOrViewingKey, LiteImportCallback onDone) +{ + auto fail = [](const std::string& why) { LiteImportResult r; r.error = why; return r; }; + if (spendingOrViewingKey.empty()) { + if (onDone) onDone(fail("no key provided")); + return false; + } + auto key = makeWipingSecretCopy(spendingOrViewingKey); + secureWipeLiteSecret(spendingOrViewingKey); + auto bridge = bridge_; + auto retired = opsRetired_; + return submitWalletOp( + [bridge, key, retired]() { return importKeyCore(*bridge, *key, retired.get()); }, std::move(onDone), fail); +} + +bool LiteWalletController::encryptWalletAsync(std::string passphrase, LiteEncryptionCallback onDone) +{ + return submitEncryptionChange(passphrase, std::move(onDone), /*encrypt=*/true); +} + +bool LiteWalletController::decryptWalletAsync(std::string passphrase, LiteEncryptionCallback onDone) +{ + return submitEncryptionChange(passphrase, std::move(onDone), /*encrypt=*/false); +} + +bool LiteWalletController::submitEncryptionChange(std::string& passphrase, LiteEncryptionCallback onDone, + bool encrypt) +{ + auto fail = [](const std::string& why) { LiteEncryptionResult r; r.error = why; return r; }; + if (passphrase.empty()) { + if (onDone) onDone(fail("passphrase required")); + return false; + } + auto pass = makeWipingSecretCopy(passphrase); + secureWipeLiteSecret(passphrase); + auto bridge = bridge_; + auto retired = opsRetired_; + return submitWalletOp( + [bridge, pass, encrypt, retired]() { return encryptionChangeCore(*bridge, *pass, encrypt, retired.get()); }, + std::move(onDone), fail); +} + +void LiteWalletController::failPendingWalletOps(const std::string& reason) +{ + auto pending = std::move(pendingOps_); // move out first: a callback may submit a new op + pendingOps_.clear(); + for (auto& kv : pending) if (kv.second) kv.second(reason); +} + +void LiteWalletController::retire(const std::string& reason) +{ + retired_.store(true); // later submits are rejected inline; the destructor skips its final save + opsRetired_->store(true); // a running op issues no further backend command (it would hit the NEXT client) + opLane_.pump(); // ops that already finished are delivered with their real result, not "reconnecting" + opLane_.abandon(); // queued ops never run (they would hit the NEXT global client); secrets wiped + failPendingWalletOps(reason); // every UI callback fires exactly once (normally none: App defers the switch) + requestStop(); // non-blocking: the worker exits after its in-flight backend command + if (bridge_) bridge_->shutdown(); // stop THIS client's mempool monitor while it is still the global one +} + bool LiteWalletController::walletExists() const { // chainName_ is always a backend-accepted value ("main"/"test"/"regtest"); litelib_wallet_ @@ -1152,19 +1344,7 @@ LiteEncryptionResult LiteWalletController::encryptWallet(std::string passphrase) out.error = "passphrase required"; return out; } - out = parseEncryptionOpResponse(bridge_->execute("encrypt", passphrase)); - secureWipeLiteSecret(passphrase); - if (out.ok) { - // Persist the now-encrypted wallet. If the save fails, do NOT report success — the - // on-disk wallet would still be unencrypted, contradicting what the user was told. - const auto saved = bridge_->execute("save", ""); - if (!saved.ok) { - out.ok = false; - out.error = "wallet encrypted in memory but saving to disk failed" + - (saved.error.empty() ? std::string() : (": " + saved.error)); - } - } - return out; + return encryptionChangeCore(*bridge_, passphrase, /*encrypt=*/true); // encrypt + save as one unit; wipes } LiteEncryptionResult LiteWalletController::decryptWallet(std::string passphrase) @@ -1179,19 +1359,7 @@ LiteEncryptionResult LiteWalletController::decryptWallet(std::string passphrase) out.error = "passphrase required"; return out; } - out = parseEncryptionOpResponse(bridge_->execute("decrypt", passphrase)); - secureWipeLiteSecret(passphrase); - if (out.ok) { - // Persist the now-unencrypted wallet. If the save fails, do NOT report success — the - // on-disk wallet would still be encrypted, contradicting what the user was told. - const auto saved = bridge_->execute("save", ""); - if (!saved.ok) { - out.ok = false; - out.error = "wallet decrypted in memory but saving to disk failed" + - (saved.error.empty() ? std::string() : (": " + saved.error)); - } - } - return out; + return encryptionChangeCore(*bridge_, passphrase, /*encrypt=*/false); // decrypt + save as one unit; wipes } bool LiteWalletController::unlockWallet(std::string passphrase) @@ -1234,25 +1402,38 @@ void LiteWalletController::startWorker() worker_ = std::thread([this] { workerLoop(); }); } +void LiteWalletController::requestStop() +{ + { + std::lock_guard lk(wakeMutex_); // flip under the mutex the worker waits on: no lost wakeup + running_.store(false); + } + wakeCv_.notify_all(); +} + void LiteWalletController::stopWorker() { - if (!running_.exchange(false)) return; // not running - wakeCv_.notify_all(); - if (worker_.joinable()) worker_.join(); + requestStop(); + if (worker_.joinable()) worker_.join(); // NOT conditional on running_: retire() may already have cleared it } void LiteWalletController::workerLoop() { + int failStreak = 0; // consecutive cycles whose syncing reads hit a backend error while (running_.load()) { if (walletOpen_.load()) { - auto model = refreshModel(); - if (model) { + bool syncReadFailed = false; + auto model = refreshModelImpl(/*honorStop=*/true, &syncReadFailed); + if (model && running_.load()) { std::lock_guard lock(modelMutex_); pendingModel_ = std::move(model); } + failStreak = syncReadFailed ? failStreak + 1 : 0; } + // Healthy: every 2 s. After a failed syncing read: 4 s, 8 s, then 15 s, so a dead server isn't retried + // back-to-back (each attempt is one do_sync with up to 62 s of backend retry back-off). std::unique_lock lock(wakeMutex_); - wakeCv_.wait_for(lock, std::chrono::milliseconds(kRefreshIntervalMs), + wakeCv_.wait_for(lock, std::chrono::milliseconds(liteRefreshDelayMs(failStreak, kRefreshIntervalMs)), [this] { return !running_.load(); }); } } diff --git a/src/wallet/lite_wallet_controller.h b/src/wallet/lite_wallet_controller.h index 5b03dac..77abaa2 100644 --- a/src/wallet/lite_wallet_controller.h +++ b/src/wallet/lite_wallet_controller.h @@ -16,6 +16,7 @@ #pragma once +#include "../util/serial_job_lane.h" #include "lite_client_bridge.h" #include "lite_connection_service.h" #include "lite_wallet_lifecycle_service.h" @@ -27,9 +28,11 @@ #include "wallet_capabilities.h" #include +#include #include #include #include +#include #include #include #include @@ -133,6 +136,22 @@ struct LiteEncryptionResult { std::string error; }; +// Main-thread completion callbacks of the *Async wallet ops. Each fires exactly once: inline when the op is rejected +// up front, from pumpWalletOps() when the backend call finishes, or with ok=false from failPendingWalletOps()/retire(). +using LiteNewAddressCallback = std::function; +using LiteImportCallback = std::function; +using LiteEncryptionCallback = std::function; + +// Server switch vs an in-flight wallet op. litelib_execute always runs on the GLOBAL client, so retiring a controller +// mid-op would let the op finish (and save) on the old client while the new client owns the same wallet file. The +// switch therefore waits for the op, but at most this long, so an op stuck behind a dead server can't pin the user to +// that server. Pure; unit-tested. +inline constexpr double kLiteServerSwitchMaxDeferSeconds = 15.0; +inline bool liteServerSwitchMayProceed(bool walletOpInFlight, double deferredSeconds) +{ + return !walletOpInFlight || deferredSeconds >= kLiteServerSwitchMaxDeferSeconds; +} + class LiteWalletController { public: LiteWalletController(WalletCapabilities capabilities, @@ -229,10 +248,39 @@ public: // True while the initial sync OR a rescan is actively scanning (not yet complete). bool scanInProgress() const { return syncStarted_ && !(syncDone_ && syncDone_->load()); } - // Generate a new address (shielded if true, else transparent) via the backend. Fast (local - // key derivation), safe to call on the UI thread; the next refresh lists the new address. + // Generate a new address (shielded if true, else transparent) via the backend. BLOCKING: the backend's + // do_new_address ends in do_save, which waits on the sync_lock a scan holds for its whole duration and every + // refresh fetch holds briefly. Tests/tools only; the UI uses newAddressAsync(). LiteNewAddressResult newAddress(bool shielded); + // --- Non-blocking wallet ops for the UI thread (wallet-op lane) --------------------------------------------------- + // new address / import / encrypt+save / decrypt+save run FIFO on one abandonable background lane; the callback is + // delivered on the main thread by pumpWalletOps() (App::update() calls it every tick). Return true if queued; false + // if rejected (callback already invoked inline with ok=false). Secrets are copied into a holder that is wiped after + // the backend call (or when a queued job is dropped) and the by-value parameter is wiped in place. + bool newAddressAsync(bool shielded, LiteNewAddressCallback onDone); + bool importKeyAsync(std::string spendingOrViewingKey, LiteImportCallback onDone); + bool encryptWalletAsync(std::string passphrase, LiteEncryptionCallback onDone); + bool decryptWalletAsync(std::string passphrase, LiteEncryptionCallback onDone); + int pumpWalletOps() { return opLane_.pump(); } // main thread + bool walletOpInProgress() const { return !pendingOps_.empty(); } // main thread (gating/animation) + bool hasWalletOpCompletions() const { return opLane_.hasCompletions(); } // any thread (idle-loop wake) + // Deliver ok=false(reason) to every undelivered op callback now; their late backend results are dropped. + void failPendingWalletOps(const std::string& reason); + // Bounded wait for the lane to drain (App::shutdown). True if idle. + bool waitWalletOpsIdle(std::chrono::milliseconds timeout) { return opLane_.waitIdle(timeout); } + + // Server switch (UI thread, non-blocking): deliver ops that already finished, stop a running op before its next + // backend command, abandon queued ops, fail pending callbacks (reason), stop the refresh + // worker's NEXT cycle, mark the controller retired (its destructor then skips the final save) and shut this backend + // client down while it is still the global one. The destructor still joins the worker, which may sit inside one + // backend call for minutes against a dead server, so App destroys a retired controller on a background thread. + void retire(const std::string& reason); + + // URL of the server the open wallet is actually connected to (failover may differ from the pinned one). + // Main thread; empty until a wallet opens. + const std::string& connectedServerUrl() const { return connectedServerUrl_; } + // --- M4: spend & backup --------------------------------------------------------------- // Build & broadcast a transaction (send) or shield all transparent funds. Proving can take // seconds, so the public entry points are ASYNCHRONOUS: they run the blocking core on a @@ -303,10 +351,16 @@ public: bool takeRefreshedModel(LiteWalletAppRefreshModel& out); private: - void onLifecycleResult(const LiteWalletLifecycleResult& result); + // deferBackground=true marks the wallet open (status, walletOpen_, persist) but leaves startSync()/startWorker() + // to the caller, so pumpLifecycleResult() can apply the passphrase before any scan holds the backend sync_lock. + void onLifecycleResult(const LiteWalletLifecycleResult& result, bool deferBackground = false); void startWorker(); void stopWorker(); + void requestStop(); // non-blocking half of stopWorker(): flips running_ under wakeMutex_ + notify void workerLoop(); + // honorStop=true (worker thread only): stop between backend commands once running_ is false, and skip the + // remaining syncing reads after the first backend error (reported through syncReadFailed, if given). + std::optional refreshModelImpl(bool honorStop, bool* syncReadFailed); // Launch `op` (a self-contained broadcast that must NOT capture `this`) on the detached // broadcast thread. Returns false if a broadcast is already running. bool startBroadcast(std::function op); @@ -317,7 +371,7 @@ private: // // Concurrent execute() is intentionally NOT guarded by a C++ mutex here. The design relies // on overlapping calls (the detached sync thread runs the long `sync` while the refresh - // worker polls `syncstatus`, and the UI thread may call `new`). litelib serializes wallet + // worker polls `syncstatus`, and the wallet-op lane may call `new`). litelib serializes wallet // access internally: LightClient.wallet is an Arc> (do_new_address takes // write(), do_balance takes read(), do_sync_internal takes read()/write() and writes // sync_status separately), so concurrent execute() calls cannot corrupt state. A coarse @@ -402,6 +456,22 @@ private: void applyPendingLifecycleSecurity(); // encrypt (create/restore) or unlock (open), then wipe void clearPendingLifecycleSecurity(); // wipe + reset (rejected request / failed open) + // Wallet-op lane (see newAddressAsync). Jobs capture the shared bridge + value copies, never `this`; completions + // capture `this` but only pumpWalletOps() runs them. Abandoned by retire() and by destruction (never joined). + util::SerialJobLane opLane_; + std::map> pendingOps_; // main thread; id -> deliver failure + std::uint64_t nextOpId_ = 1; // main thread + template + bool submitWalletOp(std::function core, + std::function onDone, + std::function makeFailure); + bool submitEncryptionChange(std::string& passphrase, LiteEncryptionCallback onDone, bool encrypt); + std::atomic retired_{false}; + // Set by retire(). Every wallet-op job holds a copy: once set, an op runs no further backend command + // (litelib_execute always runs on the GLOBAL client, which after a server switch is the NEW one) and fails instead. + std::shared_ptr> opsRetired_ = std::make_shared>(false); + std::string connectedServerUrl_; // main thread + // Joinable background refresh worker (fast iterations: syncstatus, plus data once synced). std::thread worker_; std::atomic running_{false}; diff --git a/src/wallet/lite_wallet_gateway.cpp b/src/wallet/lite_wallet_gateway.cpp index 7b52fd9..ba8a10d 100644 --- a/src/wallet/lite_wallet_gateway.cpp +++ b/src/wallet/lite_wallet_gateway.cpp @@ -51,6 +51,36 @@ const char* liteWalletGatewayCommandName(LiteWalletGatewayCommand command) return "unknown"; } +bool liteResponseIsBackendError(const std::string& response) +{ + for (char c : response) { + if (c == ' ' || c == '\t' || c == '\r' || c == '\n') continue; + return c != '{' && c != '['; + } + return true; // empty +} + +bool liteGatewayCommandReachesServer(LiteWalletGatewayCommand command) +{ + switch (command) { + case LiteWalletGatewayCommand::Info: + case LiteWalletGatewayCommand::Balance: + case LiteWalletGatewayCommand::Notes: + case LiteWalletGatewayCommand::List: return true; + case LiteWalletGatewayCommand::Height: + case LiteWalletGatewayCommand::Addresses: return false; + } + return false; +} + +int liteRefreshDelayMs(int consecutiveFailures, int baseMs) +{ + constexpr long long kMaxMs = 15000; + long long ms = baseMs > 0 ? baseMs : 2000; + for (int i = 0; i < consecutiveFailures && ms < kMaxMs; ++i) ms *= 2; + return static_cast(ms < kMaxMs ? ms : kMaxMs); +} + const char* liteWalletGatewayAvailabilityName(LiteWalletGatewayAvailability availability) { switch (availability) { @@ -221,8 +251,20 @@ LiteWalletRefreshResult LiteWalletGateway::refresh(const LiteWalletRefreshReques // Run every planned command. A single command's bridge or parse failure must NOT abort the // whole refresh — partial data beats none, and real-backend response shapes can drift per // command. assembleLiteWalletRefreshBundle() ignores the failed results. + bool syncReadFailed = false; for (const auto& commandPlan : result.plan.commands) { - result.commandResults.push_back(executePlannedCommand(commandPlan)); + if (request.keepGoing && !request.keepGoing()) { result.cancelled = true; break; } + const bool serverBound = liteGatewayCommandReachesServer(commandPlan.command); + if (serverBound && syncReadFailed && request.stopSyncReadsOnFailure) continue; // skipped, not failed + auto commandResult = executePlannedCommand(commandPlan); + if (serverBound && commandResult.backendError) syncReadFailed = true; + result.commandResults.push_back(std::move(commandResult)); + } + result.syncReadFailed = syncReadFailed; + if (result.cancelled) { + result.status = makeGatewayStatus(WalletBackendState::Disconnected, "lite refresh cancelled"); + result.error = result.status.message; + return result; } result.bundle = assembleLiteWalletRefreshBundle(result.commandResults, request); @@ -319,12 +361,15 @@ LiteWalletGatewayCommandResult LiteWalletGateway::executePlannedCommand(const Li const auto bridgeCall = bridge_->execute(plan.commandName, plan.args); result.bridgeResponseRedacted = bridgeCall.ok || !bridgeCall.error.empty() ? "" : ""; if (!bridgeCall.ok) { + result.backendError = true; result.status = makeGatewayStatus(WalletBackendState::Error, "lite wallet gateway bridge call failed"); result.error = result.status.message; return result; } - return parseBridgeResponse(plan, bridgeCall.value); + auto parsed = parseBridgeResponse(plan, bridgeCall.value); + parsed.backendError = liteResponseIsBackendError(bridgeCall.value); + return parsed; } LiteWalletGatewayCommandResult LiteWalletGateway::parseBridgeResponse( diff --git a/src/wallet/lite_wallet_gateway.h b/src/wallet/lite_wallet_gateway.h index cb493d7..7b2401a 100644 --- a/src/wallet/lite_wallet_gateway.h +++ b/src/wallet/lite_wallet_gateway.h @@ -8,6 +8,7 @@ #include "wallet_capabilities.h" #include +#include #include #include @@ -54,6 +55,7 @@ struct LiteWalletGatewayCommandResult { bool bridgeAccepted = false; LiteWalletGatewayPlan plan; LiteResultParserError parserError = LiteResultParserError::None; + bool backendError = false; // bridge call failed, or the reply was not JSON (the backend's plain-text sync error) WalletBackendStatus status; std::string bridgeResponseRedacted = ""; std::string error; @@ -76,6 +78,12 @@ struct LiteWalletRefreshRequest { bool includeTransactions = true; bool haveSyncStatus = false; LiteSyncStatusResponse syncStatus; + // Optional cooperative cancel, consulted before every backend command; returning false ends the refresh early + // (result.cancelled, model discarded by the caller). + std::function keepGoing; + // Skip the remaining server-bound commands (info/balance/notes/list) once one returned a backend error: each + // `balance`/`notes`/`list` runs do_sync, which costs ~62 s of retry back-off against a refusing server. + bool stopSyncReadsOnFailure = false; }; struct LiteWalletRefreshPlan { @@ -107,6 +115,8 @@ struct LiteWalletRefreshBundle { struct LiteWalletRefreshResult { bool ok = false; bool attempted = false; + bool cancelled = false; // keepGoing() returned false; partial results only + bool syncReadFailed = false; // a server-bound command returned a backend error this cycle LiteWalletRefreshPlan plan; std::vector commandResults; LiteWalletRefreshBundle bundle; @@ -119,6 +129,10 @@ struct LiteWalletGatewayOptions { }; const char* liteWalletGatewayCommandName(LiteWalletGatewayCommand command); +// Pure helpers (unit-tested). +bool liteResponseIsBackendError(const std::string& response); // first non-space char is neither '{' nor '[' +bool liteGatewayCommandReachesServer(LiteWalletGatewayCommand command); // Info/Balance/Notes/List; Height/Addresses are local +int liteRefreshDelayMs(int consecutiveFailures, int baseMs); // base, x2 per failure, capped at 15000 ms const char* liteWalletGatewayAvailabilityName(LiteWalletGatewayAvailability availability); std::vector liteWalletRefreshCommands(const LiteWalletRefreshRequest& request); diff --git a/tests/fake_lite_backend.h b/tests/fake_lite_backend.h index d893aea..9edb823 100644 --- a/tests/fake_lite_backend.h +++ b/tests/fake_lite_backend.h @@ -21,7 +21,10 @@ #include #include #include +#include +#include #include +#include namespace dragonx { namespace test { @@ -38,6 +41,34 @@ inline std::atomic g_liteFakeSendFails{false}; // when true, "send"/"shie inline std::atomic g_liteFakeSaveCount{0}; // counts "save" commands (persistence checks) inline std::atomic g_liteFakeEncrypted{false}; // wallet-encryption state machine (encrypt/...) inline std::atomic g_liteFakeLocked{false}; // spending-keys-locked state +inline std::atomic g_liteFakeBlockNew{false}; // "new" blocks (models do_save waiting on sync_lock) +inline std::atomic g_liteFakeBlockSave{false}; // "save" blocks +inline std::atomic g_liteFakeBlockBalance{false}; // "balance" blocks (a refresh stuck on a dead server) +inline std::atomic g_liteFakeBalanceError{false}; // "balance" returns the backend's plain-text sync error +inline std::atomic g_liteFakeBalanceEntered{0}; // calls that reached "balance" +inline std::atomic g_liteFakeBlockEncrypt{false}; // "encrypt" blocks (before changing state) +inline std::atomic g_liteFakeBlockImport{false}; // "import"/"timport" block +inline std::atomic g_liteFakeImportFails{false}; // "import"/"timport" reject the key (real do_import_key shape) +inline std::atomic g_liteFakeLogEnabled{false}; +inline std::mutex g_liteFakeLogMutex; +inline std::vector g_liteFakeCommandLog; // execute() command names, in order, while enabled +inline void liteFakeWaitWhile(const std::atomic& flag) +{ + while (flag.load()) std::this_thread::sleep_for(std::chrono::milliseconds(5)); +} +inline std::vector liteFakeTakeLog() +{ + std::lock_guard lk(g_liteFakeLogMutex); + std::vector out; + out.swap(g_liteFakeCommandLog); + return out; +} +inline long liteFakeCount(const std::vector& log, const char* command) +{ + long n = 0; + for (const auto& c : log) if (c == command) ++n; + return n; +} // initialize_existing fails for any server URL containing this substring (open-failover tests). inline std::string g_liteFakeDeadServerSubstr; // initialize_existing returns a warming-up (-28) error for URLs containing this substring. @@ -50,6 +81,15 @@ inline void resetLiteFakeCounters() g_liteFakeShutdownCalled = false; g_liteFakeDeadServerSubstr.clear(); g_liteFakeWarmupServerSubstr.clear(); + g_liteFakeBlockNew = false; + g_liteFakeBlockSave = false; + g_liteFakeBlockBalance = false; + g_liteFakeBalanceError = false; + g_liteFakeBlockEncrypt = false; + g_liteFakeBlockImport = false; + g_liteFakeImportFails = false; + g_liteFakeLogEnabled = false; + (void)liteFakeTakeLog(); } inline char* liteFakeDup(const char* s) @@ -106,10 +146,22 @@ inline char* liteFakeInitExisting(bool, const char* server) } inline char* liteFakeExecute(const char* command, const char* args) { + if (command && g_liteFakeLogEnabled.load()) { + std::lock_guard lk(g_liteFakeLogMutex); + g_liteFakeCommandLog.emplace_back(command); + } // new-address generation returns a JSON array with the new address (type from args: zs/R). if (command && std::strcmp(command, "new") == 0) { + liteFakeWaitWhile(g_liteFakeBlockNew); return liteFakeDup(args && std::strcmp(args, "R") == 0 ? "[\"R1fakenew\"]" : "[\"zs1fakenew\"]"); } + if (command && std::strcmp(command, "save") == 0) liteFakeWaitWhile(g_liteFakeBlockSave); // counted below + if (command && std::strcmp(command, "balance") == 0) { + ++g_liteFakeBalanceEntered; + liteFakeWaitWhile(g_liteFakeBlockBalance); + // Real shape of a failed do_sync: bare text, NO "Error:" prefix -> bridge ok=true, non-JSON value. + if (g_liteFakeBalanceError.load()) return liteFakeDup("Error getting latest block: simulated connect failure"); + } // A command named "boom" yields an "Error:"-prefixed response (the bridge's // looksLikeError contract maps that to ok=false). if (command && std::strcmp(command, "boom") == 0) { @@ -158,8 +210,14 @@ inline char* liteFakeExecute(const char* command, const char* args) if (std::strcmp(c, "seed") == 0) return liteFakeDup("{\"seed\":\"fake seed phrase words\",\"birthday\":0}"); // import (shielded key) / timport (transparent WIF): do_import_* pretty-print JSON. - if (std::strcmp(c, "import") == 0 || std::strcmp(c, "timport") == 0) + if (std::strcmp(c, "import") == 0 || std::strcmp(c, "timport") == 0) { + liteFakeWaitWhile(g_liteFakeBlockImport); + // The real do_import_key error REPEATS the key: "Error: '' was not recognized as ...". + if (g_liteFakeImportFails.load()) + return liteFakeDup((std::string("Error: '") + (args ? args : "") + + "' was not recognized as either a spending key or a viewing key").c_str()); return liteFakeDup("{\"result\":\"success\",\"address\":\"zs1imported\"}"); + } if (std::strcmp(c, "save") == 0) { ++g_liteFakeSaveCount; return liteFakeDup("{\"result\":\"success\"}"); @@ -168,6 +226,7 @@ inline char* liteFakeExecute(const char* command, const char* args) // which removes keys from memory right after encrypting — verified via lite_smoke --encrypt); // unlock/lock toggle locked; decrypt clears it; encryptionstatus reports {encrypted,locked}. if (std::strcmp(c, "encrypt") == 0) { + liteFakeWaitWhile(g_liteFakeBlockEncrypt); g_liteFakeEncrypted = true; g_liteFakeLocked = true; return liteFakeDup("{\"result\":\"success\"}"); } diff --git a/tests/test_phase4.cpp b/tests/test_phase4.cpp index 1c3677b..d5f958b 100644 --- a/tests/test_phase4.cpp +++ b/tests/test_phase4.cpp @@ -8054,6 +8054,414 @@ void testSerialJobLane() } } +void testLiteGatewayRefreshPolicyHelpers() +{ + using namespace dragonx::wallet; + EXPECT_TRUE(liteResponseIsBackendError("")); + EXPECT_FALSE(liteResponseIsBackendError(" {\"a\":1}")); + EXPECT_FALSE(liteResponseIsBackendError("\n[]")); + EXPECT_TRUE(liteResponseIsBackendError("Error getting latest block transport error")); + EXPECT_TRUE(liteResponseIsBackendError("Server's latest block(5) is behind ours(9)")); + EXPECT_TRUE(liteGatewayCommandReachesServer(LiteWalletGatewayCommand::Info)); + EXPECT_TRUE(liteGatewayCommandReachesServer(LiteWalletGatewayCommand::Balance)); + EXPECT_TRUE(liteGatewayCommandReachesServer(LiteWalletGatewayCommand::Notes)); + EXPECT_TRUE(liteGatewayCommandReachesServer(LiteWalletGatewayCommand::List)); + EXPECT_FALSE(liteGatewayCommandReachesServer(LiteWalletGatewayCommand::Height)); + EXPECT_FALSE(liteGatewayCommandReachesServer(LiteWalletGatewayCommand::Addresses)); + const int expected[] = {2000, 4000, 8000, 15000, 15000}; + for (int i = 0; i < 5; ++i) EXPECT_EQ(liteRefreshDelayMs(i, 2000), expected[i]); + EXPECT_EQ(liteRefreshDelayMs(1, 0), 4000); // base defaults to 2000 + EXPECT_EQ(liteRefreshDelayMs(100000, 2000), 15000); + // Server-switch deferral + EXPECT_TRUE(liteServerSwitchMayProceed(false, 0.0)); + EXPECT_FALSE(liteServerSwitchMayProceed(true, 0.0)); + EXPECT_FALSE(liteServerSwitchMayProceed(true, kLiteServerSwitchMaxDeferSeconds - 0.5)); + EXPECT_TRUE(liteServerSwitchMayProceed(true, kLiteServerSwitchMaxDeferSeconds)); +} + +void testLiteGatewayRefreshCancelAndSkip() +{ + using namespace dragonx::wallet; + const auto caps = makeWalletCapabilities(WalletBuildKind::Lite, false, true); + const auto conn = defaultLiteConnectionSettings(); + dragonx::test::resetLiteFakeCounters(); + auto bridge = LiteClientBridge::fromApi(dragonx::test::makeFakeLiteApi()); + LiteWalletGateway gateway(caps, conn, &bridge, LiteWalletGatewayOptions{true}); + + { // keepGoing stops between commands + LiteWalletRefreshRequest req; + int calls = 0; + req.keepGoing = [&calls]() { return ++calls <= 2; }; + const auto r = gateway.refresh(req); + EXPECT_TRUE(r.cancelled); + EXPECT_FALSE(r.ok); + EXPECT_EQ(r.commandResults.size(), static_cast(2)); + EXPECT_TRUE(r.commandResults.size() < r.plan.commands.size()); + } + { // after a backend error, the remaining syncing reads are skipped; local `addresses` still runs + dragonx::test::g_liteFakeBalanceError = true; + dragonx::test::g_liteFakeLogEnabled = true; + (void)dragonx::test::liteFakeTakeLog(); + LiteWalletRefreshRequest req; + req.includeInfo = false; + req.includeHeight = false; + req.stopSyncReadsOnFailure = true; + const auto r = gateway.refresh(req); + const auto log = dragonx::test::liteFakeTakeLog(); + EXPECT_TRUE(r.syncReadFailed); + EXPECT_TRUE(r.ok); + EXPECT_TRUE(r.bundle.hasAddresses); + EXPECT_FALSE(r.bundle.hasBalance); + EXPECT_EQ(dragonx::test::liteFakeCount(log, "balance"), 1L); + EXPECT_EQ(dragonx::test::liteFakeCount(log, "addresses"), 1L); + EXPECT_EQ(dragonx::test::liteFakeCount(log, "notes"), 0L); + EXPECT_EQ(dragonx::test::liteFakeCount(log, "list"), 0L); + // Without the flag (existing behaviour) every command still runs. + req.stopSyncReadsOnFailure = false; + (void)gateway.refresh(req); + const auto log2 = dragonx::test::liteFakeTakeLog(); + EXPECT_EQ(dragonx::test::liteFakeCount(log2, "notes"), 1L); + EXPECT_EQ(dragonx::test::liteFakeCount(log2, "list"), 1L); + dragonx::test::g_liteFakeBalanceError = false; + dragonx::test::g_liteFakeLogEnabled = false; + } +} + +void testLiteWalletControllerWalletOpsAsync() +{ + using namespace dragonx::wallet; + const auto caps = makeWalletCapabilities(WalletBuildKind::Lite, false, true); + const auto conn = defaultLiteConnectionSettings(); + dragonx::test::resetLiteFakeCounters(); + dragonx::test::g_liteFakeEncrypted = false; + dragonx::test::g_liteFakeLocked = false; + LiteWalletController c(caps, conn, LiteClientBridge::fromApi(dragonx::test::makeFakeLiteApi())); + + { // closed controller: rejected inline, exactly once + int n = 0; bool ok = true; + EXPECT_FALSE(c.newAddressAsync(true, [&](const LiteNewAddressResult& r) { ++n; ok = r.ok; })); + EXPECT_EQ(n, 1); + EXPECT_FALSE(ok); + EXPECT_FALSE(c.walletOpInProgress()); + } + EXPECT_TRUE(c.createWallet(LiteWalletCreateRequest{}).walletReady); + for (int i = 0; i < 400 && !c.syncComplete(); ++i) std::this_thread::sleep_for(std::chrono::milliseconds(5)); + const auto waitCompletion = [&c]() { + for (int i = 0; i < 600 && !c.hasWalletOpCompletions(); ++i) + std::this_thread::sleep_for(std::chrono::milliseconds(5)); + }; + + { // a blocked op doesn't block the caller; callback once, from pumpWalletOps() + dragonx::test::g_liteFakeBlockNew = true; + int n = 0; LiteNewAddressResult got; + const auto t0 = std::chrono::steady_clock::now(); + EXPECT_TRUE(c.newAddressAsync(true, [&](const LiteNewAddressResult& r) { ++n; got = r; })); + EXPECT_TRUE(std::chrono::steady_clock::now() - t0 < std::chrono::milliseconds(500)); + EXPECT_TRUE(c.walletOpInProgress()); + EXPECT_EQ(c.pumpWalletOps(), 0); + EXPECT_EQ(n, 0); + dragonx::test::g_liteFakeBlockNew = false; + waitCompletion(); + EXPECT_EQ(c.pumpWalletOps(), 1); + EXPECT_EQ(n, 1); + EXPECT_TRUE(got.ok); + EXPECT_EQ(got.address, std::string("zs1fakenew")); + EXPECT_FALSE(c.walletOpInProgress()); + } + { // encrypt + save is ONE op; decrypt likewise; empty passphrase rejected inline + dragonx::test::g_liteFakeBlockSave = true; + int n = 0; LiteEncryptionResult got; + EXPECT_TRUE(c.encryptWalletAsync("pw", [&](const LiteEncryptionResult& r) { ++n; got = r; })); + for (int i = 0; i < 400 && !dragonx::test::g_liteFakeEncrypted.load(); ++i) + std::this_thread::sleep_for(std::chrono::milliseconds(5)); + EXPECT_TRUE(dragonx::test::g_liteFakeEncrypted.load()); // encrypted, save still pending + EXPECT_EQ(c.pumpWalletOps(), 0); + EXPECT_EQ(n, 0); + dragonx::test::g_liteFakeBlockSave = false; + waitCompletion(); + c.pumpWalletOps(); + EXPECT_EQ(n, 1); + EXPECT_TRUE(got.ok); + EXPECT_TRUE(c.encryptionStatus().encrypted); + n = 0; + EXPECT_TRUE(c.decryptWalletAsync("pw", [&](const LiteEncryptionResult& r) { ++n; got = r; })); + waitCompletion(); + c.pumpWalletOps(); + EXPECT_EQ(n, 1); + EXPECT_TRUE(got.ok); + EXPECT_FALSE(c.encryptionStatus().encrypted); + n = 0; + EXPECT_FALSE(c.encryptWalletAsync("", [&](const LiteEncryptionResult& r) { ++n; got = r; })); + EXPECT_EQ(n, 1); + EXPECT_FALSE(got.ok); + } + { // import + int n = 0; LiteImportResult got; + EXPECT_TRUE(c.importKeyAsync("secret-extended-key-x", [&](const LiteImportResult& r) { ++n; got = r; })); + waitCompletion(); + c.pumpWalletOps(); + EXPECT_EQ(n, 1); + EXPECT_TRUE(got.ok); + } + { // failPendingWalletOps: each callback once with the reason; late results dropped + dragonx::test::g_liteFakeBlockNew = true; + int n1 = 0, n2 = 0; std::string e1, e2; + c.newAddressAsync(true, [&](const LiteNewAddressResult& r) { ++n1; e1 = r.error; }); + c.newAddressAsync(false, [&](const LiteNewAddressResult& r) { ++n2; e2 = r.error; }); + c.failPendingWalletOps("x"); + EXPECT_EQ(n1, 1); EXPECT_EQ(n2, 1); + EXPECT_EQ(e1, std::string("x")); EXPECT_EQ(e2, std::string("x")); + EXPECT_FALSE(c.walletOpInProgress()); + dragonx::test::g_liteFakeBlockNew = false; + EXPECT_TRUE(c.waitWalletOpsIdle(std::chrono::milliseconds(3000))); + c.pumpWalletOps(); + EXPECT_EQ(n1, 1); EXPECT_EQ(n2, 1); + } +} + +void testLiteWalletControllerRetire() +{ + using namespace dragonx::wallet; + const auto caps = makeWalletCapabilities(WalletBuildKind::Lite, false, true); + const auto conn = defaultLiteConnectionSettings(); + dragonx::test::resetLiteFakeCounters(); + auto c = std::make_unique(caps, conn, + LiteClientBridge::fromApi(dragonx::test::makeFakeLiteApi())); + EXPECT_TRUE(c->createWallet(LiteWalletCreateRequest{}).walletReady); + for (int i = 0; i < 400 && !c->syncComplete(); ++i) std::this_thread::sleep_for(std::chrono::milliseconds(5)); + + // One op in flight (blocked "new") + one queued (import, stuck behind it on the single lane); the worker parked + // inside "balance". + dragonx::test::g_liteFakeLogEnabled = true; + dragonx::test::g_liteFakeBlockNew = true; + int n1 = 0, n2 = 0; bool ok1 = true, ok2 = true; + EXPECT_TRUE(c->newAddressAsync(true, [&](const LiteNewAddressResult& r) { ++n1; ok1 = r.ok; })); + EXPECT_TRUE(c->importKeyAsync("secret-extended-key-x", [&](const LiteImportResult& r) { ++n2; ok2 = r.ok; })); + const long entered = dragonx::test::g_liteFakeBalanceEntered.load(); + dragonx::test::g_liteFakeBlockBalance = true; + for (int i = 0; i < 1000 && dragonx::test::g_liteFakeBalanceEntered.load() == entered; ++i) + std::this_thread::sleep_for(std::chrono::milliseconds(5)); + EXPECT_TRUE(dragonx::test::g_liteFakeBalanceEntered.load() > entered); + (void)dragonx::test::liteFakeTakeLog(); // only what happens from here on is checked + dragonx::test::g_liteFakeShutdownCalled = false; // a late bridge release from an earlier test could have set it + + const auto t0 = std::chrono::steady_clock::now(); + c->retire("switch"); + EXPECT_TRUE(std::chrono::steady_clock::now() - t0 < std::chrono::milliseconds(500)); // never joins + EXPECT_EQ(n1, 1); EXPECT_EQ(n2, 1); + EXPECT_FALSE(ok1); EXPECT_FALSE(ok2); + EXPECT_TRUE(dragonx::test::g_liteFakeShutdownCalled); + int n3 = 0; + EXPECT_FALSE(c->newAddressAsync(true, [&](const LiteNewAddressResult&) { ++n3; })); // rejected inline + EXPECT_EQ(n3, 1); + + const long saves = dragonx::test::g_liteFakeSaveCount.load(); + auto fut = std::async(std::launch::async, [&c]() { c.reset(); }); + EXPECT_TRUE(fut.wait_for(std::chrono::milliseconds(150)) == std::future_status::timeout); // joins the parked worker + dragonx::test::g_liteFakeBlockBalance = false; + EXPECT_TRUE(fut.wait_for(std::chrono::milliseconds(3000)) == std::future_status::ready); + dragonx::test::g_liteFakeBlockNew = false; // let the in-flight "new" finish; its completion is dropped + std::this_thread::sleep_for(std::chrono::milliseconds(100)); + const auto log = dragonx::test::liteFakeTakeLog(); + EXPECT_EQ(dragonx::test::g_liteFakeSaveCount.load(), saves); // retired: no final save + EXPECT_EQ(dragonx::test::liteFakeCount(log, "import") + dragonx::test::liteFakeCount(log, "timport"), 0L); + EXPECT_EQ(dragonx::test::liteFakeCount(log, "notes"), 0L); // the cancelled cycle stopped after "balance" + dragonx::test::g_liteFakeLogEnabled = false; +} + +// retire() mid-op: a running op issues no follow-up backend command (it would land on the NEXT server's client), and an +// op that had already finished is delivered with its real result instead of being failed as "reconnecting". +void testLiteWalletControllerRetireStopsOpFollowUps() +{ + using namespace dragonx::wallet; + const auto caps = makeWalletCapabilities(WalletBuildKind::Lite, false, true); + const auto conn = defaultLiteConnectionSettings(); + dragonx::test::resetLiteFakeCounters(); + dragonx::test::g_liteFakeEncrypted = false; + dragonx::test::g_liteFakeLocked = false; + const auto openController = [&caps, &conn]() { + auto c = std::make_unique(caps, conn, + LiteClientBridge::fromApi(dragonx::test::makeFakeLiteApi())); + EXPECT_TRUE(c->createWallet(LiteWalletCreateRequest{}).walletReady); + for (int i = 0; i < 400 && !c->syncComplete(); ++i) std::this_thread::sleep_for(std::chrono::milliseconds(5)); + (void)dragonx::test::liteFakeTakeLog(); + dragonx::test::g_liteFakeLogEnabled = true; + return c; + }; + const auto drainLog = [](std::vector& log) { + auto more = dragonx::test::liteFakeTakeLog(); + log.insert(log.end(), more.begin(), more.end()); + }; + // The fake logs a command before blocking in it, so this returns once `cmd` is in flight. + const auto waitForCommand = [&drainLog](std::vector& log, const char* cmd) { + for (int i = 0; i < 600 && dragonx::test::liteFakeCount(log, cmd) == 0; ++i) { + std::this_thread::sleep_for(std::chrono::milliseconds(5)); + drainLog(log); + } + return dragonx::test::liteFakeCount(log, cmd) > 0; + }; + + { // "encrypt" in flight -> retire -> its "save" never runs (it would save on the next client) + auto c = openController(); + dragonx::test::g_liteFakeBlockEncrypt = true; + int n = 0; LiteEncryptionResult got; got.ok = true; + EXPECT_TRUE(c->encryptWalletAsync("pw", [&](const LiteEncryptionResult& r) { ++n; got = r; })); + std::vector log; + EXPECT_TRUE(waitForCommand(log, "encrypt")); + c->retire("switch"); + EXPECT_EQ(n, 1); + EXPECT_FALSE(got.ok); + dragonx::test::g_liteFakeBlockEncrypt = false; + EXPECT_TRUE(c->waitWalletOpsIdle(std::chrono::milliseconds(3000))); + c.reset(); + drainLog(log); + EXPECT_EQ(dragonx::test::liteFakeCount(log, "save"), 0L); + EXPECT_EQ(n, 1); + dragonx::test::g_liteFakeLogEnabled = false; + } + { // control: an unrecognized key is retried with the other import command + auto c = openController(); + dragonx::test::g_liteFakeImportFails = true; + int n = 0; bool ok = true; + EXPECT_TRUE(c->importKeyAsync("secret-extended-key-typo", [&](const LiteImportResult& r) { ++n; ok = r.ok; })); + EXPECT_TRUE(c->waitWalletOpsIdle(std::chrono::milliseconds(3000))); + c->pumpWalletOps(); + EXPECT_EQ(n, 1); + EXPECT_FALSE(ok); + std::vector log; + drainLog(log); + EXPECT_EQ(dragonx::test::liteFakeCount(log, "import"), 1L); + EXPECT_EQ(dragonx::test::liteFakeCount(log, "timport"), 1L); + c.reset(); + dragonx::test::g_liteFakeImportFails = false; + dragonx::test::g_liteFakeLogEnabled = false; + } + { // the first import attempt in flight (and failing) -> retire -> the fallback never runs + auto c = openController(); + dragonx::test::g_liteFakeImportFails = true; + dragonx::test::g_liteFakeBlockImport = true; + int n = 0; bool ok = true; + EXPECT_TRUE(c->importKeyAsync("secret-extended-key-typo", [&](const LiteImportResult& r) { ++n; ok = r.ok; })); + std::vector log; + EXPECT_TRUE(waitForCommand(log, "import")); + c->retire("switch"); + EXPECT_EQ(n, 1); + EXPECT_FALSE(ok); + dragonx::test::g_liteFakeBlockImport = false; + EXPECT_TRUE(c->waitWalletOpsIdle(std::chrono::milliseconds(3000))); + c.reset(); + drainLog(log); + EXPECT_EQ(dragonx::test::liteFakeCount(log, "import"), 1L); + EXPECT_EQ(dragonx::test::liteFakeCount(log, "timport"), 0L); + EXPECT_EQ(n, 1); + dragonx::test::g_liteFakeImportFails = false; + dragonx::test::g_liteFakeLogEnabled = false; + } + { // an op that finished before retire() (completion not yet pumped) is delivered with its real result + auto c = openController(); + int n = 0; LiteNewAddressResult got; + EXPECT_TRUE(c->newAddressAsync(true, [&](const LiteNewAddressResult& r) { ++n; got = r; })); + for (int i = 0; i < 600 && !c->hasWalletOpCompletions(); ++i) + std::this_thread::sleep_for(std::chrono::milliseconds(5)); + EXPECT_TRUE(c->hasWalletOpCompletions()); + EXPECT_EQ(n, 0); + c->retire("switch"); + EXPECT_EQ(n, 1); + EXPECT_TRUE(got.ok); + EXPECT_EQ(got.address, std::string("zs1fakenew")); + c.reset(); + dragonx::test::g_liteFakeLogEnabled = false; + } + dragonx::test::g_liteFakeEncrypted = false; + dragonx::test::g_liteFakeLocked = false; +} + +void testLiteWalletControllerEncryptsBeforeSyncStarts() +{ + using namespace dragonx::wallet; + const auto caps = makeWalletCapabilities(WalletBuildKind::Lite, false, true); + LiteConnectionSettings conn; + conn.chainName = "main"; + conn.servers = { LiteServerEndpoint{"https://good.example", "Good", true} }; + conn.selectionMode = LiteServerSelectionMode::Sticky; + conn.stickyServerUrl = "https://good.example"; + dragonx::test::resetLiteFakeCounters(); + dragonx::test::g_liteFakeWalletExists = false; + dragonx::test::g_liteFakeEncrypted = false; + dragonx::test::g_liteFakeLocked = false; + dragonx::test::g_liteFakeLogEnabled = true; + { + LiteWalletController c(caps, conn, LiteClientBridge::fromApi(dragonx::test::makeFakeLiteApi())); + LiteWalletCreateRequest req; + req.passphrase = "hunter2"; + EXPECT_TRUE(c.beginCreateWalletAsync(req)); + for (int i = 0; i < 400 && c.lifecycleRequestInProgress(); ++i) + std::this_thread::sleep_for(std::chrono::milliseconds(5)); + c.pumpLifecycleResult(); + for (int i = 0; i < 400 && !c.syncComplete(); ++i) std::this_thread::sleep_for(std::chrono::milliseconds(5)); + const auto log = dragonx::test::liteFakeTakeLog(); + const auto idx = [&log](const char* cmd) { + for (std::size_t i = 0; i < log.size(); ++i) if (log[i] == cmd) return static_cast(i); + return -1L; + }; + EXPECT_TRUE(idx("encrypt") >= 0); + EXPECT_TRUE(idx("save") > idx("encrypt")); + EXPECT_TRUE(idx("sync") > idx("save")); // the scan starts only after the encrypted wallet is saved + } + dragonx::test::g_liteFakeLogEnabled = false; + dragonx::test::g_liteFakeWalletExists = true; +} + +void testLiteWalletControllerWorkerBacksOffOnSyncReadFailure() +{ + using namespace dragonx::wallet; + const auto caps = makeWalletCapabilities(WalletBuildKind::Lite, false, true); + const auto conn = defaultLiteConnectionSettings(); + dragonx::test::resetLiteFakeCounters(); + dragonx::test::g_liteFakeBalanceError = true; + dragonx::test::g_liteFakeLogEnabled = true; + { + LiteWalletController c(caps, conn, LiteClientBridge::fromApi(dragonx::test::makeFakeLiteApi())); + EXPECT_TRUE(c.createWallet(LiteWalletCreateRequest{}).walletReady); + std::vector log; + for (int i = 0; i < 1000 && dragonx::test::liteFakeCount(log, "balance") == 0; ++i) { + std::this_thread::sleep_for(std::chrono::milliseconds(5)); + auto more = dragonx::test::liteFakeTakeLog(); + log.insert(log.end(), more.begin(), more.end()); + } + std::this_thread::sleep_for(std::chrono::milliseconds(300)); + auto more = dragonx::test::liteFakeTakeLog(); + log.insert(log.end(), more.begin(), more.end()); + EXPECT_EQ(dragonx::test::liteFakeCount(log, "balance"), 1L); // next cycle only after the 4 s back-off + EXPECT_EQ(dragonx::test::liteFakeCount(log, "notes"), 0L); // skipped after the failed balance + EXPECT_EQ(dragonx::test::liteFakeCount(log, "list"), 0L); + EXPECT_TRUE(dragonx::test::liteFakeCount(log, "addresses") >= 1L); + } // destructor wakes the back-off wait at once + dragonx::test::g_liteFakeBalanceError = false; + dragonx::test::g_liteFakeLogEnabled = false; +} + +void testLiteWalletControllerDestroyWithBlockedOp() +{ + using namespace dragonx::wallet; + const auto caps = makeWalletCapabilities(WalletBuildKind::Lite, false, true); + const auto conn = defaultLiteConnectionSettings(); + dragonx::test::resetLiteFakeCounters(); + int n = 0; + const auto t0 = std::chrono::steady_clock::now(); + { + LiteWalletController c(caps, conn, LiteClientBridge::fromApi(dragonx::test::makeFakeLiteApi())); + EXPECT_TRUE(c.createWallet(LiteWalletCreateRequest{}).walletReady); + for (int i = 0; i < 400 && !c.syncComplete(); ++i) std::this_thread::sleep_for(std::chrono::milliseconds(5)); + dragonx::test::g_liteFakeBlockNew = true; + EXPECT_TRUE(c.newAddressAsync(true, [&](const LiteNewAddressResult&) { ++n; })); + } // lane abandoned, not joined + EXPECT_TRUE(std::chrono::steady_clock::now() - t0 < std::chrono::milliseconds(3000)); + dragonx::test::g_liteFakeBlockNew = false; + std::this_thread::sleep_for(std::chrono::milliseconds(100)); // let the detached job unwind + EXPECT_EQ(n, 0); // a destroyed controller never delivers +} + // M5b: lite-wallet rollout / kill-switch policy (wallet/lite_rollout_policy.h). void testLiteRolloutVersionCompare() { @@ -13184,6 +13592,14 @@ int main() testLiteWalletControllerWorkerProducesModel(); testLiteWalletControllerShutdownDoesNotHangDuringSync(); testSerialJobLane(); + testLiteGatewayRefreshPolicyHelpers(); + testLiteGatewayRefreshCancelAndSkip(); + testLiteWalletControllerWalletOpsAsync(); + testLiteWalletControllerRetire(); + testLiteWalletControllerRetireStopsOpFollowUps(); + testLiteWalletControllerEncryptsBeforeSyncStarts(); + testLiteWalletControllerWorkerBacksOffOnSyncReadFailure(); + testLiteWalletControllerDestroyWithBlockedOp(); testLiteRolloutVersionCompare(); testLiteRolloutBucketAndOverrideHelpers(); testLiteRolloutPolicyOverridesAndKillSwitch();