perf(history): group transaction rows by txid once per change

The History tab built a txid-to-indices map twice when its inputs
changed, once for the summary-card totals and once for the display list,
each a full pass over every transaction with string-keyed hashing.

- The rows are now grouped by txid a single time whenever the
  transaction list changes, into an index-only structure (first/next
  chains with no string copies), and both the summary pass and the
  display pass reuse it. A sort-combo change re-sorts without regrouping.

Adds a pure, unit-testable groupByTxid helper in
transactions_tab_helpers.h.

Tests: the chains match a reference map over a random id stream, and the
empty and single-row cases.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-10-13 09:24:51 -05:00
co-authored by Claude Opus 4.8
parent 01b59d24db
commit a5e4fd5c02
3 changed files with 116 additions and 18 deletions
+22 -18
View File
@@ -5,6 +5,7 @@
#include "transactions_tab.h"
#include "transaction_details_dialog.h"
#include "export_transactions_dialog.h"
#include "transactions_tab_helpers.h"
#include "../../app.h"
#include "../../util/i18n.h"
#include "../../util/platform.h"
@@ -194,6 +195,15 @@ void RenderTransactionsTab(App* app)
// per-frame O(N) FNV pass over every field of every transaction.
const std::uint64_t contentKey = state.tx_revision;
// txid grouping shared by the summary pass and the display pass. Indices only, rebuilt once per tx_revision
// (a sort-combo change re-sorts without regrouping).
static history::TxidGroups s_groups;
static std::uint64_t s_groups_key = 0; // 0 never matches: tx_revision starts at 1 and only grows
if (s_groups_key != contentKey) {
s_groups = history::groupByTxid(state.transactions);
s_groups_key = contentKey;
}
// 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
// recompute only when it moves. File-scope-style function statics persist across wallet
@@ -212,18 +222,16 @@ void RenderTransactionsTab(App* app)
// 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);
const history::TxidGroups& groups = s_groups;
std::vector<bool> isShieldLeg(state.transactions.size(), false);
for (const auto& kv : summaryTxidMap) {
if (kv.second.size() < 2) continue;
for (size_t head = 0; head < state.transactions.size(); head++) {
if (groups.first[head] != (int)head || groups.next[head] < 0) continue; // once per txid shared by 2+ rows
int send_i = -1, recv_i = -1;
for (size_t si : kv.second) {
for (int si = (int)head; si >= 0; si = groups.next[si]) {
const auto& stx = state.transactions[si];
if (stx.type == "send" && send_i < 0) send_i = (int)si;
if (stx.type == "send" && send_i < 0) send_i = si;
else if (stx.type == "receive" && recv_i < 0 &&
!stx.address.empty() && stx.address[0] == 'z') recv_i = (int)si;
!stx.address.empty() && stx.address[0] == 'z') recv_i = si;
}
if (send_i >= 0 && recv_i >= 0) {
isShieldLeg[send_i] = true;
@@ -389,11 +397,8 @@ void RenderTransactionsTab(App* app)
++s_display_gen;
s_display_cache.clear();
std::vector<DisplayTx>& display_txns = s_display_cache;
// Map txid -> indices in state.transactions
std::unordered_map<std::string, std::vector<size_t>> txid_map;
for (size_t i = 0; i < state.transactions.size(); i++) {
txid_map[state.transactions[i].txid].push_back(i);
}
display_txns.reserve(state.transactions.size());
const history::TxidGroups& groups = s_groups; // built above for this tx_revision
std::vector<bool> consumed(state.transactions.size(), false);
@@ -403,14 +408,13 @@ void RenderTransactionsTab(App* app)
// Try to find an autoshield pair (same txid, send+receive to z-addr)
bool merged = false;
const auto& siblings = txid_map[tx.txid];
if (siblings.size() >= 2) {
if (groups.hasSiblings(i)) {
int send_i = -1, recv_i = -1;
for (size_t si : siblings) {
for (int si = groups.first[i]; si >= 0; si = groups.next[si]) {
if (consumed[si]) continue;
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) recv_i = (int)si;
if (stx.type == "send" && send_i < 0) send_i = si;
else if (stx.type == "receive" && recv_i < 0) recv_i = si;
}
if (send_i >= 0 && recv_i >= 0) {
const auto& stx = state.transactions[send_i];