From 3ee90993edc7b97901b8df0f00df37f34da6bdea Mon Sep 17 00:00:00 2001 From: DanS Date: Mon, 19 Oct 2026 19:12:32 -0500 Subject: [PATCH] fix(resilience,perf): gate send on a known tip, surface dropped/stale txs, advance cached confs, memoize explorer MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Backlog mediums (display/gating/caching only — no send/sweep/encrypt call or args changed): Resilience: - Gate Send/Review on a KNOWN peer-derived tip (SyncInfo::sendTipReady): a fresh reconnect or peerless node has tip_known=false, so its local height isn't a trustworthy network tip — sending is disabled with a clear tooltip until it is. Lite is exempt (its own sync gate). The z_sendmany call is untouched. - History now shows "Dropped" for a conflicted tx (confirmations < 0) and "Expired?" for one unconfirmed > 1h, instead of "Pending" forever (new TransactionInfo::isConflicted/isStalePending; the tab inlines the check on its DisplayTx fields). - A cached z_viewtransaction row's confirmations now advance by the blocks mined since it was sampled (observed_height), instead of freezing at the cached value; never goes backwards; pending (0) isn't extrapolated. Performance: - Explorer block list uses loadRangeCached() (memoized on the cache revision) instead of loadRange(), so it only hits SQLite after the cache changes, not every frame. Tests: testResilienceMediums (tip gate, confirmations-advance, dropped/stale predicates) + loadRangeCached memoization in testExplorerBlockCache (isolated on its own cache). Held: Overview/Balance per-frame list cache, peers clippers, off-thread tx-history cache load (larger refactors). Co-Authored-By: Claude Opus 4.8 --- src/data/wallet_state.h | 12 ++++ src/services/network_refresh_service.cpp | 12 +++- src/services/network_refresh_service.h | 6 +- src/ui/windows/explorer_tab.cpp | 5 +- src/ui/windows/send_tab.cpp | 8 ++- src/ui/windows/transactions_tab.cpp | 7 ++- src/util/i18n.cpp | 3 + tests/test_phase4.cpp | 73 ++++++++++++++++++++++++ 8 files changed, 117 insertions(+), 9 deletions(-) diff --git a/src/data/wallet_state.h b/src/data/wallet_state.h index 93eeca3..183b339 100644 --- a/src/data/wallet_state.h +++ b/src/data/wallet_state.h @@ -75,6 +75,13 @@ struct TransactionInfo { std::string getTypeDisplay() const; bool isConfirmed() const { return confirmations >= 1; } bool isMature() const { return confirmations >= 100; } + // gettransaction reports confirmations < 0 for a tx conflicted with the chain (double-spent / reorged out). + bool isConflicted() const { return confirmations < 0; } + // Unconfirmed for longer than maxAgeSec since the wallet first saw it: most likely dropped from the + // mempool or expired. A display heuristic only (timestamp 0 = unknown -> never flagged). + bool isStalePending(int64_t now, int64_t maxAgeSec = 3600) const { + return confirmations == 0 && timestamp > 0 && now - timestamp > maxAgeSec; + } }; // Exact field-wise equality: decides whether a refreshed list really differs. `amount` is compared @@ -198,6 +205,11 @@ struct SyncInfo { bool tip_known = false; bool isSynced() const { return tip_known && !syncing && blocks > 0 && blocks >= headers - 2; } + + // Send precondition: the wallet is caught up to a KNOWN peer-derived tip. A fresh (re)connect or a + // peerless node has tip_known == false, so its local height can't be trusted as the network tip. + // The lite backend never populates tip_known, so lite is exempt (it has its own sync gate). + bool sendTipReady(bool liteBuild) const { return liteBuild || (tip_known && !syncing && blocks > 0); } }; /** diff --git a/src/services/network_refresh_service.cpp b/src/services/network_refresh_service.cpp index 56fd49b..55b37d3 100644 --- a/src/services/network_refresh_service.cpp +++ b/src/services/network_refresh_service.cpp @@ -1056,8 +1056,13 @@ NetworkRefreshService::TransactionViewCacheEntry NetworkRefreshService::parseVie void NetworkRefreshService::appendViewTransactionOutputs(std::vector& transactions, const std::string& txid, - const TransactionViewCacheEntry& entry) + const TransactionViewCacheEntry& entry, + int currentHeight) { + // A mined entry's confirmations grow by one per block since it was sampled; never go backwards. + int entryConfirmations = entry.confirmations; + if (entry.confirmations >= 1 && entry.observed_height >= 0 && currentHeight > entry.observed_height) + entryConfirmations += currentHeight - entry.observed_height; for (const auto& out : entry.outgoing_outputs) { bool alreadyTracked = false; for (const auto& existing : transactions) { @@ -1089,7 +1094,7 @@ void NetworkRefreshService::appendViewTransactionOutputs(std::vectorsecond.outgoing_outputs.empty()) { - appendViewTransactionOutputs(result.transactions, txid, cached->second); + appendViewTransactionOutputs(result.transactions, txid, cached->second, currentBlockHeight); // NB: do NOT extract chat metadata from our OWN outgoing memos here — ingest marks // everything Incoming, so that duplicates every sent message as a phantom "from peer" // entry. Genuine incoming comes from the z_listreceivedbyaddress path; our sends are @@ -1509,6 +1514,7 @@ NetworkRefreshService::TransactionRefreshResult NetworkRefreshService::collectTr } auto entry = parseViewTransactionCacheEntry(viewTransaction); + entry.observed_height = currentBlockHeight; // confirmations below are sampled at this tip const bool storedRow = rowBacklog.count(txid) > 0; if (storedRow && entry.timestamp == 0) { const auto settled = settledStoredMeta.find(txid); diff --git a/src/services/network_refresh_service.h b/src/services/network_refresh_service.h index 266d424..5f61507 100644 --- a/src/services/network_refresh_service.h +++ b/src/services/network_refresh_service.h @@ -224,6 +224,7 @@ public: std::string from_address; std::int64_t timestamp = 0; int confirmations = 0; + int observed_height = -1; // chain tip when `confirmations` was read (-1 unknown): lets a cached entry advance struct Output { std::string address; double value = 0.0; @@ -427,9 +428,12 @@ public: const std::set& miningAddresses = {}, std::unordered_map>* chatMemoOutputs = nullptr); static TransactionViewCacheEntry parseViewTransactionCacheEntry(const nlohmann::json& viewTransaction); + // currentHeight (>=0): the present chain tip; a cached entry that was already mined then reports + // confirmations advanced by the blocks added since it was observed (display only). static void appendViewTransactionOutputs(std::vector& transactions, const std::string& txid, - const TransactionViewCacheEntry& entry); + const TransactionViewCacheEntry& entry, + int currentHeight = -1); // 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). diff --git a/src/ui/windows/explorer_tab.cpp b/src/ui/windows/explorer_tab.cpp index 7ce7940..23b40d1 100644 --- a/src/ui/windows/explorer_tab.cpp +++ b/src/ui/windows/explorer_tab.cpp @@ -846,7 +846,8 @@ static void renderRecentBlocks(App* app, float availWidth) { std::map cachedBlocks; if (!pageHeights.empty()) { - cachedBlocks = s_recent_block_cache.loadRange(pageHeights.back(), pageHeights.front()); + // Memoized on the cache revision: SQLite is only hit after the cache changed, not every frame. + cachedBlocks = s_recent_block_cache.loadRangeCached(pageHeights.back(), pageHeights.front()); } // Fuzzy mode: a non-numeric, non-full-hash query filters the list to cached blocks whose hash @@ -890,7 +891,7 @@ static void renderRecentBlocks(App* app, float availWidth) { } std::map nextCachedBlocks; if (!nextPageHeights.empty()) { - nextCachedBlocks = s_recent_block_cache.loadRange(nextPageHeights.back(), nextPageHeights.front()); + nextCachedBlocks = s_recent_block_cache.loadRangeCached(nextPageHeights.back(), nextPageHeights.front()); } for (int height : nextPageHeights) { if (nextCachedBlocks.find(height) == nextCachedBlocks.end()) { diff --git a/src/ui/windows/send_tab.cpp b/src/ui/windows/send_tab.cpp index a8ae38a..68f4ad1 100644 --- a/src/ui/windows/send_tab.cpp +++ b/src/ui/windows/send_tab.cpp @@ -917,7 +917,8 @@ void RenderSendConfirmPopup(App* app) { // since Review. Don't broadcast a now-invalid transaction. const auto& liveState = app->getWalletState(); double liveAvail = GetAvailableBalance(app); - if (!app->isConnected() || liveState.sync.syncing || s_amount <= 0 || + if (!app->isConnected() || liveState.sync.syncing || + !liveState.sync.sendTipReady(app->isLiteBuild()) || s_amount <= 0 || !util::canAfford(s_amount, s_fee, liveAvail)) { s_tx_status = TR("send_cannot_send_now"); s_status_success = false; @@ -1048,6 +1049,7 @@ static void RenderActionButtons(App* app, float width, float vScale, } bool can_send = app->isConnected() && !state.sync.syncing && + state.sync.sendTipReady(app->isLiteBuild()) && is_valid_address && s_amount > 0 && s_from_address[0] != '\0' && @@ -1072,7 +1074,7 @@ static void RenderActionButtons(App* app, float width, float vScale, float btnH = std::max(schema::UI().drawElement("tabs.send", "action-btn-min-height").size, schema::UI().drawElement("tabs.send", "action-btn-height").size * vScale); // Explain why Review/Send is greyed while syncing (the fields above are usable now). - if (state.sync.syncing) { + if (state.sync.syncing || (app->isConnected() && !state.sync.sendTipReady(app->isLiteBuild()))) { Type().textColored(TypeStyle::Caption, Warning(), TR("send_available_when_synced")); ImGui::Dummy(ImVec2(0, Layout::spacingXs())); } @@ -1101,6 +1103,8 @@ static void RenderActionButtons(App* app, float width, float vScale, material::Tooltip("%s", TR(app->isLiteBuild() ? "lite_no_wallet_short" : "send_tooltip_not_connected")); else if (state.sync.syncing) material::Tooltip("%s", TR("send_tooltip_syncing")); + else if (!state.sync.sendTipReady(app->isLiteBuild())) + material::Tooltip("%s", TR("send_tooltip_no_tip")); else if (s_from_address[0] == '\0') material::Tooltip("%s", TR("send_tooltip_select_source")); else if (is_mining_addr) diff --git a/src/ui/windows/transactions_tab.cpp b/src/ui/windows/transactions_tab.cpp index 6ef4410..00b721c 100644 --- a/src/ui/windows/transactions_tab.cpp +++ b/src/ui/windows/transactions_tab.cpp @@ -855,7 +855,12 @@ void RenderTransactionsTab(App* app) { const char* statusStr; ImU32 statusCol; - if (tx.confirmations == 0) { + const int64_t nowSec = static_cast(std::time(nullptr)); + if (tx.confirmations < 0) { // gettransaction < 0: conflicted / double-spent / reorged out + statusStr = TR("tx_dropped"); statusCol = Error(); + } else if (tx.confirmations == 0 && tx.timestamp > 0 && nowSec - tx.timestamp > 3600) { + statusStr = TR("tx_stale_pending"); statusCol = Warning(); + } else if (tx.confirmations == 0) { statusStr = TR("pending"); statusCol = Warning(); } else if (tx.confirmations < 10) { snprintf(buf, sizeof(buf), TR("conf_count"), tx.confirmations); diff --git a/src/util/i18n.cpp b/src/util/i18n.cpp index 791d6dc..b24ecef 100644 --- a/src/util/i18n.cpp +++ b/src/util/i18n.cpp @@ -1511,6 +1511,8 @@ void I18n::loadBuiltinEnglish() strings_["confirmations"] = "Confirmations"; strings_["confirmed"] = "Confirmed"; strings_["pending"] = "Pending"; + strings_["tx_dropped"] = "Dropped"; + strings_["tx_stale_pending"] = "Expired?"; strings_["baltab_pending_chip"] = "Pending +%.4f"; strings_["baltab_pending_tip"] = "Awaiting confirmations — not spendable yet; it moves to your available balance once confirmed."; strings_["sent"] = "sent"; @@ -2703,6 +2705,7 @@ void I18n::loadBuiltinEnglish() strings_["send_tooltip_not_connected"] = "Not connected to daemon"; strings_["send_tooltip_select_source"] = "Select a source address first"; strings_["send_tooltip_syncing"] = "Wait for blockchain to sync"; + strings_["send_tooltip_no_tip"] = "Waiting for peers to report the network height. Sending is disabled until the wallet knows it is caught up."; strings_["send_available_when_synced"] = "You can fill in the details now. Sending is available once the wallet is fully synced."; strings_["send_tooltip_view_only"] = "View-only address — no spending key, cannot send"; strings_["send_total"] = "Total"; diff --git a/tests/test_phase4.cpp b/tests/test_phase4.cpp index 062b045..a37ad6b 100644 --- a/tests/test_phase4.cpp +++ b/tests/test_phase4.cpp @@ -3042,6 +3042,58 @@ void testNetworkRefreshResultModels() } } +// Resilience mediums: the send tip-gate, cached view-tx confirmations advancing, and the dropped / +// stale-pending display predicates. +void testResilienceMediums() +{ + { // 1) send tip gate + dragonx::SyncInfo s; + s.blocks = 100; s.headers = 100; s.syncing = false; s.tip_known = false; + EXPECT_FALSE(s.sendTipReady(false)); // peerless / fresh connect: tip unknown + EXPECT_TRUE(s.sendTipReady(true)); // lite exempt + s.tip_known = true; + EXPECT_TRUE(s.sendTipReady(false)); + s.syncing = true; + EXPECT_FALSE(s.sendTipReady(false)); + s.syncing = false; s.blocks = 0; + EXPECT_FALSE(s.sendTipReady(false)); + } + { // 2) cached view entry confirmations advance (never backwards; pending not extrapolated) + using R = dragonx::services::NetworkRefreshService; + R::TransactionViewCacheEntry e; + e.from_address = "zs-from"; e.confirmations = 3; e.timestamp = 500; e.observed_height = 100; + R::TransactionViewCacheEntry::Output o; o.address = "zs-dest"; o.value = 1.0; e.outgoing_outputs.push_back(o); + std::vector rows; + R::appendViewTransactionOutputs(rows, "tx1", e, 105); + EXPECT_EQ(rows.size(), static_cast(1)); + EXPECT_EQ(rows[0].confirmations, 8); // 3 + (105-100) + std::vector rows2; + R::appendViewTransactionOutputs(rows2, "tx1", e); // no tip -> unchanged + EXPECT_EQ(rows2[0].confirmations, 3); + std::vector rows3; + R::appendViewTransactionOutputs(rows3, "tx1", e, 90); // reorg/older tip: never backwards + EXPECT_EQ(rows3[0].confirmations, 3); + e.confirmations = 0; // pending entries are not extrapolated + std::vector rows4; + R::appendViewTransactionOutputs(rows4, "tx1", e, 110); + EXPECT_EQ(rows4[0].confirmations, 0); + } + { // 3) dropped / stale-pending predicates + dragonx::TransactionInfo t; + t.confirmations = -1; + EXPECT_TRUE(t.isConflicted()); + EXPECT_FALSE(t.isStalePending(100000)); + t.confirmations = 0; t.timestamp = 1000; + EXPECT_FALSE(t.isConflicted()); + EXPECT_FALSE(t.isStalePending(1000 + 3600)); // exactly the limit: still pending + EXPECT_TRUE(t.isStalePending(1000 + 3601)); + t.timestamp = 0; + EXPECT_FALSE(t.isStalePending(999999)); // unknown time never flagged + t.timestamp = 1000; t.confirmations = 1; + EXPECT_FALSE(t.isStalePending(999999)); // mined + } +} + void testOperationStatusPollParsing() { using Refresh = dragonx::services::NetworkRefreshService; @@ -9103,6 +9155,26 @@ void testExplorerBlockCache() EXPECT_TRUE(cache.storeBlock(block12)); EXPECT_EQ(cache.loadRange(12, 12)[12].tx_count, 5); + { // loadRangeCached memoizes on revision(): reads don't bump; a store invalidates; updates are seen. + // Isolated on its own cache so storing blocks here can't perturb the shared `cache`'s count assertions. + fs::path mdir = makeTempDir(); + ExplorerBlockCache mcache((mdir / "memo.sqlite").string(), (mdir / "memo.json").string()); + EXPECT_TRUE(mcache.ensureOpen()); + ExplorerBlockSummary m12; m12.height = 12; m12.hash = "m-12"; m12.tx_count = 5; m12.size = 1200; m12.time = 5200; m12.difficulty = 1.4; + EXPECT_TRUE(mcache.storeBlock(m12)); + const std::uint64_t rev0 = mcache.revision(); + EXPECT_EQ(mcache.loadRangeCached(12, 12).size(), static_cast(1)); + EXPECT_EQ(mcache.revision(), rev0); // reading does not bump + ExplorerBlockSummary m13 = m12; m13.height = 13; m13.hash = "m-13"; + EXPECT_TRUE(mcache.storeBlock(m13)); + EXPECT_TRUE(mcache.revision() != rev0); // a store invalidates the memo + EXPECT_EQ(mcache.loadRangeCached(12, 13).size(), static_cast(2)); + EXPECT_EQ(mcache.loadRangeCached(12, 13).size(), static_cast(2)); // memo hit, same answer + m13.tx_count = 99; + EXPECT_TRUE(mcache.storeBlock(m13)); + EXPECT_EQ(mcache.loadRangeCached(13, 13).at(13).tx_count, 99); // sees the update, not a stale memo + } + { ExplorerBlockCache reopened(databasePath.string(), legacyPath.string()); EXPECT_TRUE(reopened.ensureOpen()); @@ -19891,6 +19963,7 @@ int main() testResolveExchangeSelection(); testTransactionCsv(); testEarningsSeries(); + testResilienceMediums(); if (g_failures != 0) { std::cerr << g_failures << " test assertion(s) failed\n";