perf: memoize per-frame render hot paths (console, transactions, recent lists)

Immediate-mode render functions re-run every frame; these rebuilt O(N)
state each time even when nothing changed. From a 6-lens perf audit, each
finding verified on a hot-path basis:

- Console: ConsoleModel gains revision(); the full-model filter scan and the
  glyph-by-glyph text-layout pass (BuildConsoleLayout) rebuild only when the
  model / filter / wrap-width / zoom change — previously it re-shaped up to
  10,000 lines every frame even when idle/scrolled. clear() force-invalidates
  the memo mid-render (no OOB on the just-emptied visible set).
- Transactions: the summary-card totals memoize behind the tab's existing
  FNV-1a fingerprint (also folds away a now-duplicate O(N) display-key pass).
- Send / Receive recent lists: early-exit the prefix scan (state.transactions
  is kept newest-first) instead of filtering the whole tx history every frame.
- network_refresh_service: O(new x total) txid find-and-replace -> hash map.
- Sidebar unconfirmed-tx badge cached on last_tx_update + tx count; the
  daemon-memory probe (/proc scan on Linux, popen on macOS) throttled to ~1.5s.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-09-01 22:55:04 -05:00
parent 0a042df8e0
commit d9fa00bb38
9 changed files with 238 additions and 104 deletions

View File

@@ -1806,7 +1806,10 @@ void App::render()
// long confirmed (the other leg holds the real count). So: a txid with ANY confirmed leg is // long confirmed (the other leg holds the real count). So: a txid with ANY confirmed leg is
// confirmed, and we count UNIQUE unconfirmed txids — otherwise the badge sticks on stale 0-conf // confirmed, and we count UNIQUE unconfirmed txids — otherwise the badge sticks on stale 0-conf
// legs of already-confirmed transactions and double-counts multi-leg ones. // legs of already-confirmed transactions and double-counts multi-leg ones.
{ // Recompute only when the tx list actually changed (last_tx_update is bumped on every tx refresh;
// size() catches same-second content changes). Otherwise this built two unordered_sets over the whole
// wallet tx list every frame just to size a badge.
if (state_.last_tx_update != sb_unconf_key_ts_ || state_.transactions.size() != sb_unconf_key_n_) {
std::unordered_set<std::string> confirmedTxids; std::unordered_set<std::string> confirmedTxids;
for (const auto& tx : state_.transactions) { for (const auto& tx : state_.transactions) {
if (tx.confirmations >= 1) confirmedTxids.insert(tx.txid); if (tx.confirmations >= 1) confirmedTxids.insert(tx.txid);
@@ -1817,8 +1820,11 @@ void App::render()
unconfirmedTxids.insert(tx.txid); unconfirmedTxids.insert(tx.txid);
} }
} }
sbStatus.unconfirmedTxCount = static_cast<int>(unconfirmedTxids.size()); sb_unconf_count_ = static_cast<int>(unconfirmedTxids.size());
sb_unconf_key_ts_ = state_.last_tx_update;
sb_unconf_key_n_ = state_.transactions.size();
} }
sbStatus.unconfirmedTxCount = sb_unconf_count_;
// Sidebar minimum height from ui.toml schema (DPI-scaled). // Sidebar minimum height from ui.toml schema (DPI-scaled).
const float sbMinHeight = sbde("min-height", 360.0f); const float sbMinHeight = sbde("min-height", 360.0f);
@@ -5695,17 +5701,26 @@ bool App::stopDaemonForBootstrap()
double App::getDaemonMemoryUsageMB() const double App::getDaemonMemoryUsageMB() const
{ {
// The Mining tab reads this every frame, but the probe is expensive (Linux embedded/external paths
// scan /proc; macOS forks a shell). Throttle to ~1.5s so a 60Hz caller doesn't hammer the OS.
const double now = ImGui::GetTime();
if (daemon_mem_probe_at_ > 0.0 && now - daemon_mem_probe_at_ < 1.5)
return daemon_mem_cached_mb_;
daemon_mem_probe_at_ = now;
double result = 0.0;
// If we have an embedded daemon with a tracked process handle, use it // If we have an embedded daemon with a tracked process handle, use it
// directly — more reliable than a process scan since we own the handle. // directly — more reliable than a process scan since we own the handle.
if (daemon_controller_ && daemon_controller_->isRunning()) { if (daemon_controller_ && daemon_controller_->isRunning()) {
double mb = daemon_controller_->memoryUsageMB(); double mb = daemon_controller_->memoryUsageMB();
daemon_mem_diag_ = "embedded"; daemon_mem_diag_ = "embedded";
if (mb > 0.0) return mb; result = (mb > 0.0) ? mb : util::Platform::getDaemonMemoryUsageMB();
} else { } else {
daemon_mem_diag_ = "process scan"; daemon_mem_diag_ = "process scan";
result = util::Platform::getDaemonMemoryUsageMB(); // external daemon
} }
// Fall back to platform-level process scan (external daemon) daemon_mem_cached_mb_ = result;
return util::Platform::getDaemonMemoryUsageMB(); return result;
} }
// ============================================================================ // ============================================================================

