8 Commits

Author SHA1 Message Date
46189da0da Merge dev: format release-note checksums as a table 2026-09-04 05:07:40 -05:00
bc6c1de7d6 docs: format release-note checksums as a File/SHA-256 table (match v2.0.0)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BwycPDQSfEKtrTS2uC4JeT
2026-09-04 05:07:40 -05:00
ec88dac459 Merge dev: inline SHA-256 in v2.0.2 / lite v1.1.1 release notes 2026-09-04 05:05:01 -05:00
d058e5fc2a docs: inline SHA-256 checksums in v2.0.2 / lite v1.1.1 release notes
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BwycPDQSfEKtrTS2uC4JeT
2026-09-04 05:04:51 -05:00
86b9d2e00d Merge dev: v2.0.2 / lite 1.1.1 release notes 2026-09-04 04:25:52 -05:00
0056c46929 docs: add release notes for v2.0.2 and lite v1.1.1
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BwycPDQSfEKtrTS2uC4JeT
2026-09-04 04:25:44 -05:00
1151141271 Merge dev: v2.0.2 / lite 1.1.1 — FAQ CJK glyphs, outgoing-tx history, lite encryption, migrate & sync fixes 2026-09-04 04:03:12 -05:00
fae8b20fb7 fix: FAQ CJK glyphs, outgoing-tx history, lite encryption, migrate & sync safety
Bug-report fixes plus adversarial-audit follow-ups. Bumps full 2.0.1->2.0.2 and
lite 1.1.0->1.1.1.

i18n / fonts:
- Rebuild the NotoSansCJK subset with the glyphs the FAQ back-fill introduced
  (什/么/门/做); Chinese FAQ titles rendered as "??" on zh (and ja/ko).

Transaction history (enumeration gaps):
- Surface outgoing shielded z->z sends via z_listsentbyaddress (listtransactions
  and z_listreceivedbyaddress never report them).
- Surface z->t deshields by parsing z_listsentbyaddress transparentSends (which
  z_viewtransaction does not expose); dedupe t->t against listtransactions.
- Key send-row dedup on address so equal-value multi-output sends aren't dropped.

Lite wallet (key safety):
- The async create/restore/open path the UI uses now applies the passphrase:
  encrypt a new/restored wallet, unlock an existing one. It previously discarded
  the passphrase, storing the seed/keys in PLAINTEXT (create/restore) or leaving
  an encrypted wallet locked (open).

Migrate-to-seed (fund safety):
- Persist a sweep-submitted marker at broadcast so a resumed migration can't treat
  a ~0 balance (an unconfirmed in-flight sweep) as an empty wallet and adopt (swap
  wallet.dat) before the sweep confirms; best-effort locate routes to the confirm gate.

Sync / threading:
- Gate isSynced() on a peer-derived tip (tip_known) so a peerless node isn't reported
  synced (Send against a stale balance); add a "no peers" status banner.
- Guard daemon_status_ with a mutex (monitor-thread write vs UI-thread read).

Tests: extends tests/test_phase4.cpp across all of the above; suite green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BwycPDQSfEKtrTS2uC4JeT
2026-09-04 04:02:52 -05:00
18 changed files with 730 additions and 34 deletions

View File

@@ -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

View File

@@ -0,0 +1,43 @@
**DragonX (DRGX) Lite Desktop Wallet — v1.1.1**
ObsidianDragonLite is the lightweight, node-free companion to ObsidianDragon — it connects to a DragonX lite-wallet server for near-instant startup, with the same native ImGui interface and shielded-first workflow. **v1.1.1 is a maintenance release** centered on an important wallet-encryption fix.
---
## ⚠️ Security fix — please read
**Creating or restoring a Lite wallet with a passphrase now actually encrypts it.**
In prior versions, the create / restore / open flow used by the Settings UI **silently discarded the passphrase you entered**:
- A **newly created or restored** wallet was saved to disk **unencrypted** — the seed and spending keys were stored in the clear even though you set a passphrase.
- **Opening** an already-encrypted wallet did not unlock it with the supplied passphrase (it opened locked and re-prompted).
v1.1.1 fixes this: create/restore now **encrypt** the wallet with your passphrase (the backend encrypts and locks it), and open now **unlocks** it.
**What you should do:** if you created or restored a Lite wallet **with a passphrase** on an earlier version, treat that wallet's on-disk data as unencrypted. Open it in v1.1.1 and re-apply encryption from **Settings → encrypt** (and, if you're concerned the plaintext wallet file may have been exposed, move the funds to a freshly created encrypted wallet). Wallets you never set a passphrase on are unaffected.
---
## Also fixed
- **Chinese, Japanese, and Korean FAQ text renders correctly.** Several Help/FAQ titles showed `??` instead of characters (e.g. 什么, 门) after the previous translation back-fill; the bundled CJK font subset now includes those glyphs.
---
## Downloads
macOS builds are x86-64 (Intel; run on Apple Silicon via Rosetta 2).
| File | SHA-256 |
|------|---------|
| ObsidianDragonLite-1.1.1.AppImage | `323ff5f8658a5550aa12678ce4be37e32a2bfd3f3b0543074cc86a4267895dcf` |
| ObsidianDragonLite-1.1.1-Linux-x64.zip | `e8604e479aed7e020946df45bf0d9373401354907e10565011694efa9978e631` |
| ObsidianDragonLite-1.1.1.exe | `4cdcf291fe43c4fed04258aa55bb4e9b591f52780f9db5fb8f8bef522c43a351` |
| ObsidianDragonLite-1.1.1-Windows-x64.zip | `c387bfcda508f32195c41fabec9f23d8b094d693a5dba41fbf8ec4abab6f2f6e` |
| ObsidianDragonLite-1.1.1-macOS-x86_64.dmg | `bb0c491cb8f90ae55d162ec7c04c8f30988fa2f79c7911471220cddab44f99a5` |
| ObsidianDragonLite-1.1.1-macOS-x86_64.app.zip | `a2a75fe40bf1f4db3abed8206dcc40d9fc74d8db2df7704afc9c834106b1c247` |
---
For the full feature set (node-free operation, wallet lifecycle, shielded + transparent send/shield/receive, HushChat, server browser with failover, 8-language UI), see the **lite-v1.0.0** release notes — v1.1.1 changes only the items listed above.

