Auto-open: on the first update() tick (kept off init() so a slow
initialize_existing network call can't freeze startup before the window), if a
wallet file exists, open it. initialize_existing needs no passphrase — it loads
the file; a previously-synced + saved wallet resumes from its height (fast)
instead of rescanning from the checkpoint. Adds LiteWalletController::walletExists()
(bridge.walletExists on the connection's chain) + a chainName_ member.
RPC-refresh gating: the earlier connected=walletOpen() fix (so the wallet UI is
enabled in lite) had a side effect — the full-node periodic + per-page RPC
refreshes (mining/balance/peers/txs, and setCurrentPage's immediate refresh)
gate on state_.connected, so they began firing in lite and failing
("X error: Not connected"). Re-gate those on ACTUAL RPC connectivity
(rpc_ && rpc_->isConnected()) instead of the lite proxy. Full-node is unchanged
(state_.connected ⟺ rpc connected there); lite no longer issues any RPC.
Runtime-verified in WSLg with a pre-seeded wallet: app auto-opens (Starting
Mempool + sync begins), and "Not connected" / getMiningInfo / RPC-connect noise
all drop to 0 — a fully clean lite run. tests pass.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
256 lines
12 KiB
C++
256 lines
12 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_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;
|
|
};
|
|
|
|
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;
|
|
};
|
|
|
|
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.
|
|
static std::unique_ptr<LiteWalletController> createLinked(
|
|
WalletCapabilities capabilities,
|
|
LiteConnectionSettings connectionSettings);
|
|
|
|
// 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);
|
|
|
|
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();
|
|
|
|
// 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
|
|
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>>();
|
|
|
|
// 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
|