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