View File

@@ -1108,6 +1108,22 @@ NetworkRefreshService::TransactionRefreshResult NetworkRefreshService::collectRe
? &hushChatReceivedOutputs ? &hushChatReceivedOutputs
: nullptr; : nullptr;
// Index result.transactions by (txid, type) once so the two replace-loops below
// can find-and-replace in O(1) instead of a nested linear scan over the full
// (potentially thousands-large) tx list on every 'recent' refresh cycle. The map
// holds the index of the FIRST occurrence of each key (preserving the linear
// scan's break-on-first-match), and is kept in sync on every append so a later
// entry in the same cycle still finds an earlier appended one — exactly as the
// re-scanned vector did before.
auto txKey = [](const TransactionInfo& tx) {
return tx.txid + '\x1f' + tx.type;
};
std::unordered_map<std::string, std::size_t> byKey;
byKey.reserve(result.transactions.size());
for (std::size_t i = 0; i < result.transactions.size(); ++i) {
byKey.emplace(txKey(result.transactions[i]), i); // keep first-occurrence index
}
try { try {
std::set<std::string> recentTxids; std::set<std::string> recentTxids;
std::vector<TransactionInfo> recentTransactions; std::vector<TransactionInfo> recentTransactions;
@@ -1115,15 +1131,13 @@ NetworkRefreshService::TransactionRefreshResult NetworkRefreshService::collectRe
appendTransparentTransactions(recentTransactions, recentTxids, transactions, snapshot.miningAddresses); appendTransparentTransactions(recentTransactions, recentTxids, transactions, snapshot.miningAddresses);
for (auto& recent : recentTransactions) { for (auto& recent : recentTransactions) {
bool replaced = false; auto it = byKey.find(txKey(recent));
for (auto& existing : result.transactions) { if (it != byKey.end()) {
if (existing.txid == recent.txid && existing.type == recent.type) { result.transactions[it->second] = recent;
existing = recent; } else {
replaced = true; byKey.emplace(txKey(recent), result.transactions.size());
break; result.transactions.push_back(std::move(recent));
}
} }
if (!replaced) result.transactions.push_back(std::move(recent));
} }
} catch (const std::exception& e) { } catch (const std::exception& e) {
DEBUG_LOGF("recent listtransactions error: %s\n", e.what()); DEBUG_LOGF("recent listtransactions error: %s\n", e.what());
@@ -1149,15 +1163,13 @@ NetworkRefreshService::TransactionRefreshResult NetworkRefreshService::collectRe
snapshot.miningAddresses, snapshot.miningAddresses,
hushChatReceivedOutputsPtr); hushChatReceivedOutputsPtr);
for (auto& scanned : scannedTransactions) { for (auto& scanned : scannedTransactions) {
bool replaced = false; auto it = byKey.find(txKey(scanned));
for (auto& existing : result.transactions) { if (it != byKey.end()) {
if (existing.txid == scanned.txid && existing.type == scanned.type) { result.transactions[it->second] = scanned;
existing = scanned; } else {
replaced = true; byKey.emplace(txKey(scanned), result.transactions.size());
break; result.transactions.push_back(std::move(scanned));
}
} }
if (!replaced) result.transactions.push_back(std::move(scanned));
} }
if (currentBlockHeight >= 0) result.shieldedScanHeights[address] = currentBlockHeight; if (currentBlockHeight >= 0) result.shieldedScanHeights[address] = currentBlockHeight;
++result.shieldedAddressesScanned; ++result.shieldedAddressesScanned;

