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:27 -05:00
parent f30fdc5ed1
commit fae8b20fb7
16 changed files with 646 additions and 34 deletions

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