Merge dev: v2.0.2 / lite 1.1.1 — FAQ CJK glyphs, outgoing-tx history, lite encryption, migrate & sync fixes
This commit is contained in:
@@ -15,7 +15,7 @@ if(APPLE)
|
||||
endif()
|
||||
|
||||
project(ObsidianDragon
|
||||
VERSION 2.0.1
|
||||
VERSION 2.0.2
|
||||
LANGUAGES C CXX
|
||||
DESCRIPTION "DragonX Cryptocurrency Wallet"
|
||||
)
|
||||
@@ -26,7 +26,7 @@ set(DRAGONX_VERSION_SUFFIX "")
|
||||
# ObsidianDragonLite is versioned INDEPENDENTLY of the full-node app above. The active variant's
|
||||
# version flows to the generated header, the Windows .rc/manifest, and build.sh's release names via
|
||||
# DRAGONX_APP_VERSION* (resolved in the lite/full block below).
|
||||
set(DRAGONX_LITE_VERSION "1.1.0")
|
||||
set(DRAGONX_LITE_VERSION "1.1.1")
|
||||
set(DRAGONX_LITE_VERSION_SUFFIX "")
|
||||
|
||||
# C++17 standard
|
||||
|
||||
Binary file not shown.
76
src/app.cpp
76
src/app.cpp
@@ -11,6 +11,7 @@
|
||||
#include "rpc/rpc_worker.h"
|
||||
#include "rpc/connection.h"
|
||||
#include "config/settings.h"
|
||||
#include "data/seed_migration_resume.h"
|
||||
#include "wallet/lite_wallet_controller.h"
|
||||
#include "wallet/lite_wallet_server_selection_adapter.h"
|
||||
#include "wallet/lite_rollout_policy.h"
|
||||
@@ -2299,6 +2300,8 @@ void App::renderNodeStatusBanner()
|
||||
in.connection_status = connection_status_;
|
||||
in.daemon_last_error = daemon_controller_ ? daemon_controller_->lastError() : std::string();
|
||||
in.lite_open_error = lite_open_error_;
|
||||
in.tip_known = state_.sync.tip_known;
|
||||
in.blocks = state_.sync.blocks;
|
||||
|
||||
const ui::NodeBannerState banner = ui::evaluateNodeStatusBanner(in);
|
||||
if (!banner.show) return;
|
||||
@@ -2331,6 +2334,8 @@ void App::renderNodeStatusBanner()
|
||||
title = TR("node_banner_crashed_title"); icon = ICON_MD_ERROR; break;
|
||||
case ui::NodeBannerReason::LiteOpenFailed:
|
||||
title = TR("node_banner_lite_open_failed"); icon = ICON_MD_ERROR; break;
|
||||
case ui::NodeBannerReason::NoPeers:
|
||||
title = TR("node_banner_no_peers_title"); icon = ICON_MD_CLOUD_OFF; break;
|
||||
case ui::NodeBannerReason::FullNodeOffline:
|
||||
default:
|
||||
title = TR("node_banner_offline_title"); icon = ICON_MD_CLOUD_OFF; break;
|
||||
@@ -2817,7 +2822,8 @@ void App::renderStatusBar()
|
||||
ImGui::TextDisabled("%s", statusShown.c_str());
|
||||
}
|
||||
occupiedX = statusX;
|
||||
} else if (!daemon_status_.empty() && daemon_status_.find("Error") != std::string::npos) {
|
||||
} else if (const std::string ds = getDaemonStatus();
|
||||
!ds.empty() && ds.find("Error") != std::string::npos) {
|
||||
const char* errText = TR("sb_daemon_not_found");
|
||||
float statusW = ImGui::CalcTextSize(errText).x;
|
||||
float statusX = versionX - statusW - gap;
|
||||
@@ -4314,8 +4320,33 @@ void App::renderSeedMigrationDialog()
|
||||
break;
|
||||
}
|
||||
if (seed_migration_balance_ <= DRAGONX_DEFAULT_FEE) {
|
||||
// Nothing to sweep — adopt the fresh seed wallet directly. Its wallet.dat replaces the
|
||||
// current (empty) one; adopt still moves the legacy wallet aside to a timestamped backup.
|
||||
const bool sweepSubmitted = settings_ && settings_->getSeedMigrationSweepSubmitted();
|
||||
if (!dragonx::sweepGateMayAdoptEmptyWallet(sweepSubmitted)) {
|
||||
// A sweep was already broadcast but its txid was lost (its opid didn't survive a
|
||||
// daemon/app restart). A ~0 balance is AMBIGUOUS — the sweep may still be unconfirmed
|
||||
// in the mempool and could revert on a drop/reorg — so we must NOT adopt (swap
|
||||
// wallet.dat) here. Try to locate the sweep tx to resume the confirmation gate (which
|
||||
// requires >=1 conf); until then, wait/refresh. If the sweep was dropped the balance
|
||||
// simply returns and the normal Sweep UI reappears — the user is never trapped.
|
||||
ImGui::TextWrapped("%s", TR("mig_sweep_inflight"));
|
||||
ImGui::Spacing();
|
||||
ImGui::TextColored(kMedium, "%s%s", TR("mig_checking_sweep"), ui::material::LoadingDots());
|
||||
ImGui::Spacing();
|
||||
if (ui::material::TactileButton(TR("mig_check_sweep"), ImVec2(190 * dp, 0)))
|
||||
locateSweepTxid();
|
||||
ImGui::SameLine();
|
||||
if (ui::material::TactileButton(TR("refresh"), ImVec2(110 * dp, 0))) {
|
||||
refreshSeedMigrationBalance();
|
||||
locateSweepTxid();
|
||||
}
|
||||
ImGui::SameLine();
|
||||
if (ui::material::TactileButton(TR("mig_later"), ImVec2(100 * dp, 0)))
|
||||
close();
|
||||
break;
|
||||
}
|
||||
// Nothing to sweep and no sweep was ever submitted (a genuinely empty wallet) — adopt the
|
||||
// fresh seed wallet directly. Its wallet.dat replaces the current (empty) one; adopt still
|
||||
// moves the legacy wallet aside to a timestamped backup.
|
||||
ImGui::TextWrapped("%s", TR("mig_no_funds"));
|
||||
ImGui::Spacing();
|
||||
ImGui::TextWrapped("%s%s", TR("mig_to"), seed_migration_dest_.c_str());
|
||||
@@ -5166,6 +5197,18 @@ void App::setCurrentTab(int tab) {
|
||||
setCurrentPage(kTabMap[tab]);
|
||||
}
|
||||
|
||||
std::string App::getDaemonStatus() const
|
||||
{
|
||||
std::lock_guard<std::mutex> lk(daemon_status_mutex_);
|
||||
return daemon_status_; // a copy — safe to use after the lock is released
|
||||
}
|
||||
|
||||
void App::setDaemonStatus(std::string s)
|
||||
{
|
||||
std::lock_guard<std::mutex> lk(daemon_status_mutex_);
|
||||
daemon_status_ = std::move(s);
|
||||
}
|
||||
|
||||
bool App::startEmbeddedDaemon()
|
||||
{
|
||||
if (!supportsEmbeddedDaemon()) {
|
||||
@@ -5185,16 +5228,16 @@ bool App::startEmbeddedDaemon()
|
||||
// Try to extract embedded resources if available
|
||||
if (resources::hasEmbeddedResources()) {
|
||||
DEBUG_LOGF("Extracting embedded Sapling params...\n");
|
||||
daemon_status_ = TR("sb_extracting_sapling");
|
||||
setDaemonStatus(TR("sb_extracting_sapling"));
|
||||
if (!resources::extractEmbeddedResources()) {
|
||||
daemon_status_ = TR("sb_daemon_extract_failed");
|
||||
setDaemonStatus(TR("sb_daemon_extract_failed"));
|
||||
DEBUG_LOGF("[ERROR] extractEmbeddedResources() failed — disk full or permission denied?\n");
|
||||
return false;
|
||||
}
|
||||
|
||||
// Check again after extraction
|
||||
if (!rpc::Connection::verifySaplingParams()) {
|
||||
daemon_status_ = TR("sb_sapling_failed");
|
||||
setDaemonStatus(TR("sb_sapling_failed"));
|
||||
DEBUG_LOGF("Sapling params still not found after extraction!\n");
|
||||
DEBUG_LOGF("Expected location: %s\n", rpc::Connection::getSaplingParamsDir().c_str());
|
||||
return false;
|
||||
@@ -5211,7 +5254,7 @@ bool App::startEmbeddedDaemon()
|
||||
if (!exe_dir.empty()) {
|
||||
std::string dirErr;
|
||||
if (!util::Platform::ensureDirectory(daemon_dir, &dirErr)) {
|
||||
daemon_status_ = dirErr;
|
||||
setDaemonStatus(dirErr);
|
||||
DEBUG_LOGF("[ERROR] %s\n", dirErr.c_str());
|
||||
return false;
|
||||
}
|
||||
@@ -5245,7 +5288,7 @@ bool App::startEmbeddedDaemon()
|
||||
if (copied && rpc::Connection::verifySaplingParams()) {
|
||||
DEBUG_LOGF("Sapling params copied from exe directory successfully\n");
|
||||
} else {
|
||||
daemon_status_ = TR("sb_sapling_not_found");
|
||||
setDaemonStatus(TR("sb_sapling_not_found"));
|
||||
DEBUG_LOGF("Sapling params not found and no embedded resources available!\n");
|
||||
DEBUG_LOGF("Expected location: %s\n", rpc::Connection::getSaplingParamsDir().c_str());
|
||||
return false;
|
||||
@@ -5262,7 +5305,7 @@ bool App::startEmbeddedDaemon()
|
||||
if (!exe_dir.empty()) {
|
||||
std::string dirErr;
|
||||
if (!util::Platform::ensureDirectory(daemon_dir, &dirErr)) {
|
||||
daemon_status_ = dirErr;
|
||||
setDaemonStatus(dirErr);
|
||||
DEBUG_LOGF("[ERROR] %s\n", dirErr.c_str());
|
||||
return false;
|
||||
}
|
||||
@@ -5298,7 +5341,7 @@ bool App::startEmbeddedDaemon()
|
||||
if (copyFailed) {
|
||||
char buf[512];
|
||||
snprintf(buf, sizeof(buf), TR("sb_daemon_files_failed"), daemon_dir.c_str());
|
||||
daemon_status_ = buf;
|
||||
setDaemonStatus(buf);
|
||||
DEBUG_LOGF("[ERROR] One or more daemon files failed to copy to %s\n", daemon_dir.c_str());
|
||||
return false;
|
||||
}
|
||||
@@ -5311,21 +5354,22 @@ bool App::startEmbeddedDaemon()
|
||||
|
||||
// Set up state callback
|
||||
daemon_controller_->setStateCallback([this](daemon::EmbeddedDaemon::State state, const std::string& msg) {
|
||||
// NB: this callback runs on EmbeddedDaemon's monitor thread — write via the locked setter.
|
||||
switch (state) {
|
||||
case daemon::EmbeddedDaemon::State::Starting:
|
||||
daemon_status_ = TR("sb_starting_daemon");
|
||||
setDaemonStatus(TR("sb_starting_daemon"));
|
||||
break;
|
||||
case daemon::EmbeddedDaemon::State::Running:
|
||||
daemon_status_ = TR("sb_dragonxd_running");
|
||||
setDaemonStatus(TR("sb_dragonxd_running"));
|
||||
break;
|
||||
case daemon::EmbeddedDaemon::State::Stopping:
|
||||
daemon_status_ = TR("sb_dragonxd_stopping");
|
||||
setDaemonStatus(TR("sb_dragonxd_stopping"));
|
||||
break;
|
||||
case daemon::EmbeddedDaemon::State::Stopped:
|
||||
daemon_status_ = TR("sb_dragonxd_stopped");
|
||||
setDaemonStatus(TR("sb_dragonxd_stopped"));
|
||||
break;
|
||||
case daemon::EmbeddedDaemon::State::Error:
|
||||
daemon_status_ = "Error: " + msg;
|
||||
setDaemonStatus("Error: " + msg);
|
||||
break;
|
||||
}
|
||||
});
|
||||
@@ -6958,7 +7002,7 @@ std::string App::buildDiagnosticsReport()
|
||||
<< (state_.warming_up ? " (warming up)" : "") << "\n";
|
||||
|
||||
#if !DRAGONX_LITE_BUILD
|
||||
os << "daemon status: " << daemon_status_ << "\n";
|
||||
os << "daemon status: " << getDaemonStatus() << "\n";
|
||||
if (daemon_controller_) {
|
||||
os << "daemon running: " << (daemon_controller_->isRunning() ? "yes" : "no")
|
||||
<< ", crashes: " << daemon_controller_->crashCount() << "\n";
|
||||
|
||||
11
src/app.h
11
src/app.h
@@ -267,7 +267,7 @@ public:
|
||||
bool isConnected() const { return state_.connected; }
|
||||
int getBlockHeight() const { return state_.sync.blocks; }
|
||||
const std::string& getConnectionStatus() const { return connection_status_; }
|
||||
const std::string& getDaemonStatus() const { return daemon_status_; }
|
||||
std::string getDaemonStatus() const; // thread-safe copy (daemon_status_ is written off-thread)
|
||||
|
||||
// Balance info (convenience wrappers)
|
||||
double getShieldedBalance() const { return state_.shielded_balance; }
|
||||
@@ -884,6 +884,7 @@ private:
|
||||
std::function<void(bool, const std::string&)> makeSweepCompletionCallback(bool resumed);
|
||||
void beginSweepToSeedWallet(); // z_mergetoaddress ["ANY_TADDR","ANY_ZADDR"] -> dest
|
||||
void pollSweepStatus(); // Confirming step: poll sweep confirmations + legacy balance
|
||||
void locateSweepTxid(); // recover a lost sweep txid (send to dest) -> Confirming gate
|
||||
void beginAdoptSeedWallet(); // stop daemon -> swap wallet.dat -> restart with -rescan
|
||||
void provisionChatIdentityFromSecret(std::string secret);
|
||||
std::string chatReplyZaddr(); // a stable (persisted) wallet z-addr — chat IDENTITY (reply-to)
|
||||
@@ -1123,7 +1124,13 @@ private:
|
||||
|
||||
// Embedded daemon state
|
||||
bool use_embedded_daemon_ = wallet::supportsEmbeddedDaemon(wallet::currentWalletCapabilities());
|
||||
std::string daemon_status_;
|
||||
// Written by the DaemonController state callback (which fires on EmbeddedDaemon's monitor thread)
|
||||
// AND read on the UI thread — so all access goes through set/getDaemonStatus() under this mutex.
|
||||
// getDaemonStatus() returns a COPY: a returned reference could dangle if the monitor thread
|
||||
// reassigned the string mid-read (data race / use-after-free).
|
||||
mutable std::mutex daemon_status_mutex_;
|
||||
std::string daemon_status_; // guarded by daemon_status_mutex_ — use set/getDaemonStatus()
|
||||
void setDaemonStatus(std::string s);
|
||||
mutable std::string daemon_mem_diag_; // diagnostic info for daemon memory detection
|
||||
size_t daemon_output_offset_ = 0; // for incremental output parsing (rescan detection)
|
||||
|
||||
|
||||
@@ -4701,6 +4701,9 @@ void App::showSeedMigrationDialog()
|
||||
seed_migration_balance_loaded_ = false;
|
||||
seed_migration_nofunds_confirmed_ = false;
|
||||
refreshSeedMigrationBalance();
|
||||
// If a sweep was already broadcast (the marker survives a lost opid), try to locate its tx so
|
||||
// we resume at the confirmation gate instead of risking a premature "no funds -> adopt".
|
||||
if (settings_ && settings_->getSeedMigrationSweepSubmitted()) locateSweepTxid();
|
||||
break;
|
||||
case MigrationResume::Intro:
|
||||
default:
|
||||
@@ -4768,6 +4771,12 @@ void App::beginSweepToSeedWallet()
|
||||
}
|
||||
seed_migration_step_ = SeedMigrationStep::Sweeping;
|
||||
seed_migration_status_ = "Building the sweep transaction…";
|
||||
// Mark the sweep as submitted BEFORE broadcasting (persisted synchronously). If the app or daemon
|
||||
// then dies after the tx reaches the mempool but before its opid/txid is persisted, a resumed
|
||||
// migration still knows a sweep is in flight and won't mistake the resulting ~0 legacy balance for
|
||||
// an empty wallet and adopt (swap wallet.dat) prematurely — funds in an unconfirmed sweep can still
|
||||
// revert on a drop/reorg. Cleared below only if the submit definitively fails (nothing broadcast).
|
||||
if (settings_) { settings_->setSeedMigrationSweepSubmitted(true); settings_->save(); }
|
||||
const std::string dest = seed_migration_dest_;
|
||||
worker_->post([this, dest]() -> rpc::RPCWorker::MainCb {
|
||||
std::string opid, err;
|
||||
@@ -4780,6 +4789,8 @@ void App::beginSweepToSeedWallet()
|
||||
} catch (const std::exception& e) { err = e.what(); }
|
||||
return [this, opid, err]() {
|
||||
if (!err.empty() || opid.empty()) {
|
||||
// Submit failed — nothing was broadcast, so drop the "sweep in flight" marker.
|
||||
if (settings_) { settings_->setSeedMigrationSweepSubmitted(false); settings_->save(); }
|
||||
seed_migration_status_ = err.empty() ? "The sweep failed to start." : err;
|
||||
seed_migration_step_ = SeedMigrationStep::Error;
|
||||
return;
|
||||
@@ -4882,6 +4893,69 @@ void App::pollSweepStatus()
|
||||
});
|
||||
}
|
||||
|
||||
// Best-effort recovery when a sweep was broadcast but its opid was lost (e.g. a daemon restart wiped
|
||||
// the in-memory op queue) so we have no txid to confirm against. The sweep is an outgoing send to
|
||||
// seed_migration_dest_; find it among the wallet's own sends (z_listsentbyaddress) and, if present,
|
||||
// adopt its txid and resume at the confirmation gate (pollSweepStatus / Confirming). Read-only, and
|
||||
// backstopped by that gate (confs >= 1 && legacy balance ~0), so a wrong match cannot lose funds —
|
||||
// it only avoids the premature "no funds -> adopt" short-circuit while a sweep is unconfirmed.
|
||||
void App::locateSweepTxid()
|
||||
{
|
||||
if (capture_mode_) return;
|
||||
if (!rpc_ || !worker_ || !state_.connected) return;
|
||||
if (seed_migration_confirm_in_flight_ || !seed_migration_sweep_txid_.empty()) return;
|
||||
if (seed_migration_dest_.empty()) return;
|
||||
seed_migration_confirm_in_flight_ = true;
|
||||
const std::string dest = seed_migration_dest_;
|
||||
worker_->post([this, dest]() -> rpc::RPCWorker::MainCb {
|
||||
std::string found;
|
||||
rpc::RPCClient::TraceScope trace("Migrate / locate sweep");
|
||||
try {
|
||||
// z_listsentbyaddress ignores its address argument and returns ALL wallet sends
|
||||
// (oldest -> newest); the sweep is the latest send whose outputs include the destination.
|
||||
auto sent = rpc_->call("z_listsentbyaddress", {dest, 0});
|
||||
if (sent.is_array()) {
|
||||
for (const auto& e : sent) {
|
||||
if (!e.is_object() || !e.contains("sends") || !e["sends"].is_object()) continue;
|
||||
const auto& s = e["sends"];
|
||||
bool toDest = false;
|
||||
for (const char* leg : {"saplingSends", "transparentSends"}) {
|
||||
auto it = s.find(leg);
|
||||
if (it == s.end() || !it->is_array()) continue;
|
||||
for (const auto& o : *it) {
|
||||
if (o.is_object() && o.value("address", std::string()) == dest) {
|
||||
toDest = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (toDest) break;
|
||||
}
|
||||
if (toDest) {
|
||||
auto t = e.value("txid", std::string());
|
||||
if (!t.empty()) found = t; // keep scanning; entries are oldest->newest
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (...) {}
|
||||
return [this, found]() {
|
||||
seed_migration_confirm_in_flight_ = false;
|
||||
if (found.empty() || !seed_migration_sweep_txid_.empty()) return;
|
||||
// Adopt the recovered txid and hand off to the confirmation gate (which alone permits adopt).
|
||||
seed_migration_sweep_txid_ = found;
|
||||
if (settings_) {
|
||||
settings_->setSeedMigrationSweepTxid(found);
|
||||
settings_->setSeedMigrationSweepOpid("");
|
||||
settings_->save();
|
||||
}
|
||||
seed_migration_sweep_confs_ = 0;
|
||||
seed_migration_legacy_remaining_ = -1.0;
|
||||
seed_migration_poll_timer_ = 0.0f;
|
||||
seed_migration_status_.clear();
|
||||
seed_migration_step_ = SeedMigrationStep::Confirming;
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
// Phase 2 step 2: adopt the new seed wallet as the primary wallet. Stop the daemon, move the
|
||||
// legacy wallet.dat aside to a timestamped backup (NEVER delete it), copy the new wallet in, and
|
||||
// restart with -rescan so the new keys pick up the swept funds. Runs on a background thread.
|
||||
@@ -5308,6 +5382,7 @@ void App::pumpSeedMigration()
|
||||
settings_->setSeedMigrationTempDir("");
|
||||
settings_->setSeedMigrationSweepTxid("");
|
||||
settings_->setSeedMigrationSweepOpid(""); // W3-3
|
||||
settings_->setSeedMigrationSweepSubmitted(false);
|
||||
settings_->save();
|
||||
}
|
||||
seed_migration_status_ = err; // a non-empty warning here (e.g. restart hiccup) is shown on Done
|
||||
@@ -5355,6 +5430,7 @@ void App::pumpSeedMigration()
|
||||
// txid/opid (the resume block reads these whenever the migration is pending).
|
||||
settings_->setSeedMigrationSweepTxid("");
|
||||
settings_->setSeedMigrationSweepOpid("");
|
||||
settings_->setSeedMigrationSweepSubmitted(false);
|
||||
settings_->save();
|
||||
}
|
||||
} else {
|
||||
|
||||
@@ -267,6 +267,7 @@ bool Settings::load(const std::string& path)
|
||||
loadScalar(j, "seed_migration_temp_dir", seed_migration_temp_dir_);
|
||||
loadScalar(j, "seed_migration_sweep_txid", seed_migration_sweep_txid_);
|
||||
loadScalar(j, "seed_migration_sweep_opid", seed_migration_sweep_opid_);
|
||||
loadScalar(j, "seed_migration_sweep_submitted", seed_migration_sweep_submitted_);
|
||||
loadScalar(j, "auto_lock_timeout", auto_lock_timeout_);
|
||||
loadScalar(j, "unlock_duration", unlock_duration_);
|
||||
loadScalar(j, "pin_enabled", pin_enabled_);
|
||||
@@ -559,6 +560,7 @@ bool Settings::save(const std::string& path)
|
||||
j["seed_migration_temp_dir"] = seed_migration_temp_dir_;
|
||||
j["seed_migration_sweep_txid"] = seed_migration_sweep_txid_;
|
||||
j["seed_migration_sweep_opid"] = seed_migration_sweep_opid_;
|
||||
j["seed_migration_sweep_submitted"] = seed_migration_sweep_submitted_;
|
||||
j["auto_lock_timeout"] = auto_lock_timeout_;
|
||||
j["unlock_duration"] = unlock_duration_;
|
||||
j["pin_enabled"] = pin_enabled_;
|
||||
|
||||
@@ -396,6 +396,12 @@ public:
|
||||
// in the same write that persists the txid, so the txid always outranks it (see [[decideSeedMigrationResume]]).
|
||||
std::string getSeedMigrationSweepOpid() const { return seed_migration_sweep_opid_; }
|
||||
void setSeedMigrationSweepOpid(const std::string& v) { seed_migration_sweep_opid_ = v; }
|
||||
// Set TRUE synchronously when a sweep is broadcast; survives the stale-opid resume fallback so the
|
||||
// Sweep step knows a ~0 legacy balance may be an UNCONFIRMED in-flight sweep (funds not yet safe)
|
||||
// and must NOT short-circuit to adopt/swap wallet.dat. Cleared on confirmed-adopt / reset / a
|
||||
// failed submit. See [[decideSeedMigrationResume]] and App::renderSeedMigrationDialog (fund-safety).
|
||||
bool getSeedMigrationSweepSubmitted() const { return seed_migration_sweep_submitted_; }
|
||||
void setSeedMigrationSweepSubmitted(bool v) { seed_migration_sweep_submitted_ = v; }
|
||||
|
||||
// Security — auto-lock timeout (seconds; 0 = disabled)
|
||||
int getAutoLockTimeout() const { return auto_lock_timeout_; }
|
||||
@@ -640,6 +646,7 @@ private:
|
||||
std::string seed_migration_temp_dir_;
|
||||
std::string seed_migration_sweep_txid_;
|
||||
std::string seed_migration_sweep_opid_;
|
||||
bool seed_migration_sweep_submitted_ = false;
|
||||
int auto_lock_timeout_ = 900; // 15 minutes
|
||||
int unlock_duration_ = 600; // 10 minutes
|
||||
bool pin_enabled_ = false;
|
||||
|
||||
@@ -34,4 +34,12 @@ inline MigrationResume decideSeedMigrationResume(bool pending,
|
||||
return MigrationResume::SweepGate;
|
||||
}
|
||||
|
||||
// At the Sweep step with a ~0 legacy balance, may we offer the direct "no funds -> adopt" short-cut
|
||||
// (swap wallet.dat immediately)? Only when NO sweep has been submitted — a genuinely empty wallet.
|
||||
// If a sweep WAS submitted, a ~0 balance is ambiguous: the sweep tx may be unconfirmed in the mempool
|
||||
// and could revert on a drop/reorg, so adopting now can strand the funds in the moved-aside backup.
|
||||
// In that case adopt must go through the confirmation-gated path (>=1 conf) instead — never swap
|
||||
// wallet.dat on an unconfirmed sweep. (fund-safety: audit)
|
||||
inline bool sweepGateMayAdoptEmptyWallet(bool sweepSubmitted) { return !sweepSubmitted; }
|
||||
|
||||
} // namespace dragonx
|
||||
|
||||
@@ -149,7 +149,13 @@ struct SyncInfo {
|
||||
float witness_progress = 0.0f; // 0.0 - 1.0, within the current sub-phase
|
||||
int witness_remaining = 0; // blocks left in the cache walk (0 if unknown / phase 1)
|
||||
|
||||
bool isSynced() const { return !syncing && blocks > 0 && blocks >= headers - 2; }
|
||||
// True only when the daemon reports a positive peer-derived tip (longestchain > 0). A node with
|
||||
// no peers reports longestchain == 0, so `blocks` is then only the LOCAL best height and cannot be
|
||||
// trusted as "at the network tip". isSynced() gates on this so a peerless / cold-started node is
|
||||
// never reported synced (which would enable Send against a stale balance). (audit: peerless-synced)
|
||||
bool tip_known = false;
|
||||
|
||||
bool isSynced() const { return tip_known && !syncing && blocks > 0 && blocks >= headers - 2; }
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
@@ -853,8 +853,11 @@ void NetworkRefreshService::appendViewTransactionOutputs(std::vector<Transaction
|
||||
for (const auto& out : entry.outgoing_outputs) {
|
||||
bool alreadyTracked = false;
|
||||
for (const auto& existing : transactions) {
|
||||
// Key on address as well as amount: two outgoing outputs of EQUAL value to DIFFERENT
|
||||
// recipients in one tx are distinct rows — an amount-only match would drop the second.
|
||||
if (existing.txid == txid && existing.type == "send" &&
|
||||
std::abs(existing.amount + out.value) < 0.00000001) {
|
||||
std::abs(existing.amount + out.value) < 0.00000001 &&
|
||||
existing.address == out.address) {
|
||||
alreadyTracked = true;
|
||||
break;
|
||||
}
|
||||
@@ -885,6 +888,55 @@ void NetworkRefreshService::appendViewTransactionOutputs(std::vector<Transaction
|
||||
}
|
||||
}
|
||||
|
||||
void NetworkRefreshService::appendSentTransparentOutputs(std::vector<TransactionInfo>& transactions,
|
||||
const std::string& txid,
|
||||
const nlohmann::json& sentEntry)
|
||||
{
|
||||
if (!sentEntry.is_object() || !sentEntry.contains("sends") || !sentEntry["sends"].is_object())
|
||||
return;
|
||||
const auto& sends = sentEntry["sends"];
|
||||
auto it = sends.find("transparentSends");
|
||||
if (it == sends.end() || !it->is_array()) return;
|
||||
|
||||
// Per-tx metadata for the synthesized rows.
|
||||
int confirmations = 0;
|
||||
std::int64_t timestamp = 0;
|
||||
if (auto v = readOptional<int>(sentEntry, "confirmations")) confirmations = *v;
|
||||
if (auto v = readOptional<std::int64_t>(sentEntry, "blocktime")) timestamp = *v;
|
||||
if (timestamp == 0) {
|
||||
if (auto v = readOptional<std::int64_t>(sentEntry, "time")) timestamp = *v;
|
||||
}
|
||||
|
||||
for (const auto& out : *it) {
|
||||
auto amount = readOptional<double>(out, "amount");
|
||||
if (!amount) continue;
|
||||
const double debit = -std::abs(*amount); // the daemon reports -nValue; a send is a debit
|
||||
std::string address;
|
||||
if (auto a = readOptional<std::string>(out, "address")) address = *a;
|
||||
|
||||
// Dedup against rows already present — notably listtransactions-derived t->t sends, which
|
||||
// this whole-wallet payload ALSO reports. Same txid+address+amount == the same output.
|
||||
bool exists = false;
|
||||
for (const auto& e : transactions) {
|
||||
if (e.txid == txid && e.type == "send" && e.address == address &&
|
||||
std::abs(e.amount - debit) < 0.00000001) {
|
||||
exists = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (exists) continue;
|
||||
|
||||
TransactionInfo info;
|
||||
info.txid = txid;
|
||||
info.type = "send";
|
||||
info.address = address;
|
||||
info.amount = debit;
|
||||
info.confirmations = confirmations;
|
||||
info.timestamp = timestamp;
|
||||
transactions.push_back(std::move(info));
|
||||
}
|
||||
}
|
||||
|
||||
void NetworkRefreshService::sortTransactionsNewestFirst(std::vector<TransactionInfo>& transactions)
|
||||
{
|
||||
std::sort(transactions.begin(), transactions.end(),
|
||||
@@ -985,6 +1037,45 @@ NetworkRefreshService::TransactionRefreshResult NetworkRefreshService::collectTr
|
||||
result.shieldedScanComplete = !pendingShieldedIndex.has_value();
|
||||
result.nextShieldedScanStartIndex = pendingShieldedIndex.value_or(0);
|
||||
|
||||
// Discover outgoing shielded (z->z) sends. A pure z->z send moves no value in or out of the
|
||||
// transparent pool (valueBalance == 0), so the daemon's `listtransactions` never reports it,
|
||||
// and — being a send rather than a receive — it never shows up in `z_listreceivedbyaddress`
|
||||
// either. Its txid therefore never reaches `knownTxids`, so the z_viewtransaction enrichment
|
||||
// below is never run for it and the send is invisible in History (the reported bug).
|
||||
//
|
||||
// `z_listsentbyaddress` scans the whole wallet for sends — the address argument is NOT used to
|
||||
// filter (the daemon iterates every wallet tx against "*"), so a single call yields all send
|
||||
// txids. Two legs need handling:
|
||||
// * SHIELDED (z->z) outgoing legs are surfaced by z_viewtransaction, so we fold the txid into
|
||||
// `knownTxids` and let the z_viewtransaction / appendViewTransactionOutputs path build them.
|
||||
// * TRANSPARENT outgoing legs (z->t deshields) are NOT surfaced by z_viewtransaction (it
|
||||
// enumerates only Sapling outputs), and for a z-originated send listtransactions also omits
|
||||
// the external t-vout (GetAmounts' isFromMyTaddr is false when inputs are shielded). So we
|
||||
// build those rows directly from the payload's `sends.transparentSends[]` here. They are
|
||||
// deduped (by txid+address+amount) against listtransactions-derived t->t sends above, so a
|
||||
// purely-transparent (t->t) send — which the payload ALSO reports — is not double-counted.
|
||||
//
|
||||
// Gated on shieldedScanComplete so it adds no cost during the multi-cycle initial received
|
||||
// scan: it is one more O(mapWallet) pass holding cs_main (comparable to the listtransactions
|
||||
// pass), and the caller's adaptive throttle (txRefreshDue) already accounts for it via scanMs.
|
||||
if (result.shieldedScanComplete && !snapshot.shieldedAddresses.empty()) {
|
||||
try {
|
||||
json sent = rpc.call("z_listsentbyaddress",
|
||||
json::array({snapshot.shieldedAddresses.front(), 0}));
|
||||
if (sent.is_array()) {
|
||||
for (const auto& sentTx : sent) {
|
||||
auto txid = readOptional<std::string>(sentTx, "txid");
|
||||
if (!txid || txid->empty()) continue;
|
||||
appendSentTransparentOutputs(result.transactions, *txid, sentTx);
|
||||
knownTxids.insert(*txid); // shielded (z->z) legs enriched via z_viewtransaction
|
||||
}
|
||||
}
|
||||
} catch (const std::exception& e) {
|
||||
transactionRpcError = true;
|
||||
DEBUG_LOGF("z_listsentbyaddress error: %s\n", e.what());
|
||||
}
|
||||
}
|
||||
|
||||
for (const auto& txid : snapshot.sendTxids) {
|
||||
knownTxids.insert(txid);
|
||||
}
|
||||
@@ -1305,6 +1396,10 @@ void NetworkRefreshService::applyCoreRefreshResult(WalletState& state,
|
||||
if (result.headers) state.sync.headers = *result.headers;
|
||||
if (result.bestBlockHash) state.sync.best_blockhash = *result.bestBlockHash;
|
||||
if (result.verificationProgress) state.sync.verification_progress = *result.verificationProgress;
|
||||
// A peerless daemon reports longestchain == 0 (no peer heights). Track that as "tip unknown" from
|
||||
// THIS refresh's raw value (not the retained state, which keeps the last positive value) so a
|
||||
// peerless node isn't reported synced against its own stale local height. (audit: peerless-synced)
|
||||
state.sync.tip_known = (result.longestChain.has_value() && *result.longestChain > 0);
|
||||
if (result.longestChain && *result.longestChain > 0) state.longestchain = *result.longestChain;
|
||||
if (state.longestchain > 0 && state.sync.blocks > state.longestchain) state.longestchain = state.sync.blocks;
|
||||
if (state.longestchain > 0)
|
||||
|
||||
@@ -310,6 +310,12 @@ public:
|
||||
static void appendViewTransactionOutputs(std::vector<TransactionInfo>& transactions,
|
||||
const std::string& txid,
|
||||
const TransactionViewCacheEntry& entry);
|
||||
// Build "send" rows from a z_listsentbyaddress entry's sends.transparentSends[] — the transparent
|
||||
// (z->t deshield) outgoing outputs that z_viewtransaction does not surface. Deduped by
|
||||
// txid+address+amount against rows already present (e.g. listtransactions-derived t->t sends).
|
||||
static void appendSentTransparentOutputs(std::vector<TransactionInfo>& transactions,
|
||||
const std::string& txid,
|
||||
const nlohmann::json& sentEntry);
|
||||
static void sortTransactionsNewestFirst(std::vector<TransactionInfo>& transactions);
|
||||
static TransactionRefreshResult collectTransactionRefreshResult(RefreshRpcGateway& rpc,
|
||||
const TransactionRefreshSnapshot& snapshot,
|
||||
|
||||
@@ -31,6 +31,7 @@ enum class NodeBannerReason {
|
||||
FullNodeOffline, // a reachable node was lost, or never came up; reconnect offered
|
||||
DaemonCrashed, // the embedded daemon crashed repeatedly and auto-restart stopped
|
||||
LiteOpenFailed, // lite build: the wallet failed to open
|
||||
NoPeers, // connected to the daemon but no peer-derived tip (0 peers) — stalled, stale
|
||||
};
|
||||
|
||||
struct NodeBannerState {
|
||||
@@ -50,6 +51,13 @@ struct NodeBannerInputs {
|
||||
bool daemon_initializing = false; // daemon launching / block index loading
|
||||
bool connection_in_progress = false; // a connect attempt is actively running
|
||||
|
||||
// Peer connectivity: a full node connected to the daemon but with NO peer-derived tip
|
||||
// (longestchain == 0 -> tip_known false) has no peers, so it silently stalls at a stale height
|
||||
// while balance/sync look "done". `blocks` gates the check so we don't flag the brief window
|
||||
// before the first chain-info refresh. (audit: no-peer-banner)
|
||||
bool tip_known = true; // sync.tip_known — a peer-derived network tip is known
|
||||
int blocks = 0; // sync.blocks — >0 once chain info has been fetched
|
||||
|
||||
// Full-node embedded-daemon crash signal.
|
||||
bool using_embedded_daemon = false;
|
||||
bool has_daemon_controller = false;
|
||||
@@ -81,13 +89,26 @@ inline NodeBannerState evaluateNodeStatusBanner(const NodeBannerInputs& in) {
|
||||
return s;
|
||||
}
|
||||
|
||||
// Full node. Connected, or in an expected startup phase → the loading/warmup overlay owns
|
||||
// the screen, so no banner. An active connect attempt likewise shows progress, not an
|
||||
// error — don't flicker a banner over it.
|
||||
if (in.connected) return s;
|
||||
// Full node. In an expected startup phase → the loading/warmup overlay owns the screen, so no
|
||||
// banner. An active connect attempt likewise shows progress, not an error — don't flicker a
|
||||
// banner over it. (These return even if `connected` has flipped early, matching prior behavior.)
|
||||
if (in.warming_up || in.daemon_initializing) return s;
|
||||
if (in.connection_in_progress) return s;
|
||||
|
||||
// Connected to the daemon (RPC works) but with no peer-derived tip → the node has no peers and
|
||||
// cannot learn about new blocks; it silently stalls at a stale height while everything looks
|
||||
// synced. Surface it as a recoverable warning (reconnect won't help, so no action button).
|
||||
if (in.connected) {
|
||||
if (!in.tip_known && in.blocks > 0) {
|
||||
s.show = true;
|
||||
s.severity = NodeBannerSeverity::Warning;
|
||||
s.reason = NodeBannerReason::NoPeers;
|
||||
s.action = NodeBannerAction::None;
|
||||
s.detail = in.connection_status;
|
||||
}
|
||||
return s;
|
||||
}
|
||||
|
||||
// Genuinely offline. Distinguish "the embedded daemon crashed and we stopped retrying" (a
|
||||
// hard fault needing a manual restart) from an ordinary lost/failed connection (retryable).
|
||||
if (in.using_embedded_daemon && in.has_daemon_controller && !in.daemon_running &&
|
||||
|
||||
@@ -425,6 +425,9 @@ void I18n::loadBuiltinEnglish()
|
||||
// Sweep step: balance still loading / no funds to move
|
||||
strings_["mig_checking_balance"] = "Checking your balance";
|
||||
strings_["mig_no_funds"] = "Your wallet has no funds to migrate. You can adopt the new seed wallet directly — it replaces your current (empty) wallet with the seed-backed one. Your old wallet is still moved aside to a timestamped backup, just in case.";
|
||||
strings_["mig_sweep_inflight"] = "A sweep was already started and hasn't confirmed yet. Your balance reads zero because the funds are in the pending sweep transaction — they are not lost. Wait for it to confirm before finishing; the wallet will not be swapped until the sweep is mined.";
|
||||
strings_["mig_checking_sweep"] = "Looking for the sweep transaction";
|
||||
strings_["mig_check_sweep"] = "Check the sweep";
|
||||
strings_["mig_adopt_now"] = "Adopt seed wallet";
|
||||
strings_["mig_nofunds_confirm"] = "I understand this replaces my current wallet with the new seed wallet";
|
||||
// Adopting step note (shown under the spinner while the node restarts + rescans)
|
||||
@@ -1459,6 +1462,7 @@ void I18n::loadBuiltinEnglish()
|
||||
strings_["sb_wallet_needs_recovery"] = "Wallet repair available";
|
||||
// Persistent node-status banner (App::renderNodeStatusBanner).
|
||||
strings_["node_banner_offline_title"] = "Not connected to the DragonX node";
|
||||
strings_["node_banner_no_peers_title"] = "Connected, but no network peers — the node can't sync";
|
||||
strings_["node_banner_crashed_title"] = "The node stopped unexpectedly";
|
||||
strings_["node_banner_lite_open_failed"] = "Couldn't open your wallet";
|
||||
strings_["node_banner_reconnect"] = "Reconnect";
|
||||
|
||||
@@ -450,31 +450,47 @@ void LiteWalletController::pumpAsyncOpen()
|
||||
bool LiteWalletController::beginCreateWalletAsync(LiteWalletCreateRequest request)
|
||||
{
|
||||
auto req = std::make_shared<LiteWalletCreateRequest>(std::move(request));
|
||||
return beginLifecycleRequestAsync(
|
||||
// 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));
|
||||
return beginLifecycleRequestAsync(
|
||||
// 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));
|
||||
return beginLifecycleRequestAsync(
|
||||
// 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;
|
||||
@@ -484,6 +500,8 @@ bool LiteWalletController::beginRestoreWalletAsync(LiteWalletRestoreRequest requ
|
||||
secureWipeLiteSecret(req->seedPhrase);
|
||||
secureWipeLiteSecret(req->passphrase);
|
||||
});
|
||||
if (!started) clearPendingLifecycleSecurity();
|
||||
return started;
|
||||
}
|
||||
|
||||
bool LiteWalletController::beginLifecycleRequestAsync(
|
||||
@@ -585,15 +603,54 @@ void LiteWalletController::pumpLifecycleResult()
|
||||
// log the failure otherwise (shared with the synchronous lifecycle path).
|
||||
onLifecycleResult(out);
|
||||
if (out.walletReady) {
|
||||
// Wallet is now open (walletOpen_ set by onLifecycleResult) — apply the stashed passphrase:
|
||||
// encrypt a freshly created/restored wallet, or unlock an existing encrypted one (C1/M1).
|
||||
applyPendingLifecycleSecurity();
|
||||
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)
|
||||
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;
|
||||
|
||||
@@ -388,6 +388,20 @@ private:
|
||||
std::make_shared<std::optional<LiteWalletLifecycleResult>>();
|
||||
LiteWalletLifecycleResult lastLifecycleResult_; // main-thread only; last finalized request
|
||||
|
||||
// Passphrase to apply on the main thread once an async create/restore/open finalizes. The
|
||||
// backend wallet is not controller-"open" (walletOpen_) until pumpLifecycleResult() runs
|
||||
// onLifecycleResult(), and encryptWallet()/unlockWallet() gate on walletOpen_ — so the async
|
||||
// worker thread cannot do it. The passphrase is stashed here (main-thread only) and applied in
|
||||
// pumpLifecycleResult(), mirroring the synchronous createWallet()/restoreWallet()/openWallet()
|
||||
// wrappers. Without this the UI's async path silently discarded the passphrase, storing the
|
||||
// seed UNENCRYPTED (create/restore) or never unlocking an encrypted wallet (open) — audit C1/M1.
|
||||
enum class PendingSecurityOp { None, Encrypt, Unlock };
|
||||
std::string pendingLifecyclePassphrase_; // main-thread only; wiped after use
|
||||
PendingSecurityOp pendingLifecycleSecurityOp_ = PendingSecurityOp::None;
|
||||
void stashPendingLifecycleSecurity(const std::string& passphrase, PendingSecurityOp op);
|
||||
void applyPendingLifecycleSecurity(); // encrypt (create/restore) or unlock (open), then wipe
|
||||
void clearPendingLifecycleSecurity(); // wipe + reset (rejected request / failed open)
|
||||
|
||||
// Joinable background refresh worker (fast iterations: syncstatus, plus data once synced).
|
||||
std::thread worker_;
|
||||
std::atomic<bool> running_{false};
|
||||
|
||||
@@ -1628,6 +1628,7 @@ void testNetworkRefreshRpcCollectors()
|
||||
{"time", 50}, {"memoStr", "shielded memo"}}
|
||||
}));
|
||||
transactionRpc.addResponse("z_listreceivedbyaddress", json::array());
|
||||
transactionRpc.addResponse("z_listsentbyaddress", json::array());
|
||||
transactionRpc.addResponse("z_viewtransaction", json{
|
||||
{"spends", json::array({json{{"address", "zs-from"}}})},
|
||||
{"outputs", json::array({
|
||||
@@ -1639,12 +1640,13 @@ void testNetworkRefreshRpcCollectors()
|
||||
auto transactionResult = Refresh::collectTransactionRefreshResult(transactionRpc, snapshot, 321, 4);
|
||||
EXPECT_TRUE(transactionRpc.methodNames() == std::vector<std::string>({
|
||||
"listtransactions", "z_listreceivedbyaddress", "z_listreceivedbyaddress",
|
||||
"z_viewtransaction", "gettransaction"
|
||||
"z_listsentbyaddress", "z_viewtransaction", "gettransaction"
|
||||
}));
|
||||
EXPECT_EQ(transactionRpc.calls[1].params, json::array({"zs-one", 0}));
|
||||
EXPECT_EQ(transactionRpc.calls[2].params, json::array({"zs-two", 0}));
|
||||
EXPECT_EQ(transactionRpc.calls[3].params, json::array({"pending-send"}));
|
||||
EXPECT_EQ(transactionRpc.calls[3].params, json::array({"zs-one", 0}));
|
||||
EXPECT_EQ(transactionRpc.calls[4].params, json::array({"pending-send"}));
|
||||
EXPECT_EQ(transactionRpc.calls[5].params, json::array({"pending-send"}));
|
||||
EXPECT_EQ(transactionResult.blockHeight, 321);
|
||||
EXPECT_EQ(transactionResult.newViewTxEntries.size(), static_cast<size_t>(1));
|
||||
EXPECT_EQ(transactionResult.newViewTxEntries.count("pending-send"), static_cast<size_t>(1));
|
||||
@@ -1657,6 +1659,129 @@ void testNetworkRefreshRpcCollectors()
|
||||
EXPECT_EQ(transactionResult.transactions.front().timestamp, static_cast<int64_t>(500));
|
||||
EXPECT_EQ(transactionResult.transactions[1].txid, std::string("transparent-a"));
|
||||
|
||||
// Outgoing shielded (z->z) send discovery. A pure z->z send has valueBalance == 0, so it is
|
||||
// absent from listtransactions AND (being a send, not a receive) from z_listreceivedbyaddress —
|
||||
// it is surfaced only via the whole-wallet z_listsentbyaddress sweep, whose txids are folded
|
||||
// into the z_viewtransaction enrichment to build the send row. Regression test for the
|
||||
// "outgoing transactions missing from History" bug.
|
||||
{
|
||||
Refresh::TransactionRefreshSnapshot zzSnapshot;
|
||||
zzSnapshot.shieldedAddresses = {"zs-mine"};
|
||||
MockRefreshRpc zzRpc;
|
||||
zzRpc.addResponse("listtransactions", json::array()); // no transparent movement
|
||||
zzRpc.addResponse("z_listreceivedbyaddress", json::array()); // it's a send, not a receive
|
||||
zzRpc.addResponse("z_listsentbyaddress", json::array({
|
||||
json{{"txid", "zz-send"}} // discovered only here
|
||||
}));
|
||||
zzRpc.addResponse("z_viewtransaction", json{
|
||||
{"spends", json::array({json{{"address", "zs-mine"}}})},
|
||||
{"outputs", json::array({
|
||||
json{{"outgoing", true}, {"address", "zs-recipient"}, {"value", 1.25},
|
||||
{"memoStr", "z2z memo"}}
|
||||
})}
|
||||
});
|
||||
zzRpc.addResponse("gettransaction", json{{"time", 800}, {"confirmations", 3}});
|
||||
auto zzResult = Refresh::collectTransactionRefreshResult(zzRpc, zzSnapshot, 400, 4);
|
||||
EXPECT_TRUE(zzRpc.methodNames() == std::vector<std::string>({
|
||||
"listtransactions", "z_listreceivedbyaddress", "z_listsentbyaddress",
|
||||
"z_viewtransaction", "gettransaction"
|
||||
}));
|
||||
EXPECT_EQ(zzResult.transactions.size(), static_cast<size_t>(1));
|
||||
EXPECT_EQ(zzResult.transactions[0].txid, std::string("zz-send"));
|
||||
EXPECT_EQ(zzResult.transactions[0].type, std::string("send"));
|
||||
EXPECT_NEAR(zzResult.transactions[0].amount, -1.25, 0.00000001);
|
||||
EXPECT_EQ(zzResult.transactions[0].address, std::string("zs-recipient"));
|
||||
EXPECT_EQ(zzResult.transactions[0].memo, std::string("z2z memo"));
|
||||
EXPECT_EQ(zzResult.transactions[0].timestamp, static_cast<int64_t>(800));
|
||||
EXPECT_EQ(zzResult.newViewTxEntries.count("zz-send"), static_cast<size_t>(1));
|
||||
|
||||
// Guard: with no shielded addresses the sweep is skipped entirely (transparent sends are
|
||||
// already covered by listtransactions), so no z_listsentbyaddress call is made.
|
||||
Refresh::TransactionRefreshSnapshot noZSnapshot;
|
||||
MockRefreshRpc noZRpc;
|
||||
noZRpc.addResponse("listtransactions", json::array());
|
||||
auto noZResult = Refresh::collectTransactionRefreshResult(noZRpc, noZSnapshot, 401, 4);
|
||||
EXPECT_TRUE(noZRpc.methodNames() == std::vector<std::string>({"listtransactions"}));
|
||||
EXPECT_EQ(noZResult.transactions.size(), static_cast<size_t>(0));
|
||||
}
|
||||
|
||||
// z->t deshield: the external transparent recipient is reported ONLY in z_listsentbyaddress's
|
||||
// sends.transparentSends (z_viewtransaction sees only the shielded change note, isOutgoing=false;
|
||||
// listtransactions omits the t-vout because inputs are shielded). The row must be synthesized
|
||||
// directly from that payload — a direct sibling of the z->z gap.
|
||||
{
|
||||
Refresh::TransactionRefreshSnapshot ztSnapshot;
|
||||
ztSnapshot.shieldedAddresses = {"zs-mine"};
|
||||
MockRefreshRpc ztRpc;
|
||||
ztRpc.addResponse("listtransactions", json::array()); // z-originated: no t-input row
|
||||
ztRpc.addResponse("z_listreceivedbyaddress", json::array());
|
||||
ztRpc.addResponse("z_listsentbyaddress", json::array({
|
||||
json{{"txid", "zt-send"}, {"confirmations", 5}, {"blocktime", 900},
|
||||
{"sends", json{
|
||||
{"transparentSends", json::array({
|
||||
json{{"address", "R-recipient"}, {"amount", -2.5}, {"vout", 0}}
|
||||
})},
|
||||
{"saplingSends", json::array()}
|
||||
}}}
|
||||
}));
|
||||
// z->t viewtransaction: only a change note back to self -> no outgoing outputs.
|
||||
ztRpc.addResponse("z_viewtransaction", json{{"spends", json::array()}, {"outputs", json::array()}});
|
||||
auto ztResult = Refresh::collectTransactionRefreshResult(ztRpc, ztSnapshot, 500, 4);
|
||||
EXPECT_EQ(ztResult.transactions.size(), static_cast<size_t>(1));
|
||||
EXPECT_EQ(ztResult.transactions[0].txid, std::string("zt-send"));
|
||||
EXPECT_EQ(ztResult.transactions[0].type, std::string("send"));
|
||||
EXPECT_NEAR(ztResult.transactions[0].amount, -2.5, 0.00000001);
|
||||
EXPECT_EQ(ztResult.transactions[0].address, std::string("R-recipient"));
|
||||
EXPECT_EQ(ztResult.transactions[0].confirmations, 5);
|
||||
}
|
||||
|
||||
// t->t send is reported by BOTH listtransactions AND z_listsentbyaddress's transparentSends —
|
||||
// it must appear ONCE (deduped by txid+address+amount), not doubled.
|
||||
{
|
||||
Refresh::TransactionRefreshSnapshot ttSnapshot;
|
||||
ttSnapshot.shieldedAddresses = {"zs-mine"};
|
||||
MockRefreshRpc ttRpc;
|
||||
ttRpc.addResponse("listtransactions", json::array({
|
||||
json{{"txid", "tt-send"}, {"category", "send"}, {"amount", -1.0},
|
||||
{"time", 800}, {"confirmations", 3}, {"address", "R-dest"}}
|
||||
}));
|
||||
ttRpc.addResponse("z_listreceivedbyaddress", json::array());
|
||||
ttRpc.addResponse("z_listsentbyaddress", json::array({
|
||||
json{{"txid", "tt-send"}, {"confirmations", 3}, {"blocktime", 800},
|
||||
{"sends", json{{"transparentSends", json::array({
|
||||
json{{"address", "R-dest"}, {"amount", -1.0}, {"vout", 0}}
|
||||
})}}}}
|
||||
}));
|
||||
ttRpc.addResponse("z_viewtransaction", json{{"spends", json::array()}, {"outputs", json::array()}});
|
||||
auto ttResult = Refresh::collectTransactionRefreshResult(ttRpc, ttSnapshot, 500, 4);
|
||||
int ttSends = 0;
|
||||
for (const auto& t : ttResult.transactions)
|
||||
if (t.txid == std::string("tt-send") && t.type == std::string("send")) ++ttSends;
|
||||
EXPECT_EQ(ttSends, 1); // not double-counted
|
||||
}
|
||||
|
||||
// Multi-output send: two outgoing outputs of EQUAL value to DIFFERENT recipients in one tx must
|
||||
// both render (address-keyed dedup) — an amount-only dedup dropped the second.
|
||||
{
|
||||
Refresh::TransactionRefreshSnapshot multiSnapshot;
|
||||
multiSnapshot.sendTxids = {"multi-send"};
|
||||
MockRefreshRpc multiRpc;
|
||||
multiRpc.addResponse("listtransactions", json::array());
|
||||
multiRpc.addResponse("z_viewtransaction", json{
|
||||
{"spends", json::array({json{{"address", "zs-from"}}})},
|
||||
{"outputs", json::array({
|
||||
json{{"outgoing", true}, {"address", "zs-a"}, {"value", 0.5}, {"memoStr", "a"}},
|
||||
json{{"outgoing", true}, {"address", "zs-b"}, {"value", 0.5}, {"memoStr", "b"}}
|
||||
})}
|
||||
});
|
||||
multiRpc.addResponse("gettransaction", json{{"time", 700}, {"confirmations", 2}});
|
||||
auto multiResult = Refresh::collectTransactionRefreshResult(multiRpc, multiSnapshot, 500, 4);
|
||||
int multiSends = 0;
|
||||
for (const auto& t : multiResult.transactions)
|
||||
if (t.txid == std::string("multi-send") && t.type == std::string("send")) ++multiSends;
|
||||
EXPECT_EQ(multiSends, 2); // both equal-value outputs kept
|
||||
}
|
||||
|
||||
Refresh::TransactionRefreshSnapshot cachedOnlySnapshot;
|
||||
auto cachedOnlyEntry = cachedEntry;
|
||||
cachedOnlyEntry.timestamp = 450;
|
||||
@@ -1802,6 +1927,7 @@ void testNetworkRefreshRpcCollectors()
|
||||
strictRpc.addResponse("listtransactions", json::array());
|
||||
strictRpc.addResponse("z_listreceivedbyaddress", json::array());
|
||||
strictRpc.addResponse("z_listreceivedbyaddress", json::array());
|
||||
strictRpc.addResponse("z_listsentbyaddress", json::array());
|
||||
auto strictResult = Refresh::collectTransactionRefreshResult(strictRpc, strictSnapshot, 102, 4);
|
||||
EXPECT_EQ(strictResult.shieldedAddressesScanned, static_cast<size_t>(2)); // both re-scanned
|
||||
|
||||
@@ -1813,10 +1939,12 @@ void testNetworkRefreshRpcCollectors()
|
||||
MockRefreshRpc tolerantRpc;
|
||||
tolerantRpc.addResponse("listtransactions", json::array());
|
||||
// No z_listreceivedbyaddress responses: the addresses must be skipped (else call() throws).
|
||||
// The shielded scan still completes, so the whole-wallet z_listsentbyaddress sweep runs once.
|
||||
tolerantRpc.addResponse("z_listsentbyaddress", json::array());
|
||||
auto tolerantResult = Refresh::collectTransactionRefreshResult(tolerantRpc, tolerantSnapshot, 102, 4);
|
||||
EXPECT_EQ(tolerantResult.shieldedAddressesScanned, static_cast<size_t>(0)); // both skipped
|
||||
EXPECT_TRUE(tolerantResult.shieldedScanComplete);
|
||||
EXPECT_TRUE(tolerantRpc.methodNames() == std::vector<std::string>({"listtransactions"}));
|
||||
EXPECT_TRUE(tolerantRpc.methodNames() == std::vector<std::string>({"listtransactions", "z_listsentbyaddress"}));
|
||||
}
|
||||
|
||||
Refresh::TransactionRefreshSnapshot recentSnapshot;
|
||||
@@ -1902,10 +2030,11 @@ void testNetworkRefreshRpcCollectors()
|
||||
MockRefreshRpc finalShieldedRpc;
|
||||
finalShieldedRpc.addResponse("listtransactions", json::array());
|
||||
finalShieldedRpc.addResponse("z_listreceivedbyaddress", json::array());
|
||||
finalShieldedRpc.addResponse("z_listsentbyaddress", json::array());
|
||||
auto finalShielded = Refresh::collectTransactionRefreshResult(
|
||||
finalShieldedRpc, partialShieldedSnapshot, 400, 0);
|
||||
EXPECT_TRUE(finalShieldedRpc.methodNames() == std::vector<std::string>({
|
||||
"listtransactions", "z_listreceivedbyaddress"
|
||||
"listtransactions", "z_listreceivedbyaddress", "z_listsentbyaddress"
|
||||
}));
|
||||
EXPECT_EQ(finalShieldedRpc.calls[1].params, json::array({"zs-two", 0}));
|
||||
EXPECT_TRUE(finalShielded.shieldedScanComplete);
|
||||
@@ -1922,9 +2051,10 @@ void testNetworkRefreshRpcCollectors()
|
||||
});
|
||||
MockRefreshRpc cachedShieldedRpc;
|
||||
cachedShieldedRpc.addResponse("listtransactions", json::array());
|
||||
cachedShieldedRpc.addResponse("z_listsentbyaddress", json::array());
|
||||
auto cachedShielded = Refresh::collectTransactionRefreshResult(
|
||||
cachedShieldedRpc, cachedShieldedSnapshot, 500, 0);
|
||||
EXPECT_TRUE(cachedShieldedRpc.methodNames() == std::vector<std::string>({"listtransactions"}));
|
||||
EXPECT_TRUE(cachedShieldedRpc.methodNames() == std::vector<std::string>({"listtransactions", "z_listsentbyaddress"}));
|
||||
EXPECT_TRUE(cachedShielded.shieldedScanComplete);
|
||||
EXPECT_EQ(cachedShielded.shieldedAddressesScanned, static_cast<size_t>(0));
|
||||
EXPECT_EQ(cachedShielded.transactions.size(), static_cast<size_t>(1));
|
||||
@@ -1970,6 +2100,7 @@ void testNetworkRefreshRpcCollectors()
|
||||
json{{"txid", "shielded-mined"}, {"amount", 4.0}, {"confirmations", 102},
|
||||
{"time", 210}, {"memoStr", "pool"}}
|
||||
}));
|
||||
miningTxRpc.addResponse("z_listsentbyaddress", json::array());
|
||||
auto miningTxResult = Refresh::collectTransactionRefreshResult(miningTxRpc, miningSnapshot, 328, 0);
|
||||
EXPECT_EQ(miningTxResult.transactions.size(), static_cast<size_t>(2));
|
||||
EXPECT_EQ(miningTxResult.transactions[0].type, std::string("mined"));
|
||||
@@ -2005,6 +2136,25 @@ void testNetworkRefreshResultModels()
|
||||
EXPECT_EQ(state.longestchain, 110);
|
||||
EXPECT_EQ(state.notarized, 90);
|
||||
EXPECT_EQ(state.last_balance_update, static_cast<int64_t>(1234));
|
||||
EXPECT_TRUE(state.sync.tip_known); // longestchain>0 -> peer-derived tip is known
|
||||
EXPECT_FALSE(state.sync.isSynced()); // still catching up (blocks 100 < tip 110)
|
||||
|
||||
// Peerless node: the daemon reports longestchain == 0. Even at the local best height (blocks ==
|
||||
// headers) it must NOT be reported synced (the tip is unknown) — otherwise Send would be offered
|
||||
// against a stale balance on a node that has silently lost all peers. (audit: peerless-synced)
|
||||
{
|
||||
dragonx::WalletState peerless;
|
||||
auto pc = Refresh::parseCoreRefreshResult(
|
||||
json{{"private", "5.00000000"}, {"transparent", "0.00000000"}, {"total", "5.00000000"}},
|
||||
json{{"private", "5.00000000"}, {"transparent", "0.00000000"}, {"total", "5.00000000"}},
|
||||
true,
|
||||
json{{"blocks", 200}, {"headers", 200}, {"bestblockhash", "peerless-200"},
|
||||
{"verificationprogress", 1.0}, {"longestchain", 0}, {"notarized", 0}},
|
||||
true);
|
||||
Refresh::applyCoreRefreshResult(peerless, pc, 1235);
|
||||
EXPECT_FALSE(peerless.sync.tip_known);
|
||||
EXPECT_FALSE(peerless.sync.isSynced()); // peerless -> not synced despite blocks == headers
|
||||
}
|
||||
|
||||
auto connectionInfo = Refresh::parseConnectionInfoResult(
|
||||
json{{"version", 120000}, {"protocolversion", 170002}, {"p2pport", 8233},
|
||||
@@ -2638,6 +2788,28 @@ void testNodeStatusBanner()
|
||||
NodeBannerInputs in; in.connected = true;
|
||||
EXPECT_TRUE(!evaluateNodeStatusBanner(in).show);
|
||||
}
|
||||
// Connected but no peer-derived tip (0 peers) once chain info is in → amber "no peers" banner.
|
||||
{
|
||||
NodeBannerInputs in;
|
||||
in.connected = true; in.tip_known = false; in.blocks = 3262059;
|
||||
in.connection_status = "Peers: 0";
|
||||
NodeBannerState s = evaluateNodeStatusBanner(in);
|
||||
EXPECT_TRUE(s.show);
|
||||
EXPECT_TRUE(s.severity == NodeBannerSeverity::Warning);
|
||||
EXPECT_TRUE(s.reason == NodeBannerReason::NoPeers);
|
||||
EXPECT_TRUE(s.action == NodeBannerAction::None);
|
||||
EXPECT_EQ(s.detail, std::string("Peers: 0"));
|
||||
}
|
||||
// Connected, tip unknown, but no chain info yet (blocks==0) → no banner (avoid startup flicker).
|
||||
{
|
||||
NodeBannerInputs in; in.connected = true; in.tip_known = false; in.blocks = 0;
|
||||
EXPECT_TRUE(!evaluateNodeStatusBanner(in).show);
|
||||
}
|
||||
// Connected with a known tip → no banner.
|
||||
{
|
||||
NodeBannerInputs in; in.connected = true; in.tip_known = true; in.blocks = 100;
|
||||
EXPECT_TRUE(!evaluateNodeStatusBanner(in).show);
|
||||
}
|
||||
// Expected startup phases own the screen (loading/warmup overlay) → no banner.
|
||||
{
|
||||
NodeBannerInputs in; in.warming_up = true;
|
||||
@@ -2792,6 +2964,12 @@ void testSeedMigrationResume()
|
||||
// No txid and no opid → the Sweep gate (whether or not connected).
|
||||
EXPECT_TRUE(decideSeedMigrationResume(true, true, "", "", true) == MigrationResume::SweepGate);
|
||||
EXPECT_TRUE(decideSeedMigrationResume(true, true, "", "", false) == MigrationResume::SweepGate);
|
||||
|
||||
// fund-safety: the Sweep step may only short-circuit a ~0 balance straight to adopt when NO sweep
|
||||
// was ever submitted (a genuinely empty wallet). Once a sweep has been broadcast, a ~0 balance is
|
||||
// ambiguous (the tx may be unconfirmed in the mempool), so direct adopt must be blocked.
|
||||
EXPECT_TRUE(dragonx::sweepGateMayAdoptEmptyWallet(/*sweepSubmitted=*/false)); // empty wallet -> ok
|
||||
EXPECT_FALSE(dragonx::sweepGateMayAdoptEmptyWallet(/*sweepSubmitted=*/true)); // in-flight -> blocked
|
||||
}
|
||||
|
||||
void testLoggerFileSink()
|
||||
@@ -5301,6 +5479,92 @@ void testLiteWalletControllerAsyncLifecycleFailover()
|
||||
dragonx::test::g_liteFakeWarmupServerSubstr.clear();
|
||||
}
|
||||
|
||||
// C1/M1: the ASYNC lifecycle path (the one the Settings UI actually calls) must apply the
|
||||
// passphrase — encrypt a freshly created/restored wallet, unlock an existing encrypted one.
|
||||
// Previously the async path discarded the passphrase (encrypt/unlock lived only in the sync
|
||||
// wrappers), so a UI-created wallet stored its seed in PLAINTEXT and an encrypted wallet opened
|
||||
// but stayed locked.
|
||||
void testLiteWalletControllerAsyncAppliesPassphrase()
|
||||
{
|
||||
using namespace dragonx::wallet;
|
||||
const auto liteCaps = makeWalletCapabilities(WalletBuildKind::Lite, false, true);
|
||||
LiteConnectionSettings conn;
|
||||
conn.chainName = "main";
|
||||
conn.servers = { LiteServerEndpoint{"https://good.example", "Good", true} };
|
||||
conn.selectionMode = LiteServerSelectionMode::Sticky;
|
||||
conn.stickyServerUrl = "https://good.example";
|
||||
|
||||
const auto drain = [](LiteWalletController& c) {
|
||||
for (int i = 0; i < 400 && c.lifecycleRequestInProgress(); ++i)
|
||||
std::this_thread::sleep_for(std::chrono::milliseconds(5));
|
||||
c.pumpLifecycleResult();
|
||||
};
|
||||
|
||||
// C1: async create with a passphrase encrypts (and locks) the new wallet; same passphrase unlocks.
|
||||
{
|
||||
dragonx::test::resetLiteFakeCounters();
|
||||
dragonx::test::g_liteFakeWalletExists = false;
|
||||
dragonx::test::g_liteFakeEncrypted = false;
|
||||
dragonx::test::g_liteFakeLocked = false;
|
||||
LiteWalletController controller(liteCaps, conn,
|
||||
LiteClientBridge::fromApi(dragonx::test::makeFakeLiteApi()));
|
||||
LiteWalletCreateRequest req;
|
||||
req.passphrase = "hunter2";
|
||||
EXPECT_TRUE(controller.beginCreateWalletAsync(req));
|
||||
drain(controller);
|
||||
EXPECT_TRUE(controller.walletOpen());
|
||||
const auto s = controller.encryptionStatus();
|
||||
EXPECT_TRUE(s.encrypted); // async create ENCRYPTED the wallet (C1 fixed)
|
||||
EXPECT_TRUE(s.locked); // encrypt locks immediately
|
||||
EXPECT_TRUE(controller.unlockWallet("hunter2"));
|
||||
EXPECT_FALSE(controller.encryptionStatus().locked);
|
||||
}
|
||||
|
||||
// C1: async restore with a passphrase likewise encrypts the restored wallet.
|
||||
{
|
||||
dragonx::test::resetLiteFakeCounters();
|
||||
dragonx::test::g_liteFakeWalletExists = false;
|
||||
dragonx::test::g_liteFakeEncrypted = false;
|
||||
dragonx::test::g_liteFakeLocked = false;
|
||||
LiteWalletController controller(liteCaps, conn,
|
||||
LiteClientBridge::fromApi(dragonx::test::makeFakeLiteApi()));
|
||||
LiteWalletRestoreRequest req;
|
||||
req.seedPhrase = "abandon abandon abandon abandon abandon abandon abandon abandon "
|
||||
"abandon abandon abandon abandon abandon abandon abandon abandon "
|
||||
"abandon abandon abandon abandon abandon abandon abandon art";
|
||||
req.birthday = 0;
|
||||
req.passphrase = "hunter2";
|
||||
EXPECT_TRUE(controller.beginRestoreWalletAsync(req));
|
||||
drain(controller);
|
||||
EXPECT_TRUE(controller.walletOpen());
|
||||
EXPECT_TRUE(controller.encryptionStatus().encrypted); // C1 fixed for restore
|
||||
}
|
||||
|
||||
// M1: async open of an already-encrypted+locked wallet unlocks it with the supplied passphrase.
|
||||
{
|
||||
dragonx::test::resetLiteFakeCounters();
|
||||
dragonx::test::g_liteFakeWalletExists = true;
|
||||
dragonx::test::g_liteFakeEncrypted = true; // existing wallet is encrypted...
|
||||
dragonx::test::g_liteFakeLocked = true; // ...and locked at open time
|
||||
LiteWalletController controller(liteCaps, conn,
|
||||
LiteClientBridge::fromApi(dragonx::test::makeFakeLiteApi()));
|
||||
LiteWalletOpenRequest req;
|
||||
req.passphrase = "hunter2";
|
||||
EXPECT_TRUE(controller.beginOpenWalletAsync(req));
|
||||
drain(controller);
|
||||
EXPECT_TRUE(controller.walletOpen());
|
||||
const auto s = controller.encryptionStatus();
|
||||
EXPECT_TRUE(s.encrypted);
|
||||
EXPECT_FALSE(s.locked); // async open UNLOCKED it (M1 fixed)
|
||||
}
|
||||
|
||||
dragonx::test::g_liteFakeWalletExists = false;
|
||||
dragonx::test::g_liteFakeEncrypted = false;
|
||||
dragonx::test::g_liteFakeLocked = false;
|
||||
dragonx::test::g_liteFakeDeadServerSubstr.clear();
|
||||
dragonx::test::g_liteFakeWarmupServerSubstr.clear();
|
||||
}
|
||||
|
||||
// M2: a parsed lite refresh bundle maps through to the app's WalletState (the last hop
|
||||
// the Balance/Receive/Transactions tabs read), with zatoshi->DRGX conversion, z/t address
|
||||
// split, transaction typing, confirmations, and sync progress.
|
||||
@@ -7500,6 +7764,7 @@ int main()
|
||||
testLiteWalletControllerM5Persistence();
|
||||
testLiteWalletControllerEncryption();
|
||||
testLiteWalletControllerCreateEncryptsWithPassphrase();
|
||||
testLiteWalletControllerAsyncAppliesPassphrase();
|
||||
testLiteChainNameMigration();
|
||||
testLiteRefreshModelAppliesToWalletState();
|
||||
testLiteSendShowsRecipientFromOutgoing();
|
||||
|
||||
Reference in New Issue
Block a user