View File

@@ -37,18 +37,21 @@ ConsoleModel::DrainResult ConsoleModel::drain()
lines_.pop_front(); lines_.pop_front();
++result.popped; ++result.popped;
} }
++revision_; // deque changed — lets the view memoize its filter/layout passes
return result; return result;
} }
void ConsoleModel::clear() void ConsoleModel::clear()
{ {
lines_.clear(); lines_.clear();
++revision_;
} }
bool ConsoleModel::toggleCollapsed(std::size_t i) bool ConsoleModel::toggleCollapsed(std::size_t i)
{ {
if (i >= lines_.size() || lines_[i].foldSpan <= 0) return false; if (i >= lines_.size() || lines_[i].foldSpan <= 0) return false;
lines_[i].collapsed = !lines_[i].collapsed; lines_[i].collapsed = !lines_[i].collapsed;
++revision_; // fold change alters which lines are visible
return lines_[i].collapsed; return lines_[i].collapsed;
} }

View File

@@ -22,6 +22,7 @@
#include "console_channel.h" #include "console_channel.h"
#include <cstddef> #include <cstddef>
#include <cstdint>
#include <deque> #include <deque>
#include <mutex> #include <mutex>
#include <string> #include <string>
@@ -76,11 +77,16 @@ public:
const ConsoleModelLine& operator[](std::size_t i) const { return lines_[i]; } const ConsoleModelLine& operator[](std::size_t i) const { return lines_[i]; }
const ConsoleModelLine& back() const { return lines_.back(); } const ConsoleModelLine& back() const { return lines_.back(); }
// Monotonic counter bumped whenever the visible deque changes (drain added/evicted lines, clear,
// fold toggle). The view memoizes its per-frame filter + text-layout passes against this.
std::uint64_t revision() const { return revision_; }
private: private:
const std::size_t max_lines_; const std::size_t max_lines_;
std::deque<ConsoleModelLine> lines_; // visible model — main thread only std::deque<ConsoleModelLine> lines_; // visible model — main thread only
std::vector<ConsoleModelLine> pending_; // guarded by ingest_mutex_ std::vector<ConsoleModelLine> pending_; // guarded by ingest_mutex_
std::mutex ingest_mutex_; std::mutex ingest_mutex_;
std::uint64_t revision_ = 0;
}; };
} // namespace ui } // namespace ui

View File

