// DragonX Wallet - ImGui Edition // Copyright 2024-2026 The Hush Developers // Released under the GPLv3 #include "lite_wallet_controller.h" #include "lite_diagnostics.h" #include "lite_result_parsers.h" #include "../data/wallet_state.h" #include "../util/address_validation.h" #include #include #include #include #include #include #include #include #include #include #include namespace dragonx { namespace wallet { namespace { constexpr double kZatoshisPerCoin = 100000000.0; // DRGX has 1e8 zatoshis per coin // A lightwalletd open error that means "server is up but still warming up" (JSON-RPC -28 / // "Activating best chain", "Loading"/"Verifying"/"Rescanning" phases) rather than a dead server. // Such a server will be ready shortly, so the caller should retry it soon. bool liteOpenErrorIsWarmup(const std::string& error) { const auto has = [&error](const char* s) { return error.find(s) != std::string::npos; }; return has("-28") || has("Activating best chain") || has("warming up") || has("Loading block") || has("Verifying blocks") || has("Rescanning"); } // Extract a backend {"error":..} message (string or arbitrary JSON) into a plain string. std::string extractJsonError(const nlohmann::json& value) { const auto& e = value.at("error"); return e.is_string() ? e.get() : e.dump(); } // True for the BIP39 phrase of all-zero entropy (23 x "abandon", then "art"), compared word by word in place so the // secret is never copied. A real wallet has this seed with probability 2^-256. bool isZeroEntropySeedPhrase(const std::string& phrase) { const auto isSpace = [](char c) { return c == ' ' || c == '\t' || c == '\n' || c == '\r'; }; std::size_t i = 0, words = 0; while (true) { while (i < phrase.size() && isSpace(phrase[i])) ++i; if (i == phrase.size()) break; std::size_t end = i; while (end < phrase.size() && !isSpace(phrase[end])) ++end; ++words; if (words > 24 || phrase.compare(i, end - i, words < 24 ? "abandon" : "art") != 0) return false; i = end; } return words == 24; } // Parse a send/shield response: success is {"txid":".."}, failure is {"error":".."} (NOT an // "Error:"-prefixed string), and malformed args make the backend return plain-text help. LiteBroadcastResult parseBroadcastResponse(const LiteBridgeStringResult& bridgeCall) { LiteBroadcastResult out; if (!bridgeCall.ok) { out.error = bridgeCall.error.empty() ? "backend call failed" : bridgeCall.error; return out; } try { const auto j = nlohmann::json::parse(bridgeCall.value); if (j.is_object()) { if (j.contains("error")) { out.error = extractJsonError(j); return out; } if (j.contains("txid") && j.at("txid").is_string()) { out.txid = j.at("txid").get(); out.ok = !out.txid.empty(); if (!out.ok) out.error = "backend returned an empty txid"; return out; } } } catch (...) { // Non-JSON (e.g. the command's plain-text help on bad args) -> ambiguous error below. } // The bridge call itself succeeded but the response is unrecognizable, so we genuinely // can't tell whether the tx was broadcast. Use cautious wording (don't claim a hard // failure) so the user verifies in Transactions before retrying — avoiding a double-spend. out.error = "Transaction status could not be confirmed — check Transactions before retrying"; return out; } // The backend does not auto-save after send/shield. Persist the new transaction now so it // survives a restart; retry once on a transient failure (disk lock, etc.). bool persistAfterBroadcast(LiteClientBridge& bridge) { for (int attempt = 0; attempt < 2; ++attempt) { if (bridge.execute("save", "").ok) return true; } // Persistent failure: the spent note will be re-derived from the chain on the next sync, so this // is a robustness gap, not fund loss. Log it (W5-1) — both callers discard this return, so the // failure was previously completely silent. liteLog("save failed after send/shield — the wallet will re-derive it on the next sync"); return false; } // True when the wallet is encrypted AND currently unlocked. The backend's `save` re-locks such a wallet (do_save), // so a background save must not run under a user who just unlocked. Fail-open: any error reads as "not unlocked" and // the save proceeds as it always did. bool liteWalletEncryptedAndUnlocked(LiteClientBridge& bridge) { const auto r = bridge.execute("encryptionstatus", ""); if (!r.ok) return false; try { const auto j = nlohmann::json::parse(r.value); return j.is_object() && !j.contains("error") && j.value("encrypted", false) && !j.value("locked", false); } catch (...) { return false; } } // The detached sync/rescan thread. `firstCommand` is "sync" or "rescan"; every retry is "sync" (a failed rescan has // already cleared the wallet, so re-running it would throw away the partial progress). Partial progress is saved after // every attempt. Captures shared state only. void runLiteScanThread(const std::shared_ptr& bridge, const std::shared_ptr>& done, const std::shared_ptr& scan, const char* firstCommand) { if (bridge) { const char* command = firstCommand; for (int attempt = 1;; ++attempt) { const auto call = bridge->execute(command, ""); // blocks until synced (or errors out) LiteSyncOpResult op; if (call.ok) { op = parseLiteSyncOpResponse(call.value); } else { op.error = call.error.empty() ? "sync failed" : call.error; } // The backend does NOT auto-save after a sync; persist what was scanned even when the attempt failed. // Not while the user has the wallet unlocked: the backend save re-locks an encrypted+unlocked wallet, which // would yank the unlock out from under a spend/unlock in progress. The progress is saved by the next scan // once locked (and by the shutdown save); a crash in between only costs a re-scan from the checkpoint. if (liteWalletEncryptedAndUnlocked(*bridge)) liteLog("sync save deferred: wallet is unlocked (a save would re-lock it)"); else if (!bridge->execute("save", "").ok) // W5-2 liteLog("save failed after sync — the next launch will re-scan from the checkpoint"); if (op.ok) { scan->setError(std::string()); scan->failed.store(false); break; } liteLog(std::string(command) + " failed (attempt " + std::to_string(attempt) + "): " + op.error); scan->setError(op.error); command = "sync"; if (scan->cancel.load()) break; if (op.terminal || attempt >= kLiteScanMaxAttempts) { scan->failed.store(true); // explicit failed state; done is still published below liteLog("sync gave up: " + op.error); break; } const int delayMs = liteSyncRetryDelayMs(attempt, scan->retryBaseMs.load()); for (int waited = 0; waited < delayMs && !scan->cancel.load(); waited += 50) std::this_thread::sleep_for(std::chrono::milliseconds(50)); if (scan->cancel.load()) break; } } // Only after the save, so a syncComplete() observer sees a fully-persisted wallet. done->store(true); } // Build the JSON-array send payload and broadcast it. litelib_execute passes the whole args // string as ONE argument (no whitespace splitting), so send MUST use the JSON-array form // ([{address,amount,memo},..]); the space-separated CLI form would never parse. LiteBroadcastResult doSend(LiteClientBridge& bridge, const LiteSendRequest& request) { LiteBroadcastResult out; if (request.recipients.empty()) { out.error = "no recipients"; return out; } nlohmann::json arr = nlohmann::json::array(); for (const auto& r : request.recipients) { if (r.address.empty()) { out.error = "recipient address is empty"; return out; } nlohmann::json o; o["address"] = r.address; o["amount"] = r.amountZatoshis; // zatoshis (puposhis) if (!r.memo.empty()) o["memo"] = r.memo; arr.push_back(std::move(o)); } auto result = parseBroadcastResponse(bridge.execute("send", arr.dump())); if (result.ok) persistAfterBroadcast(bridge); return result; } LiteBroadcastResult doShield(LiteClientBridge& bridge, const std::string& optionalAddress) { // Empty address -> shield all transparent funds; otherwise shield to the given address. auto result = parseBroadcastResponse(bridge.execute("shield", optionalAddress)); if (result.ok) persistAfterBroadcast(bridge); // shield does not auto-save either return result; } // --- Blocking cores of the wallet ops (no `this`, so they also run on the wallet-op lane) ------------------------ LiteEncryptionResult parseEncryptionOpResponse(const LiteBridgeStringResult& bridgeCall); // defined further down // Heap copy of a secret, wiped when the last reference goes (after the backend call, or when a queued job is dropped // unrun). Copy, never move: moving a short (SSO) std::string leaves its bytes behind in the source. std::shared_ptr makeWipingSecretCopy(const std::string& secret) { return std::shared_ptr(new std::string(secret), [](std::string* s) { secureWipeLiteSecret(*s); delete s; }); } // A lane op whose controller was retired (server switch) runs no further backend command: litelib_execute always runs // on the GLOBAL client, which is then the NEXT server's. `retired` is null for the synchronous (main-thread) callers. constexpr const char* kLiteOpRetiredError = "lite wallet reconnecting"; bool liteOpRetired(const std::atomic* retired) { return retired && retired->load(); } LiteNewAddressResult newAddressCore(LiteClientBridge& bridge, bool shielded) { LiteNewAddressResult out; // Backend address-type tokens: "zs" (shielded) / "R" (transparent) (do_new_address). const auto result = bridge.execute("new", shielded ? "zs" : "R"); if (!result.ok) { out.error = result.error.empty() ? "new address generation failed" : result.error; return out; } // Response is a JSON array with the new address, e.g. ["zs1..."]. try { const auto parsed = nlohmann::json::parse(result.value); if (parsed.is_array() && !parsed.empty() && parsed[0].is_string()) { out.address = parsed[0].get(); } else if (parsed.is_string()) { out.address = parsed.get(); } } catch (...) { // fall through to the error below } out.ok = !out.address.empty(); if (!out.ok) out.error = "could not parse new address response"; return out; } // The backend's import errors can repeat the key itself (do_import_key: "'' was not recognized as either a // spending key or a viewing key ..."), so that text is never logged or shown: only a fixed, non-secret reason. std::string liteImportFailureReason(const std::string& backendError) { const auto has = [&backendError](const char* s) { return backendError.find(s) != std::string::npos; }; if (has("was not recognized")) return "unrecognized key format"; // Checked before "locked": unlocking does not help, the backend refuses private-key imports while encrypted. if (has("encrypted")) return "wallet is encrypted - remove encryption to import a private key"; if (has("locked")) return "wallet is locked"; if (has("already exists")) return "key already exists"; if (has("decode")) return "key could not be decoded"; return "key rejected by the backend"; } // `key` must be non-empty; it is wiped before returning. A failure's error is a fixed reason, never backend text. LiteImportResult importKeyCore(LiteClientBridge& bridge, std::string& key, const std::atomic* retired = nullptr) { // Classify first, then call exactly one backend command, or none (C251). There is no retry with the other // command: the wrong command never imported the key anyway, and that retry is what fed shielded keys to the // backend's transparent-key decoder. // 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, so a // "zivks…" key is refused here with a fixed reason. enum class Kind { Shielded, FullViewing, FullNodeIvk, TransparentWif, UncompressedWif, Unknown }; Kind kind = Kind::Unknown; if (key.rfind("secret-extended-key-", 0) == 0) kind = Kind::Shielded; else if (key.rfind("zxview", 0) == 0) kind = Kind::FullViewing; // SDXL extended full viewing key else if (key.rfind("zivks", 0) == 0) kind = Kind::FullNodeIvk; // full-node incoming VK: not importable else { // A transparent WIF is Base58Check with the mainnet (0xBC) or Bitcoin-style (0x80) version byte and the // compressed-key flag. The Lite app is mainnet-only, so a testnet (0xEF) WIF is not accepted. std::vector payload; if (util::decodeBase58Check(key, payload)) { if (payload.size() == 34 && payload[33] == 0x01 && (payload[0] == 0xBC || payload[0] == 0x80)) kind = Kind::TransparentWif; else if (payload.size() == 33) kind = Kind::UncompressedWif; } if (!payload.empty()) sodium_memzero(payload.data(), payload.size()); } 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 = liteImportFailureReason(result.error); return r; } try { const auto j = nlohmann::json::parse(result.value); if (j.is_object() && j.contains("error")) { r.error = liteImportFailureReason(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; switch (kind) { case Kind::Shielded: case Kind::FullViewing: out = runImport("import"); break; case Kind::TransparentWif: out = runImport("timport"); break; case Kind::FullNodeIvk: out.error = "this is a full-node incoming viewing key (zivks); the Lite wallet needs an extended viewing key " "(zxviews)"; break; case Kind::UncompressedWif: out.error = "uncompressed transparent keys are not supported by the Lite wallet"; break; default: out.error = "unrecognized key format"; break; } if (!out.ok && liteOpRetired(retired)) out.error = kLiteOpRetiredError; if (!out.ok) liteLog("Key import failed: " + out.error); // a fixed reason: the backend's text can repeat the key secureWipeLiteSecret(key); // wipe our copy return out; } // encrypt (encrypt=true) or remove encryption, then save, as ONE unit; wipes `passphrase` (must be non-empty). LiteEncryptionResult encryptionChangeCore(LiteClientBridge& bridge, std::string& passphrase, bool encrypt, const std::atomic* retired = nullptr) { LiteEncryptionResult out = parseEncryptionOpResponse(bridge.execute(encrypt ? "encrypt" : "decrypt", passphrase)); secureWipeLiteSecret(passphrase); if (out.ok && liteOpRetired(retired)) { // Retired mid-op: the save would run on the NEXT server's client. The file on disk keeps its old state. out.ok = false; out.error = std::string(encrypt ? "wallet encrypted" : "wallet decrypted") + " in memory but not saved: " + kLiteOpRetiredError; } else if (out.ok) { // Persist the now-(un)encrypted wallet. If the save fails, do NOT report success — the on-disk wallet would // still be in its old state, contradicting what the user was told. The backend's `save` reports failure as // {"result":"error","error":..} (no "Error:" prefix), so check the reply, not just the bridge status. const auto saved = parseEncryptionOpResponse(bridge.execute("save", "")); if (!saved.ok) { out.ok = false; out.error = std::string(encrypt ? "wallet encrypted" : "wallet decrypted") + " in memory but saving to disk failed" + (saved.error.empty() ? std::string() : (": " + saved.error)); } } return out; } } // namespace void secureWipeLiteSecret(std::string& secret) { if (!secret.empty()) { sodium_memzero(&secret[0], secret.size()); } secret.clear(); } void applyLiteRefreshModelToWalletState(const LiteWalletAppRefreshModel& model, dragonx::WalletState& state) { if (model.hasBalance) { state.privateBalance = static_cast(model.balance.shieldedZatoshis) / kZatoshisPerCoin; state.transparentBalance = static_cast(model.balance.transparentZatoshis) / kZatoshisPerCoin; state.totalBalance = static_cast(model.balance.totalZatoshis) / kZatoshisPerCoin; state.unconfirmedBalance = static_cast(model.balance.unconfirmedZatoshis) / kZatoshisPerCoin; // Use the backend's SPENDABLE (verified + confirmed) shielded balance for the spend gate, not the // gross shielded total which includes unverified/unconfirmed notes you can't yet spend. The // spendable_zbalance field is required by the parser, so it's always present when hasBalance is // true; clamp to the shielded total defensively. Transparent is already confirmed on lite. const double spendableShielded = static_cast( std::min(model.balance.spendableShieldedZatoshis, model.balance.shieldedZatoshis)) / kZatoshisPerCoin; state.spendablePrivateBalance = spendableShielded; state.spendableTransparentBalance = state.transparentBalance; state.spendableTotalBalance = spendableShielded + state.transparentBalance; } if (model.hasAddresses) { // Per-address balances from unspent notes/utxos (when the notes command succeeded). // Sum the value of confirmed, unspent outputs at each address: skip anything spent, // unconfirmed-spent, or still pending (notes/utxos from an unconfirmed received tx) so // the per-address figures match the confirmed balance the wallet treats as available. std::unordered_map perAddressZatoshis; std::unordered_map perAddressSpendableZatoshis; // backend 'spendable' only if (model.hasSpendableOutputs) { for (const auto& output : model.spendableOutputs) { if (output.spent || output.unconfirmedSpent || output.pending) continue; perAddressZatoshis[output.address] += output.valueZatoshis; if (output.spendable) perAddressSpendableZatoshis[output.address] += output.valueZatoshis; } } // If the notes/utxo command failed this cycle (a tolerated partial refresh), we don't // actually know per-address balances. Preserve the previously displayed ones instead // of zeroing every address — a zeroed breakdown next to a correct nonzero total looks // like fund loss and breaks "send from this address". std::unordered_map priorBalances; std::unordered_map priorSpendable; std::unordered_map priorHasKey; if (!model.hasSpendableOutputs) { for (const auto& a : state.addresses) { priorBalances[a.address] = a.balance; priorSpendable[a.address] = a.spendableBalance; priorHasKey[a.address] = a.has_spending_key; } } state.addresses.clear(); state.z_addresses.clear(); state.t_addresses.clear(); for (const auto& addr : model.addresses) { AddressInfo info; info.address = addr.address; const auto it = perAddressZatoshis.find(addr.address); if (it != perAddressZatoshis.end()) { info.balance = static_cast(it->second) / kZatoshisPerCoin; } else if (!model.hasSpendableOutputs) { const auto pit = priorBalances.find(addr.address); // keep last-known on notes failure info.balance = pit != priorBalances.end() ? pit->second : 0.0; } else { info.balance = 0.0; // notes succeeded and address has no spendable outputs } // balance stays the gross confirmed-unspent sum; spendableBalance counts only outputs the backend // marks spendable (spending key + anchor depth). Notes failed this cycle -> keep last-known. if (model.hasSpendableOutputs) { const auto sit = perAddressSpendableZatoshis.find(addr.address); info.spendableBalance = sit != perAddressSpendableZatoshis.end() ? static_cast(sit->second) / kZatoshisPerCoin : 0.0; } else { const auto psit = priorSpendable.find(addr.address); info.spendableBalance = psit != priorSpendable.end() ? psit->second : 0.0; } info.type = (addr.kind == LiteWalletAppAddressKind::Shielded) ? "shielded" : "transparent"; info.has_spending_key = addr.spendabilityKnown ? addr.spendable : true; if (!addr.spendabilityKnown && !model.hasSpendableOutputs) { const auto pkit = priorHasKey.find(addr.address); // keep last-known view-only verdict if (pkit != priorHasKey.end()) info.has_spending_key = pkit->second; } if (addr.kind == LiteWalletAppAddressKind::Shielded) { state.z_addresses.push_back(info); } else { state.t_addresses.push_back(info); } state.addresses.push_back(std::move(info)); } } if (model.hasTransactions) { // Build aside and swap in only on a real change: this runs every ~2 s, and a bump rebuilds History. std::vector nextTransactions; nextTransactions.reserve(model.transactions.size()); const int64_t chainHeight = model.hasSyncStatus ? static_cast(model.sync.chainHeight) : 0; for (const auto& record : model.transactions) { TransactionInfo tx; tx.txid = record.txid; if (record.kind == LiteWalletAppTransactionKind::Send) { tx.type = "send"; } else if (record.kind == LiteWalletAppTransactionKind::Receive) { tx.type = "receive"; } else { tx.type = record.signedAmountZatoshis < 0 ? "send" : "receive"; } // Signed like the full node: a send is a negative debit, a receive positive. Consumers // (History/Details/CSV) pick sign + colour from this; the summary cards use std::abs. tx.amount = static_cast(record.signedAmountZatoshis) / kZatoshisPerCoin; tx.timestamp = record.timestamp; tx.address = record.address; tx.memo = record.memo; // For a Send the recipient address/memo live in outgoingOutputs — the top-level // address/memo are only filled for Receives. Surface the first recipient so the // list shows the destination + memo instead of blanks (single-recipient case). if (tx.type == "send" && tx.address.empty() && !record.outgoingOutputs.empty()) { tx.address = record.outgoingOutputs.front().address; if (tx.memo.empty()) tx.memo = record.outgoingOutputs.front().memo; } if (record.unconfirmed || !record.blockHeight.has_value() || chainHeight == 0) { tx.confirmations = record.unconfirmed ? 0 : 1; } else { const int64_t confs = chainHeight - *record.blockHeight + 1; tx.confirmations = confs > 0 ? static_cast(confs) : 0; } nextTransactions.push_back(std::move(tx)); } // The backend lists oldest-first; every consumer of state.transactions (Recent Transactions / Recent Sends / // Recent Received, CSV export) assumes newest-first. Same key as the History tab's default: pending (0-conf) // on top, then newest. STABLE: one txid yields several records with the same datetime (send entry + per-note // entries, same-block txs), and stability keeps their order deterministic across refreshes, so the // order-sensitive sameTransactionList() still reports "unchanged" and tx_revision is not bumped every 2 s. std::stable_sort(nextTransactions.begin(), nextTransactions.end(), [](const TransactionInfo& a, const TransactionInfo& b) { const bool ap = a.confirmations == 0, bp = b.confirmations == 0; if (ap != bp) return ap; return a.timestamp > b.timestamp; }); state.setTransactionsIfChanged(std::move(nextTransactions)); } if (model.hasSyncStatus) { state.sync.blocks = static_cast(model.sync.walletHeight); state.sync.headers = static_cast(model.sync.chainHeight); state.sync.verification_progress = model.sync.progress; state.sync.syncing = !model.sync.complete; } state.sync.lite_error = model.syncError; if (model.hasEncryptionStatus) { // Reflect backend encryption state so isLocked()/isEncrypted() gate the UI correctly, and mark // it known: the wallet tabs wait for that, and without it lite always sat behind the loading // overlay until its 15 s "assume unencrypted" fallback. state.encrypted = model.encrypted; state.locked = model.locked; state.encryption_state_known = true; } } LiteWalletController::LiteWalletController(WalletCapabilities capabilities, LiteConnectionSettings connectionSettings, LiteClientBridge bridge, LiteWalletControllerOptions options) : bridge_(std::make_shared(std::move(bridge))), chainName_(connectionSettings.chainName), connectionSettings_(connectionSettings), capabilities_(capabilities), lifecycleOptions_{options.allowBridgeCalls, options.rolloutBlocked, options.rolloutMessage}, lifecycle_(capabilities, connectionSettings, bridge_.get(), LiteWalletLifecycleOptions{options.allowBridgeCalls, options.rolloutBlocked, options.rolloutMessage}), gateway_(capabilities, connectionSettings, bridge_.get(), LiteWalletGatewayOptions{options.allowBridgeCalls}), sync_(capabilities, connectionSettings, bridge_.get(), LiteSyncServiceOptions{options.allowBridgeCalls}) { status_ = lifecycle_.status(); } void LiteWalletController::flushOnExit(std::chrono::milliseconds budget) { // Same guards as the destructor, plus walletOpInProgress(): App::shutdown() already ran // waitWalletOpsIdle(); if an op is still pending it holds sync_lock and issues its own save, // so skip rather than block the budget on a save that cannot acquire the lock. if (retired_.load() || !walletOpen_.load() || !syncComplete() || broadcastInProgress() || walletOpInProgress() || !bridge_) return; struct Done { std::mutex m; std::condition_variable cv; bool done = false; }; auto st = std::make_shared(); auto bridge = bridge_; // shared_ptr copy; never captures `this` try { std::thread([bridge, st]() { bridge->execute("save", ""); // may block on sync_lock; abandoned on timeout { std::lock_guard l(st->m); st->done = true; } st->cv.notify_all(); }).detach(); } catch (...) { return; } // no thread: skip (pre-fix behaviour, no crash) std::unique_lock l(st->m); st->cv.wait_for(l, budget, [&] { return st->done; }); // timeout: abandon the thread; _Exit follows } LiteWalletController::~LiteWalletController() { scan_->cancel.store(true); // the detached sync thread stops retrying (it may be in a back-off sleep) stopWorker(); // joins the fast poll worker (short iterations) // Best-effort flush on shutdown: the mempool monitor's unconfirmed updates aren't persisted // 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. // 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 // shared refs (bridge_ + syncDone_), so it stays safe and the bridge survives until it // finishes — on the quit path the process is exiting, so a late litelib_shutdown is harmless // (a server switch already shut the bridge down in retire()). if (syncThread_.joinable()) syncThread_.detach(); // Likewise the broadcast thread (send/shield proving): it captures shared refs (bridge + // running flag + result slot), never `this`, so detaching is safe. if (broadcastThread_.joinable()) broadcastThread_.detach(); // The interactive-console command thread follows the same shared-lifetime pattern. if (consoleThread_.joinable()) consoleThread_.detach(); // The async-open failover thread captures only shared refs (bridge + running flag + result // slot), never `this`, so detaching is safe if it's still trying servers at shutdown. if (openThread_.joinable()) openThread_.detach(); // The async full-lifecycle thread builds its own local lifecycle service from captured value // copies + the shared bridge (never `this`), so detaching is likewise safe. if (lifecycleThread_.joinable()) lifecycleThread_.detach(); } std::unique_ptr LiteWalletController::createLinked( WalletCapabilities capabilities, LiteConnectionSettings connectionSettings, LiteRolloutDecision rollout) { return std::make_unique( capabilities, std::move(connectionSettings), LiteClientBridge::linkedSdxl(), LiteWalletControllerOptions{true, !rollout.allowed, rollout.message}); } 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_(); 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)); } } std::vector LiteWalletController::failoverServerUrls() const { std::vector urls; const auto add = [&urls](const std::string& url) { if (!isLiteServerUrlUsable(url)) return; if (std::find(urls.begin(), urls.end(), url) == urls.end()) urls.push_back(url); }; // Preferred server first (honours the user's sticky/random selection), then every other // usable configured server as a fallback so one dead server can't strand the wallet. const auto selected = selectLiteServer(connectionSettings_); if (selected.ok) add(selected.server.url); for (const auto& endpoint : connectionSettings_.servers) { if (endpoint.enabled) add(endpoint.url); } return urls; } bool LiteWalletController::beginOpenExisting() { return beginAsyncLifecycle(/*create=*/false); } bool LiteWalletController::beginCreateWallet() { return beginAsyncLifecycle(/*create=*/true); } bool LiteWalletController::beginAsyncLifecycle(bool create) { // Don't race a Settings-page async lifecycle request (create/open/restore) on the same bridge. if (walletOpen_.load() || openRunning_->load() || lifecycleRunning_->load()) return false; const char* verb = create ? "Create" : "Open"; if (lifecycle_.availability() != LiteWalletLifecycleAvailability::Ready) { const std::string& reason = lifecycle_.status().message; status_ = WalletBackendStatus{WalletBackendState::Error, reason.empty() ? "lite wallet is not available" : reason, {}, {}, 0.0}; lastOpenError_ = status_.message; liteLog(std::string(verb) + " blocked: " + lastOpenError_); return false; } auto servers = failoverServerUrls(); if (servers.empty()) { status_ = WalletBackendStatus{WalletBackendState::Error, "no usable lite servers are configured", {}, {}, 0.0}; lastOpenError_ = status_.message; liteLog(std::string(verb) + " blocked: " + lastOpenError_); return false; } if (openThread_.joinable()) openThread_.join(); // a prior attempt has fully finished { std::lock_guard lk(*openResultMutex_); openResult_->reset(); } openRunning_->store(true); status_ = WalletBackendStatus{WalletBackendState::Connecting, create ? "creating wallet" : "opening wallet", {}, {}, 0.0}; liteLog(std::string(create ? "Creating wallet — trying " : "Opening wallet — trying ") + std::to_string(servers.size()) + " server(s)"); // Capture only shared refs + value copies (never `this`) so the thread can safely outlive us. auto bridge = bridge_; auto running = openRunning_; auto resultMutex = openResultMutex_; auto resultSlot = openResult_; openThread_ = std::thread([bridge, servers, running, resultMutex, resultSlot, create]() { OpenOutcome outcome; std::string preferredError; // error from the FIRST (preferred) server — the actionable one for (const auto& url : servers) { if (!bridge) break; liteLog(" connecting to " + url + " ..."); // create: initialize_new generates a NEW seed+wallet, but contacts the server FIRST, // so an unreachable server fails before writing any file — safe to try the next. // open: initialize_existing just loads the file. ok && non-empty value == ready. auto call = create ? bridge->initializeNew(/*dangerous=*/false, url) : bridge->initializeExisting(/*dangerous=*/false, url); const bool ready = call.ok && !call.value.empty(); // create's response IS the secret seed — wipe our copy; it's read back via exportSeed(). if (create) secureWipeLiteSecret(call.value); if (ready) { liteLog(" " + url + ": connected"); // s2-D14: an open that fell back to the backup reports "OK: recovered from backup; damaged file kept // as ". Never for create: its value was the seed (wiped above). if (!create && call.value != "OK") liteLog(" " + call.value); outcome.ok = true; outcome.serverUrl = url; break; } const std::string why = call.error.empty() ? "unreachable" : call.error; liteLog(" " + url + ": " + why); if (!call.error.empty()) { if (preferredError.empty()) preferredError = call.error; // keep the preferred one if (liteOpenErrorIsWarmup(call.error)) outcome.warming = true; // healthy, just starting } } // On total failure, report the preferred server's error (not whichever broken fallback // happened to be tried last), since that's the server the user expects to use. if (!outcome.ok) outcome.error = preferredError.empty() ? "could not reach any lite server" : preferredError; { std::lock_guard lk(*resultMutex); *resultSlot = outcome; } running->store(false); }); return true; } void LiteWalletController::pumpAsyncOpen() { OpenOutcome outcome; { std::lock_guard lk(*openResultMutex_); if (!openResult_->has_value()) return; // nothing finished since last pump outcome = std::move(openResult_->value()); openResult_->reset(); } invalidateWalletExists(); // a real open result was consumed: the wallet file may have changed if (openThread_.joinable()) openThread_.join(); // producer set its result, then exits if (outcome.ok) { walletOpen_ = true; lastOpenError_.clear(); 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 } else { lastOpenError_ = outcome.error; lastOpenWarming_ = outcome.warming; // a healthy server was warming up -> retry sooner status_ = WalletBackendStatus{WalletBackendState::Error, outcome.error, {}, {}, 0.0}; liteLog("Open failed: " + outcome.error + (outcome.warming ? " (a server is warming up — will retry shortly)" : "")); } } bool LiteWalletController::beginCreateWalletAsync(LiteWalletCreateRequest request) { auto req = std::make_shared(std::move(request)); // Encrypt the brand-new wallet with the user's passphrase once it opens. The async worker // can't (walletOpen_ isn't set until pumpLifecycleResult runs), so stash it and apply on the // main-thread finalize — otherwise the passphrase is discarded and the seed stored in // plaintext (audit C1). Mirrors the synchronous createWallet() wrapper. stashPendingLifecycleSecurity(req->passphrase, PendingSecurityOp::Encrypt); const bool started = beginLifecycleRequestAsync( "Create", [req](LiteWalletLifecycleService& svc, const std::string& url) { req->serverUrl = url; return svc.createWallet(*req); }, [req]() { secureWipeLiteSecret(req->passphrase); }); if (!started) clearPendingLifecycleSecurity(); return started; } bool LiteWalletController::beginOpenWalletAsync(LiteWalletOpenRequest request) { auto req = std::make_shared(std::move(request)); // An existing wallet may be encrypted+locked — unlock it with the supplied passphrase on the // main-thread finalize (the async worker can't; walletOpen_ isn't set yet). Without this an // encrypted wallet opens but stays locked and the passphrase is never verified (audit M1). stashPendingLifecycleSecurity(req->passphrase, PendingSecurityOp::Unlock); const bool started = beginLifecycleRequestAsync( "Open", [req](LiteWalletLifecycleService& svc, const std::string& url) { req->serverUrl = url; return svc.openWallet(*req); }, [req]() { secureWipeLiteSecret(req->passphrase); }); if (!started) clearPendingLifecycleSecurity(); return started; } bool LiteWalletController::beginRestoreWalletAsync(LiteWalletRestoreRequest request) { auto req = std::make_shared(std::move(request)); // Encrypt the restored wallet with the user's passphrase once it opens (same rationale and // main-thread finalize as create — audit C1). stashPendingLifecycleSecurity(req->passphrase, PendingSecurityOp::Encrypt); const bool started = beginLifecycleRequestAsync( "Restore", [req](LiteWalletLifecycleService& svc, const std::string& url) { req->serverUrl = url; return svc.restoreWallet(*req); }, [req]() { secureWipeLiteSecret(req->seedPhrase); secureWipeLiteSecret(req->passphrase); }); if (!started) clearPendingLifecycleSecurity(); return started; } bool LiteWalletController::beginLifecycleRequestAsync( const char* verb, std::function exec, std::function wipeSecrets) { // Reject (and still wipe the caller's secrets) if a wallet is already open or any async // open/lifecycle attempt is in flight. if (walletOpen_.load() || lifecycleRunning_->load() || openRunning_->load()) { if (wipeSecrets) wipeSecrets(); return false; } auto servers = failoverServerUrls(); if (servers.empty()) { if (wipeSecrets) wipeSecrets(); status_ = WalletBackendStatus{WalletBackendState::Error, "no usable lite servers are configured", {}, {}, 0.0}; lastOpenError_ = status_.message; liteLog(std::string(verb) + " blocked: " + lastOpenError_); return false; } if (lifecycleThread_.joinable()) lifecycleThread_.join(); // a prior attempt has fully finished { std::lock_guard lk(*lifecycleResultMutex_); lifecycleResult_->reset(); } lifecycleRunning_->store(true); status_ = WalletBackendStatus{WalletBackendState::Connecting, std::string(verb) + " wallet…", {}, {}, 0.0}; liteLog(std::string(verb) + " wallet — trying " + std::to_string(servers.size()) + " server(s)"); // Capture only value copies + the shared bridge (never `this`): the thread builds its own // local lifecycle service, so it can safely outlive the controller (mirrors the open thread). auto bridge = bridge_; auto caps = capabilities_; auto conn = connectionSettings_; auto opts = lifecycleOptions_; auto running = lifecycleRunning_; auto resultMutex = lifecycleResultMutex_; auto resultSlot = lifecycleResult_; lifecycleThread_ = std::thread( [bridge, caps, conn, opts, servers, exec, wipeSecrets, running, resultMutex, resultSlot]() { LiteWalletLifecycleService localLifecycle(caps, conn, bridge.get(), opts); LiteWalletLifecycleResult chosen; bool have = false; for (const auto& url : servers) { if (!bridge) break; liteLog(" connecting to " + url + " ..."); LiteWalletLifecycleResult r = exec(localLifecycle, url); if (r.walletReady) { liteLog(" " + url + ": ready"); chosen = std::move(r); have = true; break; } // A non-attempted result is a structural block (availability / validation): the same // for every server, so stop and surface it rather than retrying pointlessly. if (!r.attempted) { chosen = std::move(r); have = true; break; } liteLog(" " + url + ": " + (r.error.empty() ? r.status.message : r.error)); // Keep the PREFERRED (first) server's failure — the actionable one for the user — // rather than whichever fallback happened to be tried last. if (!have) { chosen = std::move(r); have = true; } } if (!have) { chosen.error = "could not reach any lite server"; chosen.status = WalletBackendStatus{WalletBackendState::Error, chosen.error, {}, {}, 0.0}; } if (wipeSecrets) wipeSecrets(); // wipe the request's secrets once the attempt is done { std::lock_guard lk(*resultMutex); *resultSlot = std::move(chosen); } running->store(false); }); return true; } void LiteWalletController::pumpLifecycleResult() { LiteWalletLifecycleResult out; { std::lock_guard lk(*lifecycleResultMutex_); if (!lifecycleResult_->has_value()) return; // nothing finished since last pump out = std::move(lifecycleResult_->value()); lifecycleResult_->reset(); } invalidateWalletExists(); // a real lifecycle result was consumed: the wallet file may have changed if (lifecycleThread_.joinable()) lifecycleThread_.join(); // producer set its result, then exits // 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, /*deferBackground=*/true); if (out.walletReady) { // 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 { clearPendingLifecycleSecurity(); // wallet didn't open — drop the stashed passphrase lastOpenError_ = out.error.empty() ? out.status.message : out.error; lastOpenWarming_ = liteOpenErrorIsWarmup(lastOpenError_); } lastLifecycleResult_ = std::move(out); } void LiteWalletController::stashPendingLifecycleSecurity(const std::string& passphrase, PendingSecurityOp op) { clearPendingLifecycleSecurity(); // wipe any stale prior stash first pendingLifecyclePassphrase_ = passphrase; // independent copy; wiped in apply/clear pendingLifecycleSecurityOp_ = op; } void LiteWalletController::applyPendingLifecycleSecurity() { const PendingSecurityOp op = pendingLifecycleSecurityOp_; if (op == PendingSecurityOp::None || pendingLifecyclePassphrase_.empty() || !walletOpen_.load() || !bridge_) { clearPendingLifecycleSecurity(); return; } if (op == PendingSecurityOp::Encrypt) { // encryptWallet() takes its own copy and wipes it; the backend encrypts + locks + saves. const auto enc = encryptWallet(pendingLifecyclePassphrase_); if (!enc.ok) { lifecycleEncryptFailed_ = true; // surfaced to the user by the App (takeEncryptionFailureNotice) liteLog("wallet created/restored but ENCRYPTION FAILED — wallet is NOT encrypted: " + enc.error); } } else if (op == PendingSecurityOp::Unlock) { if (!unlockWallet(pendingLifecyclePassphrase_)) liteLog("wallet opened but unlock failed (wrong passphrase?)"); } clearPendingLifecycleSecurity(); } void LiteWalletController::clearPendingLifecycleSecurity() { secureWipeLiteSecret(pendingLifecyclePassphrase_); // no-op if already empty pendingLifecycleSecurityOp_ = PendingSecurityOp::None; } void LiteWalletController::startSync() { if (syncLaunched_.exchange(true)) return; syncStarted_ = true; liteLog("Background sync started"); // The backend `sync` command is a blocking, uninterruptible full chain scan, so run it on // a detached thread. Capture shared refs (not the controller) so it is safe to outlive us. auto bridge = bridge_; auto done = syncDone_; auto scan = scan_; syncThread_ = std::thread([bridge, done, scan] { runLiteScanThread(bridge, done, scan, "sync"); }); } 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; // Reset the done flag so refreshModel() re-enters its "scanning, publish progress only" path // and the UI shows progress again; clearing it BEFORE launching the thread avoids a window // where the worker would query balances mid-rescan. syncDone_->store(false); syncStarted_ = true; liteLog("Block re-download (rescan) started"); // The previous sync/rescan thread has finished (syncDone_ was true above); detach it before // reassigning syncThread_. Like startSync's thread it captures shared refs (bridge_ + syncDone_), // never `this`, so detaching is safe. if (syncThread_.joinable()) syncThread_.detach(); auto bridge = bridge_; auto done = syncDone_; auto scan = scan_; scan->failed.store(false); // a fresh attempt: the previous failure no longer applies scan->setError(std::string()); // `rescan` clears the wallet's synced block cache and re-downloads/re-scans from the birthday height — a // blocking, uninterruptible full scan, same as `sync`. Retries (if it fails) are `sync`. syncThread_ = std::thread([bridge, done, scan] { runLiteScanThread(bridge, done, scan, "rescan"); }); return true; } std::optional LiteWalletController::refreshModel() { return refreshModelImpl(/*honorStop=*/false, nullptr); // tests / direct callers: never cancelled } std::optional LiteWalletController::refreshModelImpl(bool honorStop, bool* syncReadFailed) { if (!walletOpen_.load()) return std::nullopt; // syncstatus is fast (reads shared state the sync thread updates). Poll it every time. const auto syncResult = sync_.pollSyncStatus(LiteSyncStatusRequest{}); // Encryption status reads local wallet state (available even mid-sync); fold it into every // model so WalletState.isLocked()/isEncrypted() track the backend. const auto encStatus = encryptionStatus(); auto applyEncryption = [&encStatus](LiteWalletAppRefreshModel& m) { if (encStatus.ok) { m.hasEncryptionStatus = true; m.encrypted = encStatus.encrypted; m.locked = encStatus.locked; } }; if (!syncDone_->load()) { // Sync still running: publish progress only. Data queries (balance/list) would block // until the chain is synced, so don't issue them yet. if (!syncResult.ok) return std::nullopt; LiteWalletAppRefreshModel model; applyEncryption(model); model.syncError = scan_->errorCopy(); model.hasSyncStatus = true; model.sync.walletHeight = syncResult.syncStatus.syncedBlocks; model.sync.chainHeight = syncResult.syncStatus.totalBlocks; // syncDone_ is authoritative: the detached sync thread is still running, so we are NOT // complete regardless of what syncstatus reports. The backend briefly returns the idle // shape ({"syncing":"false"} -> parser progress=1.0, complete=true) before the scan // starts publishing in-progress status; don't surface that as a misleading 100%/done. model.sync.complete = false; model.sync.progress = syncResult.syncStatus.complete ? 0.0 : syncResult.syncStatus.progress; return model; } // Synced: full refresh (balance/addresses/transactions are fast now). LiteWalletRefreshRequest request; // Skip `info` (a fresh TLS connection + GetLightdInfo every cycle, and only a fallback for the chat // tip, which the height below already provides) and the gateway's `height` (the explicit call after // the refresh replaces it, and runs after the syncing commands so it sees the newest block). request.includeInfo = false; request.includeHeight = false; if (syncResult.ok) { 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; } const auto mapped = mapLiteWalletRefreshResult(refreshResult); if (!mapped.ok) return std::nullopt; auto model = mapped.model; applyEncryption(model); // `syncstatus` only reports synced_blocks/total_blocks WHILE actively scanning; once idle it // 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. // Do not force "synced" from the height read while syncstatus says the backend is still scanning (work remaining) // or could not be interpreted: the height only proves the last scanned block, not that we reached the tip. const bool backendBusy = syncResult.ok && !syncResult.syncStatus.complete && (syncResult.syncStatus.indeterminate || syncResult.syncStatus.syncedBlocks < syncResult.syncStatus.totalBlocks); if (bridge_ && !backendBusy && (!honorStop || running_.load())) { const auto h = bridge_->execute("height", ""); if (h.ok) { const auto parsed = parseLiteHeightResponse(h.value); if (parsed.ok && parsed.height.height > 0) { model.hasSyncStatus = true; model.sync.walletHeight = parsed.height.height; model.sync.chainHeight = parsed.height.height; // synced: wallet height == chain tip model.sync.complete = true; model.sync.progress = 1.0; } } } // A scan that gave up must not read as "synced". Recovery is observed, not guessed: the data commands each run a // backend sync, so a refresh whose server-bound reads all succeeded means the wallet really caught up. if (scan_->failed.load()) { if (!refreshResult.syncReadFailed && refreshResult.bundle.successfulCommandCount > 0) { scan_->failed.store(false); scan_->setError(std::string()); } else if (model.hasSyncStatus) { model.sync.complete = false; model.sync.progress = std::min(model.sync.progress, 0.999); } } // A server-bound read failed this cycle (server lost after catch-up): not synced, and say why. Without this the // height override above kept reporting Synced because only a failed *scan* (scan_->failed) demoted it. if (refreshResult.syncReadFailed && model.hasSyncStatus) { model.sync.complete = false; model.sync.progress = std::min(model.sync.progress, 0.999); } model.syncError = scan_->errorCopy(); if (model.syncError.empty() && refreshResult.syncReadFailed) model.syncError = "server unreachable"; return model; } LiteNewAddressResult LiteWalletController::newAddress(bool shielded) { if (!walletOpen_.load() || !bridge_) { LiteNewAddressResult out; out.error = "no wallet is open"; return out; } return newAddressCore(*bridge_, shielded); } LiteBroadcastResult LiteWalletController::sendTransactionBlocking(const LiteSendRequest& request) { LiteBroadcastResult out; if (!walletOpen_.load() || !bridge_) { out.error = "no wallet is open"; return out; } return doSend(*bridge_, request); } LiteBroadcastResult LiteWalletController::shieldFundsBlocking(const std::string& optionalAddress) { LiteBroadcastResult out; if (!walletOpen_.load() || !bridge_) { out.error = "no wallet is open"; return out; } return doShield(*bridge_, optionalAddress); } bool LiteWalletController::startBroadcast(std::function op) { bool expected = false; if (!broadcastRunning_->compare_exchange_strong(expected, true)) return false; // one at a time { std::lock_guard lock(*broadcastResultMutex_); broadcastResult_->reset(); // drop any already-consumed prior result } // A previous broadcast thread (already finished) may still be joinable; detach before reuse. if (broadcastThread_.joinable()) broadcastThread_.detach(); auto running = broadcastRunning_; auto mutex = broadcastResultMutex_; auto slot = broadcastResult_; broadcastThread_ = std::thread([op = std::move(op), running, mutex, slot] { LiteBroadcastResult result = op(); { std::lock_guard lock(*mutex); *slot = std::move(result); } running->store(false); }); return true; } bool LiteWalletController::sendTransaction(const LiteSendRequest& request) { if (!walletOpen_.load() || !bridge_) return false; auto bridge = bridge_; // shared copy; the op must not capture `this` return startBroadcast([bridge, request]() { return doSend(*bridge, request); }); } bool LiteWalletController::shieldFunds(const std::string& optionalAddress) { if (!walletOpen_.load() || !bridge_) return false; auto bridge = bridge_; return startBroadcast([bridge, optionalAddress]() { return doShield(*bridge, optionalAddress); }); } bool LiteWalletController::takeBroadcastResult(LiteBroadcastResult& out) { std::lock_guard lock(*broadcastResultMutex_); if (!broadcastResult_->has_value()) return false; out = std::move(**broadcastResult_); broadcastResult_->reset(); return true; } bool LiteWalletController::runConsoleCommand(std::string commandLine) { if (!bridge_ || consoleRunning_->load()) return false; // Split into command (first token) + the remainder as a single arg string: litelib_execute // passes args through as ONE element (it does not whitespace-split), matching how send uses // the JSON-array form. const size_t cmdBegin = commandLine.find_first_not_of(" \t"); if (cmdBegin == std::string::npos) return false; // blank line const size_t cmdEnd = commandLine.find_first_of(" \t", cmdBegin); std::string command = commandLine.substr( cmdBegin, cmdEnd == std::string::npos ? std::string::npos : cmdEnd - cmdBegin); std::string args; if (cmdEnd != std::string::npos) { const size_t argBegin = commandLine.find_first_not_of(" \t", cmdEnd); if (argBegin != std::string::npos) args = commandLine.substr(argBegin); } if (consoleThread_.joinable()) consoleThread_.join(); // a prior command has fully finished { std::lock_guard lk(*consoleResultMutex_); consoleResult_->reset(); } consoleRunning_->store(true); // Capture only the shared bridge + flags/slot (never `this`) so the thread can outlive us. auto bridge = bridge_; auto running = consoleRunning_; auto mutex = consoleResultMutex_; auto slot = consoleResult_; consoleThread_ = std::thread( [bridge, command, args, echo = std::move(commandLine), running, mutex, slot]() { LiteConsoleResult r; r.command = echo; if (bridge) { const auto call = bridge->execute(command, args); r.ok = call.ok; r.response = call.ok ? call.value : (call.error.empty() ? "command failed" : call.error); // send/shield/import mutate wallet state and the backend does NOT auto-save (same reason // doSend/doShield call persistAfterBroadcast). Persist so a console-driven tx survives a // restart instead of only being re-derived on the next full sync. (M-02) if (call.ok && (command == "send" || command == "shield" || command == "import" || command == "timport")) { persistAfterBroadcast(*bridge); } } else { r.response = "lite backend unavailable"; } { std::lock_guard lk(*mutex); *slot = std::move(r); } running->store(false); }); return true; } bool LiteWalletController::takeConsoleResult(LiteConsoleResult& out) { { std::lock_guard lk(*consoleResultMutex_); if (!consoleResult_->has_value()) return false; out = std::move(**consoleResult_); consoleResult_->reset(); } if (consoleThread_.joinable()) consoleThread_.join(); // producer set its result, then exits return true; } LiteImportResult LiteWalletController::importKey(std::string spendingOrViewingKey) { LiteImportResult out; if (!walletOpen_.load() || !bridge_) { secureWipeLiteSecret(spendingOrViewingKey); out.error = "no wallet is open"; return out; } if (spendingOrViewingKey.empty()) { out.error = "no key provided"; return out; } return importKeyCore(*bridge_, spendingOrViewingKey); // wipes the key } // Recursively zero every string node of a parsed secret JSON tree (seed / private keys) so the parse // copies don't linger in freed heap. Best-effort (nlohmann's own lexer scratch isn't reachable). static void wipeJsonStrings(nlohmann::json& j) { if (j.is_string()) { secureWipeLiteSecret(j.get_ref()); } else if (j.is_object() || j.is_array()) { for (auto& el : j) wipeJsonStrings(el); } } LiteExportResult LiteWalletController::exportPrivateKeys(const std::string& optionalAddress) { LiteExportResult out; if (!walletOpen_.load() || !bridge_) { out.error = "no wallet is open"; return out; } // Empty address -> export keys for all addresses; otherwise just the given address. auto result = bridge_->execute("export", optionalAddress); // non-const: result.value is a secret if (!result.ok) { out.error = result.error.empty() ? "export failed" : result.error; return out; } nlohmann::json j; // result.value + the parse tree hold the private keys; wipe them on every return path. out already // has its own copy (out.privateKeysJson) which the caller wipes after use. struct Scrub { std::string& v; nlohmann::json& tree; ~Scrub() { secureWipeLiteSecret(v); wipeJsonStrings(tree); } } scrub{result.value, j}; try { j = nlohmann::json::parse(result.value); if (j.is_object() && j.contains("error")) { out.error = extractJsonError(j); return out; } } catch (...) { out.error = "could not parse export response"; return out; } out.privateKeysJson = result.value; // SECRET — caller must not log; wipe after use out.ok = true; return out; } LiteSeedResult LiteWalletController::exportSeed() { LiteSeedResult out; if (!walletOpen_.load() || !bridge_) { out.error = "no wallet is open"; return out; } auto result = bridge_->execute("seed", ""); // non-const: result.value holds the seed if (!result.ok) { out.error = result.error.empty() ? "seed export failed" : result.error; return out; } nlohmann::json j; // result.value + the parse tree contain the seed phrase; wipe them on every return path (out.seedPhrase // is extracted separately and returned to the caller). struct Scrub { std::string& v; nlohmann::json& tree; ~Scrub() { secureWipeLiteSecret(v); wipeJsonStrings(tree); } } scrub{result.value, j}; try { j = nlohmann::json::parse(result.value); if (j.is_object()) { if (j.contains("error")) { out.error = extractJsonError(j); return out; } if (j.contains("seed") && j.at("seed").is_string()) { out.seedPhrase = j.at("seed").get(); // SECRET if (j.contains("birthday") && j.at("birthday").is_number_unsigned()) { out.birthday = j.at("birthday").get(); } if (isZeroEntropySeedPhrase(out.seedPhrase)) { // A backend built before the seed-export fix returns this for a locked wallet whose seed is // wiped. It is never the real backup, so it must not be shown or used to derive anything. secureWipeLiteSecret(out.seedPhrase); out.error = "backend returned the all-zero placeholder seed (is the wallet locked?)"; return out; } out.ok = !out.seedPhrase.empty(); if (!out.ok) out.error = "backend returned an empty seed"; return out; } } } catch (...) { // fall through to the generic parse error } out.error = "could not parse seed response"; return out; } bool LiteWalletController::saveWallet() { if (!walletOpen_.load() || !bridge_) return false; return bridge_->execute("save", "").ok; } template bool LiteWalletController::submitWalletOp(std::function core, std::function onDone, std::function makeFailure) { if (retired_.load() || !walletOpen_.load() || !bridge_) { if (onDone) onDone(makeFailure(retired_.load() ? "lite wallet reconnecting" : "no wallet is open")); return false; } const std::uint64_t id = nextOpId_++; pendingOps_[id] = [onDone, makeFailure](const std::string& why) { if (onDone) onDone(makeFailure(why)); }; // The job runs on the lane thread and never dereferences `this`; only its completion does, and only // pumpWalletOps() runs completions (main thread, controller alive). A throwing core still yields a completion, // so the UI callback can never be left pending. const bool queued = opLane_.post( [this, id, core = std::move(core), onDone, makeFailure, retired = opsRetired_]() -> util::SerialJobLane::Completion { R result; if (retired->load()) { result = makeFailure(kLiteOpRetiredError); // dequeued just before retire(): never start it } else { try { result = core(); } catch (...) { result = makeFailure("internal error"); } } return [this, id, onDone, result = std::move(result)]() { if (pendingOps_.erase(id) == 0) return; // already failed by failPendingWalletOps(): exactly once if (onDone) onDone(result); }; }); if (!queued) { // lane abandoned or no thread could be started: fail now, exactly once auto it = pendingOps_.find(id); if (it != pendingOps_.end()) { auto fail = std::move(it->second); pendingOps_.erase(it); if (fail) fail("could not start the wallet operation"); } return false; } return true; } bool LiteWalletController::newAddressAsync(bool shielded, LiteNewAddressCallback onDone) { auto bridge = bridge_; return submitWalletOp( [bridge, shielded]() { return newAddressCore(*bridge, shielded); }, std::move(onDone), [](const std::string& why) { LiteNewAddressResult r; r.error = why; return r; }); } bool LiteWalletController::importKeyAsync(std::string spendingOrViewingKey, LiteImportCallback onDone) { auto fail = [](const std::string& why) { LiteImportResult r; r.error = why; return r; }; if (spendingOrViewingKey.empty()) { if (onDone) onDone(fail("no key provided")); return false; } auto key = makeWipingSecretCopy(spendingOrViewingKey); secureWipeLiteSecret(spendingOrViewingKey); auto bridge = bridge_; auto retired = opsRetired_; return submitWalletOp( [bridge, key, retired]() { return importKeyCore(*bridge, *key, retired.get()); }, std::move(onDone), fail); } bool LiteWalletController::encryptWalletAsync(std::string passphrase, LiteEncryptionCallback onDone) { return submitEncryptionChange(passphrase, std::move(onDone), /*encrypt=*/true); } bool LiteWalletController::decryptWalletAsync(std::string passphrase, LiteEncryptionCallback onDone) { return submitEncryptionChange(passphrase, std::move(onDone), /*encrypt=*/false); } bool LiteWalletController::submitEncryptionChange(std::string& passphrase, LiteEncryptionCallback onDone, bool encrypt) { auto fail = [](const std::string& why) { LiteEncryptionResult r; r.error = why; return r; }; if (passphrase.empty()) { if (onDone) onDone(fail("passphrase required")); return false; } auto pass = makeWipingSecretCopy(passphrase); secureWipeLiteSecret(passphrase); auto bridge = bridge_; auto retired = opsRetired_; return submitWalletOp( [bridge, pass, encrypt, retired]() { return encryptionChangeCore(*bridge, *pass, encrypt, retired.get()); }, std::move(onDone), fail); } void LiteWalletController::failPendingWalletOps(const std::string& reason) { auto pending = std::move(pendingOps_); // move out first: a callback may submit a new op pendingOps_.clear(); for (auto& kv : pending) if (kv.second) kv.second(reason); } void LiteWalletController::retire(const std::string& reason) { retired_.store(true); // later submits are rejected inline; the destructor skips its final save opsRetired_->store(true); // a running op issues no further backend command (it would hit the NEXT client) opLane_.pump(); // ops that already finished are delivered with their real result, not "reconnecting" opLane_.abandon(); // queued ops never run (they would hit the NEXT global client); secrets wiped failPendingWalletOps(reason); // every UI callback fires exactly once (normally none: App defers the switch) scan_->cancel.store(true); // the detached sync thread stops retrying (it may be in a back-off sleep) 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_ // exists panics on unknown chains, but settings migration guarantees a valid one. // Cached ~2s: called every frame while no wallet is open (main-thread only). const auto now = std::chrono::steady_clock::now(); if (wexCached_ && now - wexAt_ < std::chrono::seconds(2)) return wexValue_; wexValue_ = bridge_ && bridge_->walletExists(chainName_); wexCached_ = true; wexAt_ = now; return wexValue_; } bool LiteWalletController::walletExistsFresh() const { wexValue_ = bridge_ && bridge_->walletExists(chainName_); wexCached_ = true; wexAt_ = std::chrono::steady_clock::now(); return wexValue_; } namespace { // encrypt/unlock/lock/decrypt return {"result":"success"} or {"error":..} (or an "Error:"-prefixed // string the bridge maps to ok=false). Success = bridge ok and no error field. LiteEncryptionResult parseEncryptionOpResponse(const LiteBridgeStringResult& bridgeCall) { LiteEncryptionResult out; if (!bridgeCall.ok) { out.error = bridgeCall.error.empty() ? "operation failed" : bridgeCall.error; return out; } try { const auto j = nlohmann::json::parse(bridgeCall.value); if (j.is_object() && j.contains("error")) { out.error = extractJsonError(j); return out; } } catch (...) { // a non-JSON success payload is acceptable } out.ok = true; return out; } } // namespace LiteEncryptionStatus LiteWalletController::encryptionStatus() { LiteEncryptionStatus out; if (!walletOpen_.load() || !bridge_) { out.error = "no wallet is open"; return out; } const auto result = bridge_->execute("encryptionstatus", ""); if (!result.ok) { out.error = result.error.empty() ? "encryptionstatus failed" : result.error; return out; } try { const auto j = nlohmann::json::parse(result.value); if (j.is_object()) { if (j.contains("error")) { out.error = extractJsonError(j); return out; } out.encrypted = j.value("encrypted", false); out.locked = j.value("locked", false); out.ok = true; return out; } } catch (...) { // fall through } out.error = "could not parse encryptionstatus response"; return out; } LiteEncryptionResult LiteWalletController::encryptWallet(std::string passphrase) { LiteEncryptionResult out; if (!walletOpen_.load() || !bridge_) { secureWipeLiteSecret(passphrase); out.error = "no wallet is open"; return out; } if (passphrase.empty()) { out.error = "passphrase required"; return out; } return encryptionChangeCore(*bridge_, passphrase, /*encrypt=*/true); // encrypt + save as one unit; wipes } LiteEncryptionResult LiteWalletController::decryptWallet(std::string passphrase) { LiteEncryptionResult out; if (!walletOpen_.load() || !bridge_) { secureWipeLiteSecret(passphrase); out.error = "no wallet is open"; return out; } if (passphrase.empty()) { out.error = "passphrase required"; return out; } return encryptionChangeCore(*bridge_, passphrase, /*encrypt=*/false); // decrypt + save as one unit; wipes } bool LiteWalletController::unlockWallet(std::string passphrase) { if (!walletOpen_.load() || !bridge_ || passphrase.empty()) { secureWipeLiteSecret(passphrase); return false; } const bool ok = parseEncryptionOpResponse(bridge_->execute("unlock", passphrase)).ok; secureWipeLiteSecret(passphrase); return ok; } bool LiteWalletController::takeEncryptionFailureNotice() { const bool failed = lifecycleEncryptFailed_; lifecycleEncryptFailed_ = false; return failed; } bool LiteWalletController::lockWallet() { if (!walletOpen_.load() || !bridge_) return false; return parseEncryptionOpResponse(bridge_->execute("lock", "")).ok; } bool LiteWalletController::refreshWalletState(dragonx::WalletState& state) { auto model = refreshModel(); if (!model) return false; applyLiteRefreshModelToWalletState(*model, state); return true; } bool LiteWalletController::takeRefreshedModel(LiteWalletAppRefreshModel& out) { std::lock_guard lock(modelMutex_); if (!pendingModel_) return false; out = std::move(*pendingModel_); pendingModel_.reset(); return true; } void LiteWalletController::startWorker() { if (running_.exchange(true)) return; // already running worker_ = std::thread([this] { workerLoop(); }); } void LiteWalletController::requestStop() { { std::lock_guard lk(wakeMutex_); // flip under the mutex the worker waits on: no lost wakeup running_.store(false); } wakeCv_.notify_all(); } void LiteWalletController::stopWorker() { 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 RefreshGate gate; // worker-local; haveFull=false => the FIRST cycle is always a full refresh std::optional lastModel; // last published full model (to demote on a failed probe) while (running_.load()) { if (walletOpen_.load()) { const GateDecision decision = evaluateRefreshGate(gate, failStreak); if (decision == GateDecision::Skip) { // Nothing changed since the last full model: no gateway refresh, no re-publish. // pendingModel_ is left untouched (the UI keeps the last applied model). } else if (decision == GateDecision::ProbeFailed) { // The cheap `sync` probe failed (server unreachable / behind / terminal reorg). Do NOT // run a second do_sync this cycle; treat it as a failed cycle so the existing back-off // applies and we don't double the dead-server stall. Next cycle takes the full path. failStreak += 1; // The probe path never re-derived sync state, so the last 'Synced' model stayed on screen while the // server was gone. Re-publish it demoted, with the reason. if (lastModel && running_.load()) { auto demoted = *lastModel; if (demoted.hasSyncStatus) { demoted.sync.complete = false; demoted.sync.progress = std::min(demoted.sync.progress, 0.999); } if (demoted.syncError.empty()) demoted.syncError = "server unreachable"; lastModel = demoted; std::lock_guard lock(modelMutex_); pendingModel_ = std::move(demoted); } } else { // DoFull // Sample the change baselines BEFORE the data commands (race rule). const std::uint64_t epoch0 = bridge_ ? bridge_->mutationEpoch() : 0; const auto height0 = readScannedHeight(); bool syncReadFailed = false; auto model = refreshModelImpl(/*honorStop=*/true, &syncReadFailed); if (model && running_.load()) { const auto& m = *model; const bool full = m.hasBalance && m.hasSyncStatus && m.sync.complete && m.syncError.empty() && !syncReadFailed && height0.has_value(); gate.haveFull = full; gate.height = height0.value_or(0); gate.epoch = epoch0; gate.encOk = m.hasEncryptionStatus; gate.encrypted = m.encrypted; gate.locked = m.locked; gate.fullAt = std::chrono::steady_clock::now(); lastModel = m; std::lock_guard lock(modelMutex_); pendingModel_ = std::move(model); } else { gate.haveFull = false; // syncing / failed / cancelled: next cycle is a full path } failStreak = syncReadFailed ? failStreak + 1 : 0; } } // Healthy: every 2 s. After a failed read (or a failed probe): 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 back-off). std::unique_lock lock(wakeMutex_); wakeCv_.wait_for(lock, std::chrono::milliseconds(liteRefreshDelayMs(failStreak, refreshIntervalMs_.load())), [this] { return !running_.load(); }); } } // Worker-thread only. Decide whether this cycle can skip the full refresh. A cycle is a Skip ONLY when // the wallet is synced, healthy, and every cheap signal proves nothing changed since the last published // full model. ANY failed/unparsable read, or any doubt, returns DoFull (fail toward freshness). Runs // exactly one server-bound `sync` probe (the mandatory confirmed-block ingest); a probe failure returns // ProbeFailed so the caller backs off without a second sync. LiteWalletController::GateDecision LiteWalletController::evaluateRefreshGate(RefreshGate& g, int failStreak) { if (!g.haveFull || failStreak != 0 || !bridge_ || !running_.load()) return GateDecision::DoFull; if (!syncDone_->load() || scan_->failed.load()) { g.haveFull = false; return GateDecision::DoFull; } if (std::chrono::steady_clock::now() - g.fullAt >= std::chrono::milliseconds(gateHeartbeatMs_.load())) return GateDecision::DoFull; // heartbeat: bounded-staleness backstop if (bridge_->mutationEpoch() != g.epoch) // local mutation since the baseline return GateDecision::DoFull; // The mandatory one-connection confirmed-block ingest. Without it the wallet never advances. const auto probe = bridge_->execute("sync", ""); if (!running_.load()) return GateDecision::DoFull; if (!probe.ok || !parseLiteSyncOpResponse(probe.value).ok) { g.haveFull = false; return GateDecision::ProbeFailed; } const auto h = readScannedHeight(); if (!h.has_value() || *h == 0 || *h != g.height) return GateDecision::DoFull; const auto enc = encryptionStatus(); if (!enc.ok || !g.encOk || enc.encrypted != g.encrypted || enc.locked != g.locked) return GateDecision::DoFull; if (bridge_->mutationEpoch() != g.epoch) return GateDecision::DoFull; if (!syncDone_->load() || !running_.load()) { g.haveFull = false; return GateDecision::DoFull; } return GateDecision::Skip; // provably unchanged } std::optional LiteWalletController::readScannedHeight() { if (!bridge_) return std::nullopt; const auto h = bridge_->execute("height", ""); // local read in the backend (no TLS connection) if (!h.ok) return std::nullopt; const auto parsed = parseLiteHeightResponse(h.value); if (!parsed.ok || parsed.height.height == 0) return std::nullopt; return static_cast(parsed.height.height); } LiteWalletLifecycleResult LiteWalletController::createWallet(LiteWalletCreateRequest request) { auto result = lifecycle_.createWallet(request); onLifecycleResult(result); // If the user supplied a passphrase, encrypt the brand-new wallet with it now that it's open // (the backend encrypts + locks + saves). Previously this passphrase was collected but never // used (W5-3) — a passphrase field that silently did nothing. encryptWallet() takes its own // copy and wipes it. if (walletOpen_.load() && !request.passphrase.empty()) { const auto enc = encryptWallet(request.passphrase); if (!enc.ok) { lifecycleEncryptFailed_ = true; // the wallet is open but NOT encrypted; App surfaces this liteLog("wallet created but encryption failed: " + enc.error); } } secureWipeLiteSecret(request.passphrase); return result; } LiteWalletLifecycleResult LiteWalletController::openWallet(LiteWalletOpenRequest request) { auto result = lifecycle_.openWallet(request); onLifecycleResult(result); // An existing wallet may be encrypted + locked — use the supplied passphrase to unlock it so it // opens ready to use. Only meaningful when the wallet is actually locked (W5-3). if (walletOpen_.load() && !request.passphrase.empty()) { const auto encStatus = encryptionStatus(); if (encStatus.ok && encStatus.encrypted && encStatus.locked) { if (!unlockWallet(request.passphrase)) liteLog("wallet opened but unlock failed (wrong passphrase?)"); } } secureWipeLiteSecret(request.passphrase); return result; } LiteWalletLifecycleResult LiteWalletController::restoreWallet(LiteWalletRestoreRequest request) { auto result = lifecycle_.restoreWallet(request); onLifecycleResult(result); // If the user supplied a passphrase, encrypt the restored wallet with it now that it's open (W5-3). if (walletOpen_.load() && !request.passphrase.empty()) { const auto enc = encryptWallet(request.passphrase); if (!enc.ok) { lifecycleEncryptFailed_ = true; // the wallet is open but NOT encrypted; App surfaces this liteLog("wallet restored but encryption failed: " + enc.error); } } secureWipeLiteSecret(request.seedPhrase); secureWipeLiteSecret(request.passphrase); return result; } } // namespace wallet } // namespace dragonx