View File

@@ -0,0 +1,41 @@
**DragonX (DRGX) Full-Node Desktop Wallet — v2.0.2**
ObsidianDragon is a native ImGui-based full-node wallet for the DragonX network, with an embedded `dragonxd` daemon and the integrated DRG-XMRig CPU miner. **v2.0.2 is a maintenance release** — a focused set of transaction-history, sync-safety, and correctness fixes on top of v2.0.x. No configuration changes are required; just replace the binary.
---
## What's Fixed in v2.0.2
### Transaction history
- **Outgoing shielded sends now appear in History.** Sends *from* a shielded (z-)address — both **z→z** (shielded-to-shielded) and **z→t** (deshield to a transparent address) — were missing from the History tab, because the daemon's `listtransactions` doesn't report them and `z_viewtransaction` doesn't expose the transparent leg. The wallet now enumerates them via `z_listsentbyaddress`, so **your History matches your balance.** (Your funds were never affected — only the display.)
- **More accurate send rows** — a single transaction with two equal-value outputs to different recipients is no longer collapsed into one row, and a transparent self-send / change output is no longer double-counted as both sent and received.
### Sync & fund safety
- **A node that loses all its peers is no longer reported as "synced."** Previously, a wallet whose node dropped to **0 peers** could keep showing a stale balance as if it were current — and allow a Send against it. The wallet now treats a node with no peer-derived chain tip as *not synced*, and a new **"Connected, but no network peers — the node can't sync"** banner explains the stall instead of silently looking healthy.
- **Migrate-to-seed hardening.** If a migrate-to-seed was interrupted (app or daemon restart) *after* the sweep transaction had broadcast but *before* it confirmed, the wallet could mistake the temporarily-zero balance for an empty wallet and adopt the new seed wallet — swapping `wallet.dat` — before the sweep was mined. It now records that a sweep is in flight and **will not swap your wallet until the sweep confirms**, so funds can't be stranded in the timestamped backup.
- **Fixed a rare crash** caused by a data race on the daemon-status text (the daemon-monitor thread writing it while the UI thread read it).
### Display / internationalization
- **Chinese, Japanese, and Korean FAQ text renders correctly.** Several Help/FAQ titles showed `??` instead of characters (e.g. 什么, 门) after the previous translation back-fill; the bundled CJK font subset now includes those glyphs.
---
## Downloads
macOS builds are x86-64 (Intel; run on Apple Silicon via Rosetta 2).
| File | SHA-256 |
|------|---------|
| ObsidianDragon-2.0.2.AppImage | `9bf7328965c6348113ba7a30b0284f15787c635af17efae3dfa8a87e56e10c9b` |
| ObsidianDragon-2.0.2-Linux-x64.zip | `1371880c4187a94d4cb56d535471142c5f1a8053ef73fffd99d59e7b78c75c42` |
| ObsidianDragon-2.0.2.exe | `673347d2696fb6f5bd09dc25e415d5ae8e532abde75b8d48c3cd26545461de50` |
| ObsidianDragon-2.0.2-Windows-x64.zip | `f5ea7c45e66c6c1b90a8127f96dee04235691acb913ddc86327f2060798cd28c` |
| ObsidianDragon-2.0.2-macOS-x86_64.dmg | `7038580807a8e93e5443bdc04f2aede3ae1c30e5bc9f8776a207edb2e87771d1` |
| ObsidianDragon-2.0.2-macOS-x86_64.app.zip | `78159512ca684b95528231fd46f2d07b5bcc6f2da4d815fc1ecb2ff52127f41c` |
---
For the full feature set (HushChat messaging, seed-phrase wallets & migrate-to-seed, in-app node/miner updaters, built-in mining, 8-language UI), see the **v2.0.0** release notes — v2.0.2 changes only the fixes listed above.

Binary file not shown.

View File

@@ -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";

View File

@@ -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)

View File

@@ -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 {

View File

@@ -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_;

View File

@@ -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;

View File

@@ -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

View File

@@ -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; }
};
/**

View File

@@ -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)

View File

@@ -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,

View File

@@ -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 &&

View File

@@ -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";

View File

@@ -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;

View File

@@ -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};

View File

@@ -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();