perf(lite): move wallet ops and server switches off the UI thread
New address, Import key, Encrypt and Remove encryption called the
backend on the UI thread. Each ends in the backend's save, which waits on
the lock a scan holds for its whole duration and every refresh read holds
briefly, so during a scan the window froze until the scan ended. A create
or restore with a passphrase encrypted and saved just after its first
scan had started: for a restore, a freeze for the whole initial scan. A
server switch destroyed the old controller on the UI thread and joined
its refresh worker, which can sit for minutes in a read against a dead
server (each balance, notes or list read retries for ~62 s).
- The four ops run one at a time on a background lane and their results
are applied on the main thread; Settings shows "Working…" meanwhile.
Key and passphrase copies are wiped after the call, or when a queued
op is dropped.
- A create or restore encrypts and saves (an open unlocks) before the
first scan starts, so the save takes milliseconds and the scan runs on
the encrypted file.
- Import, Encrypt, Remove encryption and Receive's New address are
disabled while a scan runs ("Wait for blockchain to sync"); Redownload
blocks is disabled while an op is queued.
- A switch retires the old controller without blocking (queued ops
dropped, pending ones reported failed, its client shut down) and
destroys it on a background thread. It no longer saves on the way out:
the new client re-scans the blocks since the last save, as after a
restart.
- A switch waits for an op in flight, at most 15 s, so the op can't
finish and save on the old client while the new one owns the wallet
file. The Network tab already shows the new selection meanwhile.
- The refresh worker stops between backend commands, skips the remaining
server reads once one fails, and then retries after 4, 8, then 15 s
instead of every 2 s.
- Quit waits up to 2.5 s for an op in flight, e.g. Encrypt's save.
Encrypt and Remove encryption now report a failed save: the backend
returns it as a JSON error that the old check never saw, so the failure
was reported as success. The trimmed copy of a key typed into Import is
now wiped too, and stopping the refresh worker can no longer miss its
wakeup.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
+51
-6
@@ -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<double>(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::function<void(bo
|
||||
bool App::hasPendingBackgroundResults() const {
|
||||
return (worker_ && worker_->hasPendingResults())
|
||||
|| (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
|
||||
|
||||
@@ -768,9 +768,9 @@ public:
|
||||
void verifyDebugCredential(const std::string& secret, std::function<void(bool)> 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.
|
||||
|
||||
+22
-14
@@ -3688,22 +3688,30 @@ void App::createNewTAddress(std::function<void(const std::string&)> callback)
|
||||
void App::createNewAddress(bool shielded, std::function<void(const std::string&)> 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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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<int>(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;
|
||||
|
||||
@@ -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<std::string> makeWipingSecretCopy(const std::string& secret)
|
||||
{
|
||||
return std::shared_ptr<std::string>(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<bool>* 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<std::string>();
|
||||
} else if (parsed.is_string()) {
|
||||
out.address = parsed.get<std::string>();
|
||||
}
|
||||
} 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<bool>* 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<bool>* 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> 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<LiteWalletAppRefreshModel> LiteWalletController::refreshModel()
|
||||
{
|
||||
return refreshModelImpl(/*honorStop=*/false, nullptr); // tests / direct callers: never cancelled
|
||||
}
|
||||
|
||||
std::optional<LiteWalletAppRefreshModel> LiteWalletController::refreshModelImpl(bool honorStop, bool* syncReadFailed)
|
||||
{
|
||||
if (!walletOpen_.load()) return std::nullopt;
|
||||
|
||||
@@ -756,7 +891,13 @@ std::optional<LiteWalletAppRefreshModel> 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<LiteWalletAppRefreshModel> 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<LiteWalletAppRefreshModel> 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<std::string>();
|
||||
} else if (parsed.is_string()) {
|
||||
out.address = parsed.get<std::string>();
|
||||
}
|
||||
} 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 <class R>
|
||||
bool LiteWalletController::submitWalletOp(std::function<R()> core,
|
||||
std::function<void(const R&)> onDone,
|
||||
std::function<R(const std::string&)> 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<LiteNewAddressResult>(
|
||||
[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<LiteImportResult>(
|
||||
[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<LiteEncryptionResult>(
|
||||
[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<std::mutex> 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<std::mutex> 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<std::mutex> lock(wakeMutex_);
|
||||
wakeCv_.wait_for(lock, std::chrono::milliseconds(kRefreshIntervalMs),
|
||||
wakeCv_.wait_for(lock, std::chrono::milliseconds(liteRefreshDelayMs(failStreak, kRefreshIntervalMs)),
|
||||
[this] { return !running_.load(); });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 <atomic>
|
||||
#include <chrono>
|
||||
#include <condition_variable>
|
||||
#include <cstdint>
|
||||
#include <functional>
|
||||
#include <map>
|
||||
#include <memory>
|
||||
#include <mutex>
|
||||
#include <optional>
|
||||
@@ -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<void(const LiteNewAddressResult&)>;
|
||||
using LiteImportCallback = std::function<void(const LiteImportResult&)>;
|
||||
using LiteEncryptionCallback = std::function<void(const LiteEncryptionResult&)>;
|
||||
|
||||
// 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<LiteWalletAppRefreshModel> 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<LiteBroadcastResult()> 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<RwLock<LightWallet>> (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<std::uint64_t, std::function<void(const std::string&)>> pendingOps_; // main thread; id -> deliver failure
|
||||
std::uint64_t nextOpId_ = 1; // main thread
|
||||
template <class R>
|
||||
bool submitWalletOp(std::function<R()> core,
|
||||
std::function<void(const R&)> onDone,
|
||||
std::function<R(const std::string&)> makeFailure);
|
||||
bool submitEncryptionChange(std::string& passphrase, LiteEncryptionCallback onDone, bool encrypt);
|
||||
std::atomic<bool> 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<std::atomic<bool>> opsRetired_ = std::make_shared<std::atomic<bool>>(false);
|
||||
std::string connectedServerUrl_; // main thread
|
||||
|
||||
// Joinable background refresh worker (fast iterations: syncstatus, plus data once synced).
|
||||
std::thread worker_;
|
||||
std::atomic<bool> running_{false};
|
||||
|
||||
@@ -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<int>(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() ? "<redacted>" : "<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(
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
#include "wallet_capabilities.h"
|
||||
|
||||
#include <cstddef>
|
||||
#include <functional>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
@@ -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 = "<empty>";
|
||||
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<bool()> 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<LiteWalletGatewayCommandResult> 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<LiteWalletGatewayCommand> liteWalletRefreshCommands(const LiteWalletRefreshRequest& request);
|
||||
|
||||
@@ -21,7 +21,10 @@
|
||||
#include <chrono>
|
||||
#include <cstdlib>
|
||||
#include <cstring>
|
||||
#include <mutex>
|
||||
#include <string>
|
||||
#include <thread>
|
||||
#include <vector>
|
||||
|
||||
namespace dragonx {
|
||||
namespace test {
|
||||
@@ -38,6 +41,34 @@ inline std::atomic<bool> g_liteFakeSendFails{false}; // when true, "send"/"shie
|
||||
inline std::atomic<long> g_liteFakeSaveCount{0}; // counts "save" commands (persistence checks)
|
||||
inline std::atomic<bool> g_liteFakeEncrypted{false}; // wallet-encryption state machine (encrypt/...)
|
||||
inline std::atomic<bool> g_liteFakeLocked{false}; // spending-keys-locked state
|
||||
inline std::atomic<bool> g_liteFakeBlockNew{false}; // "new" blocks (models do_save waiting on sync_lock)
|
||||
inline std::atomic<bool> g_liteFakeBlockSave{false}; // "save" blocks
|
||||
inline std::atomic<bool> g_liteFakeBlockBalance{false}; // "balance" blocks (a refresh stuck on a dead server)
|
||||
inline std::atomic<bool> g_liteFakeBalanceError{false}; // "balance" returns the backend's plain-text sync error
|
||||
inline std::atomic<long> g_liteFakeBalanceEntered{0}; // calls that reached "balance"
|
||||
inline std::atomic<bool> g_liteFakeBlockEncrypt{false}; // "encrypt" blocks (before changing state)
|
||||
inline std::atomic<bool> g_liteFakeBlockImport{false}; // "import"/"timport" block
|
||||
inline std::atomic<bool> g_liteFakeImportFails{false}; // "import"/"timport" reject the key (real do_import_key shape)
|
||||
inline std::atomic<bool> g_liteFakeLogEnabled{false};
|
||||
inline std::mutex g_liteFakeLogMutex;
|
||||
inline std::vector<std::string> g_liteFakeCommandLog; // execute() command names, in order, while enabled
|
||||
inline void liteFakeWaitWhile(const std::atomic<bool>& flag)
|
||||
{
|
||||
while (flag.load()) std::this_thread::sleep_for(std::chrono::milliseconds(5));
|
||||
}
|
||||
inline std::vector<std::string> liteFakeTakeLog()
|
||||
{
|
||||
std::lock_guard<std::mutex> lk(g_liteFakeLogMutex);
|
||||
std::vector<std::string> out;
|
||||
out.swap(g_liteFakeCommandLog);
|
||||
return out;
|
||||
}
|
||||
inline long liteFakeCount(const std::vector<std::string>& 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<std::mutex> 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: '<key>' 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\"}");
|
||||
}
|
||||
|
||||
@@ -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<std::size_t>(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<LiteWalletController>(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<LiteWalletController>(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<std::string>& 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<std::string>& 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<std::string> 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<std::string> 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<std::string> 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<long>(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<std::string> 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();
|
||||
|
||||
Reference in New Issue
Block a user