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:
@@ -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(); });
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user