perf(history): key History caches on tx_revision and memoize the filter

Every frame the History tab:

- hashed the txid, amount, confirmations, time, type and address of
  all N transactions (about 0.6-5 ms at 5k-20k) to decide whether its
  totals and its merged, sorted row list were stale;
- copied every chat txid into a fresh set;
- ran the type and search filter over all N rows, with up to three
  case-insensitive substring searches each.

The totals and the row list now key on tx_revision (the row list also
on the sort mode), the Message badge and the Chat filter use the chat
store's txid index, and the filtered rows are rebuilt only when the row
list, the type filter, the search text or, under the Chat filter, the
chat store changes. A frame is now a few compares plus one txid lookup
per row of the current page. An unchanged full-node refresh no longer
rebuilds the totals and the row list either: the old fingerprint folded
in the refresh time.

On lite, a change to only a memo or a sender address now shows at once.
The old fingerprint left those fields out and relied on a refresh time
that lite never sets, so such a row stayed stale until something else
in the list changed, such as the next block.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-10 09:50:03 -05:00
co-authored by Claude Opus 5.5
parent d3fa09128e
commit e45f4c7d90
+63 -77
View File
@@ -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<std::string> 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<std::uint64_t>(c));
};
mix(state.transactions.size());
mix(static_cast<std::uint64_t>(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<std::uint64_t>(t.confirmations));
mix(static_cast<std::uint64_t>(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<DisplayTx> 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<std::uint64_t>(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<DisplayTx>& 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<DisplayTx>& display_txns = s_display_cache;
// Apply type + search filters
std::vector<size_t> 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<size_t> 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<size_t>& filtered_indices = s_filtered_cache;
filtered_count = static_cast<int>(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) {