@@ -820,11 +820,23 @@ void ConsoleTab::renderOutput()
// segment records which bytes of the source text appear on that visual row, so // segment records which bytes of the source text appear on that visual row, so
// hit-testing and selection highlight can map screen positions to exact char offsets. // hit-testing and selection highlight can map screen positions to exact char offsets.
float wrap_width = ClampConsoleWrapWidth(ImGui::GetContentRegionAvail().x, padX); float wrap_width = ClampConsoleWrapWidth(ImGui::GetContentRegionAvail().x, padX);
ImFontConsoleMeasure measure(ImGui::GetFont(), ImGui::GetFontSize()); // Memoize the text-shaping pass: rebuild only when the visible set, wrap width, line height, gap or
layout_ = BuildConsoleLayout( // zoom changed. Otherwise this re-wrapped and re-measured every visible line (glyph-by-glyph) every
static_cast<int>(visible_indices_.size()), // frame, even while idle/scrolled. layout_ is a member, so the cached geometry stays valid for
[this](int vi) -> const std::string& { return model_[visible_indices_[vi]].text; }, // drawVisibleLines / screenToTextPos when we skip the rebuild.
wrap_width, line_height, interLineGap, measure); std::uint64_t layoutKey = vis_generation_ * 1000003ull;
layoutKey = layoutKey * 131ull + static_cast<std::uint64_t>(wrap_width * 16.0f);
layoutKey = layoutKey * 131ull + static_cast<std::uint64_t>(line_height * 16.0f);
layoutKey = layoutKey * 131ull + static_cast<std::uint64_t>(interLineGap * 16.0f);
layoutKey = layoutKey * 131ull + static_cast<std::uint64_t>(s_console_zoom * 1000.0f);
if (layoutKey != layout_key_) {
ImFontConsoleMeasure measure(ImGui::GetFont(), ImGui::GetFontSize());
layout_ = BuildConsoleLayout(
static_cast<int>(visible_indices_.size()),
[this](int vi) -> const std::string& { return model_[visible_indices_[vi]].text; },
wrap_width, line_height, interLineGap, measure);
layout_key_ = layoutKey;
}
// Mouse/keyboard interaction (wheel-up detach, selection drag, Ctrl+C/A). Raw IO bypasses // Mouse/keyboard interaction (wheel-up detach, selection drag, Ctrl+C/A). Raw IO bypasses
// the child window's event consumption. // the child window's event consumption.
@@ -891,6 +903,20 @@ void ConsoleTab::renderOutput()
void ConsoleTab::computeVisibleLines(bool& hasTextFilter, std::string& filterLower) void ConsoleTab::computeVisibleLines(bool& hasTextFilter, std::string& filterLower)
{ {
// Memoize: rebuild the visible set only when the model changed or the filter state changed.
// Otherwise this scanned the entire (up to 10k-line) model with a per-line filter predicate every
// frame. The out-params + filter_match_count_/folding_active_ are members that stay valid until the
// key moves, so an early return leaves last frame's (still-correct) results in place.
std::uint64_t visKey = model_.revision() * 1000003ull;
for (const char* p = filter_text_; *p; ++p) visKey = visKey * 131ull + static_cast<unsigned char>(*p);
visKey = visKey * 2ull + (s_daemon_messages_enabled ? 1u : 0u);
visKey = visKey * 2ull + (s_errors_only_enabled ? 1u : 0u);
visKey = visKey * 2ull + (s_rpc_trace_enabled ? 1u : 0u);
visKey = visKey * 2ull + (s_app_messages_enabled ? 1u : 0u);
if (visKey == vis_key_) return; // nothing that affects the visible set changed
vis_key_ = visKey;
++vis_generation_; // the layout pass keys off this
ConsoleOutputFilter outputFilter{filter_text_, s_daemon_messages_enabled, ConsoleOutputFilter outputFilter{filter_text_, s_daemon_messages_enabled,
s_errors_only_enabled, s_rpc_trace_enabled, s_errors_only_enabled, s_rpc_trace_enabled,
s_app_messages_enabled}; s_app_messages_enabled};
@@ -2079,6 +2105,12 @@ void ConsoleTab::clear()
// line indices) here to avoid an out-of-bounds crash. computeVisibleLines() rebuilds them next frame. // line indices) here to avoid an out-of-bounds crash. computeVisibleLines() rebuilds them next frame.
visible_indices_.clear(); visible_indices_.clear();
selection_.clear(); selection_.clear();
// Force both memoized passes to rebuild: renderOutput() runs later THIS frame against the now-empty
// visible_indices_, and computeVisibleLines() recomputes next frame — without these resets the layout
// memo would skip the rebuild and keep stale geometry for the just-emptied set.
vis_key_ = ~0ull;
layout_key_ = ~0ull;
++vis_generation_;
stop_confirm_pending_ = false; // a pending 'stop' confirmation is cancelled by clearing stop_confirm_pending_ = false; // a pending 'stop' confirmation is cancelled by clearing
addLine(TR("console_cleared"), ConsoleChannel::Info); addLine(TR("console_cleared"), ConsoleChannel::Info);
} }

View File

