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
+96 -1
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)