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
+76
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 {