Lite-parity audit, FIX C (gating/validation/presentation only — no encrypt/unlock/send backend call changed): - Lite unlock went straight to the backend with no lockout. New App::unlockLiteWallet() runs the SAME escalating, persisted attempt-lockout as the full node (restoreLockoutState/applyUnlockFailure), wired into the Settings unlock, the send-time prompt and the lock screen. A wrong-passphrase failure counts; an already-unlocked/not-encrypted result does not. - state_.locked is now cleared immediately on a successful unlock (the ~2s status poll lagged, so the UI stayed "locked" after a correct passphrase). - PIN quick-unlock controls are hidden in lite Settings — lite has no PIN vault, so they were dead. - A create/restore whose passphrase encryption FAILS now raises a one-shot visible error (takeEncryptionFailureNotice -> lite_encrypt_failed_unencrypted) instead of only a log line; the wallet is left open but unencrypted. Fixed the flag on BOTH the lifecycle and the direct create/restore encrypt paths. - A background sync save is skipped while an encrypted wallet is unlocked (the backend save re-locks it), resuming once locked. Compile- and unit-verified (testLiteWalletControllerLockUnlockParity covers the save-deferral + encrypt-failure notice, run twice for the flaky guard); the lock-screen lockout/PIN-hiding UX itself is runtime/GUI-only and not exercised here — worth a manual lite GUI pass. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1844 lines
85 KiB
C++
1844 lines
85 KiB
C++
// DragonX Wallet - ImGui Edition
|
|
// Copyright 2024-2026 The Hush Developers
|
|
// Released under the GPLv3
|
|
|
|
#include "lite_wallet_controller.h"
|
|
|
|
#include "lite_diagnostics.h"
|
|
#include "lite_result_parsers.h"
|
|
#include "../data/wallet_state.h"
|
|
#include "../util/address_validation.h"
|
|
|
|
#include <algorithm>
|
|
#include <chrono>
|
|
#include <condition_variable>
|
|
#include <cstdint>
|
|
#include <memory>
|
|
#include <mutex>
|
|
#include <thread>
|
|
#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
|
|
|
|
// A lightwalletd open error that means "server is up but still warming up" (JSON-RPC -28 /
|
|
// "Activating best chain", "Loading"/"Verifying"/"Rescanning" phases) rather than a dead server.
|
|
// Such a server will be ready shortly, so the caller should retry it soon.
|
|
bool liteOpenErrorIsWarmup(const std::string& error)
|
|
{
|
|
const auto has = [&error](const char* s) { return error.find(s) != std::string::npos; };
|
|
return has("-28") || has("Activating best chain") || has("warming up") ||
|
|
has("Loading block") || has("Verifying blocks") || has("Rescanning");
|
|
}
|
|
|
|
// 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();
|
|
}
|
|
|
|
// True for the BIP39 phrase of all-zero entropy (23 x "abandon", then "art"), compared word by word in place so the
|
|
// secret is never copied. A real wallet has this seed with probability 2^-256.
|
|
bool isZeroEntropySeedPhrase(const std::string& phrase)
|
|
{
|
|
const auto isSpace = [](char c) { return c == ' ' || c == '\t' || c == '\n' || c == '\r'; };
|
|
std::size_t i = 0, words = 0;
|
|
while (true) {
|
|
while (i < phrase.size() && isSpace(phrase[i])) ++i;
|
|
if (i == phrase.size()) break;
|
|
std::size_t end = i;
|
|
while (end < phrase.size() && !isSpace(phrase[end])) ++end;
|
|
++words;
|
|
if (words > 24 || phrase.compare(i, end - i, words < 24 ? "abandon" : "art") != 0) return false;
|
|
i = end;
|
|
}
|
|
return words == 24;
|
|
}
|
|
|
|
// 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) -> ambiguous error below.
|
|
}
|
|
// The bridge call itself succeeded but the response is unrecognizable, so we genuinely
|
|
// can't tell whether the tx was broadcast. Use cautious wording (don't claim a hard
|
|
// failure) so the user verifies in Transactions before retrying — avoiding a double-spend.
|
|
out.error = "Transaction status could not be confirmed — check Transactions before retrying";
|
|
return out;
|
|
}
|
|
|
|
// The backend does not auto-save after send/shield. Persist the new transaction now so it
|
|
// survives a restart; retry once on a transient failure (disk lock, etc.).
|
|
bool persistAfterBroadcast(LiteClientBridge& bridge)
|
|
{
|
|
for (int attempt = 0; attempt < 2; ++attempt) {
|
|
if (bridge.execute("save", "").ok) return true;
|
|
}
|
|
// Persistent failure: the spent note will be re-derived from the chain on the next sync, so this
|
|
// is a robustness gap, not fund loss. Log it (W5-1) — both callers discard this return, so the
|
|
// failure was previously completely silent.
|
|
liteLog("save failed after send/shield — the wallet will re-derive it on the next sync");
|
|
return false;
|
|
}
|
|
|
|
// True when the wallet is encrypted AND currently unlocked. The backend's `save` re-locks such a wallet (do_save),
|
|
// so a background save must not run under a user who just unlocked. Fail-open: any error reads as "not unlocked" and
|
|
// the save proceeds as it always did.
|
|
bool liteWalletEncryptedAndUnlocked(LiteClientBridge& bridge)
|
|
{
|
|
const auto r = bridge.execute("encryptionstatus", "");
|
|
if (!r.ok) return false;
|
|
try {
|
|
const auto j = nlohmann::json::parse(r.value);
|
|
return j.is_object() && !j.contains("error") && j.value("encrypted", false) && !j.value("locked", false);
|
|
} catch (...) {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
// The detached sync/rescan thread. `firstCommand` is "sync" or "rescan"; every retry is "sync" (a failed rescan has
|
|
// already cleared the wallet, so re-running it would throw away the partial progress). Partial progress is saved after
|
|
// every attempt. Captures shared state only.
|
|
void runLiteScanThread(const std::shared_ptr<LiteClientBridge>& bridge,
|
|
const std::shared_ptr<std::atomic<bool>>& done,
|
|
const std::shared_ptr<LiteScanState>& scan,
|
|
const char* firstCommand)
|
|
{
|
|
if (bridge) {
|
|
const char* command = firstCommand;
|
|
for (int attempt = 1;; ++attempt) {
|
|
const auto call = bridge->execute(command, ""); // blocks until synced (or errors out)
|
|
LiteSyncOpResult op;
|
|
if (call.ok) {
|
|
op = parseLiteSyncOpResponse(call.value);
|
|
} else {
|
|
op.error = call.error.empty() ? "sync failed" : call.error;
|
|
}
|
|
// The backend does NOT auto-save after a sync; persist what was scanned even when the attempt failed.
|
|
// Not while the user has the wallet unlocked: the backend save re-locks an encrypted+unlocked wallet, which
|
|
// would yank the unlock out from under a spend/unlock in progress. The progress is saved by the next scan
|
|
// once locked (and by the shutdown save); a crash in between only costs a re-scan from the checkpoint.
|
|
if (liteWalletEncryptedAndUnlocked(*bridge))
|
|
liteLog("sync save deferred: wallet is unlocked (a save would re-lock it)");
|
|
else if (!bridge->execute("save", "").ok) // W5-2
|
|
liteLog("save failed after sync — the next launch will re-scan from the checkpoint");
|
|
if (op.ok) {
|
|
scan->setError(std::string());
|
|
scan->failed.store(false);
|
|
break;
|
|
}
|
|
liteLog(std::string(command) + " failed (attempt " + std::to_string(attempt) + "): " + op.error);
|
|
scan->setError(op.error);
|
|
command = "sync";
|
|
if (scan->cancel.load()) break;
|
|
if (op.terminal || attempt >= kLiteScanMaxAttempts) {
|
|
scan->failed.store(true); // explicit failed state; done is still published below
|
|
liteLog("sync gave up: " + op.error);
|
|
break;
|
|
}
|
|
const int delayMs = liteSyncRetryDelayMs(attempt, scan->retryBaseMs.load());
|
|
for (int waited = 0; waited < delayMs && !scan->cancel.load(); waited += 50)
|
|
std::this_thread::sleep_for(std::chrono::milliseconds(50));
|
|
if (scan->cancel.load()) break;
|
|
}
|
|
}
|
|
// Only after the save, so a syncComplete() observer sees a fully-persisted wallet.
|
|
done->store(true);
|
|
}
|
|
|
|
// 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()));
|
|
if (result.ok) persistAfterBroadcast(bridge);
|
|
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) persistAfterBroadcast(bridge); // shield does not auto-save either
|
|
return result;
|
|
}
|
|
|
|
// --- Blocking cores of the wallet ops (no `this`, so they also run on the wallet-op lane) ------------------------
|
|
LiteEncryptionResult parseEncryptionOpResponse(const LiteBridgeStringResult& bridgeCall); // defined further down
|
|
|
|
// Heap copy of a secret, wiped when the last reference goes (after the backend call, or when a queued job is dropped
|
|
// unrun). Copy, never move: moving a short (SSO) std::string leaves its bytes behind in the source.
|
|
std::shared_ptr<std::string> makeWipingSecretCopy(const std::string& secret)
|
|
{
|
|
return std::shared_ptr<std::string>(new std::string(secret),
|
|
[](std::string* s) { secureWipeLiteSecret(*s); delete s; });
|
|
}
|
|
|
|
// A lane op whose controller was retired (server switch) runs no further backend command: litelib_execute always runs
|
|
// on the GLOBAL client, which is then the NEXT server's. `retired` is null for the synchronous (main-thread) callers.
|
|
constexpr const char* kLiteOpRetiredError = "lite wallet reconnecting";
|
|
bool liteOpRetired(const std::atomic<bool>* retired) { return retired && retired->load(); }
|
|
|
|
LiteNewAddressResult newAddressCore(LiteClientBridge& bridge, bool shielded)
|
|
{
|
|
LiteNewAddressResult 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;
|
|
}
|
|
|
|
// The backend's import errors can repeat the key itself (do_import_key: "'<key>' was not recognized as either a
|
|
// spending key or a viewing key ..."), so that text is never logged or shown: only a fixed, non-secret reason.
|
|
std::string liteImportFailureReason(const std::string& backendError)
|
|
{
|
|
const auto has = [&backendError](const char* s) { return backendError.find(s) != std::string::npos; };
|
|
if (has("was not recognized")) return "unrecognized key format";
|
|
// Checked before "locked": unlocking does not help, the backend refuses private-key imports while encrypted.
|
|
if (has("encrypted")) return "wallet is encrypted - remove encryption to import a private key";
|
|
if (has("locked")) return "wallet is locked";
|
|
if (has("already exists")) return "key already exists";
|
|
if (has("decode")) return "key could not be decoded";
|
|
return "key rejected by the backend";
|
|
}
|
|
|
|
// `key` must be non-empty; it is wiped before returning. A failure's error is a fixed reason, never backend text.
|
|
LiteImportResult importKeyCore(LiteClientBridge& bridge, std::string& key, const std::atomic<bool>* retired = nullptr)
|
|
{
|
|
// Classify first, then call exactly one backend command, or none (C251). There is no retry with the other
|
|
// command: the wrong command never imported the key anyway, and that retry is what fed shielded keys to the
|
|
// backend's transparent-key decoder.
|
|
// NB: the lite/SDXL backend's viewing key is an *extended full* viewing key ("zxviews…",
|
|
// hrp_sapling_viewing_key), which is genuinely correct here — do NOT "harmonize" this with the
|
|
// full node, whose z_importviewingkey takes an *incoming* viewing key ("zivks…") instead. The
|
|
// two variants accept different viewing-key forms; a VK is not portable between them, so a
|
|
// "zivks…" key is refused here with a fixed reason.
|
|
enum class Kind { Shielded, FullViewing, FullNodeIvk, TransparentWif, UncompressedWif, Unknown };
|
|
Kind kind = Kind::Unknown;
|
|
if (key.rfind("secret-extended-key-", 0) == 0) kind = Kind::Shielded;
|
|
else if (key.rfind("zxview", 0) == 0) kind = Kind::FullViewing; // SDXL extended full viewing key
|
|
else if (key.rfind("zivks", 0) == 0) kind = Kind::FullNodeIvk; // full-node incoming VK: not importable
|
|
else {
|
|
// A transparent WIF is Base58Check with the mainnet (0xBC) or Bitcoin-style (0x80) version byte and the
|
|
// compressed-key flag. The Lite app is mainnet-only, so a testnet (0xEF) WIF is not accepted.
|
|
std::vector<std::uint8_t> payload;
|
|
if (util::decodeBase58Check(key, payload)) {
|
|
if (payload.size() == 34 && payload[33] == 0x01 && (payload[0] == 0xBC || payload[0] == 0x80))
|
|
kind = Kind::TransparentWif;
|
|
else if (payload.size() == 33)
|
|
kind = Kind::UncompressedWif;
|
|
}
|
|
if (!payload.empty()) sodium_memzero(payload.data(), payload.size());
|
|
}
|
|
|
|
auto runImport = [&](const char* command) -> LiteImportResult {
|
|
LiteImportResult r;
|
|
const auto result = bridge.execute(command, key);
|
|
if (!result.ok) { // do_import_* failures come back "Error:"-prefixed (bridge -> ok=false)
|
|
r.error = liteImportFailureReason(result.error);
|
|
return r;
|
|
}
|
|
try {
|
|
const auto j = nlohmann::json::parse(result.value);
|
|
if (j.is_object() && j.contains("error")) {
|
|
r.error = liteImportFailureReason(extractJsonError(j));
|
|
return r;
|
|
}
|
|
} catch (...) {
|
|
// A non-JSON success payload is acceptable; fall through.
|
|
}
|
|
r.detail = result.value;
|
|
r.ok = true;
|
|
return r;
|
|
};
|
|
|
|
LiteImportResult out;
|
|
switch (kind) {
|
|
case Kind::Shielded:
|
|
case Kind::FullViewing: out = runImport("import"); break;
|
|
case Kind::TransparentWif: out = runImport("timport"); break;
|
|
case Kind::FullNodeIvk:
|
|
out.error = "this is a full-node incoming viewing key (zivks); the Lite wallet needs an extended viewing key "
|
|
"(zxviews)";
|
|
break;
|
|
case Kind::UncompressedWif:
|
|
out.error = "uncompressed transparent keys are not supported by the Lite wallet";
|
|
break;
|
|
default: out.error = "unrecognized key format"; break;
|
|
}
|
|
if (!out.ok && liteOpRetired(retired)) out.error = kLiteOpRetiredError;
|
|
if (!out.ok) liteLog("Key import failed: " + out.error); // a fixed reason: the backend's text can repeat the key
|
|
secureWipeLiteSecret(key); // wipe our copy
|
|
return out;
|
|
}
|
|
|
|
// encrypt (encrypt=true) or remove encryption, then save, as ONE unit; wipes `passphrase` (must be non-empty).
|
|
LiteEncryptionResult encryptionChangeCore(LiteClientBridge& bridge, std::string& passphrase, bool encrypt,
|
|
const std::atomic<bool>* retired = nullptr)
|
|
{
|
|
LiteEncryptionResult out =
|
|
parseEncryptionOpResponse(bridge.execute(encrypt ? "encrypt" : "decrypt", passphrase));
|
|
secureWipeLiteSecret(passphrase);
|
|
if (out.ok && liteOpRetired(retired)) {
|
|
// Retired mid-op: the save would run on the NEXT server's client. The file on disk keeps its old state.
|
|
out.ok = false;
|
|
out.error = std::string(encrypt ? "wallet encrypted" : "wallet decrypted") + " in memory but not saved: " +
|
|
kLiteOpRetiredError;
|
|
} else if (out.ok) {
|
|
// Persist the now-(un)encrypted wallet. If the save fails, do NOT report success — the on-disk wallet would
|
|
// still be in its old state, contradicting what the user was told. The backend's `save` reports failure as
|
|
// {"result":"error","error":..} (no "Error:" prefix), so check the reply, not just the bridge status.
|
|
const auto saved = parseEncryptionOpResponse(bridge.execute("save", ""));
|
|
if (!saved.ok) {
|
|
out.ok = false;
|
|
out.error = std::string(encrypt ? "wallet encrypted" : "wallet decrypted") +
|
|
" in memory but saving to disk failed" +
|
|
(saved.error.empty() ? std::string() : (": " + saved.error));
|
|
}
|
|
}
|
|
return out;
|
|
}
|
|
} // 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;
|
|
// Use the backend's SPENDABLE (verified + confirmed) shielded balance for the spend gate, not the
|
|
// gross shielded total which includes unverified/unconfirmed notes you can't yet spend. The
|
|
// spendable_zbalance field is required by the parser, so it's always present when hasBalance is
|
|
// true; clamp to the shielded total defensively. Transparent is already confirmed on lite.
|
|
const double spendableShielded = static_cast<double>(
|
|
std::min(model.balance.spendableShieldedZatoshis, model.balance.shieldedZatoshis)) / kZatoshisPerCoin;
|
|
state.spendablePrivateBalance = spendableShielded;
|
|
state.spendableTransparentBalance = state.transparentBalance;
|
|
state.spendableTotalBalance = spendableShielded + state.transparentBalance;
|
|
}
|
|
|
|
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;
|
|
std::unordered_map<std::string, std::uint64_t> perAddressSpendableZatoshis; // backend 'spendable' only
|
|
if (model.hasSpendableOutputs) {
|
|
for (const auto& output : model.spendableOutputs) {
|
|
if (output.spent || output.unconfirmedSpent || output.pending) continue;
|
|
perAddressZatoshis[output.address] += output.valueZatoshis;
|
|
if (output.spendable) perAddressSpendableZatoshis[output.address] += output.valueZatoshis;
|
|
}
|
|
}
|
|
|
|
// If the notes/utxo command failed this cycle (a tolerated partial refresh), we don't
|
|
// actually know per-address balances. Preserve the previously displayed ones instead
|
|
// of zeroing every address — a zeroed breakdown next to a correct nonzero total looks
|
|
// like fund loss and breaks "send from this address".
|
|
std::unordered_map<std::string, double> priorBalances;
|
|
std::unordered_map<std::string, double> priorSpendable;
|
|
std::unordered_map<std::string, bool> priorHasKey;
|
|
if (!model.hasSpendableOutputs) {
|
|
for (const auto& a : state.addresses) {
|
|
priorBalances[a.address] = a.balance;
|
|
priorSpendable[a.address] = a.spendableBalance;
|
|
priorHasKey[a.address] = a.has_spending_key;
|
|
}
|
|
}
|
|
|
|
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);
|
|
if (it != perAddressZatoshis.end()) {
|
|
info.balance = static_cast<double>(it->second) / kZatoshisPerCoin;
|
|
} else if (!model.hasSpendableOutputs) {
|
|
const auto pit = priorBalances.find(addr.address); // keep last-known on notes failure
|
|
info.balance = pit != priorBalances.end() ? pit->second : 0.0;
|
|
} else {
|
|
info.balance = 0.0; // notes succeeded and address has no spendable outputs
|
|
}
|
|
// balance stays the gross confirmed-unspent sum; spendableBalance counts only outputs the backend
|
|
// marks spendable (spending key + anchor depth). Notes failed this cycle -> keep last-known.
|
|
if (model.hasSpendableOutputs) {
|
|
const auto sit = perAddressSpendableZatoshis.find(addr.address);
|
|
info.spendableBalance = sit != perAddressSpendableZatoshis.end()
|
|
? static_cast<double>(sit->second) / kZatoshisPerCoin : 0.0;
|
|
} else {
|
|
const auto psit = priorSpendable.find(addr.address);
|
|
info.spendableBalance = psit != priorSpendable.end() ? psit->second : 0.0;
|
|
}
|
|
info.type = (addr.kind == LiteWalletAppAddressKind::Shielded) ? "shielded" : "transparent";
|
|
info.has_spending_key = addr.spendabilityKnown ? addr.spendable : true;
|
|
if (!addr.spendabilityKnown && !model.hasSpendableOutputs) {
|
|
const auto pkit = priorHasKey.find(addr.address); // keep last-known view-only verdict
|
|
if (pkit != priorHasKey.end()) info.has_spending_key = pkit->second;
|
|
}
|
|
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) {
|
|
// Build aside and swap in only on a real change: this runs every ~2 s, and a bump rebuilds History.
|
|
std::vector<TransactionInfo> nextTransactions;
|
|
nextTransactions.reserve(model.transactions.size());
|
|
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";
|
|
}
|
|
// Signed like the full node: a send is a negative debit, a receive positive. Consumers
|
|
// (History/Details/CSV) pick sign + colour from this; the summary cards use std::abs.
|
|
tx.amount = static_cast<double>(record.signedAmountZatoshis) / kZatoshisPerCoin;
|
|
tx.timestamp = record.timestamp;
|
|
tx.address = record.address;
|
|
tx.memo = record.memo;
|
|
// For a Send the recipient address/memo live in outgoingOutputs — the top-level
|
|
// address/memo are only filled for Receives. Surface the first recipient so the
|
|
// list shows the destination + memo instead of blanks (single-recipient case).
|
|
if (tx.type == "send" && tx.address.empty() && !record.outgoingOutputs.empty()) {
|
|
tx.address = record.outgoingOutputs.front().address;
|
|
if (tx.memo.empty()) tx.memo = record.outgoingOutputs.front().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;
|
|
}
|
|
nextTransactions.push_back(std::move(tx));
|
|
}
|
|
// The backend lists oldest-first; every consumer of state.transactions (Recent Transactions / Recent Sends /
|
|
// Recent Received, CSV export) assumes newest-first. Same key as the History tab's default: pending (0-conf)
|
|
// on top, then newest. STABLE: one txid yields several records with the same datetime (send entry + per-note
|
|
// entries, same-block txs), and stability keeps their order deterministic across refreshes, so the
|
|
// order-sensitive sameTransactionList() still reports "unchanged" and tx_revision is not bumped every 2 s.
|
|
std::stable_sort(nextTransactions.begin(), nextTransactions.end(),
|
|
[](const TransactionInfo& a, const TransactionInfo& b) {
|
|
const bool ap = a.confirmations == 0, bp = b.confirmations == 0;
|
|
if (ap != bp) return ap;
|
|
return a.timestamp > b.timestamp;
|
|
});
|
|
state.setTransactionsIfChanged(std::move(nextTransactions));
|
|
}
|
|
|
|
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;
|
|
}
|
|
state.sync.lite_error = model.syncError;
|
|
|
|
if (model.hasEncryptionStatus) {
|
|
// Reflect backend encryption state so isLocked()/isEncrypted() gate the UI correctly, and mark
|
|
// it known: the wallet tabs wait for that, and without it lite always sat behind the loading
|
|
// overlay until its 15 s "assume unencrypted" fallback.
|
|
state.encrypted = model.encrypted;
|
|
state.locked = model.locked;
|
|
state.encryption_state_known = true;
|
|
}
|
|
}
|
|
|
|
LiteWalletController::LiteWalletController(WalletCapabilities capabilities,
|
|
LiteConnectionSettings connectionSettings,
|
|
LiteClientBridge bridge,
|
|
LiteWalletControllerOptions options)
|
|
: bridge_(std::make_shared<LiteClientBridge>(std::move(bridge))),
|
|
chainName_(connectionSettings.chainName),
|
|
connectionSettings_(connectionSettings),
|
|
capabilities_(capabilities),
|
|
lifecycleOptions_{options.allowBridgeCalls, options.rolloutBlocked, options.rolloutMessage},
|
|
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();
|
|
}
|
|
|
|
void LiteWalletController::flushOnExit(std::chrono::milliseconds budget)
|
|
{
|
|
// Same guards as the destructor, plus walletOpInProgress(): App::shutdown() already ran
|
|
// waitWalletOpsIdle(); if an op is still pending it holds sync_lock and issues its own save,
|
|
// so skip rather than block the budget on a save that cannot acquire the lock.
|
|
if (retired_.load() || !walletOpen_.load() || !syncComplete() ||
|
|
broadcastInProgress() || walletOpInProgress() || !bridge_) return;
|
|
struct Done { std::mutex m; std::condition_variable cv; bool done = false; };
|
|
auto st = std::make_shared<Done>();
|
|
auto bridge = bridge_; // shared_ptr copy; never captures `this`
|
|
try {
|
|
std::thread([bridge, st]() {
|
|
bridge->execute("save", ""); // may block on sync_lock; abandoned on timeout
|
|
{ std::lock_guard<std::mutex> l(st->m); st->done = true; }
|
|
st->cv.notify_all();
|
|
}).detach();
|
|
} catch (...) { return; } // no thread: skip (pre-fix behaviour, no crash)
|
|
std::unique_lock<std::mutex> l(st->m);
|
|
st->cv.wait_for(l, budget, [&] { return st->done; }); // timeout: abandon the thread; _Exit follows
|
|
}
|
|
|
|
LiteWalletController::~LiteWalletController()
|
|
{
|
|
scan_->cancel.store(true); // the detached sync thread stops retrying (it may be in a back-off sleep)
|
|
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.
|
|
// A retired controller (server switch) never saves: it is destroyed on a background thread while the NEW client
|
|
// may already own the wallet file, and two LightClients saving at once both write wallet.dat.tmp. What this flush
|
|
// persisted (refresh-scanned blocks, mempool txs) is chain-derived; the new client re-scans it.
|
|
if (!retired_.load() && 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 — on the quit path the process is exiting, so a late litelib_shutdown is harmless
|
|
// (a server switch already shut the bridge down in retire()).
|
|
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();
|
|
// The interactive-console command thread follows the same shared-lifetime pattern.
|
|
if (consoleThread_.joinable()) consoleThread_.detach();
|
|
// The async-open failover thread captures only shared refs (bridge + running flag + result
|
|
// slot), never `this`, so detaching is safe if it's still trying servers at shutdown.
|
|
if (openThread_.joinable()) openThread_.detach();
|
|
// The async full-lifecycle thread builds its own local lifecycle service from captured value
|
|
// copies + the shared bridge (never `this`), so detaching is likewise safe.
|
|
if (lifecycleThread_.joinable()) lifecycleThread_.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, bool deferBackground)
|
|
{
|
|
status_ = result.status;
|
|
const std::string op = liteWalletLifecycleOperationName(result.operation);
|
|
if (result.walletReady) {
|
|
liteLog(op + ": wallet ready");
|
|
walletOpen_ = true;
|
|
connectedServerUrl_ = result.plan.server.url;
|
|
if (persist_) persist_();
|
|
if (!deferBackground) {
|
|
startSync(); // begin background sync on the backend
|
|
startWorker(); // begin periodic refresh -> WalletState (via takeRefreshedModel)
|
|
}
|
|
} else if (result.attempted) {
|
|
liteLog(op + " failed: " +
|
|
(result.error.empty() ? result.status.message : result.error));
|
|
}
|
|
}
|
|
|
|
std::vector<std::string> LiteWalletController::failoverServerUrls() const
|
|
{
|
|
std::vector<std::string> urls;
|
|
const auto add = [&urls](const std::string& url) {
|
|
if (!isLiteServerUrlUsable(url)) return;
|
|
if (std::find(urls.begin(), urls.end(), url) == urls.end()) urls.push_back(url);
|
|
};
|
|
// Preferred server first (honours the user's sticky/random selection), then every other
|
|
// usable configured server as a fallback so one dead server can't strand the wallet.
|
|
const auto selected = selectLiteServer(connectionSettings_);
|
|
if (selected.ok) add(selected.server.url);
|
|
for (const auto& endpoint : connectionSettings_.servers) {
|
|
if (endpoint.enabled) add(endpoint.url);
|
|
}
|
|
return urls;
|
|
}
|
|
|
|
bool LiteWalletController::beginOpenExisting() { return beginAsyncLifecycle(/*create=*/false); }
|
|
bool LiteWalletController::beginCreateWallet() { return beginAsyncLifecycle(/*create=*/true); }
|
|
|
|
bool LiteWalletController::beginAsyncLifecycle(bool create)
|
|
{
|
|
// Don't race a Settings-page async lifecycle request (create/open/restore) on the same bridge.
|
|
if (walletOpen_.load() || openRunning_->load() || lifecycleRunning_->load()) return false;
|
|
const char* verb = create ? "Create" : "Open";
|
|
if (lifecycle_.availability() != LiteWalletLifecycleAvailability::Ready) {
|
|
const std::string& reason = lifecycle_.status().message;
|
|
status_ = WalletBackendStatus{WalletBackendState::Error,
|
|
reason.empty() ? "lite wallet is not available" : reason, {}, {}, 0.0};
|
|
lastOpenError_ = status_.message;
|
|
liteLog(std::string(verb) + " blocked: " + lastOpenError_);
|
|
return false;
|
|
}
|
|
auto servers = failoverServerUrls();
|
|
if (servers.empty()) {
|
|
status_ = WalletBackendStatus{WalletBackendState::Error,
|
|
"no usable lite servers are configured", {}, {}, 0.0};
|
|
lastOpenError_ = status_.message;
|
|
liteLog(std::string(verb) + " blocked: " + lastOpenError_);
|
|
return false;
|
|
}
|
|
|
|
if (openThread_.joinable()) openThread_.join(); // a prior attempt has fully finished
|
|
{
|
|
std::lock_guard<std::mutex> lk(*openResultMutex_);
|
|
openResult_->reset();
|
|
}
|
|
openRunning_->store(true);
|
|
status_ = WalletBackendStatus{WalletBackendState::Connecting,
|
|
create ? "creating wallet" : "opening wallet", {}, {}, 0.0};
|
|
liteLog(std::string(create ? "Creating wallet — trying " : "Opening wallet — trying ") +
|
|
std::to_string(servers.size()) + " server(s)");
|
|
|
|
// Capture only shared refs + value copies (never `this`) so the thread can safely outlive us.
|
|
auto bridge = bridge_;
|
|
auto running = openRunning_;
|
|
auto resultMutex = openResultMutex_;
|
|
auto resultSlot = openResult_;
|
|
openThread_ = std::thread([bridge, servers, running, resultMutex, resultSlot, create]() {
|
|
OpenOutcome outcome;
|
|
std::string preferredError; // error from the FIRST (preferred) server — the actionable one
|
|
for (const auto& url : servers) {
|
|
if (!bridge) break;
|
|
liteLog(" connecting to " + url + " ...");
|
|
// create: initialize_new generates a NEW seed+wallet, but contacts the server FIRST,
|
|
// so an unreachable server fails before writing any file — safe to try the next.
|
|
// open: initialize_existing just loads the file. ok && non-empty value == ready.
|
|
auto call = create ? bridge->initializeNew(/*dangerous=*/false, url)
|
|
: bridge->initializeExisting(/*dangerous=*/false, url);
|
|
const bool ready = call.ok && !call.value.empty();
|
|
// create's response IS the secret seed — wipe our copy; it's read back via exportSeed().
|
|
if (create) secureWipeLiteSecret(call.value);
|
|
if (ready) {
|
|
liteLog(" " + url + ": connected");
|
|
// s2-D14: an open that fell back to the backup reports "OK: recovered from backup; damaged file kept
|
|
// as <name>". Never for create: its value was the seed (wiped above).
|
|
if (!create && call.value != "OK") liteLog(" " + call.value);
|
|
outcome.ok = true;
|
|
outcome.serverUrl = url;
|
|
break;
|
|
}
|
|
const std::string why = call.error.empty() ? "unreachable" : call.error;
|
|
liteLog(" " + url + ": " + why);
|
|
if (!call.error.empty()) {
|
|
if (preferredError.empty()) preferredError = call.error; // keep the preferred one
|
|
if (liteOpenErrorIsWarmup(call.error)) outcome.warming = true; // healthy, just starting
|
|
}
|
|
}
|
|
// On total failure, report the preferred server's error (not whichever broken fallback
|
|
// happened to be tried last), since that's the server the user expects to use.
|
|
if (!outcome.ok)
|
|
outcome.error = preferredError.empty() ? "could not reach any lite server" : preferredError;
|
|
{
|
|
std::lock_guard<std::mutex> lk(*resultMutex);
|
|
*resultSlot = outcome;
|
|
}
|
|
running->store(false);
|
|
});
|
|
return true;
|
|
}
|
|
|
|
void LiteWalletController::pumpAsyncOpen()
|
|
{
|
|
OpenOutcome outcome;
|
|
{
|
|
std::lock_guard<std::mutex> lk(*openResultMutex_);
|
|
if (!openResult_->has_value()) return; // nothing finished since last pump
|
|
outcome = std::move(openResult_->value());
|
|
openResult_->reset();
|
|
}
|
|
invalidateWalletExists(); // a real open result was consumed: the wallet file may have changed
|
|
if (openThread_.joinable()) openThread_.join(); // producer set its result, then exits
|
|
|
|
if (outcome.ok) {
|
|
walletOpen_ = true;
|
|
lastOpenError_.clear();
|
|
lastOpenWarming_ = false;
|
|
status_ = WalletBackendStatus{WalletBackendState::Ready, "wallet open", {}, {}, 0.0};
|
|
liteLog("Wallet ready via " + outcome.serverUrl);
|
|
connectedServerUrl_ = outcome.serverUrl;
|
|
if (persist_) persist_();
|
|
startSync(); // begin background sync on the backend
|
|
startWorker(); // begin periodic refresh -> WalletState
|
|
} else {
|
|
lastOpenError_ = outcome.error;
|
|
lastOpenWarming_ = outcome.warming; // a healthy server was warming up -> retry sooner
|
|
status_ = WalletBackendStatus{WalletBackendState::Error, outcome.error, {}, {}, 0.0};
|
|
liteLog("Open failed: " + outcome.error +
|
|
(outcome.warming ? " (a server is warming up — will retry shortly)" : ""));
|
|
}
|
|
}
|
|
|
|
bool LiteWalletController::beginCreateWalletAsync(LiteWalletCreateRequest request)
|
|
{
|
|
auto req = std::make_shared<LiteWalletCreateRequest>(std::move(request));
|
|
// Encrypt the brand-new wallet with the user's passphrase once it opens. The async worker
|
|
// can't (walletOpen_ isn't set until pumpLifecycleResult runs), so stash it and apply on the
|
|
// main-thread finalize — otherwise the passphrase is discarded and the seed stored in
|
|
// plaintext (audit C1). Mirrors the synchronous createWallet() wrapper.
|
|
stashPendingLifecycleSecurity(req->passphrase, PendingSecurityOp::Encrypt);
|
|
const bool started = beginLifecycleRequestAsync(
|
|
"Create",
|
|
[req](LiteWalletLifecycleService& svc, const std::string& url) {
|
|
req->serverUrl = url;
|
|
return svc.createWallet(*req);
|
|
},
|
|
[req]() { secureWipeLiteSecret(req->passphrase); });
|
|
if (!started) clearPendingLifecycleSecurity();
|
|
return started;
|
|
}
|
|
|
|
bool LiteWalletController::beginOpenWalletAsync(LiteWalletOpenRequest request)
|
|
{
|
|
auto req = std::make_shared<LiteWalletOpenRequest>(std::move(request));
|
|
// An existing wallet may be encrypted+locked — unlock it with the supplied passphrase on the
|
|
// main-thread finalize (the async worker can't; walletOpen_ isn't set yet). Without this an
|
|
// encrypted wallet opens but stays locked and the passphrase is never verified (audit M1).
|
|
stashPendingLifecycleSecurity(req->passphrase, PendingSecurityOp::Unlock);
|
|
const bool started = beginLifecycleRequestAsync(
|
|
"Open",
|
|
[req](LiteWalletLifecycleService& svc, const std::string& url) {
|
|
req->serverUrl = url;
|
|
return svc.openWallet(*req);
|
|
},
|
|
[req]() { secureWipeLiteSecret(req->passphrase); });
|
|
if (!started) clearPendingLifecycleSecurity();
|
|
return started;
|
|
}
|
|
|
|
bool LiteWalletController::beginRestoreWalletAsync(LiteWalletRestoreRequest request)
|
|
{
|
|
auto req = std::make_shared<LiteWalletRestoreRequest>(std::move(request));
|
|
// Encrypt the restored wallet with the user's passphrase once it opens (same rationale and
|
|
// main-thread finalize as create — audit C1).
|
|
stashPendingLifecycleSecurity(req->passphrase, PendingSecurityOp::Encrypt);
|
|
const bool started = beginLifecycleRequestAsync(
|
|
"Restore",
|
|
[req](LiteWalletLifecycleService& svc, const std::string& url) {
|
|
req->serverUrl = url;
|
|
return svc.restoreWallet(*req);
|
|
},
|
|
[req]() {
|
|
secureWipeLiteSecret(req->seedPhrase);
|
|
secureWipeLiteSecret(req->passphrase);
|
|
});
|
|
if (!started) clearPendingLifecycleSecurity();
|
|
return started;
|
|
}
|
|
|
|
bool LiteWalletController::beginLifecycleRequestAsync(
|
|
const char* verb,
|
|
std::function<LiteWalletLifecycleResult(LiteWalletLifecycleService&, const std::string&)> exec,
|
|
std::function<void()> wipeSecrets)
|
|
{
|
|
// Reject (and still wipe the caller's secrets) if a wallet is already open or any async
|
|
// open/lifecycle attempt is in flight.
|
|
if (walletOpen_.load() || lifecycleRunning_->load() || openRunning_->load()) {
|
|
if (wipeSecrets) wipeSecrets();
|
|
return false;
|
|
}
|
|
auto servers = failoverServerUrls();
|
|
if (servers.empty()) {
|
|
if (wipeSecrets) wipeSecrets();
|
|
status_ = WalletBackendStatus{WalletBackendState::Error,
|
|
"no usable lite servers are configured", {}, {}, 0.0};
|
|
lastOpenError_ = status_.message;
|
|
liteLog(std::string(verb) + " blocked: " + lastOpenError_);
|
|
return false;
|
|
}
|
|
|
|
if (lifecycleThread_.joinable()) lifecycleThread_.join(); // a prior attempt has fully finished
|
|
{
|
|
std::lock_guard<std::mutex> lk(*lifecycleResultMutex_);
|
|
lifecycleResult_->reset();
|
|
}
|
|
lifecycleRunning_->store(true);
|
|
status_ = WalletBackendStatus{WalletBackendState::Connecting,
|
|
std::string(verb) + " wallet…", {}, {}, 0.0};
|
|
liteLog(std::string(verb) + " wallet — trying " + std::to_string(servers.size()) + " server(s)");
|
|
|
|
// Capture only value copies + the shared bridge (never `this`): the thread builds its own
|
|
// local lifecycle service, so it can safely outlive the controller (mirrors the open thread).
|
|
auto bridge = bridge_;
|
|
auto caps = capabilities_;
|
|
auto conn = connectionSettings_;
|
|
auto opts = lifecycleOptions_;
|
|
auto running = lifecycleRunning_;
|
|
auto resultMutex = lifecycleResultMutex_;
|
|
auto resultSlot = lifecycleResult_;
|
|
lifecycleThread_ = std::thread(
|
|
[bridge, caps, conn, opts, servers, exec, wipeSecrets, running, resultMutex, resultSlot]() {
|
|
LiteWalletLifecycleService localLifecycle(caps, conn, bridge.get(), opts);
|
|
LiteWalletLifecycleResult chosen;
|
|
bool have = false;
|
|
for (const auto& url : servers) {
|
|
if (!bridge) break;
|
|
liteLog(" connecting to " + url + " ...");
|
|
LiteWalletLifecycleResult r = exec(localLifecycle, url);
|
|
if (r.walletReady) {
|
|
liteLog(" " + url + ": ready");
|
|
chosen = std::move(r);
|
|
have = true;
|
|
break;
|
|
}
|
|
// A non-attempted result is a structural block (availability / validation): the same
|
|
// for every server, so stop and surface it rather than retrying pointlessly.
|
|
if (!r.attempted) {
|
|
chosen = std::move(r);
|
|
have = true;
|
|
break;
|
|
}
|
|
liteLog(" " + url + ": " + (r.error.empty() ? r.status.message : r.error));
|
|
// Keep the PREFERRED (first) server's failure — the actionable one for the user —
|
|
// rather than whichever fallback happened to be tried last.
|
|
if (!have) {
|
|
chosen = std::move(r);
|
|
have = true;
|
|
}
|
|
}
|
|
if (!have) {
|
|
chosen.error = "could not reach any lite server";
|
|
chosen.status = WalletBackendStatus{WalletBackendState::Error, chosen.error, {}, {}, 0.0};
|
|
}
|
|
if (wipeSecrets) wipeSecrets(); // wipe the request's secrets once the attempt is done
|
|
{
|
|
std::lock_guard<std::mutex> lk(*resultMutex);
|
|
*resultSlot = std::move(chosen);
|
|
}
|
|
running->store(false);
|
|
});
|
|
return true;
|
|
}
|
|
|
|
void LiteWalletController::pumpLifecycleResult()
|
|
{
|
|
LiteWalletLifecycleResult out;
|
|
{
|
|
std::lock_guard<std::mutex> lk(*lifecycleResultMutex_);
|
|
if (!lifecycleResult_->has_value()) return; // nothing finished since last pump
|
|
out = std::move(lifecycleResult_->value());
|
|
lifecycleResult_->reset();
|
|
}
|
|
invalidateWalletExists(); // a real lifecycle result was consumed: the wallet file may have changed
|
|
if (lifecycleThread_.joinable()) lifecycleThread_.join(); // producer set its result, then exits
|
|
|
|
// Finalize on the main thread: flip walletOpen()/status, persist, start sync/worker on success;
|
|
// log the failure otherwise (shared with the synchronous lifecycle path).
|
|
onLifecycleResult(out, /*deferBackground=*/true);
|
|
if (out.walletReady) {
|
|
// Wallet is now open (walletOpen_ set by onLifecycleResult) — apply the stashed passphrase BEFORE the scan
|
|
// starts: encrypt a freshly created/restored wallet, or unlock an existing encrypted one (C1/M1). The save in
|
|
// encrypt waits on the backend sync_lock, which a running scan holds for its whole duration (C24); with no scan
|
|
// yet it is a millisecond save of a small, unscanned wallet, and the scan then runs on the encrypted file.
|
|
applyPendingLifecycleSecurity();
|
|
startSync(); // begin background sync on the backend
|
|
startWorker(); // begin periodic refresh -> WalletState (via takeRefreshedModel)
|
|
lastOpenError_.clear();
|
|
lastOpenWarming_ = false;
|
|
} else {
|
|
clearPendingLifecycleSecurity(); // wallet didn't open — drop the stashed passphrase
|
|
lastOpenError_ = out.error.empty() ? out.status.message : out.error;
|
|
lastOpenWarming_ = liteOpenErrorIsWarmup(lastOpenError_);
|
|
}
|
|
lastLifecycleResult_ = std::move(out);
|
|
}
|
|
|
|
void LiteWalletController::stashPendingLifecycleSecurity(const std::string& passphrase,
|
|
PendingSecurityOp op)
|
|
{
|
|
clearPendingLifecycleSecurity(); // wipe any stale prior stash first
|
|
pendingLifecyclePassphrase_ = passphrase; // independent copy; wiped in apply/clear
|
|
pendingLifecycleSecurityOp_ = op;
|
|
}
|
|
|
|
void LiteWalletController::applyPendingLifecycleSecurity()
|
|
{
|
|
const PendingSecurityOp op = pendingLifecycleSecurityOp_;
|
|
if (op == PendingSecurityOp::None || pendingLifecyclePassphrase_.empty() ||
|
|
!walletOpen_.load() || !bridge_) {
|
|
clearPendingLifecycleSecurity();
|
|
return;
|
|
}
|
|
if (op == PendingSecurityOp::Encrypt) {
|
|
// encryptWallet() takes its own copy and wipes it; the backend encrypts + locks + saves.
|
|
const auto enc = encryptWallet(pendingLifecyclePassphrase_);
|
|
if (!enc.ok) {
|
|
lifecycleEncryptFailed_ = true; // surfaced to the user by the App (takeEncryptionFailureNotice)
|
|
liteLog("wallet created/restored but ENCRYPTION FAILED — wallet is NOT encrypted: " +
|
|
enc.error);
|
|
}
|
|
} else if (op == PendingSecurityOp::Unlock) {
|
|
if (!unlockWallet(pendingLifecyclePassphrase_))
|
|
liteLog("wallet opened but unlock failed (wrong passphrase?)");
|
|
}
|
|
clearPendingLifecycleSecurity();
|
|
}
|
|
|
|
void LiteWalletController::clearPendingLifecycleSecurity()
|
|
{
|
|
secureWipeLiteSecret(pendingLifecyclePassphrase_); // no-op if already empty
|
|
pendingLifecycleSecurityOp_ = PendingSecurityOp::None;
|
|
}
|
|
|
|
void LiteWalletController::startSync()
|
|
{
|
|
if (syncLaunched_.exchange(true)) return;
|
|
syncStarted_ = true;
|
|
liteLog("Background sync started");
|
|
// 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_;
|
|
auto scan = scan_;
|
|
syncThread_ = std::thread([bridge, done, scan] { runLiteScanThread(bridge, done, scan, "sync"); });
|
|
}
|
|
|
|
bool LiteWalletController::startRescan()
|
|
{
|
|
if (!walletOpen_.load()) return false;
|
|
// A queued wallet op's save would wait out the whole re-scan (sync_lock): don't start one under it.
|
|
if (!pendingOps_.empty()) return false;
|
|
// Refuse if a sync/rescan is already running (would race two scans on the backend wallet lock).
|
|
if (!syncDone_ || !syncDone_->load()) return false;
|
|
|
|
// Reset the done flag so refreshModel() re-enters its "scanning, publish progress only" path
|
|
// and the UI shows progress again; clearing it BEFORE launching the thread avoids a window
|
|
// where the worker would query balances mid-rescan.
|
|
syncDone_->store(false);
|
|
syncStarted_ = true;
|
|
liteLog("Block re-download (rescan) started");
|
|
|
|
// The previous sync/rescan thread has finished (syncDone_ was true above); detach it before
|
|
// reassigning syncThread_. Like startSync's thread it captures shared refs (bridge_ + syncDone_),
|
|
// never `this`, so detaching is safe.
|
|
if (syncThread_.joinable()) syncThread_.detach();
|
|
|
|
auto bridge = bridge_;
|
|
auto done = syncDone_;
|
|
auto scan = scan_;
|
|
scan->failed.store(false); // a fresh attempt: the previous failure no longer applies
|
|
scan->setError(std::string());
|
|
// `rescan` clears the wallet's synced block cache and re-downloads/re-scans from the birthday height — a
|
|
// blocking, uninterruptible full scan, same as `sync`. Retries (if it fails) are `sync`.
|
|
syncThread_ = std::thread([bridge, done, scan] { runLiteScanThread(bridge, done, scan, "rescan"); });
|
|
return true;
|
|
}
|
|
|
|
std::optional<LiteWalletAppRefreshModel> LiteWalletController::refreshModel()
|
|
{
|
|
return refreshModelImpl(/*honorStop=*/false, nullptr); // tests / direct callers: never cancelled
|
|
}
|
|
|
|
std::optional<LiteWalletAppRefreshModel> LiteWalletController::refreshModelImpl(bool honorStop, bool* syncReadFailed)
|
|
{
|
|
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.syncError = scan_->errorCopy();
|
|
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;
|
|
// Skip `info` (a fresh TLS connection + GetLightdInfo every cycle, and only a fallback for the chat
|
|
// tip, which the height below already provides) and the gateway's `height` (the explicit call after
|
|
// the refresh replaces it, and runs after the syncing commands so it sees the newest block).
|
|
request.includeInfo = false;
|
|
request.includeHeight = false;
|
|
if (syncResult.ok) {
|
|
request.haveSyncStatus = true;
|
|
request.syncStatus = syncResult.syncStatus;
|
|
}
|
|
if (honorStop) { // background worker only: stop between backend commands, don't hammer a dead server
|
|
request.keepGoing = [this]() { return running_.load(); };
|
|
request.stopSyncReadsOnFailure = true;
|
|
}
|
|
const auto refreshResult = gateway_.refresh(request);
|
|
if (refreshResult.cancelled) return std::nullopt; // controller is stopping: discard the partial model
|
|
if (syncReadFailed) *syncReadFailed = refreshResult.syncReadFailed;
|
|
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);
|
|
|
|
// `syncstatus` only reports synced_blocks/total_blocks WHILE actively scanning; once idle it
|
|
// returns just {"syncing":"false"}, so the mapped walletHeight is 0 and the status bar showed
|
|
// "blocks: 0" when fully synced. Query the wallet's last-scanned height (a fast local read) and
|
|
// surface it as the synced height/tip so the block count is correct at rest.
|
|
// Do not force "synced" from the height read while syncstatus says the backend is still scanning (work remaining)
|
|
// or could not be interpreted: the height only proves the last scanned block, not that we reached the tip.
|
|
const bool backendBusy = syncResult.ok && !syncResult.syncStatus.complete &&
|
|
(syncResult.syncStatus.indeterminate ||
|
|
syncResult.syncStatus.syncedBlocks < syncResult.syncStatus.totalBlocks);
|
|
if (bridge_ && !backendBusy && (!honorStop || running_.load())) {
|
|
const auto h = bridge_->execute("height", "");
|
|
if (h.ok) {
|
|
const auto parsed = parseLiteHeightResponse(h.value);
|
|
if (parsed.ok && parsed.height.height > 0) {
|
|
model.hasSyncStatus = true;
|
|
model.sync.walletHeight = parsed.height.height;
|
|
model.sync.chainHeight = parsed.height.height; // synced: wallet height == chain tip
|
|
model.sync.complete = true;
|
|
model.sync.progress = 1.0;
|
|
}
|
|
}
|
|
}
|
|
// A scan that gave up must not read as "synced". Recovery is observed, not guessed: the data commands each run a
|
|
// backend sync, so a refresh whose server-bound reads all succeeded means the wallet really caught up.
|
|
if (scan_->failed.load()) {
|
|
if (!refreshResult.syncReadFailed && refreshResult.bundle.successfulCommandCount > 0) {
|
|
scan_->failed.store(false);
|
|
scan_->setError(std::string());
|
|
} else if (model.hasSyncStatus) {
|
|
model.sync.complete = false;
|
|
model.sync.progress = std::min(model.sync.progress, 0.999);
|
|
}
|
|
}
|
|
// A server-bound read failed this cycle (server lost after catch-up): not synced, and say why. Without this the
|
|
// height override above kept reporting Synced because only a failed *scan* (scan_->failed) demoted it.
|
|
if (refreshResult.syncReadFailed && model.hasSyncStatus) {
|
|
model.sync.complete = false;
|
|
model.sync.progress = std::min(model.sync.progress, 0.999);
|
|
}
|
|
model.syncError = scan_->errorCopy();
|
|
if (model.syncError.empty() && refreshResult.syncReadFailed) model.syncError = "server unreachable";
|
|
return model;
|
|
}
|
|
|
|
LiteNewAddressResult LiteWalletController::newAddress(bool shielded)
|
|
{
|
|
if (!walletOpen_.load() || !bridge_) {
|
|
LiteNewAddressResult out;
|
|
out.error = "no wallet is open";
|
|
return out;
|
|
}
|
|
return newAddressCore(*bridge_, shielded);
|
|
}
|
|
|
|
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;
|
|
}
|
|
|
|
bool LiteWalletController::runConsoleCommand(std::string commandLine)
|
|
{
|
|
if (!bridge_ || consoleRunning_->load()) return false;
|
|
|
|
// Split into command (first token) + the remainder as a single arg string: litelib_execute
|
|
// passes args through as ONE element (it does not whitespace-split), matching how send uses
|
|
// the JSON-array form.
|
|
const size_t cmdBegin = commandLine.find_first_not_of(" \t");
|
|
if (cmdBegin == std::string::npos) return false; // blank line
|
|
const size_t cmdEnd = commandLine.find_first_of(" \t", cmdBegin);
|
|
std::string command = commandLine.substr(
|
|
cmdBegin, cmdEnd == std::string::npos ? std::string::npos : cmdEnd - cmdBegin);
|
|
std::string args;
|
|
if (cmdEnd != std::string::npos) {
|
|
const size_t argBegin = commandLine.find_first_not_of(" \t", cmdEnd);
|
|
if (argBegin != std::string::npos) args = commandLine.substr(argBegin);
|
|
}
|
|
|
|
if (consoleThread_.joinable()) consoleThread_.join(); // a prior command has fully finished
|
|
{
|
|
std::lock_guard<std::mutex> lk(*consoleResultMutex_);
|
|
consoleResult_->reset();
|
|
}
|
|
consoleRunning_->store(true);
|
|
|
|
// Capture only the shared bridge + flags/slot (never `this`) so the thread can outlive us.
|
|
auto bridge = bridge_;
|
|
auto running = consoleRunning_;
|
|
auto mutex = consoleResultMutex_;
|
|
auto slot = consoleResult_;
|
|
consoleThread_ = std::thread(
|
|
[bridge, command, args, echo = std::move(commandLine), running, mutex, slot]() {
|
|
LiteConsoleResult r;
|
|
r.command = echo;
|
|
if (bridge) {
|
|
const auto call = bridge->execute(command, args);
|
|
r.ok = call.ok;
|
|
r.response = call.ok ? call.value
|
|
: (call.error.empty() ? "command failed" : call.error);
|
|
// send/shield/import mutate wallet state and the backend does NOT auto-save (same reason
|
|
// doSend/doShield call persistAfterBroadcast). Persist so a console-driven tx survives a
|
|
// restart instead of only being re-derived on the next full sync. (M-02)
|
|
if (call.ok && (command == "send" || command == "shield" ||
|
|
command == "import" || command == "timport")) {
|
|
persistAfterBroadcast(*bridge);
|
|
}
|
|
} else {
|
|
r.response = "lite backend unavailable";
|
|
}
|
|
{
|
|
std::lock_guard<std::mutex> lk(*mutex);
|
|
*slot = std::move(r);
|
|
}
|
|
running->store(false);
|
|
});
|
|
return true;
|
|
}
|
|
|
|
bool LiteWalletController::takeConsoleResult(LiteConsoleResult& out)
|
|
{
|
|
{
|
|
std::lock_guard<std::mutex> lk(*consoleResultMutex_);
|
|
if (!consoleResult_->has_value()) return false;
|
|
out = std::move(**consoleResult_);
|
|
consoleResult_->reset();
|
|
}
|
|
if (consoleThread_.joinable()) consoleThread_.join(); // producer set its result, then exits
|
|
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;
|
|
}
|
|
return importKeyCore(*bridge_, spendingOrViewingKey); // wipes the key
|
|
}
|
|
|
|
// Recursively zero every string node of a parsed secret JSON tree (seed / private keys) so the parse
|
|
// copies don't linger in freed heap. Best-effort (nlohmann's own lexer scratch isn't reachable).
|
|
static void wipeJsonStrings(nlohmann::json& j)
|
|
{
|
|
if (j.is_string()) { secureWipeLiteSecret(j.get_ref<nlohmann::json::string_t&>()); }
|
|
else if (j.is_object() || j.is_array()) { for (auto& el : j) wipeJsonStrings(el); }
|
|
}
|
|
|
|
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.
|
|
auto result = bridge_->execute("export", optionalAddress); // non-const: result.value is a secret
|
|
if (!result.ok) {
|
|
out.error = result.error.empty() ? "export failed" : result.error;
|
|
return out;
|
|
}
|
|
nlohmann::json j;
|
|
// result.value + the parse tree hold the private keys; wipe them on every return path. out already
|
|
// has its own copy (out.privateKeysJson) which the caller wipes after use.
|
|
struct Scrub { std::string& v; nlohmann::json& tree; ~Scrub() { secureWipeLiteSecret(v); wipeJsonStrings(tree); } }
|
|
scrub{result.value, j};
|
|
try {
|
|
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;
|
|
}
|
|
auto result = bridge_->execute("seed", ""); // non-const: result.value holds the seed
|
|
if (!result.ok) {
|
|
out.error = result.error.empty() ? "seed export failed" : result.error;
|
|
return out;
|
|
}
|
|
nlohmann::json j;
|
|
// result.value + the parse tree contain the seed phrase; wipe them on every return path (out.seedPhrase
|
|
// is extracted separately and returned to the caller).
|
|
struct Scrub { std::string& v; nlohmann::json& tree; ~Scrub() { secureWipeLiteSecret(v); wipeJsonStrings(tree); } }
|
|
scrub{result.value, j};
|
|
try {
|
|
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>();
|
|
}
|
|
if (isZeroEntropySeedPhrase(out.seedPhrase)) {
|
|
// A backend built before the seed-export fix returns this for a locked wallet whose seed is
|
|
// wiped. It is never the real backup, so it must not be shown or used to derive anything.
|
|
secureWipeLiteSecret(out.seedPhrase);
|
|
out.error = "backend returned the all-zero placeholder seed (is the wallet locked?)";
|
|
return out;
|
|
}
|
|
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;
|
|
}
|
|
|
|
template <class R>
|
|
bool LiteWalletController::submitWalletOp(std::function<R()> core,
|
|
std::function<void(const R&)> onDone,
|
|
std::function<R(const std::string&)> makeFailure)
|
|
{
|
|
if (retired_.load() || !walletOpen_.load() || !bridge_) {
|
|
if (onDone) onDone(makeFailure(retired_.load() ? "lite wallet reconnecting" : "no wallet is open"));
|
|
return false;
|
|
}
|
|
const std::uint64_t id = nextOpId_++;
|
|
pendingOps_[id] = [onDone, makeFailure](const std::string& why) {
|
|
if (onDone) onDone(makeFailure(why));
|
|
};
|
|
// The job runs on the lane thread and never dereferences `this`; only its completion does, and only
|
|
// pumpWalletOps() runs completions (main thread, controller alive). A throwing core still yields a completion,
|
|
// so the UI callback can never be left pending.
|
|
const bool queued = opLane_.post(
|
|
[this, id, core = std::move(core), onDone, makeFailure,
|
|
retired = opsRetired_]() -> util::SerialJobLane::Completion {
|
|
R result;
|
|
if (retired->load()) {
|
|
result = makeFailure(kLiteOpRetiredError); // dequeued just before retire(): never start it
|
|
} else {
|
|
try { result = core(); } catch (...) { result = makeFailure("internal error"); }
|
|
}
|
|
return [this, id, onDone, result = std::move(result)]() {
|
|
if (pendingOps_.erase(id) == 0) return; // already failed by failPendingWalletOps(): exactly once
|
|
if (onDone) onDone(result);
|
|
};
|
|
});
|
|
if (!queued) { // lane abandoned or no thread could be started: fail now, exactly once
|
|
auto it = pendingOps_.find(id);
|
|
if (it != pendingOps_.end()) {
|
|
auto fail = std::move(it->second);
|
|
pendingOps_.erase(it);
|
|
if (fail) fail("could not start the wallet operation");
|
|
}
|
|
return false;
|
|
}
|
|
return true;
|
|
}
|
|
|
|
bool LiteWalletController::newAddressAsync(bool shielded, LiteNewAddressCallback onDone)
|
|
{
|
|
auto bridge = bridge_;
|
|
return submitWalletOp<LiteNewAddressResult>(
|
|
[bridge, shielded]() { return newAddressCore(*bridge, shielded); },
|
|
std::move(onDone),
|
|
[](const std::string& why) { LiteNewAddressResult r; r.error = why; return r; });
|
|
}
|
|
|
|
bool LiteWalletController::importKeyAsync(std::string spendingOrViewingKey, LiteImportCallback onDone)
|
|
{
|
|
auto fail = [](const std::string& why) { LiteImportResult r; r.error = why; return r; };
|
|
if (spendingOrViewingKey.empty()) {
|
|
if (onDone) onDone(fail("no key provided"));
|
|
return false;
|
|
}
|
|
auto key = makeWipingSecretCopy(spendingOrViewingKey);
|
|
secureWipeLiteSecret(spendingOrViewingKey);
|
|
auto bridge = bridge_;
|
|
auto retired = opsRetired_;
|
|
return submitWalletOp<LiteImportResult>(
|
|
[bridge, key, retired]() { return importKeyCore(*bridge, *key, retired.get()); }, std::move(onDone), fail);
|
|
}
|
|
|
|
bool LiteWalletController::encryptWalletAsync(std::string passphrase, LiteEncryptionCallback onDone)
|
|
{
|
|
return submitEncryptionChange(passphrase, std::move(onDone), /*encrypt=*/true);
|
|
}
|
|
|
|
bool LiteWalletController::decryptWalletAsync(std::string passphrase, LiteEncryptionCallback onDone)
|
|
{
|
|
return submitEncryptionChange(passphrase, std::move(onDone), /*encrypt=*/false);
|
|
}
|
|
|
|
bool LiteWalletController::submitEncryptionChange(std::string& passphrase, LiteEncryptionCallback onDone,
|
|
bool encrypt)
|
|
{
|
|
auto fail = [](const std::string& why) { LiteEncryptionResult r; r.error = why; return r; };
|
|
if (passphrase.empty()) {
|
|
if (onDone) onDone(fail("passphrase required"));
|
|
return false;
|
|
}
|
|
auto pass = makeWipingSecretCopy(passphrase);
|
|
secureWipeLiteSecret(passphrase);
|
|
auto bridge = bridge_;
|
|
auto retired = opsRetired_;
|
|
return submitWalletOp<LiteEncryptionResult>(
|
|
[bridge, pass, encrypt, retired]() { return encryptionChangeCore(*bridge, *pass, encrypt, retired.get()); },
|
|
std::move(onDone), fail);
|
|
}
|
|
|
|
void LiteWalletController::failPendingWalletOps(const std::string& reason)
|
|
{
|
|
auto pending = std::move(pendingOps_); // move out first: a callback may submit a new op
|
|
pendingOps_.clear();
|
|
for (auto& kv : pending) if (kv.second) kv.second(reason);
|
|
}
|
|
|
|
void LiteWalletController::retire(const std::string& reason)
|
|
{
|
|
retired_.store(true); // later submits are rejected inline; the destructor skips its final save
|
|
opsRetired_->store(true); // a running op issues no further backend command (it would hit the NEXT client)
|
|
opLane_.pump(); // ops that already finished are delivered with their real result, not "reconnecting"
|
|
opLane_.abandon(); // queued ops never run (they would hit the NEXT global client); secrets wiped
|
|
failPendingWalletOps(reason); // every UI callback fires exactly once (normally none: App defers the switch)
|
|
scan_->cancel.store(true); // the detached sync thread stops retrying (it may be in a back-off sleep)
|
|
requestStop(); // non-blocking: the worker exits after its in-flight backend command
|
|
if (bridge_) bridge_->shutdown(); // stop THIS client's mempool monitor while it is still the global one
|
|
}
|
|
|
|
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.
|
|
// Cached ~2s: called every frame while no wallet is open (main-thread only).
|
|
const auto now = std::chrono::steady_clock::now();
|
|
if (wexCached_ && now - wexAt_ < std::chrono::seconds(2)) return wexValue_;
|
|
wexValue_ = bridge_ && bridge_->walletExists(chainName_);
|
|
wexCached_ = true;
|
|
wexAt_ = now;
|
|
return wexValue_;
|
|
}
|
|
|
|
bool LiteWalletController::walletExistsFresh() const
|
|
{
|
|
wexValue_ = bridge_ && bridge_->walletExists(chainName_);
|
|
wexCached_ = true;
|
|
wexAt_ = std::chrono::steady_clock::now();
|
|
return wexValue_;
|
|
}
|
|
|
|
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;
|
|
}
|
|
return encryptionChangeCore(*bridge_, passphrase, /*encrypt=*/true); // encrypt + save as one unit; wipes
|
|
}
|
|
|
|
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;
|
|
}
|
|
return encryptionChangeCore(*bridge_, passphrase, /*encrypt=*/false); // decrypt + save as one unit; wipes
|
|
}
|
|
|
|
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::takeEncryptionFailureNotice()
|
|
{
|
|
const bool failed = lifecycleEncryptFailed_;
|
|
lifecycleEncryptFailed_ = false;
|
|
return failed;
|
|
}
|
|
|
|
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::requestStop()
|
|
{
|
|
{
|
|
std::lock_guard<std::mutex> lk(wakeMutex_); // flip under the mutex the worker waits on: no lost wakeup
|
|
running_.store(false);
|
|
}
|
|
wakeCv_.notify_all();
|
|
}
|
|
|
|
void LiteWalletController::stopWorker()
|
|
{
|
|
requestStop();
|
|
if (worker_.joinable()) worker_.join(); // NOT conditional on running_: retire() may already have cleared it
|
|
}
|
|
|
|
void LiteWalletController::workerLoop()
|
|
{
|
|
int failStreak = 0; // consecutive cycles whose syncing reads hit a backend error
|
|
RefreshGate gate; // worker-local; haveFull=false => the FIRST cycle is always a full refresh
|
|
std::optional<LiteWalletAppRefreshModel> lastModel; // last published full model (to demote on a failed probe)
|
|
while (running_.load()) {
|
|
if (walletOpen_.load()) {
|
|
const GateDecision decision = evaluateRefreshGate(gate, failStreak);
|
|
if (decision == GateDecision::Skip) {
|
|
// Nothing changed since the last full model: no gateway refresh, no re-publish.
|
|
// pendingModel_ is left untouched (the UI keeps the last applied model).
|
|
} else if (decision == GateDecision::ProbeFailed) {
|
|
// The cheap `sync` probe failed (server unreachable / behind / terminal reorg). Do NOT
|
|
// run a second do_sync this cycle; treat it as a failed cycle so the existing back-off
|
|
// applies and we don't double the dead-server stall. Next cycle takes the full path.
|
|
failStreak += 1;
|
|
// The probe path never re-derived sync state, so the last 'Synced' model stayed on screen while the
|
|
// server was gone. Re-publish it demoted, with the reason.
|
|
if (lastModel && running_.load()) {
|
|
auto demoted = *lastModel;
|
|
if (demoted.hasSyncStatus) {
|
|
demoted.sync.complete = false;
|
|
demoted.sync.progress = std::min(demoted.sync.progress, 0.999);
|
|
}
|
|
if (demoted.syncError.empty()) demoted.syncError = "server unreachable";
|
|
lastModel = demoted;
|
|
std::lock_guard<std::mutex> lock(modelMutex_);
|
|
pendingModel_ = std::move(demoted);
|
|
}
|
|
} else { // DoFull
|
|
// Sample the change baselines BEFORE the data commands (race rule).
|
|
const std::uint64_t epoch0 = bridge_ ? bridge_->mutationEpoch() : 0;
|
|
const auto height0 = readScannedHeight();
|
|
bool syncReadFailed = false;
|
|
auto model = refreshModelImpl(/*honorStop=*/true, &syncReadFailed);
|
|
if (model && running_.load()) {
|
|
const auto& m = *model;
|
|
const bool full = m.hasBalance && m.hasSyncStatus && m.sync.complete
|
|
&& m.syncError.empty() && !syncReadFailed && height0.has_value();
|
|
gate.haveFull = full;
|
|
gate.height = height0.value_or(0);
|
|
gate.epoch = epoch0;
|
|
gate.encOk = m.hasEncryptionStatus;
|
|
gate.encrypted = m.encrypted;
|
|
gate.locked = m.locked;
|
|
gate.fullAt = std::chrono::steady_clock::now();
|
|
lastModel = m;
|
|
std::lock_guard<std::mutex> lock(modelMutex_);
|
|
pendingModel_ = std::move(model);
|
|
} else {
|
|
gate.haveFull = false; // syncing / failed / cancelled: next cycle is a full path
|
|
}
|
|
failStreak = syncReadFailed ? failStreak + 1 : 0;
|
|
}
|
|
}
|
|
// Healthy: every 2 s. After a failed read (or a failed probe): 4 s, 8 s, then 15 s, so a dead
|
|
// server isn't retried back-to-back (each attempt is one do_sync with up to 62 s of back-off).
|
|
std::unique_lock<std::mutex> lock(wakeMutex_);
|
|
wakeCv_.wait_for(lock,
|
|
std::chrono::milliseconds(liteRefreshDelayMs(failStreak, refreshIntervalMs_.load())),
|
|
[this] { return !running_.load(); });
|
|
}
|
|
}
|
|
|
|
// Worker-thread only. Decide whether this cycle can skip the full refresh. A cycle is a Skip ONLY when
|
|
// the wallet is synced, healthy, and every cheap signal proves nothing changed since the last published
|
|
// full model. ANY failed/unparsable read, or any doubt, returns DoFull (fail toward freshness). Runs
|
|
// exactly one server-bound `sync` probe (the mandatory confirmed-block ingest); a probe failure returns
|
|
// ProbeFailed so the caller backs off without a second sync.
|
|
LiteWalletController::GateDecision
|
|
LiteWalletController::evaluateRefreshGate(RefreshGate& g, int failStreak)
|
|
{
|
|
if (!g.haveFull || failStreak != 0 || !bridge_ || !running_.load())
|
|
return GateDecision::DoFull;
|
|
if (!syncDone_->load() || scan_->failed.load()) {
|
|
g.haveFull = false;
|
|
return GateDecision::DoFull;
|
|
}
|
|
if (std::chrono::steady_clock::now() - g.fullAt
|
|
>= std::chrono::milliseconds(gateHeartbeatMs_.load()))
|
|
return GateDecision::DoFull; // heartbeat: bounded-staleness backstop
|
|
if (bridge_->mutationEpoch() != g.epoch) // local mutation since the baseline
|
|
return GateDecision::DoFull;
|
|
|
|
// The mandatory one-connection confirmed-block ingest. Without it the wallet never advances.
|
|
const auto probe = bridge_->execute("sync", "");
|
|
if (!running_.load()) return GateDecision::DoFull;
|
|
if (!probe.ok || !parseLiteSyncOpResponse(probe.value).ok) {
|
|
g.haveFull = false;
|
|
return GateDecision::ProbeFailed;
|
|
}
|
|
|
|
const auto h = readScannedHeight();
|
|
if (!h.has_value() || *h == 0 || *h != g.height)
|
|
return GateDecision::DoFull;
|
|
|
|
const auto enc = encryptionStatus();
|
|
if (!enc.ok || !g.encOk || enc.encrypted != g.encrypted || enc.locked != g.locked)
|
|
return GateDecision::DoFull;
|
|
|
|
if (bridge_->mutationEpoch() != g.epoch) return GateDecision::DoFull;
|
|
if (!syncDone_->load() || !running_.load()) { g.haveFull = false; return GateDecision::DoFull; }
|
|
|
|
return GateDecision::Skip; // provably unchanged
|
|
}
|
|
|
|
std::optional<std::uint64_t> LiteWalletController::readScannedHeight()
|
|
{
|
|
if (!bridge_) return std::nullopt;
|
|
const auto h = bridge_->execute("height", ""); // local read in the backend (no TLS connection)
|
|
if (!h.ok) return std::nullopt;
|
|
const auto parsed = parseLiteHeightResponse(h.value);
|
|
if (!parsed.ok || parsed.height.height == 0) return std::nullopt;
|
|
return static_cast<std::uint64_t>(parsed.height.height);
|
|
}
|
|
|
|
LiteWalletLifecycleResult LiteWalletController::createWallet(LiteWalletCreateRequest request)
|
|
{
|
|
auto result = lifecycle_.createWallet(request);
|
|
onLifecycleResult(result);
|
|
// If the user supplied a passphrase, encrypt the brand-new wallet with it now that it's open
|
|
// (the backend encrypts + locks + saves). Previously this passphrase was collected but never
|
|
// used (W5-3) — a passphrase field that silently did nothing. encryptWallet() takes its own
|
|
// copy and wipes it.
|
|
if (walletOpen_.load() && !request.passphrase.empty()) {
|
|
const auto enc = encryptWallet(request.passphrase);
|
|
if (!enc.ok) {
|
|
lifecycleEncryptFailed_ = true; // the wallet is open but NOT encrypted; App surfaces this
|
|
liteLog("wallet created but encryption failed: " + enc.error);
|
|
}
|
|
}
|
|
secureWipeLiteSecret(request.passphrase);
|
|
return result;
|
|
}
|
|
|
|
LiteWalletLifecycleResult LiteWalletController::openWallet(LiteWalletOpenRequest request)
|
|
{
|
|
auto result = lifecycle_.openWallet(request);
|
|
onLifecycleResult(result);
|
|
// An existing wallet may be encrypted + locked — use the supplied passphrase to unlock it so it
|
|
// opens ready to use. Only meaningful when the wallet is actually locked (W5-3).
|
|
if (walletOpen_.load() && !request.passphrase.empty()) {
|
|
const auto encStatus = encryptionStatus();
|
|
if (encStatus.ok && encStatus.encrypted && encStatus.locked) {
|
|
if (!unlockWallet(request.passphrase))
|
|
liteLog("wallet opened but unlock failed (wrong passphrase?)");
|
|
}
|
|
}
|
|
secureWipeLiteSecret(request.passphrase);
|
|
return result;
|
|
}
|
|
|
|
LiteWalletLifecycleResult LiteWalletController::restoreWallet(LiteWalletRestoreRequest request)
|
|
{
|
|
auto result = lifecycle_.restoreWallet(request);
|
|
onLifecycleResult(result);
|
|
// If the user supplied a passphrase, encrypt the restored wallet with it now that it's open (W5-3).
|
|
if (walletOpen_.load() && !request.passphrase.empty()) {
|
|
const auto enc = encryptWallet(request.passphrase);
|
|
if (!enc.ok) {
|
|
lifecycleEncryptFailed_ = true; // the wallet is open but NOT encrypted; App surfaces this
|
|
liteLog("wallet restored but encryption failed: " + enc.error);
|
|
}
|
|
}
|
|
secureWipeLiteSecret(request.seedPhrase);
|
|
secureWipeLiteSecret(request.passphrase);
|
|
return result;
|
|
}
|
|
|
|
} // namespace wallet
|
|
} // namespace dragonx
|