diff --git a/src/ui/windows/transactions_tab.cpp b/src/ui/windows/transactions_tab.cpp index 3312cc7..e2b10eb 100644 --- a/src/ui/windows/transactions_tab.cpp +++ b/src/ui/windows/transactions_tab.cpp @@ -143,11 +143,11 @@ void RenderTransactionsTab(App* app) const auto addrLabel = S.label("tabs.transactions", "address-label"); const auto& state = app->state(); - // Txids that carried a chat message (sent or received) — drives the "Message" badge and the Chat - // filter below. Rebuilt each frame from the in-memory chat store (cheap: O(chat messages), which - // is small); empty when chat is disabled or the wallet has no chat identity. Both variants populate - // the chat store before this tab renders, so the same lookup serves full-node and lite. - const std::unordered_set chatTxids = app->chatService().store().chatTxids(); + // Txids that carried a chat message (sent or received) drive the "Message" badge and the Chat filter. + // Looked up through the chat store's revision-keyed txid index (rebuilt only when the store mutates; an + // empty store — chat disabled / no identity — is O(1)), not copied per frame. Both variants populate the + // store before this tab renders, so the same lookup serves full-node and lite. + const chat::ChatStore& chatStore = app->chatService().store(); // Responsive scale factors (recomputed every frame) ImVec2 contentAvail = ImGui::GetContentRegionAvail(); @@ -187,38 +187,12 @@ void RenderTransactionsTab(App* app) // Summary Cards — Received | Sent | Mined // ================================================================ { - // Content fingerprint over state.transactions, shared by the summary-card totals (below) and - // the display-list memoization (further down). This is a cheap, allocation-free FNV-1a hash - // computed ONCE per frame and folds in every field the *totals* and the *displayed rows* - // depend on: tx count, last update time, and per-tx txid (drives autoshield pairing), - // amount (drives the totals — bit-exact), confirmations, timestamp, type, and address. It is - // sort-independent (s_sort_mode is NOT folded here); the display key mixes s_sort_mode on top - // so a sort change re-sorts the display cache without invalidating the totals (which don't - // depend on order). NB vs. the old display key: this additionally folds full txid + amount + - // full type/address (not just first char), which the summary totals require — the old key - // omitted them, so it could not have gated the totals correctly. - std::uint64_t contentKey; - { - std::uint64_t h = 1469598103934665603ULL; // FNV-1a offset basis - auto mix = [&h](std::uint64_t v) { h = (h ^ v) * 1099511628211ULL; }; - auto mixBytes = [&mix](const std::string& s) { - mix(s.size()); - for (unsigned char c : s) mix(static_cast(c)); - }; - mix(state.transactions.size()); - mix(static_cast(state.last_tx_update)); - for (const auto& t : state.transactions) { - mixBytes(t.txid); - std::uint64_t amtBits = 0; - std::memcpy(&amtBits, &t.amount, sizeof(double)); // bit-exact fold of the double - mix(amtBits); - mix(static_cast(t.confirmations)); - mix(static_cast(t.timestamp)); - mixBytes(t.type); - mixBytes(t.address); - } - contentKey = h; - } + // Change signal for state.transactions, shared by the summary-card totals (below) and the display-list + // memoization (further down): WalletState::tx_revision, bumped by every writer of the list (see + // wallet_state.h) and never reset. Sort-independent: the display cache additionally compares s_sort_mode, + // so a sort change re-sorts without invalidating the totals (which don't depend on order). Replaces a + // per-frame O(N) FNV pass over every field of every transaction. + const std::uint64_t contentKey = state.tx_revision; // Summary-card totals: Received / Sent / Mined (counts + amounts). These are three static // numbers that only change when the tx list changes, so memoize them behind contentKey and @@ -399,27 +373,20 @@ void RenderTransactionsTab(App* app) // "shield" entry. We first index by txid, then build the list. // // This merge + sort is O(N log N) with several heap allocations, so it is MEMOIZED: it only - // rebuilds when the underlying transactions actually change, not every frame. The cache key - // reuses the shared per-frame contentKey computed above (a cheap, allocation-free FNV-1a - // fingerprint over count / last update time / every tx's txid+amount+confirmations+timestamp+ - // type+address) and mixes in s_sort_mode on top, so the display cache also rebuilds on a sort - // change. contentKey already folds in more than the display list strictly needs (amount, - // full txid/type/address) — a superset, so any change that would have flipped the old - // per-first-char key still flips this one. A new block bumps every confirmation, so the key - // changes and we rebuild; between changes (the common case while the user reads/scrolls) we - // reuse the cache. The result is already sorted newest-first by the refresh service, but we - // re-sort here to apply the "pending first" ordering — also folded into the memoized build. + // rebuilds when the underlying transactions actually change (contentKey = tx_revision) or the sort + // mode changes, not every frame. The build reads nothing but state.transactions and s_sort_mode. + // A new block bumps confirmations (a real change), so the list rebuilds; between changes (the common + // case while the user reads/scrolls) the cache is reused. The result is already sorted newest-first by + // the refresh service, but we re-sort here to apply the "pending first" ordering. static std::vector s_display_cache; - static std::uint64_t s_display_cache_key = 0; + static std::uint64_t s_display_cache_key = 0; // tx_revision the cache was built from + static int s_display_cache_sort = 0; // sort mode the cache was built with static bool s_display_cache_valid = false; + static std::uint64_t s_display_gen = 0; // bumped on every rebuild; keys the filtered-index cache below - // Derive the display key from the shared contentKey (avoids a second full pass over the - // transactions) plus the sort mode — the only display-affecting input contentKey omits. - std::uint64_t displayKey = - (contentKey ^ (static_cast(s_sort_mode) + 0x9E3779B97F4A7C15ULL)) - * 1099511628211ULL; - - if (!s_display_cache_valid || displayKey != s_display_cache_key) { + // Two separate fields, not a mixed hash: small revision/sort values would collide under xor-mixing. + if (!s_display_cache_valid || contentKey != s_display_cache_key || s_sort_mode != s_display_cache_sort) { + ++s_display_gen; s_display_cache.clear(); std::vector& display_txns = s_display_cache; // Map txid -> indices in state.transactions @@ -525,35 +492,54 @@ void RenderTransactionsTab(App* app) } }); - s_display_cache_key = displayKey; + s_display_cache_key = contentKey; + s_display_cache_sort = s_sort_mode; s_display_cache_valid = true; } - // The merged + sorted list (rebuilt above only on change). Filtering/search/pagination below - // run per-frame over this cache — they're cheap (a linear scan) and depend on interactive - // filter/search state, so they are intentionally not memoized. + // The merged + sorted list (rebuilt above only on change). Pagination below runs per-frame (it only + // slices); the type/search filter is memoized too. const std::vector& display_txns = s_display_cache; - // Apply type + search filters - std::vector filtered_indices; - for (size_t i = 0; i < display_txns.size(); i++) { - const auto& dtx = display_txns[i]; - if (type_filter != 0) { - if (type_filter == 1 && dtx.display_type != "send" && dtx.display_type != "shield") continue; - if (type_filter == 2 && dtx.display_type != "receive") continue; - if (type_filter == 3 && dtx.display_type != "generate" && dtx.display_type != "immature" && dtx.display_type != "mined") continue; - if (type_filter == 4 && chatTxids.count(dtx.txid) == 0) continue; // chat-only - } - if (!search_str.empty()) { - if (!containsIgnoreCase(dtx.address, search_str) && - !containsIgnoreCase(dtx.txid, search_str) && - !containsIgnoreCase(dtx.memo, search_str) && - !(dtx.is_shield && containsIgnoreCase(std::string("shielded"), search_str))) { - continue; + // Apply type + search filters. MEMOIZED: the result depends only on the display list (display generation), + // the type filter, the exact search text and — for the Chat filter only — the chat store revision. All of + // those are settled earlier this frame (cards/combos/search box draw above). It used to be an O(N) pass with + // up to three case-insensitive substring searches per row, every frame. + static std::vector s_filtered_cache; + static bool s_filtered_valid = false; + static std::uint64_t s_filtered_display_gen = ~0ull; + static int s_filtered_type = 0; + static std::string s_filtered_search; + static std::uint64_t s_filtered_chat_rev = 0; + const std::uint64_t chatRev = (type_filter == 4) ? chatStore.revision() : 0; + if (s_filtered_display_gen != s_display_gen || !s_filtered_valid || type_filter != s_filtered_type || + search_str != s_filtered_search || chatRev != s_filtered_chat_rev) { + s_filtered_cache.clear(); + for (size_t i = 0; i < display_txns.size(); i++) { + const auto& dtx = display_txns[i]; + if (type_filter != 0) { + if (type_filter == 1 && dtx.display_type != "send" && dtx.display_type != "shield") continue; + if (type_filter == 2 && dtx.display_type != "receive") continue; + if (type_filter == 3 && dtx.display_type != "generate" && dtx.display_type != "immature" && dtx.display_type != "mined") continue; + if (type_filter == 4 && !chatStore.hasChatTxid(dtx.txid)) continue; // chat-only } + if (!search_str.empty()) { + if (!containsIgnoreCase(dtx.address, search_str) && + !containsIgnoreCase(dtx.txid, search_str) && + !containsIgnoreCase(dtx.memo, search_str) && + !(dtx.is_shield && containsIgnoreCase(std::string("shielded"), search_str))) { + continue; + } + } + s_filtered_cache.push_back(i); } - filtered_indices.push_back(i); + s_filtered_display_gen = s_display_gen; + s_filtered_type = type_filter; + s_filtered_search = search_str; + s_filtered_chat_rev = chatRev; + s_filtered_valid = true; } + const std::vector& filtered_indices = s_filtered_cache; filtered_count = static_cast(filtered_indices.size()); // Pagination — slice filtered results into pages @@ -704,7 +690,7 @@ void RenderTransactionsTab(App* app) // Determine type info bool shieldedDisplay = tx.display_type == "shield"; - const bool isChatTx = chatTxids.count(tx.txid) > 0; // carried a chat message + const bool isChatTx = chatStore.hasChatTxid(tx.txid); // carried a chat message ImU32 iconCol; const char* typeStr; if (shieldedDisplay) {