@@ -15,6 +15,7 @@
#include "../../rpc/rpc_client.h" #include "../../rpc/rpc_client.h"
#include "../../rpc/rpc_worker.h" #include "../../rpc/rpc_worker.h"
#include <cstdint>
#include <string> #include <string>
#include <vector> #include <vector>
#include <deque> #include <deque>
@@ -188,6 +189,14 @@ private:
bool has_text_filter_ = false; // computed once per frame (before the toolbar draws it) bool has_text_filter_ = false; // computed once per frame (before the toolbar draws it)
std::string filter_lower_; // lowercased filter needle for match highlighting std::string filter_lower_; // lowercased filter needle for match highlighting
// Memoization keys so the two expensive per-frame passes rebuild only on change (not every frame):
// computeVisibleLines (filter scan over the whole model) is keyed on the model revision + filter
// state; BuildConsoleLayout (glyph-by-glyph text shaping of every visible line) is keyed on the
// resulting visible-set generation + wrap width + line height/zoom.
std::uint64_t vis_key_ = ~0ull; // key of the last computeVisibleLines
std::uint64_t vis_generation_ = 0; // bumped whenever visible_indices_ is rebuilt
std::uint64_t layout_key_ = ~0ull; // key of the last BuildConsoleLayout
// Wrap layout for the visible lines (segments + per-line heights + cumulative Y), // Wrap layout for the visible lines (segments + per-line heights + cumulative Y),
// recomputed each frame by the pure BuildConsoleLayout (console_text_layout.h) and // recomputed each frame by the pure BuildConsoleLayout (console_text_layout.h) and
// consumed by the renderer + hit-testing. // consumed by the renderer + hit-testing.

View File

@@ -370,20 +370,25 @@ static void RenderRecentReceived(const AddressInfo& /* addr */,
S.drawElement("tabs.balance", "recent-tx-icon-size").size * hs); S.drawElement("tabs.balance", "recent-tx-icon-size").size * hs);
ImU32 recvCol = Success(); ImU32 recvCol = Success();
// Collect matching transactions
std::vector<const TransactionInfo*> recvs;
for (const auto& tx : state.transactions) {
if (tx.type != "receive" && tx.type != "mined") continue;
recvs.push_back(&tx);
}
// Grow the list to fill the dead space beneath the (fixed-height) receive card: // Grow the list to fill the dead space beneath the (fixed-height) receive card:
// fit as many newest-first rows as the remaining region can show, instead of a // fit as many newest-first rows as the remaining region can show, instead of a
// fixed 4. The child scrolls if the real history exceeds what fits, so nothing is // fixed 4. The child scrolls if the real history exceeds what fits, so nothing is
// lost. A floor of 4 keeps the section substantial when the region is short. // lost. A floor of 4 keeps the section substantial when the region is short.
// Compute maxRows BEFORE the collect loop so the scan can stop early (below).
float listH = std::max(rowH, ImGui::GetContentRegionAvail().y); float listH = std::max(rowH, ImGui::GetContentRegionAvail().y);
size_t maxRows = std::max<size_t>(4, (size_t)std::floor(listH / rowH)); size_t maxRows = std::max<size_t>(4, (size_t)std::floor(listH / rowH));
if (recvs.size() > maxRows) recvs.resize(maxRows); // newest-first
// Collect matching transactions. state.transactions is newest-first, so the first
// maxRows matches ARE exactly the rows we render — stop scanning once we have them
// instead of filtering the entire history every frame (mirrors RenderSharedRecentTx
// in balance_components.cpp).
std::vector<const TransactionInfo*> recvs;
recvs.reserve(maxRows);
for (const auto& tx : state.transactions) {
if (tx.type != "receive" && tx.type != "mined") continue;
recvs.push_back(&tx);
if (recvs.size() >= maxRows) break; // newest-first: prefix is all we show
}
ImGui::BeginChild("##RecentReceivedRows", ImVec2(width, listH), false, ImGui::BeginChild("##RecentReceivedRows", ImVec2(width, listH), false,
ImGuiWindowFlags_NoBackground); ImGuiWindowFlags_NoBackground);

View File

