// 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 #include #include #include #include #include #include #include #include #include #include #include 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 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; using LiteImportCallback = std::function; using LiteEncryptionCallback = std::function; // 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 cancel{false}; // controller destroyed / retired: stop retrying std::atomic failed{false}; // gave up (terminal error or too many attempts); cleared by recovery/rescan std::atomic 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 l(mutex); error = e; } std::string errorCopy() const { std::lock_guard 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 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 createLinked( WalletCapabilities capabilities, LiteConnectionSettings connectionSettings, LiteRolloutDecision rollout = LiteRolloutDecision{}); // Invoked after a wallet becomes ready, so the owner can persist settings. void setPersistCallback(std::function 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; 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 " [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 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 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 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> (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 bridge_; std::string chainName_; // backend chain id (for walletExists); from connection settings 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 persist_; std::atomic walletOpen_{false}; std::atomic 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 syncLaunched_{false}; // startSync() guard (set on the main thread) std::shared_ptr> syncDone_ = std::make_shared>(false); std::shared_ptr scan_ = std::make_shared(); // 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> broadcastRunning_ = std::make_shared>(false); std::shared_ptr broadcastResultMutex_ = std::make_shared(); std::shared_ptr> broadcastResult_ = std::make_shared>(); // 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> consoleRunning_ = std::make_shared>(false); std::shared_ptr consoleResultMutex_ = std::make_shared(); std::shared_ptr> consoleResult_ = std::make_shared>(); // 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 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> openRunning_ = std::make_shared>(false); std::shared_ptr openResultMutex_ = std::make_shared(); std::shared_ptr> openResult_ = std::make_shared>(); 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 exec, std::function wipeSecrets); std::thread lifecycleThread_; std::shared_ptr> lifecycleRunning_ = std::make_shared>(false); std::shared_ptr lifecycleResultMutex_ = std::make_shared(); std::shared_ptr> lifecycleResult_ = std::make_shared>(); 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> pendingOps_; // main thread; id -> deliver failure std::uint64_t nextOpId_ = 1; // main thread template bool submitWalletOp(std::function core, std::function onDone, std::function makeFailure); bool submitEncryptionChange(std::string& passphrase, LiteEncryptionCallback onDone, bool encrypt); std::atomic 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> opsRetired_ = std::make_shared>(false); std::string connectedServerUrl_; // main thread // Joinable background refresh worker (fast iterations: syncstatus, plus data once synced). std::thread worker_; std::atomic running_{false}; std::mutex wakeMutex_; std::condition_variable wakeCv_; std::mutex modelMutex_; std::optional pendingModel_; // guarded by modelMutex_ static constexpr int kRefreshIntervalMs = 2000; }; } // namespace wallet } // namespace dragonx