// 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 "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 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; }; // 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. 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; }; 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(); } 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(); } // 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(); // Generate a new address (shielded if true, else transparent) via the backend. Fast (local // key derivation), safe to call on the UI thread; the next refresh lists the new address. LiteNewAddressResult newAddress(bool shielded); // --- 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); // 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: void onLifecycleResult(const LiteWalletLifecycleResult& result); void startWorker(); void stopWorker(); void workerLoop(); // 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 UI thread 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 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); // 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>(); // 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 // 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