Files
ObsidianDragon/src/wallet/lite_wallet_controller.h
T
DanSandClaude Opus 4.8 d9dae45c39 fix(lite): flush the wallet save on quit before _Exit
On quit the lite controller's destructor never runs, because main()
ends the process with _Exit(). Any state the background refresh had
scanned since the last save was lost: the destructor is where that
final save would normally happen, and it was being skipped on the
one path that always runs.

- shutdown() now calls a new flushOnExit() that persists once on the
  way out, bounded to 4 s so the existing 15 s shutdown watchdog
  budget still holds. On timeout it abandons the save thread and lets
  _Exit follow.
- It is a plain save through the same backend path, which writes a
  temp file and renames it into place. That cannot corrupt the wallet
  if it is cut off or abandoned, and it does not change the wallet
  file format - it only persists sooner on the quit path.
- It reuses the destructor's guards and also skips when a wallet op
  is already in progress: shutdown() has already waited for ops to go
  idle, and a still-pending op holds the sync lock and issues its own
  save, so flushOnExit would only block the budget on a save that
  cannot take the lock.

Tests cover a save on the open, synced, idle path (exactly once), a
blocked save returning within the budget, and the retired and
never-opened guards skipping the save.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-10-14 07:51:34 -05:00

521 lines
31 KiB
C++

