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
This commit is contained in:
2026-09-04 04:02:52 -05:00
co-authored by Claude Opus 4.8
parent f30fdc5ed1
commit fae8b20fb7
16 changed files with 646 additions and 34 deletions
+60 -16
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";