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>
326 lines
16 KiB
C++
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
|