Files
ObsidianDragon/src/wallet/lite_wallet_controller.h
DanS 320c659689 feat(lite): async wallet creation with server failover
Mirror the async-open path for wallet creation. beginOpenExisting() and
beginCreateWallet() now both delegate to beginAsyncLifecycle(bool create),
which runs the backend init on a detached thread and walks the failover
server list (preferred server first, then all usable defaults), reporting
the preferred server's error on total failure. The first-run wizard's
Create button drives this through a non-blocking "creating" poll state so
the UI no longer freezes while the backend contacts a (possibly flaky)
lightwalletd. The created seed response is securely wiped immediately and
read back via exportSeed for the reveal/verify steps.

Safe because litelib_initialize_new contacts the server before writing any
wallet file and LightClient::new errors if a wallet already exists, so a
failed candidate leaves no partial state.

Tests: fake backend's initialize_new now honors the dead/warmup server
substrings; testLiteWalletControllerOpenFailover gains a create-failover
case (preferred dead, fallback good -> walletOpen).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-08 11:29:59 -05:00

326 lines
16 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;
};
// 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(); }
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<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
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>>();
// 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
// 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