Adds a fail-open, local-only gate that decides whether the lite wallet may run,
so a post-release issue can disable it and rollout can be staged — without any
phone-home (privacy posture: no runtime network fetch; the per-install rollout
bucket is a hashed, never-transmitted local id).
- wallet/lite_rollout_policy.{h,cpp}: a pure decision core. Order — emergency env
kill-switch (absolute) -> local override -> manifest gates (global enable /
version floor-ceiling / blocklist / staged-rollout permille) -> fail-open allow.
Plus a JSON manifest loader (missing/invalid -> fail-open) and FNV-1a bucketing.
- Threads the decision through LiteWalletController -> LiteWalletLifecycleService:
new availability() reason RolloutDisabled blocks create/open/restore and surfaces
the gate's user-facing message via the lifecycle status.
- App::rebuildLiteWallet() resolves it from: DRAGONX_LITE_KILL_SWITCH (env), the
lite_rollout setting (auto/force_on/force_off), and a locally-cached manifest at
<config-dir>/lite_rollout.json. install id generated once via libsodium.
- Settings: persist lite_rollout override + the install id.
A signed remote fetcher can populate the manifest cache later without touching the
policy. Unit-tested (version compare, bucketing, override/env precedence, manifest
gates, staged rollout, loader fail-open, controller integration) and runtime-verified
on Linux (env kill-switch, manifest disable, control sync). Both variants build;
full suite passes; hygiene clean.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
716 lines
26 KiB
C++
716 lines
26 KiB
C++
// DragonX Wallet - ImGui Edition
|
|
// Copyright 2024-2026 The Hush Developers
|
|
// Released under the GPLv3
|
|
|
|
#include "lite_wallet_controller.h"
|
|
|
|
#include "../data/wallet_state.h"
|
|
|
|
#include <chrono>
|
|
#include <cstdint>
|
|
#include <unordered_map>
|
|
#include <utility>
|
|
|
|
#include <nlohmann/json.hpp>
|
|
#include <sodium.h>
|
|
|
|
namespace dragonx {
|
|
namespace wallet {
|
|
|
|
namespace {
|
|
constexpr double kZatoshisPerCoin = 100000000.0; // DRGX has 1e8 zatoshis per coin
|
|
|
|
// Extract a backend {"error":..} message (string or arbitrary JSON) into a plain string.
|
|
std::string extractJsonError(const nlohmann::json& value)
|
|
{
|
|
const auto& e = value.at("error");
|
|
return e.is_string() ? e.get<std::string>() : e.dump();
|
|
}
|
|
|
|
// Parse a send/shield response: success is {"txid":".."}, failure is {"error":".."} (NOT an
|
|
// "Error:"-prefixed string), and malformed args make the backend return plain-text help.
|
|
LiteBroadcastResult parseBroadcastResponse(const LiteBridgeStringResult& bridgeCall)
|
|
{
|
|
LiteBroadcastResult out;
|
|
if (!bridgeCall.ok) {
|
|
out.error = bridgeCall.error.empty() ? "backend call failed" : bridgeCall.error;
|
|
return out;
|
|
}
|
|
try {
|
|
const auto j = nlohmann::json::parse(bridgeCall.value);
|
|
if (j.is_object()) {
|
|
if (j.contains("error")) {
|
|
out.error = extractJsonError(j);
|
|
return out;
|
|
}
|
|
if (j.contains("txid") && j.at("txid").is_string()) {
|
|
out.txid = j.at("txid").get<std::string>();
|
|
out.ok = !out.txid.empty();
|
|
if (!out.ok) out.error = "backend returned an empty txid";
|
|
return out;
|
|
}
|
|
}
|
|
} catch (...) {
|
|
// Non-JSON (e.g. the command's plain-text help on bad args) -> generic error below.
|
|
}
|
|
out.error = "could not parse transaction response";
|
|
return out;
|
|
}
|
|
|
|
// Build the JSON-array send payload and broadcast it. litelib_execute passes the whole args
|
|
// string as ONE argument (no whitespace splitting), so send MUST use the JSON-array form
|
|
// ([{address,amount,memo},..]); the space-separated CLI form would never parse.
|
|
LiteBroadcastResult doSend(LiteClientBridge& bridge, const LiteSendRequest& request)
|
|
{
|
|
LiteBroadcastResult out;
|
|
if (request.recipients.empty()) {
|
|
out.error = "no recipients";
|
|
return out;
|
|
}
|
|
nlohmann::json arr = nlohmann::json::array();
|
|
for (const auto& r : request.recipients) {
|
|
if (r.address.empty()) {
|
|
out.error = "recipient address is empty";
|
|
return out;
|
|
}
|
|
nlohmann::json o;
|
|
o["address"] = r.address;
|
|
o["amount"] = r.amountZatoshis; // zatoshis (puposhis)
|
|
if (!r.memo.empty()) o["memo"] = r.memo;
|
|
arr.push_back(std::move(o));
|
|
}
|
|
auto result = parseBroadcastResponse(bridge.execute("send", arr.dump()));
|
|
// The backend does NOT auto-save after a send, so persist the new transaction now (so it
|
|
// survives a restart). Best-effort: a save failure doesn't undo a broadcast that succeeded.
|
|
if (result.ok) bridge.execute("save", "");
|
|
return result;
|
|
}
|
|
|
|
LiteBroadcastResult doShield(LiteClientBridge& bridge, const std::string& optionalAddress)
|
|
{
|
|
// Empty address -> shield all transparent funds; otherwise shield to the given address.
|
|
auto result = parseBroadcastResponse(bridge.execute("shield", optionalAddress));
|
|
if (result.ok) bridge.execute("save", ""); // shield does not auto-save either
|
|
return result;
|
|
}
|
|
} // namespace
|
|
|
|
void secureWipeLiteSecret(std::string& secret)
|
|
{
|
|
if (!secret.empty()) {
|
|
sodium_memzero(&secret[0], secret.size());
|
|
}
|
|
secret.clear();
|
|
}
|
|
|
|
void applyLiteRefreshModelToWalletState(const LiteWalletAppRefreshModel& model,
|
|
dragonx::WalletState& state)
|
|
{
|
|
if (model.hasBalance) {
|
|
state.privateBalance = static_cast<double>(model.balance.shieldedZatoshis) / kZatoshisPerCoin;
|
|
state.transparentBalance = static_cast<double>(model.balance.transparentZatoshis) / kZatoshisPerCoin;
|
|
state.totalBalance = static_cast<double>(model.balance.totalZatoshis) / kZatoshisPerCoin;
|
|
state.unconfirmedBalance = static_cast<double>(model.balance.unconfirmedZatoshis) / kZatoshisPerCoin;
|
|
}
|
|
|
|
if (model.hasAddresses) {
|
|
// Per-address balances from unspent notes/utxos (when the notes command succeeded).
|
|
// Sum the value of confirmed, unspent outputs at each address: skip anything spent,
|
|
// unconfirmed-spent, or still pending (notes/utxos from an unconfirmed received tx) so
|
|
// the per-address figures match the confirmed balance the wallet treats as available.
|
|
std::unordered_map<std::string, std::uint64_t> perAddressZatoshis;
|
|
if (model.hasSpendableOutputs) {
|
|
for (const auto& output : model.spendableOutputs) {
|
|
if (output.spent || output.unconfirmedSpent || output.pending) continue;
|
|
perAddressZatoshis[output.address] += output.valueZatoshis;
|
|
}
|
|
}
|
|
|
|
state.addresses.clear();
|
|
state.z_addresses.clear();
|
|
state.t_addresses.clear();
|
|
for (const auto& addr : model.addresses) {
|
|
AddressInfo info;
|
|
info.address = addr.address;
|
|
const auto it = perAddressZatoshis.find(addr.address);
|
|
info.balance = it != perAddressZatoshis.end()
|
|
? static_cast<double>(it->second) / kZatoshisPerCoin
|
|
: 0.0;
|
|
info.type = (addr.kind == LiteWalletAppAddressKind::Shielded) ? "shielded" : "transparent";
|
|
info.has_spending_key = addr.spendabilityKnown ? addr.spendable : true;
|
|
if (addr.kind == LiteWalletAppAddressKind::Shielded) {
|
|
state.z_addresses.push_back(info);
|
|
} else {
|
|
state.t_addresses.push_back(info);
|
|
}
|
|
state.addresses.push_back(std::move(info));
|
|
}
|
|
}
|
|
|
|
if (model.hasTransactions) {
|
|
state.transactions.clear();
|
|
const int64_t chainHeight =
|
|
model.hasSyncStatus ? static_cast<int64_t>(model.sync.chainHeight) : 0;
|
|
for (const auto& record : model.transactions) {
|
|
TransactionInfo tx;
|
|
tx.txid = record.txid;
|
|
if (record.kind == LiteWalletAppTransactionKind::Send) {
|
|
tx.type = "send";
|
|
} else if (record.kind == LiteWalletAppTransactionKind::Receive) {
|
|
tx.type = "receive";
|
|
} else {
|
|
tx.type = record.signedAmountZatoshis < 0 ? "send" : "receive";
|
|
}
|
|
tx.amount = static_cast<double>(record.amountZatoshis) / kZatoshisPerCoin;
|
|
tx.timestamp = record.timestamp;
|
|
tx.address = record.address;
|
|
tx.memo = record.memo;
|
|
if (record.unconfirmed || !record.blockHeight.has_value() || chainHeight == 0) {
|
|
tx.confirmations = record.unconfirmed ? 0 : 1;
|
|
} else {
|
|
const int64_t confs = chainHeight - *record.blockHeight + 1;
|
|
tx.confirmations = confs > 0 ? static_cast<int>(confs) : 0;
|
|
}
|
|
state.transactions.push_back(std::move(tx));
|
|
}
|
|
}
|
|
|
|
if (model.hasSyncStatus) {
|
|
state.sync.blocks = static_cast<int>(model.sync.walletHeight);
|
|
state.sync.headers = static_cast<int>(model.sync.chainHeight);
|
|
state.sync.verification_progress = model.sync.progress;
|
|
state.sync.syncing = !model.sync.complete;
|
|
}
|
|
|
|
if (model.hasEncryptionStatus) {
|
|
// Reflect backend encryption state so isLocked()/isEncrypted() gate the UI correctly.
|
|
state.encrypted = model.encrypted;
|
|
state.locked = model.locked;
|
|
}
|
|
}
|
|
|
|
LiteWalletController::LiteWalletController(WalletCapabilities capabilities,
|
|
LiteConnectionSettings connectionSettings,
|
|
LiteClientBridge bridge,
|
|
LiteWalletControllerOptions options)
|
|
: bridge_(std::make_shared<LiteClientBridge>(std::move(bridge))),
|
|
chainName_(connectionSettings.chainName),
|
|
lifecycle_(capabilities, connectionSettings, bridge_.get(),
|
|
LiteWalletLifecycleOptions{options.allowBridgeCalls,
|
|
options.rolloutBlocked, options.rolloutMessage}),
|
|
gateway_(capabilities, connectionSettings, bridge_.get(),
|
|
LiteWalletGatewayOptions{options.allowBridgeCalls}),
|
|
sync_(capabilities, connectionSettings, bridge_.get(),
|
|
LiteSyncServiceOptions{options.allowBridgeCalls})
|
|
{
|
|
status_ = lifecycle_.status();
|
|
}
|
|
|
|
LiteWalletController::~LiteWalletController()
|
|
{
|
|
stopWorker(); // joins the fast poll worker (short iterations)
|
|
// Best-effort flush on shutdown: the mempool monitor's unconfirmed updates aren't persisted
|
|
// by the backend (sync/send/shield already save inline). Guarded by syncDone_ and no in-flight
|
|
// broadcast so we never block shutdown waiting on the wallet lock held by an uninterruptible
|
|
// scan or an in-progress send proving.
|
|
if (walletOpen_.load() && syncDone_->load() && !broadcastInProgress() && bridge_) {
|
|
bridge_->execute("save", "");
|
|
}
|
|
// The sync thread may be blocked in an uninterruptible full scan; detach it. It holds
|
|
// shared refs (bridge_ + syncDone_), so it stays safe and the bridge survives until it
|
|
// finishes — the process is exiting, so a late litelib_shutdown is harmless.
|
|
if (syncThread_.joinable()) syncThread_.detach();
|
|
// Likewise the broadcast thread (send/shield proving): it captures shared refs (bridge +
|
|
// running flag + result slot), never `this`, so detaching is safe.
|
|
if (broadcastThread_.joinable()) broadcastThread_.detach();
|
|
}
|
|
|
|
std::unique_ptr<LiteWalletController> LiteWalletController::createLinked(
|
|
WalletCapabilities capabilities,
|
|
LiteConnectionSettings connectionSettings,
|
|
LiteRolloutDecision rollout)
|
|
{
|
|
return std::make_unique<LiteWalletController>(
|
|
capabilities,
|
|
std::move(connectionSettings),
|
|
LiteClientBridge::linkedSdxl(),
|
|
LiteWalletControllerOptions{true, !rollout.allowed, rollout.message});
|
|
}
|
|
|
|
void LiteWalletController::onLifecycleResult(const LiteWalletLifecycleResult& result)
|
|
{
|
|
status_ = result.status;
|
|
if (result.walletReady) {
|
|
walletOpen_ = true;
|
|
if (persist_) persist_();
|
|
startSync(); // begin background sync on the backend
|
|
startWorker(); // begin periodic refresh -> WalletState (via takeRefreshedModel)
|
|
}
|
|
}
|
|
|
|
void LiteWalletController::startSync()
|
|
{
|
|
if (syncLaunched_.exchange(true)) return;
|
|
syncStarted_ = true;
|
|
// The backend `sync` command is a blocking, uninterruptible full chain scan, so run it on
|
|
// a detached thread. Capture shared refs (not the controller) so it is safe to outlive us.
|
|
auto bridge = bridge_;
|
|
auto done = syncDone_;
|
|
syncThread_ = std::thread([bridge, done] {
|
|
if (bridge) {
|
|
bridge->execute("sync", ""); // blocks until synced (or errors out)
|
|
// The backend does NOT auto-save after a sync, so persist the freshly-scanned wallet;
|
|
// otherwise the next launch re-scans from the checkpoint (~30 min). Set `done` only
|
|
// after the save so a syncComplete() observer sees a fully-persisted wallet.
|
|
bridge->execute("save", "");
|
|
}
|
|
done->store(true);
|
|
});
|
|
}
|
|
|
|
std::optional<LiteWalletAppRefreshModel> LiteWalletController::refreshModel()
|
|
{
|
|
if (!walletOpen_.load()) return std::nullopt;
|
|
|
|
// syncstatus is fast (reads shared state the sync thread updates). Poll it every time.
|
|
const auto syncResult = sync_.pollSyncStatus(LiteSyncStatusRequest{});
|
|
|
|
// Encryption status reads local wallet state (available even mid-sync); fold it into every
|
|
// model so WalletState.isLocked()/isEncrypted() track the backend.
|
|
const auto encStatus = encryptionStatus();
|
|
auto applyEncryption = [&encStatus](LiteWalletAppRefreshModel& m) {
|
|
if (encStatus.ok) {
|
|
m.hasEncryptionStatus = true;
|
|
m.encrypted = encStatus.encrypted;
|
|
m.locked = encStatus.locked;
|
|
}
|
|
};
|
|
|
|
if (!syncDone_->load()) {
|
|
// Sync still running: publish progress only. Data queries (balance/list) would block
|
|
// until the chain is synced, so don't issue them yet.
|
|
if (!syncResult.ok) return std::nullopt;
|
|
LiteWalletAppRefreshModel model;
|
|
applyEncryption(model);
|
|
model.hasSyncStatus = true;
|
|
model.sync.walletHeight = syncResult.syncStatus.syncedBlocks;
|
|
model.sync.chainHeight = syncResult.syncStatus.totalBlocks;
|
|
// syncDone_ is authoritative: the detached sync thread is still running, so we are NOT
|
|
// complete regardless of what syncstatus reports. The backend briefly returns the idle
|
|
// shape ({"syncing":"false"} -> parser progress=1.0, complete=true) before the scan
|
|
// starts publishing in-progress status; don't surface that as a misleading 100%/done.
|
|
model.sync.complete = false;
|
|
model.sync.progress = syncResult.syncStatus.complete ? 0.0 : syncResult.syncStatus.progress;
|
|
return model;
|
|
}
|
|
|
|
// Synced: full refresh (balance/addresses/transactions are fast now).
|
|
LiteWalletRefreshRequest request;
|
|
if (syncResult.ok) {
|
|
request.haveSyncStatus = true;
|
|
request.syncStatus = syncResult.syncStatus;
|
|
}
|
|
const auto refreshResult = gateway_.refresh(request);
|
|
if (refreshResult.bundle.successfulCommandCount == 0 && !request.haveSyncStatus) {
|
|
return std::nullopt;
|
|
}
|
|
const auto mapped = mapLiteWalletRefreshResult(refreshResult);
|
|
if (!mapped.ok) return std::nullopt;
|
|
auto model = mapped.model;
|
|
applyEncryption(model);
|
|
return model;
|
|
}
|
|
|
|
LiteNewAddressResult LiteWalletController::newAddress(bool shielded)
|
|
{
|
|
LiteNewAddressResult out;
|
|
if (!walletOpen_.load() || !bridge_) {
|
|
out.error = "no wallet is open";
|
|
return out;
|
|
}
|
|
// Backend address-type tokens: "zs" (shielded) / "R" (transparent) (do_new_address).
|
|
const auto result = bridge_->execute("new", shielded ? "zs" : "R");
|
|
if (!result.ok) {
|
|
out.error = result.error.empty() ? "new address generation failed" : result.error;
|
|
return out;
|
|
}
|
|
// Response is a JSON array with the new address, e.g. ["zs1..."].
|
|
try {
|
|
const auto parsed = nlohmann::json::parse(result.value);
|
|
if (parsed.is_array() && !parsed.empty() && parsed[0].is_string()) {
|
|
out.address = parsed[0].get<std::string>();
|
|
} else if (parsed.is_string()) {
|
|
out.address = parsed.get<std::string>();
|
|
}
|
|
} catch (...) {
|
|
// fall through to the error below
|
|
}
|
|
out.ok = !out.address.empty();
|
|
if (!out.ok) out.error = "could not parse new address response";
|
|
return out;
|
|
}
|
|
|
|
LiteBroadcastResult LiteWalletController::sendTransactionBlocking(const LiteSendRequest& request)
|
|
{
|
|
LiteBroadcastResult out;
|
|
if (!walletOpen_.load() || !bridge_) {
|
|
out.error = "no wallet is open";
|
|
return out;
|
|
}
|
|
return doSend(*bridge_, request);
|
|
}
|
|
|
|
LiteBroadcastResult LiteWalletController::shieldFundsBlocking(const std::string& optionalAddress)
|
|
{
|
|
LiteBroadcastResult out;
|
|
if (!walletOpen_.load() || !bridge_) {
|
|
out.error = "no wallet is open";
|
|
return out;
|
|
}
|
|
return doShield(*bridge_, optionalAddress);
|
|
}
|
|
|
|
bool LiteWalletController::startBroadcast(std::function<LiteBroadcastResult()> op)
|
|
{
|
|
bool expected = false;
|
|
if (!broadcastRunning_->compare_exchange_strong(expected, true)) return false; // one at a time
|
|
{
|
|
std::lock_guard<std::mutex> lock(*broadcastResultMutex_);
|
|
broadcastResult_->reset(); // drop any already-consumed prior result
|
|
}
|
|
// A previous broadcast thread (already finished) may still be joinable; detach before reuse.
|
|
if (broadcastThread_.joinable()) broadcastThread_.detach();
|
|
|
|
auto running = broadcastRunning_;
|
|
auto mutex = broadcastResultMutex_;
|
|
auto slot = broadcastResult_;
|
|
broadcastThread_ = std::thread([op = std::move(op), running, mutex, slot] {
|
|
LiteBroadcastResult result = op();
|
|
{
|
|
std::lock_guard<std::mutex> lock(*mutex);
|
|
*slot = std::move(result);
|
|
}
|
|
running->store(false);
|
|
});
|
|
return true;
|
|
}
|
|
|
|
bool LiteWalletController::sendTransaction(const LiteSendRequest& request)
|
|
{
|
|
if (!walletOpen_.load() || !bridge_) return false;
|
|
auto bridge = bridge_; // shared copy; the op must not capture `this`
|
|
return startBroadcast([bridge, request]() { return doSend(*bridge, request); });
|
|
}
|
|
|
|
bool LiteWalletController::shieldFunds(const std::string& optionalAddress)
|
|
{
|
|
if (!walletOpen_.load() || !bridge_) return false;
|
|
auto bridge = bridge_;
|
|
return startBroadcast([bridge, optionalAddress]() { return doShield(*bridge, optionalAddress); });
|
|
}
|
|
|
|
bool LiteWalletController::takeBroadcastResult(LiteBroadcastResult& out)
|
|
{
|
|
std::lock_guard<std::mutex> lock(*broadcastResultMutex_);
|
|
if (!broadcastResult_->has_value()) return false;
|
|
out = std::move(**broadcastResult_);
|
|
broadcastResult_->reset();
|
|
return true;
|
|
}
|
|
|
|
LiteImportResult LiteWalletController::importKey(std::string spendingOrViewingKey)
|
|
{
|
|
LiteImportResult out;
|
|
if (!walletOpen_.load() || !bridge_) {
|
|
secureWipeLiteSecret(spendingOrViewingKey);
|
|
out.error = "no wallet is open";
|
|
return out;
|
|
}
|
|
if (spendingOrViewingKey.empty()) {
|
|
out.error = "no key provided";
|
|
return out;
|
|
}
|
|
// Transparent WIFs begin with U/5/K/L (TImportCommand); shielded keys begin with
|
|
// "secret-..." / viewing keys "zxview...", so this prefix check won't collide.
|
|
const char first = spendingOrViewingKey[0];
|
|
const bool transparentWif = (first == 'U' || first == '5' || first == 'K' || first == 'L');
|
|
const auto result = bridge_->execute(transparentWif ? "timport" : "import", spendingOrViewingKey);
|
|
secureWipeLiteSecret(spendingOrViewingKey); // wipe our copy ASAP
|
|
|
|
if (!result.ok) { // do_import_* failures come back "Error:"-prefixed (bridge -> ok=false)
|
|
out.error = result.error.empty() ? "key import failed" : result.error;
|
|
return out;
|
|
}
|
|
try {
|
|
const auto j = nlohmann::json::parse(result.value);
|
|
if (j.is_object() && j.contains("error")) {
|
|
out.error = extractJsonError(j);
|
|
return out;
|
|
}
|
|
} catch (...) {
|
|
// A non-JSON success payload is acceptable; fall through.
|
|
}
|
|
out.detail = result.value;
|
|
out.ok = true;
|
|
return out;
|
|
}
|
|
|
|
LiteExportResult LiteWalletController::exportPrivateKeys(const std::string& optionalAddress)
|
|
{
|
|
LiteExportResult out;
|
|
if (!walletOpen_.load() || !bridge_) {
|
|
out.error = "no wallet is open";
|
|
return out;
|
|
}
|
|
// Empty address -> export keys for all addresses; otherwise just the given address.
|
|
const auto result = bridge_->execute("export", optionalAddress);
|
|
if (!result.ok) {
|
|
out.error = result.error.empty() ? "export failed" : result.error;
|
|
return out;
|
|
}
|
|
try {
|
|
const auto j = nlohmann::json::parse(result.value);
|
|
if (j.is_object() && j.contains("error")) {
|
|
out.error = extractJsonError(j);
|
|
return out;
|
|
}
|
|
} catch (...) {
|
|
out.error = "could not parse export response";
|
|
return out;
|
|
}
|
|
out.privateKeysJson = result.value; // SECRET — caller must not log; wipe after use
|
|
out.ok = true;
|
|
return out;
|
|
}
|
|
|
|
LiteSeedResult LiteWalletController::exportSeed()
|
|
{
|
|
LiteSeedResult out;
|
|
if (!walletOpen_.load() || !bridge_) {
|
|
out.error = "no wallet is open";
|
|
return out;
|
|
}
|
|
const auto result = bridge_->execute("seed", "");
|
|
if (!result.ok) {
|
|
out.error = result.error.empty() ? "seed export failed" : result.error;
|
|
return out;
|
|
}
|
|
try {
|
|
const auto j = nlohmann::json::parse(result.value);
|
|
if (j.is_object()) {
|
|
if (j.contains("error")) {
|
|
out.error = extractJsonError(j);
|
|
return out;
|
|
}
|
|
if (j.contains("seed") && j.at("seed").is_string()) {
|
|
out.seedPhrase = j.at("seed").get<std::string>(); // SECRET
|
|
if (j.contains("birthday") && j.at("birthday").is_number_unsigned()) {
|
|
out.birthday = j.at("birthday").get<std::uint64_t>();
|
|
}
|
|
out.ok = !out.seedPhrase.empty();
|
|
if (!out.ok) out.error = "backend returned an empty seed";
|
|
return out;
|
|
}
|
|
}
|
|
} catch (...) {
|
|
// fall through to the generic parse error
|
|
}
|
|
out.error = "could not parse seed response";
|
|
return out;
|
|
}
|
|
|
|
bool LiteWalletController::saveWallet()
|
|
{
|
|
if (!walletOpen_.load() || !bridge_) return false;
|
|
return bridge_->execute("save", "").ok;
|
|
}
|
|
|
|
bool LiteWalletController::walletExists() const
|
|
{
|
|
// chainName_ is always a backend-accepted value ("main"/"test"/"regtest"); litelib_wallet_
|
|
// exists panics on unknown chains, but settings migration guarantees a valid one.
|
|
return bridge_ && bridge_->walletExists(chainName_);
|
|
}
|
|
|
|
namespace {
|
|
// encrypt/unlock/lock/decrypt return {"result":"success"} or {"error":..} (or an "Error:"-prefixed
|
|
// string the bridge maps to ok=false). Success = bridge ok and no error field.
|
|
LiteEncryptionResult parseEncryptionOpResponse(const LiteBridgeStringResult& bridgeCall)
|
|
{
|
|
LiteEncryptionResult out;
|
|
if (!bridgeCall.ok) {
|
|
out.error = bridgeCall.error.empty() ? "operation failed" : bridgeCall.error;
|
|
return out;
|
|
}
|
|
try {
|
|
const auto j = nlohmann::json::parse(bridgeCall.value);
|
|
if (j.is_object() && j.contains("error")) {
|
|
out.error = extractJsonError(j);
|
|
return out;
|
|
}
|
|
} catch (...) {
|
|
// a non-JSON success payload is acceptable
|
|
}
|
|
out.ok = true;
|
|
return out;
|
|
}
|
|
} // namespace
|
|
|
|
LiteEncryptionStatus LiteWalletController::encryptionStatus()
|
|
{
|
|
LiteEncryptionStatus out;
|
|
if (!walletOpen_.load() || !bridge_) {
|
|
out.error = "no wallet is open";
|
|
return out;
|
|
}
|
|
const auto result = bridge_->execute("encryptionstatus", "");
|
|
if (!result.ok) {
|
|
out.error = result.error.empty() ? "encryptionstatus failed" : result.error;
|
|
return out;
|
|
}
|
|
try {
|
|
const auto j = nlohmann::json::parse(result.value);
|
|
if (j.is_object()) {
|
|
if (j.contains("error")) {
|
|
out.error = extractJsonError(j);
|
|
return out;
|
|
}
|
|
out.encrypted = j.value("encrypted", false);
|
|
out.locked = j.value("locked", false);
|
|
out.ok = true;
|
|
return out;
|
|
}
|
|
} catch (...) {
|
|
// fall through
|
|
}
|
|
out.error = "could not parse encryptionstatus response";
|
|
return out;
|
|
}
|
|
|
|
LiteEncryptionResult LiteWalletController::encryptWallet(std::string passphrase)
|
|
{
|
|
LiteEncryptionResult out;
|
|
if (!walletOpen_.load() || !bridge_) {
|
|
secureWipeLiteSecret(passphrase);
|
|
out.error = "no wallet is open";
|
|
return out;
|
|
}
|
|
if (passphrase.empty()) {
|
|
out.error = "passphrase required";
|
|
return out;
|
|
}
|
|
out = parseEncryptionOpResponse(bridge_->execute("encrypt", passphrase));
|
|
secureWipeLiteSecret(passphrase);
|
|
if (out.ok) bridge_->execute("save", ""); // persist the now-encrypted wallet
|
|
return out;
|
|
}
|
|
|
|
LiteEncryptionResult LiteWalletController::decryptWallet(std::string passphrase)
|
|
{
|
|
LiteEncryptionResult out;
|
|
if (!walletOpen_.load() || !bridge_) {
|
|
secureWipeLiteSecret(passphrase);
|
|
out.error = "no wallet is open";
|
|
return out;
|
|
}
|
|
if (passphrase.empty()) {
|
|
out.error = "passphrase required";
|
|
return out;
|
|
}
|
|
out = parseEncryptionOpResponse(bridge_->execute("decrypt", passphrase));
|
|
secureWipeLiteSecret(passphrase);
|
|
if (out.ok) bridge_->execute("save", ""); // persist the now-unencrypted wallet
|
|
return out;
|
|
}
|
|
|
|
bool LiteWalletController::unlockWallet(std::string passphrase)
|
|
{
|
|
if (!walletOpen_.load() || !bridge_ || passphrase.empty()) {
|
|
secureWipeLiteSecret(passphrase);
|
|
return false;
|
|
}
|
|
const bool ok = parseEncryptionOpResponse(bridge_->execute("unlock", passphrase)).ok;
|
|
secureWipeLiteSecret(passphrase);
|
|
return ok;
|
|
}
|
|
|
|
bool LiteWalletController::lockWallet()
|
|
{
|
|
if (!walletOpen_.load() || !bridge_) return false;
|
|
return parseEncryptionOpResponse(bridge_->execute("lock", "")).ok;
|
|
}
|
|
|
|
bool LiteWalletController::refreshWalletState(dragonx::WalletState& state)
|
|
{
|
|
auto model = refreshModel();
|
|
if (!model) return false;
|
|
applyLiteRefreshModelToWalletState(*model, state);
|
|
return true;
|
|
}
|
|
|
|
bool LiteWalletController::takeRefreshedModel(LiteWalletAppRefreshModel& out)
|
|
{
|
|
std::lock_guard<std::mutex> lock(modelMutex_);
|
|
if (!pendingModel_) return false;
|
|
out = std::move(*pendingModel_);
|
|
pendingModel_.reset();
|
|
return true;
|
|
}
|
|
|
|
void LiteWalletController::startWorker()
|
|
{
|
|
if (running_.exchange(true)) return; // already running
|
|
worker_ = std::thread([this] { workerLoop(); });
|
|
}
|
|
|
|
void LiteWalletController::stopWorker()
|
|
{
|
|
if (!running_.exchange(false)) return; // not running
|
|
wakeCv_.notify_all();
|
|
if (worker_.joinable()) worker_.join();
|
|
}
|
|
|
|
void LiteWalletController::workerLoop()
|
|
{
|
|
while (running_.load()) {
|
|
if (walletOpen_.load()) {
|
|
auto model = refreshModel();
|
|
if (model) {
|
|
std::lock_guard<std::mutex> lock(modelMutex_);
|
|
pendingModel_ = std::move(model);
|
|
}
|
|
}
|
|
std::unique_lock<std::mutex> lock(wakeMutex_);
|
|
wakeCv_.wait_for(lock, std::chrono::milliseconds(kRefreshIntervalMs),
|
|
[this] { return !running_.load(); });
|
|
}
|
|
}
|
|
|
|
LiteWalletLifecycleResult LiteWalletController::createWallet(LiteWalletCreateRequest request)
|
|
{
|
|
auto result = lifecycle_.createWallet(request);
|
|
secureWipeLiteSecret(request.passphrase);
|
|
onLifecycleResult(result);
|
|
return result;
|
|
}
|
|
|
|
LiteWalletLifecycleResult LiteWalletController::openWallet(LiteWalletOpenRequest request)
|
|
{
|
|
auto result = lifecycle_.openWallet(request);
|
|
secureWipeLiteSecret(request.passphrase);
|
|
onLifecycleResult(result);
|
|
return result;
|
|
}
|
|
|
|
LiteWalletLifecycleResult LiteWalletController::restoreWallet(LiteWalletRestoreRequest request)
|
|
{
|
|
auto result = lifecycle_.restoreWallet(request);
|
|
secureWipeLiteSecret(request.seedPhrase);
|
|
secureWipeLiteSecret(request.passphrase);
|
|
onLifecycleResult(result);
|
|
return result;
|
|
}
|
|
|
|
} // namespace wallet
|
|
} // namespace dragonx
|