@@ -1120,20 +1120,25 @@ static void RenderRecentSends(const WalletState& state, float width, ImFont* cap
S.drawElement("tabs.balance", "recent-tx-icon-size").size * hs); S.drawElement("tabs.balance", "recent-tx-icon-size").size * hs);
ImU32 sendCol = Error(); ImU32 sendCol = Error();
// Collect matching transactions
std::vector<const TransactionInfo*> sends;
for (const auto& tx : state.transactions) {
if (tx.type != "send" && tx.type != "shield") continue;
sends.push_back(&tx);
}
// Grow the list to fill the dead space beneath the (fixed-height) compose card: // Grow the list to fill the dead space beneath the (fixed-height) compose card:
// fit as many newest-first rows as the remaining region can show, instead of a // fit as many newest-first rows as the remaining region can show, instead of a
// fixed 4. The child scrolls if the real history exceeds what fits, so nothing is // fixed 4. The child scrolls if the real history exceeds what fits, so nothing is
// lost. A floor of 4 keeps the section substantial when the region is short. // lost. A floor of 4 keeps the section substantial when the region is short.
// Row budget is hoisted above the collect loop (it only needs the remaining region
// height + row height, no per-row state) so the scan can early-exit.
float listH = std::max(rowH, ImGui::GetContentRegionAvail().y); float listH = std::max(rowH, ImGui::GetContentRegionAvail().y);
size_t maxRows = std::max<size_t>(4, (size_t)std::floor(listH / rowH)); size_t maxRows = std::max<size_t>(4, (size_t)std::floor(listH / rowH));
if (sends.size() > maxRows) sends.resize(maxRows); // newest-first
// Collect matching transactions. state.transactions is newest-first, so scan only a
// bounded prefix and stop once maxRows matches are gathered (mirrors the early-exit in
// balance_components.cpp:RenderSharedRecentTx) instead of filtering the whole history.
std::vector<const TransactionInfo*> sends;
sends.reserve(maxRows);
for (const auto& tx : state.transactions) {
if (tx.type != "send" && tx.type != "shield") continue;
sends.push_back(&tx);
if (sends.size() == maxRows) break; // newest-first: enough rows to fill the region
}
ImGui::BeginChild("##RecentSendRows", ImVec2(width, listH), false, ImGui::BeginChild("##RecentSendRows", ImVec2(width, listH), false,
ImGuiWindowFlags_NoBackground); ImGuiWindowFlags_NoBackground);

View File

