Files
ObsidianDragon/src/wallet/lite_wallet_controller.h
DanS 4a65dce947 feat(lite): make the Console tab interactive (run backend commands)
The lite backend's litelib_execute() is the same command interface as
silentdragonxlite-cli (balance, info, height, list, notes, addresses, sync,
syncstatus, new, send, shield, encrypt, …), so the lite Console can be a real
interactive console — like the full-node RPC console — instead of a read-only
diagnostics log.

Controller: add an async arbitrary-command runner mirroring the broadcast
pattern — runConsoleCommand() splits "<command> [args]" (the first token is the
command, the remainder is passed as the single arg string litelib_execute
expects, since it does NOT whitespace-split), runs the bridge call on a detached
thread that captures the shared bridge (never `this`), and delivers the result
to a main-thread slot drained by takeConsoleResult(). Results are NEVER routed
through LiteDiagnostics (seed/export can return secrets).

Console tab: a command input (Enter to run, Up/Down history via the shared
console_input_model helpers) over a unified scroll buffer that interleaves the
automatic diagnostics events with user command I/O, colour-coded, with the live
status header preserved. The input is disabled while a command runs.

Two backend footguns are intercepted at the UI layer before forwarding:
`clear` (the backend command WIPES wallet tx history — re-bound to clearing the
view, what the user expects) and `quit`/`exit` (would only save; the embedded
backend must stay running with the app).

Test: runConsoleCommand drives the fake backend (info -> raw response; "new zs"
-> exercises the command/arg split; blank line rejected).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-09 20:32:03 -05:00

394 lines
21 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 "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 <condition_variable>
#include <cstdint>
#include <functional>
#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.
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<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;
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(); }
// 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);
// --- 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:
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<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 UI thread 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
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);
// 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
// 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