// DragonX Wallet - ImGui Edition
// Copyright 2024-2026 The Hush Developers
// Released under the GPLv3
//
// App-owned controller that drives the lite wallet. It constructs and owns the
// lite services with bridge calls ENABLED (the services otherwise default to
// allowBridgeCalls=false and are never instantiated), executes real create/open/
// restore operations through the linked SDXL backend, securely wipes secrets after
// use, tracks wallet-open state, and invokes a persistence callback on success.
//
// Construction:
// - Production: LiteWalletController::createLinked(caps, connectionSettings)
// (uses LiteClientBridge::linkedSdxl(); requires DRAGONX_ENABLE_LITE_BACKEND).
// - Tests: construct directly with an injected bridge
// (e.g. LiteClientBridge::fromApi(makeFakeLiteApi())).
#pragma once
#include "../util/serial_job_lane.h"
#include "lite_client_bridge.h"
#include "lite_connection_service.h"
#include "lite_wallet_lifecycle_service.h"
#include "lite_wallet_gateway.h"
#include "lite_rollout_policy.h"
#include "lite_sync_service.h"
#include "lite_wallet_state_mapper.h"
#include "wallet_backend.h"
#include "wallet_capabilities.h"
#include <atomic>
#include <chrono>
#include <condition_variable>
#include <cstdint>
#include <functional>
#include <map>
#include <memory>
#include <mutex>
#include <optional>
#include <string>
#include <thread>
#include <vector>
namespace dragonx {
struct WalletState; // data/wallet_state.h
namespace wallet {
// Securely zero and clear a string holding secret material (seed/passphrase).
void secureWipeLiteSecret(std::string& secret);
// Apply a normalized lite refresh model onto the app's WalletState (the last hop the
// existing Balance/Receive/Transactions tabs read from). Converts zatoshis -> DRGX,
// splits addresses into shielded/transparent, and maps sync progress. Mutates in place
// (WalletState is non-copyable); only sections present in the model are touched.
void applyLiteRefreshModelToWalletState(const LiteWalletAppRefreshModel& model,
dragonx::WalletState& state);
struct LiteWalletControllerOptions {
bool allowBridgeCalls = true;
bool rolloutBlocked = false; // runtime kill-switch / staged-rollout gate is blocking
std::string rolloutMessage; // user-facing reason when rolloutBlocked
};
struct LiteNewAddressResult {
bool ok = false;
std::string address;
std::string error;
};
// Result of an interactive console command (lite Console tab). `response` is the raw backend
// output (JSON or text). It may contain SECRET material (e.g. seed/export) because the user can
// run any command, so it is shown only in the console view and NEVER routed through LiteDiagnostics.
struct LiteConsoleResult {
std::string command; // the command line that was run, echoed back
std::string response; // raw backend response (or an error message)
bool ok = false;
};
// A single send recipient. amountZatoshis is in zatoshis (1e-8 DRGX); memo is ignored by the
// backend for transparent destination addresses.
struct LiteSendRecipient {
std::string address;
std::uint64_t amountZatoshis = 0;
std::string memo;
};
struct LiteSendRequest {
std::vector<LiteSendRecipient> recipients;
};
// send and shield both broadcast a transaction and yield its txid (or an error). NOTE: the
// backend reports failure via {"error":..} in the response body, NOT the bridge's "Error:"
// prefix, so the result is derived by inspecting the parsed JSON.
struct LiteBroadcastResult {
bool ok = false;
std::string txid;
std::string error;
};
// importKey outcome. `detail` is the backend's (non-secret) confirmation payload. `error` is a fixed, non-secret reason,
// never the backend's text (which can repeat the key).
struct LiteImportResult {
bool ok = false;
std::string detail;
std::string error;
};
// SECRET RESULT: holds exported per-address private keys ({address, private_key, viewing_key}).
// Treat privateKeysJson as sensitive: never log it; wipe it after the user has saved the backup.
struct LiteExportResult {
bool ok = false;
std::string privateKeysJson;
std::string error;
};
// SECRET RESULT: holds the wallet seed phrase. Treat seedPhrase as sensitive: never log it;
// wipe it after the user has saved the backup.
struct LiteSeedResult {
bool ok = false;
std::string seedPhrase;
std::uint64_t birthday = 0;
std::string error;
};
// Wallet encryption state (from the backend `encryptionstatus`). `locked` means the spending
// keys are not in memory (an encrypted wallet loads locked; unlock to spend).
struct LiteEncryptionStatus {
bool ok = false;
bool encrypted = false;
bool locked = false;
std::string error;
};
// Result of an encrypt/decrypt operation (the passphrase is wiped by the controller).
struct LiteEncryptionResult {
bool ok = false;
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;
}
// Outcome of the background sync/rescan thread, shared with it by shared_ptr (the thread never touches the controller).
struct LiteScanState {
std::atomic<bool> cancel{false}; // controller destroyed / retired: stop retrying
std::atomic<bool> failed{false}; // gave up (terminal error or too many attempts); cleared by recovery/rescan
std::atomic<int> retryBaseMs{5000}; // first retry delay (tests shrink it)
mutable std::mutex mutex;
std::string error; // last failure text (guarded by mutex); empty = none
void setError(const std::string& e) { std::lock_guard<std::mutex> l(mutex); error = e; }
std::string errorCopy() const { std::lock_guard<std::mutex> l(mutex); return error; }
};
constexpr int kLiteScanMaxAttempts = 6; // first try + 5 retries (the backend already retries 6x62 s inside each)
class LiteWalletController {
public:
LiteWalletController(WalletCapabilities capabilities,
LiteConnectionSettings connectionSettings,
LiteClientBridge bridge,
LiteWalletControllerOptions options = LiteWalletControllerOptions{});
~LiteWalletController(); // stops + joins the background refresh worker
// Best-effort persist on the quit path (the destructor's save is skipped because main() _Exit()s).
// Bounded: abandons the save thread on timeout; the process is about to _Exit. Same guards as ~dtor.
void flushOnExit(std::chrono::milliseconds budget);
LiteWalletController(const LiteWalletController&) = delete;
LiteWalletController& operator=(const LiteWalletController&) = delete;
// Production factory: links the SDXL backend compiled into this build. The optional rollout
// decision (runtime kill-switch / staged rollout) gates lifecycle execution: when not allowed,
// availability() reports RolloutDisabled and create/open/restore are blocked with its message.
static std::unique_ptr<LiteWalletController> createLinked(
WalletCapabilities capabilities,
LiteConnectionSettings connectionSettings,
LiteRolloutDecision rollout = LiteRolloutDecision{});
// Invoked after a wallet becomes ready, so the owner can persist settings.
void setPersistCallback(std::function<void()> callback) { persist_ = std::move(callback); }
bool walletOpen() const { return walletOpen_; }
// True if a wallet file already exists on disk for this chain. Use to auto-open on startup
// (open via openWallet({}) needs no passphrase — initialize_existing just loads the file).
bool walletExists() const;
bool walletExistsFresh() const; // uncached; also refreshes the cache
const WalletBackendStatus& status() const { return status_; }
LiteWalletLifecycleAvailability availability() const { return lifecycle_.availability(); }
// Execute a real lifecycle operation. The request is taken by value; its secret
// fields are securely wiped before returning. On a ready wallet, walletOpen()
// becomes true and the persist callback (if any) fires.
LiteWalletLifecycleResult createWallet(LiteWalletCreateRequest request);
LiteWalletLifecycleResult openWallet(LiteWalletOpenRequest request);
LiteWalletLifecycleResult restoreWallet(LiteWalletRestoreRequest request);
// Asynchronous open of an EXISTING wallet WITH server failover: tries the selected lite
// server first, then the other usable defaults, on a background thread — so an unreachable
// server never freezes the UI and a single dead server no longer strands the wallet.
// Non-blocking; call pumpAsyncOpen() each tick to finalize. Returns false if a wallet is
// already open, an open is already in progress, no wallet exists, or the rollout gate blocks
// lite. On all-servers-fail, status()/lastOpenError() report the reason.
bool beginOpenExisting();
// Asynchronously CREATE a new wallet (new seed) with the same server failover as open:
// initialize_new contacts the server before writing any file, so an unreachable server fails
// cleanly and the next is tried. On success the seed lives in the wallet — read it back with
// exportSeed(). Non-blocking; finalized by pumpAsyncOpen(). Returns false if a wallet is open,
// an open/create is in progress, the rollout gate blocks lite, or no usable server exists.
bool beginCreateWallet();
// Finalize a completed async open/create on the calling (main) thread: flips walletOpen()/status
// and starts sync, or records the failure. Cheap no-op when nothing is pending. Call each tick.
void pumpAsyncOpen();
bool openInProgress() const { return openRunning_ && openRunning_->load(); }
// Asynchronous FULL lifecycle (create / open / restore WITH passphrase + restore params) with
// the same server failover as beginOpenExisting(). Unlike beginCreateWallet() (plain new seed,
// no passphrase), these carry the complete request so the Settings page can create with
// encryption, open an encrypted wallet, or restore from a seed without freezing the UI on a
// flaky server. Non-blocking; finalized by pumpLifecycleResult() on the main thread. The
// request is taken by value and its secret fields are securely wiped once the attempt finishes.
// Returns false if a wallet is open, a lifecycle/open is already in progress, or no usable
// server exists (the request's secrets are still wiped on that path).
bool beginCreateWalletAsync(LiteWalletCreateRequest request);
bool beginOpenWalletAsync(LiteWalletOpenRequest request);
bool beginRestoreWalletAsync(LiteWalletRestoreRequest request);
bool lifecycleRequestInProgress() const { return lifecycleRunning_ && lifecycleRunning_->load(); }
// Finalize a completed async lifecycle request on the main thread (flip walletOpen()/status,
// persist, start sync) and cache the result for the UI. Cheap no-op when nothing is pending.
// Call each tick alongside pumpAsyncOpen().
void pumpLifecycleResult();
// The most recent finalized async-lifecycle result (create/open/restore), for UI display.
const LiteWalletLifecycleResult& lastLifecycleResult() const { return lastLifecycleResult_; }
const std::string& lastOpenError() const { return lastOpenError_; }
// True if the last failed open hit a server that was merely warming up (JSON-RPC -28 /
// "Activating best chain"): the server is healthy and will be ready shortly, so the caller
// should retry sooner rather than waiting out the normal interval.
bool lastOpenWasWarmup() const { return lastOpenWarming_; }
bool syncStarted() const { return syncStarted_; }
bool syncComplete() const { return syncDone_ && syncDone_->load(); }
// True after the background sync/rescan gave up (see LiteScanState); scanError() is the backend's text (also set
// while it is still retrying). Neither is persisted. Safe from any thread.
bool scanFailed() const { return scan_->failed.load(); }
std::string scanError() const { return scan_->errorCopy(); }
// Tests only: first sync-retry delay in ms (default 5000); call before createWallet()/startSync().
void setSyncRetryBaseMsForTest(int ms) { scan_->retryBaseMs.store(ms); }
// Launch the backend sync on a detached background thread (NON-blocking; the backend's
// `sync` command runs a full, uninterruptible chain scan). Auto-invoked when a lifecycle
// op produces a ready wallet; safe to call once.
void startSync();
// Re-download and re-scan every block from the lite server: runs the backend `rescan`
// command (which clears the wallet's synced block cache and re-syncs from its birthday
// height) on a detached thread, reusing the sync progress + refresh machinery. No-op if no
// wallet is open or a scan is already running. True if a rescan was actually started.
bool startRescan();
// 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. 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
// detached thread and deliver the result to a main-thread slot drained by
// takeBroadcastResult(). Each returns false if a broadcast is already in flight or no wallet
// is open. Only one broadcast runs at a time. After a successful broadcast the periodic
// refresh surfaces the new (unconfirmed) transaction and tracks its confirmations.
bool sendTransaction(const LiteSendRequest& request);
bool shieldFunds(const std::string& optionalAddress = {});
bool broadcastInProgress() const { return broadcastRunning_ && broadcastRunning_->load(); }
bool takeBroadcastResult(LiteBroadcastResult& out);
// --- Interactive console (lite Console tab) ---------------------------------------------
// Run an arbitrary backend command — the same verbs as silentdragonxlite-cli (balance, info,
// height, list, notes, addresses, sync, syncstatus, new, send, shield, encrypt, …). ASYNC:
// some commands (sync/rescan/send/shield/import) block, so the bridge call runs on a detached
// thread (captures the shared bridge, never `this`) and the result is delivered to a main-
// thread slot drained by takeConsoleResult(). `commandLine` is "<command> [args]" — the first
// whitespace-delimited token is the command and the remainder is passed as the single arg
// string the backend expects (litelib_execute does not split args; use the JSON form for send).
// Returns false if a console command is already running or no backend is linked. The caller
// (tab) intercepts `clear`/`quit` BEFORE calling this — backend `clear` wipes wallet history.
// Responses may contain secrets and are NEVER logged to LiteDiagnostics.
bool runConsoleCommand(std::string commandLine);
bool consoleCommandInProgress() const { return consoleRunning_ && consoleRunning_->load(); }
bool takeConsoleResult(LiteConsoleResult& out);
// Synchronous cores for send/shield (block on the backend; safe off the UI thread). Used by
// the async entry points above and directly by tests.
LiteBroadcastResult sendTransactionBlocking(const LiteSendRequest& request);
LiteBroadcastResult shieldFundsBlocking(const std::string& optionalAddress = {});
// Fast, local secret operations (synchronous). importKey takes the key BY VALUE and securely
// wipes it before returning (transparent WIF vs shielded key is auto-detected). exportPrivate
// Keys / exportSeed return SECRET material the caller must treat as sensitive and wipe.
LiteImportResult importKey(std::string spendingOrViewingKey);
LiteExportResult exportPrivateKeys(const std::string& optionalAddress = {});
LiteSeedResult exportSeed();
// Persist the wallet to disk (backend `save`). The backend auto-saves on new-address/import,
// but NOT after sync/send/shield — the controller triggers a save at those points so a scan
// (~30 min on first run) and sent transactions survive a restart. Also callable explicitly.
// Returns false when no wallet is open or the backend save fails.
bool saveWallet();
// --- Wallet encryption (passphrase). All take the passphrase BY VALUE and securely wipe it. ---
// encrypt: set a passphrase on an unencrypted wallet. unlock/lock: bring the spending keys
// into / out of memory. decrypt: permanently remove encryption. encryptionStatus: query state
// (also folded into the periodic refresh so WalletState.isLocked()/isEncrypted() track it).
LiteEncryptionResult encryptWallet(std::string passphrase);
bool unlockWallet(std::string passphrase);
bool lockWallet();
LiteEncryptionResult decryptWallet(std::string passphrase);
LiteEncryptionStatus encryptionStatus();
// Poll sync status + fetch balance/addresses/transactions, and apply the result into the
// app's WalletState. Returns true if state was updated. Safe no-op when no wallet is open.
// Synchronous (blocks on the backend); used by tests and as the worker's unit of work.
bool refreshWalletState(dragonx::WalletState& state);
// Synchronous refresh that returns the mapped model (or nullopt). Pure w.r.t. shared
// state; safe to call from the background worker. nullopt when no wallet is open or the
// refresh produced nothing usable.
std::optional<LiteWalletAppRefreshModel> refreshModel();
// Main-thread handoff: if the background worker has produced a fresh model since the last
// call, move it into `out` and return true. Apply it with applyLiteRefreshModelToWalletState.
bool takeRefreshedModel(LiteWalletAppRefreshModel& out);
private:
// 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);
// The bridge is shared (not just owned) so the detached, uninterruptible sync thread can
// safely outlive the controller: it holds a ref, so the underlying bridge is destroyed
// (and litelib_shutdown called) only once BOTH the controller and a running sync release it.
//
// 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 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
// mutex would instead serialize sync against syncstatus polling and defeat the design.
std::shared_ptr<LiteClientBridge> bridge_;
std::string chainName_; // backend chain id (for walletExists); from connection settings
// walletExists() is called every frame while no wallet is open; the backend call touches the
// filesystem (and prints). Cache it (main-thread only - no lock). walletExistsFresh() bypasses
// the cache for the click-time overwrite decision.
mutable bool wexCached_ = false;
mutable bool wexValue_ = false;
mutable std::chrono::steady_clock::time_point wexAt_{};
void invalidateWalletExists() { wexCached_ = false; }
LiteConnectionSettings connectionSettings_; // kept for the failover candidate-server list
WalletCapabilities capabilities_; // for thread-local lifecycle services (async path)
LiteWalletLifecycleOptions lifecycleOptions_; // ditto (allowBridgeCalls / rollout gate)
LiteWalletLifecycleService lifecycle_;
LiteWalletGateway gateway_;
LiteSyncService sync_;
std::function<void()> persist_;
std::atomic<bool> walletOpen_{false};
std::atomic<bool> syncStarted_{false};
WalletBackendStatus status_; // written only on the main thread (lifecycle ops)
// Detached background sync (backend `sync` is a blocking, uninterruptible full scan).
std::thread syncThread_;
std::atomic<bool> syncLaunched_{false}; // startSync() guard (set on the main thread)
std::shared_ptr<std::atomic<bool>> syncDone_ = std::make_shared<std::atomic<bool>>(false);
std::shared_ptr<LiteScanState> scan_ = std::make_shared<LiteScanState>();
// Detached background broadcast (send/shield): proving can take seconds. Mirrors the sync
// thread's shared-lifetime pattern (shared running flag + result slot captured by the
// thread, never `this`) so the thread can safely outlive the controller.
std::thread broadcastThread_;
std::shared_ptr<std::atomic<bool>> broadcastRunning_ =
std::make_shared<std::atomic<bool>>(false);
std::shared_ptr<std::mutex> broadcastResultMutex_ = std::make_shared<std::mutex>();
std::shared_ptr<std::optional<LiteBroadcastResult>> broadcastResult_ =
std::make_shared<std::optional<LiteBroadcastResult>>();
// Detached interactive-console command runner (same shared-lifetime pattern as broadcast:
// captures the shared bridge + running flag + result slot, never `this`).
std::thread consoleThread_;
std::shared_ptr<std::atomic<bool>> consoleRunning_ = std::make_shared<std::atomic<bool>>(false);
std::shared_ptr<std::mutex> consoleResultMutex_ = std::make_shared<std::mutex>();
std::shared_ptr<std::optional<LiteConsoleResult>> consoleResult_ =
std::make_shared<std::optional<LiteConsoleResult>>();
// Asynchronous open with server failover (mirrors the sync/broadcast shared-lifetime pattern:
// the detached thread captures only shared_ptrs + value copies, never `this`, so it can
// safely outlive the controller). pumpAsyncOpen() finalizes the result on the main thread.
std::vector<std::string> failoverServerUrls() const;
// Shared async open/create-with-failover core (create=true uses initialize_new).
bool beginAsyncLifecycle(bool create);
struct OpenOutcome { bool ok = false; bool warming = false; std::string serverUrl; std::string error; };
std::thread openThread_;
std::shared_ptr<std::atomic<bool>> openRunning_ = std::make_shared<std::atomic<bool>>(false);
std::shared_ptr<std::mutex> openResultMutex_ = std::make_shared<std::mutex>();
std::shared_ptr<std::optional<OpenOutcome>> openResult_ =
std::make_shared<std::optional<OpenOutcome>>();
std::string lastOpenError_; // main-thread only
bool lastOpenWarming_ = false; // last failed open hit a warming-up (-28) server
// Asynchronous FULL lifecycle (create/open/restore with passphrase + restore params). Same
// shared-lifetime discipline as the open thread: the detached thread builds its OWN local
// LiteWalletLifecycleService from captured value copies + the shared bridge (never `this`), so
// it can safely outlive the controller. pumpLifecycleResult() finalizes on the main thread.
bool beginLifecycleRequestAsync(
const char* verb,
std::function<LiteWalletLifecycleResult(LiteWalletLifecycleService&, const std::string&)> exec,
std::function<void()> wipeSecrets);
std::thread lifecycleThread_;
std::shared_ptr<std::atomic<bool>> lifecycleRunning_ = std::make_shared<std::atomic<bool>>(false);
std::shared_ptr<std::mutex> lifecycleResultMutex_ = std::make_shared<std::mutex>();
std::shared_ptr<std::optional<LiteWalletLifecycleResult>> lifecycleResult_ =
std::make_shared<std::optional<LiteWalletLifecycleResult>>();
LiteWalletLifecycleResult lastLifecycleResult_; // main-thread only; last finalized request
// Passphrase to apply on the main thread once an async create/restore/open finalizes. The
// backend wallet is not controller-"open" (walletOpen_) until pumpLifecycleResult() runs
// onLifecycleResult(), and encryptWallet()/unlockWallet() gate on walletOpen_ — so the async
// worker thread cannot do it. The passphrase is stashed here (main-thread only) and applied in
// pumpLifecycleResult(), mirroring the synchronous createWallet()/restoreWallet()/openWallet()
// wrappers. Without this the UI's async path silently discarded the passphrase, storing the
// seed UNENCRYPTED (create/restore) or never unlocking an encrypted wallet (open) — audit C1/M1.
enum class PendingSecurityOp { None, Encrypt, Unlock };
std::string pendingLifecyclePassphrase_; // main-thread only; wiped after use
PendingSecurityOp pendingLifecycleSecurityOp_ = PendingSecurityOp::None;
void stashPendingLifecycleSecurity(const std::string& passphrase, PendingSecurityOp op);
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};
std::mutex wakeMutex_;
std::condition_variable wakeCv_;
std::mutex modelMutex_;
std::optional<LiteWalletAppRefreshModel> pendingModel_; // guarded by modelMutex_
static constexpr int kRefreshIntervalMs = 2000;
};
} // namespace wallet
} // namespace dragonx