@@ -23,6 +23,7 @@
#include <algorithm> #include <algorithm>
#include <cctype> #include <cctype>
#include <cstdint> #include <cstdint>
#include <cstring>
#include <ctime> #include <ctime>
#include <cmath> #include <cmath>
#include <unordered_map> #include <unordered_map>
@@ -186,53 +187,106 @@ void RenderTransactionsTab(App* app)
// Summary Cards — Received | Sent | Mined // Summary Cards — Received | Sent | Mined
// ================================================================ // ================================================================
{ {
int recvCount = 0, sendCount = 0, minedCount = 0; // Content fingerprint over state.transactions, shared by the summary-card totals (below) and
double recvTotal = 0.0, sendTotal = 0.0, minedTotal = 0.0; // 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*
// Identify autoshield legs (same txid with a "send" leg and a "receive"-to-z leg): // depend on: tx count, last update time, and per-tx txid (drives autoshield pairing),
// that pair is a single internal shielding move — shown as one "Shield" row in the // amount (drives the totals — bit-exact), confirmations, timestamp, type, and address. It is
// list below — not income or spending. Counting both legs double-counts the amount // sort-independent (s_sort_mode is NOT folded here); the display key mixes s_sort_mode on top
// into BOTH the Sent and Received totals, so the cards disagree with the list. // so a sort change re-sorts the display cache without invalidating the totals (which don't
// Mirror the list's pairing logic and exclude both legs from the totals. // depend on order). NB vs. the old display key: this additionally folds full txid + amount +
std::unordered_map<std::string, std::vector<size_t>> summaryTxidMap; // full type/address (not just first char), which the summary totals require — the old key
for (size_t i = 0; i < state.transactions.size(); i++) // omitted them, so it could not have gated the totals correctly.
summaryTxidMap[state.transactions[i].txid].push_back(i); std::uint64_t contentKey;
std::vector<bool> isShieldLeg(state.transactions.size(), false); {
for (const auto& kv : summaryTxidMap) { std::uint64_t h = 1469598103934665603ULL; // FNV-1a offset basis
if (kv.second.size() < 2) continue; auto mix = [&h](std::uint64_t v) { h = (h ^ v) * 1099511628211ULL; };
int send_i = -1, recv_i = -1; auto mixBytes = [&mix](const std::string& s) {
for (size_t si : kv.second) { mix(s.size());
const auto& stx = state.transactions[si]; for (unsigned char c : s) mix(static_cast<std::uint64_t>(c));
if (stx.type == "send" && send_i < 0) send_i = (int)si; };
else if (stx.type == "receive" && recv_i < 0 && mix(state.transactions.size());
!stx.address.empty() && stx.address[0] == 'z') recv_i = (int)si; mix(static_cast<std::uint64_t>(state.last_tx_update));
} for (const auto& t : state.transactions) {
if (send_i >= 0 && recv_i >= 0) { mixBytes(t.txid);
isShieldLeg[send_i] = true; std::uint64_t amtBits = 0;
isShieldLeg[recv_i] = true; std::memcpy(&amtBits, &t.amount, sizeof(double)); // bit-exact fold of the double
// The list shows the merged shield under the "Sent" filter, so count it there too — mix(amtBits);
// otherwise the Sent card reads 0 while the Sent list is non-empty. Use the shielded mix(static_cast<std::uint64_t>(t.confirmations));
// (receive-leg) amount, which is what the merged row displays. mix(static_cast<std::uint64_t>(t.timestamp));
sendCount++; mixBytes(t.type);
sendTotal += std::abs(state.transactions[recv_i].amount); mixBytes(t.address);
} }
contentKey = h;
} }
for (size_t i = 0; i < state.transactions.size(); i++) { // Summary-card totals: Received / Sent / Mined (counts + amounts). These are three static
if (isShieldLeg[i]) continue; // internal shielding move — not received or sent // numbers that only change when the tx list changes, so memoize them behind contentKey and
const auto& tx = state.transactions[i]; // recompute only when it moves. File-scope-style function statics persist across wallet
if (tx.type == "receive") { // switches, but a switch/refresh mutates state.transactions (count, txids, amounts …), which
recvCount++; // bumps contentKey, so the sentinel initial key + change detection covers that for free.
recvTotal += std::abs(tx.amount); static int s_recvCount = 0, s_sendCount = 0, s_minedCount = 0;
} else if (tx.type == "send") { static double s_recvTotal = 0.0, s_sendTotal = 0.0, s_minedTotal = 0.0;
sendCount++; static std::uint64_t s_summaryKey = 0; // sentinel; first frame always recomputes
sendTotal += std::abs(tx.amount);
} else if (tx.type == "generate" || tx.type == "immature" || tx.type == "mined") { if (contentKey != s_summaryKey) {
minedCount++; int recvCount = 0, sendCount = 0, minedCount = 0;
minedTotal += std::abs(tx.amount); double recvTotal = 0.0, sendTotal = 0.0, minedTotal = 0.0;
// Identify autoshield legs (same txid with a "send" leg and a "receive"-to-z leg):
// that pair is a single internal shielding move — shown as one "Shield" row in the
// list below — not income or spending. Counting both legs double-counts the amount
// into BOTH the Sent and Received totals, so the cards disagree with the list.
// Mirror the list's pairing logic and exclude both legs from the totals.
std::unordered_map<std::string, std::vector<size_t>> summaryTxidMap;
for (size_t i = 0; i < state.transactions.size(); i++)
summaryTxidMap[state.transactions[i].txid].push_back(i);
std::vector<bool> isShieldLeg(state.transactions.size(), false);
for (const auto& kv : summaryTxidMap) {
if (kv.second.size() < 2) continue;
int send_i = -1, recv_i = -1;
for (size_t si : kv.second) {
const auto& stx = state.transactions[si];
if (stx.type == "send" && send_i < 0) send_i = (int)si;
else if (stx.type == "receive" && recv_i < 0 &&
!stx.address.empty() && stx.address[0] == 'z') recv_i = (int)si;
}
if (send_i >= 0 && recv_i >= 0) {
isShieldLeg[send_i] = true;
isShieldLeg[recv_i] = true;
// The list shows the merged shield under the "Sent" filter, so count it there too —
// otherwise the Sent card reads 0 while the Sent list is non-empty. Use the shielded
// (receive-leg) amount, which is what the merged row displays.
sendCount++;
sendTotal += std::abs(state.transactions[recv_i].amount);
}
} }
for (size_t i = 0; i < state.transactions.size(); i++) {
if (isShieldLeg[i]) continue; // internal shielding move — not received or sent
const auto& tx = state.transactions[i];
if (tx.type == "receive") {
recvCount++;
recvTotal += std::abs(tx.amount);
} else if (tx.type == "send") {
sendCount++;
sendTotal += std::abs(tx.amount);
} else if (tx.type == "generate" || tx.type == "immature" || tx.type == "mined") {
minedCount++;
minedTotal += std::abs(tx.amount);
}
}
s_recvCount = recvCount; s_recvTotal = recvTotal;
s_sendCount = sendCount; s_sendTotal = sendTotal;
s_minedCount = minedCount; s_minedTotal = minedTotal;
s_summaryKey = contentKey;
} }
// Render from the cached totals (identical values — only the recomputation is skipped).
const int recvCount = s_recvCount, sendCount = s_sendCount, minedCount = s_minedCount;
const double recvTotal = s_recvTotal, sendTotal = s_sendTotal, minedTotal = s_minedTotal;
float availWidth = ImGui::GetContentRegionAvail().x; float availWidth = ImGui::GetContentRegionAvail().x;
float cardGap = cGap; float cardGap = cGap;
float cardW = (availWidth - 2 * cardGap) / 3.0f; float cardW = (availWidth - 2 * cardGap) / 3.0f;
@@ -346,31 +400,24 @@ void RenderTransactionsTab(App* app)
// //
// This merge + sort is O(N log N) with several heap allocations, so it is MEMOIZED: it only // 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 // rebuilds when the underlying transactions actually change, not every frame. The cache key
// is a cheap, allocation-free FNV-1a fingerprint over the fields that affect the displayed // reuses the shared per-frame contentKey computed above (a cheap, allocation-free FNV-1a
// rows (count, last update time, and each tx's confirmations / timestamp / type+address // fingerprint over count / last update time / every tx's txid+amount+confirmations+timestamp+
// first char). A new block bumps every confirmation, so the key changes and we rebuild; // type+address) and mixes in s_sort_mode on top, so the display cache also rebuilds on a sort
// between changes (the common case while the user reads/scrolls) we reuse the cache. The // change. contentKey already folds in more than the display list strictly needs (amount,
// result is already sorted newest-first by the refresh service, but we re-sort here to apply // full txid/type/address) — a superset, so any change that would have flipped the old
// the "pending first" ordering — also folded into the memoized build. // 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.
static std::vector<DisplayTx> s_display_cache; 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;
static bool s_display_cache_valid = false; static bool s_display_cache_valid = false;
std::uint64_t displayKey; // 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 h = 1469598103934665603ULL; // FNV-1a offset basis std::uint64_t displayKey =
auto mix = [&h](std::uint64_t v) { h = (h ^ v) * 1099511628211ULL; }; (contentKey ^ (static_cast<std::uint64_t>(s_sort_mode) + 0x9E3779B97F4A7C15ULL))
mix(state.transactions.size()); * 1099511628211ULL;
mix(static_cast<std::uint64_t>(state.last_tx_update));
mix(static_cast<std::uint64_t>(s_sort_mode)); // re-sort the cache when the mode changes
for (const auto& t : state.transactions) {
mix(static_cast<std::uint64_t>(t.confirmations));
mix(static_cast<std::uint64_t>(t.timestamp));
mix(t.type.empty() ? 0u : static_cast<unsigned char>(t.type[0]));
mix(t.address.empty() ? 0u : static_cast<unsigned char>(t.address[0]));
}
displayKey = h;
}
if (!s_display_cache_valid || displayKey != s_display_cache_key) { if (!s_display_cache_valid || displayKey != s_display_cache_key) {
s_display_cache.clear(); s_display_cache.clear();