diff --git a/CMakeLists.txt b/CMakeLists.txt index 70df660..8ac50ff 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -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 diff --git a/res/fonts/NotoSansCJK-Subset.ttf b/res/fonts/NotoSansCJK-Subset.ttf index f751d67..d8acc07 100644 Binary files a/res/fonts/NotoSansCJK-Subset.ttf and b/res/fonts/NotoSansCJK-Subset.ttf differ diff --git a/src/app.cpp b/src/app.cpp index 4e87716..b1ea337 100644 --- a/src/app.cpp +++ b/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 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 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"; diff --git a/src/app.h b/src/app.h index dbb6794..6b2b289 100644 --- a/src/app.h +++ b/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 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) diff --git a/src/app_network.cpp b/src/app_network.cpp index a987c54..ed58e89 100644 --- a/src/app_network.cpp +++ b/src/app_network.cpp @@ -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 { diff --git a/src/config/settings.cpp b/src/config/settings.cpp index e7973c2..8793d3d 100644 --- a/src/config/settings.cpp +++ b/src/config/settings.cpp @@ -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_; diff --git a/src/config/settings.h b/src/config/settings.h index e9bdd88..8ec5709 100644 --- a/src/config/settings.h +++ b/src/config/settings.h @@ -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; diff --git a/src/data/seed_migration_resume.h b/src/data/seed_migration_resume.h index fe138d9..9b29d77 100644 --- a/src/data/seed_migration_resume.h +++ b/src/data/seed_migration_resume.h @@ -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 diff --git a/src/data/wallet_state.h b/src/data/wallet_state.h index dc0a00f..058677a 100644 --- a/src/data/wallet_state.h +++ b/src/data/wallet_state.h @@ -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; } }; /** diff --git a/src/services/network_refresh_service.cpp b/src/services/network_refresh_service.cpp index 2d79484..964ecb9 100644 --- a/src/services/network_refresh_service.cpp +++ b/src/services/network_refresh_service.cpp @@ -853,8 +853,11 @@ void NetworkRefreshService::appendViewTransactionOutputs(std::vector& 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(sentEntry, "confirmations")) confirmations = *v; + if (auto v = readOptional(sentEntry, "blocktime")) timestamp = *v; + if (timestamp == 0) { + if (auto v = readOptional(sentEntry, "time")) timestamp = *v; + } + + for (const auto& out : *it) { + auto amount = readOptional(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(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& 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(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) diff --git a/src/services/network_refresh_service.h b/src/services/network_refresh_service.h index 426463e..ac94feb 100644 --- a/src/services/network_refresh_service.h +++ b/src/services/network_refresh_service.h @@ -310,6 +310,12 @@ public: static void appendViewTransactionOutputs(std::vector& 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& transactions, + const std::string& txid, + const nlohmann::json& sentEntry); static void sortTransactionsNewestFirst(std::vector& transactions); static TransactionRefreshResult collectTransactionRefreshResult(RefreshRpcGateway& rpc, const TransactionRefreshSnapshot& snapshot, diff --git a/src/ui/node_status_banner.h b/src/ui/node_status_banner.h index 6233e5e..a4dbe2a 100644 --- a/src/ui/node_status_banner.h +++ b/src/ui/node_status_banner.h @@ -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 && diff --git a/src/util/i18n.cpp b/src/util/i18n.cpp index 8780f1d..8c3df39 100644 --- a/src/util/i18n.cpp +++ b/src/util/i18n.cpp @@ -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"; diff --git a/src/wallet/lite_wallet_controller.cpp b/src/wallet/lite_wallet_controller.cpp index f5971e0..2818cf6 100644 --- a/src/wallet/lite_wallet_controller.cpp +++ b/src/wallet/lite_wallet_controller.cpp @@ -450,31 +450,47 @@ void LiteWalletController::pumpAsyncOpen() bool LiteWalletController::beginCreateWalletAsync(LiteWalletCreateRequest request) { auto req = std::make_shared(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(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(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; diff --git a/src/wallet/lite_wallet_controller.h b/src/wallet/lite_wallet_controller.h index 7ea90df..5b03dac 100644 --- a/src/wallet/lite_wallet_controller.h +++ b/src/wallet/lite_wallet_controller.h @@ -388,6 +388,20 @@ private: std::make_shared>(); 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 running_{false}; diff --git a/tests/test_phase4.cpp b/tests/test_phase4.cpp index e909b23..5f88379 100644 --- a/tests/test_phase4.cpp +++ b/tests/test_phase4.cpp @@ -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({ "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(1)); EXPECT_EQ(transactionResult.newViewTxEntries.count("pending-send"), static_cast(1)); @@ -1657,6 +1659,129 @@ void testNetworkRefreshRpcCollectors() EXPECT_EQ(transactionResult.transactions.front().timestamp, static_cast(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({ + "listtransactions", "z_listreceivedbyaddress", "z_listsentbyaddress", + "z_viewtransaction", "gettransaction" + })); + EXPECT_EQ(zzResult.transactions.size(), static_cast(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(800)); + EXPECT_EQ(zzResult.newViewTxEntries.count("zz-send"), static_cast(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({"listtransactions"})); + EXPECT_EQ(noZResult.transactions.size(), static_cast(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(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(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(0)); // both skipped EXPECT_TRUE(tolerantResult.shieldedScanComplete); - EXPECT_TRUE(tolerantRpc.methodNames() == std::vector({"listtransactions"})); + EXPECT_TRUE(tolerantRpc.methodNames() == std::vector({"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({ - "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({"listtransactions"})); + EXPECT_TRUE(cachedShieldedRpc.methodNames() == std::vector({"listtransactions", "z_listsentbyaddress"})); EXPECT_TRUE(cachedShielded.shieldedScanComplete); EXPECT_EQ(cachedShielded.shieldedAddressesScanned, static_cast(0)); EXPECT_EQ(cachedShielded.transactions.size(), static_cast(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(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(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();