diff --git a/src/ui/windows/transactions_tab.cpp b/src/ui/windows/transactions_tab.cpp index e2b10eb..ae9bbbc 100644 --- a/src/ui/windows/transactions_tab.cpp +++ b/src/ui/windows/transactions_tab.cpp @@ -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> 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 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& display_txns = s_display_cache; - // Map txid -> indices in state.transactions - std::unordered_map> 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 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]; diff --git a/src/ui/windows/transactions_tab_helpers.h b/src/ui/windows/transactions_tab_helpers.h new file mode 100644 index 0000000..bd8a330 --- /dev/null +++ b/src/ui/windows/transactions_tab_helpers.h @@ -0,0 +1,50 @@ +// DragonX Wallet - ImGui Edition +// Copyright 2024-2026 The Hush Developers +// Released under the GPLv3 +#pragma once +// Pure helpers behind the History tab (no ImGui), so they can be unit-tested. +#include "../../data/wallet_state.h" +#include +#include +#include +#include +#include +#include + +namespace dragonx { +namespace ui { +namespace history { + +// Rows of one txid chained in ascending index order. first[i] = lowest index sharing tx i's txid; +// next[i] = next higher index with the same txid, or -1. Holds indices only (no string copies, no +// pointers into the list), so it stays valid for as long as the list is unchanged (same tx_revision). +struct TxidGroups { + std::vector first; + std::vector next; + bool hasSiblings(std::size_t i) const { return next[static_cast(first[i])] >= 0; } +}; + +inline TxidGroups groupByTxid(const std::vector& txs) +{ + TxidGroups g; + const std::size_t n = txs.size(); + g.first.assign(n, -1); + g.next.assign(n, -1); + std::unordered_map last; // txid (view into txs) -> last index seen + last.reserve(n); + for (std::size_t i = 0; i < n; ++i) { + auto ins = last.try_emplace(std::string_view(txs[i].txid), static_cast(i)); + if (ins.second) { + g.first[i] = static_cast(i); + } else { + g.first[i] = g.first[static_cast(ins.first->second)]; + g.next[static_cast(ins.first->second)] = static_cast(i); + ins.first->second = static_cast(i); + } + } + return g; +} + +} // namespace history +} // namespace ui +} // namespace dragonx diff --git a/tests/test_phase4.cpp b/tests/test_phase4.cpp index 1461e22..5698398 100644 --- a/tests/test_phase4.cpp +++ b/tests/test_phase4.cpp @@ -63,6 +63,7 @@ #include "ui/windows/console_tab_helpers.h" #include "ui/windows/console_text_layout.h" #include "ui/windows/mining_benchmark.h" +#include "ui/windows/transactions_tab_helpers.h" #include "ui/windows/mining_pool_panel.h" #include "ui/windows/mining_tab_helpers.h" #include "ui/material/scroll_edge_fade.h" @@ -10527,6 +10528,48 @@ void testSameTransactionList() } } +void testHistoryTxidGroups() +{ + using dragonx::ui::history::groupByTxid; + auto mk = [](const std::vector& ids) { + std::vector v; + for (const auto& id : ids) { dragonx::TransactionInfo t; t.txid = id; v.push_back(t); } + return v; + }; + { + auto g = groupByTxid(mk({"A", "B", "A", "C", "B", "A"})); + EXPECT_TRUE((g.first == std::vector{0, 1, 0, 3, 1, 0})); + EXPECT_TRUE((g.next == std::vector{2, 4, 5, -1, -1, -1})); + EXPECT_TRUE(g.hasSiblings(0) && g.hasSiblings(1) && g.hasSiblings(2) && g.hasSiblings(5)); + EXPECT_TRUE(!g.hasSiblings(3)); + } + { // property check against a reference map + unsigned seed = 12345; + std::vector ids; + for (int i = 0; i < 500; ++i) { seed = seed * 1103515245u + 12345u; ids.push_back("t" + std::to_string((seed >> 16) % 40)); } + auto txs = mk(ids); + auto g = groupByTxid(txs); + std::unordered_map> ref; + for (size_t i = 0; i < txs.size(); ++i) ref[txs[i].txid].push_back(i); + bool ok = true; + for (const auto& kv : ref) { + int cur = (int)kv.second[0]; + for (size_t k = 0; k < kv.second.size(); ++k) { + if (cur != (int)kv.second[k] || g.first[kv.second[k]] != (int)kv.second[0]) { ok = false; break; } + cur = g.next[cur]; + } + if (cur != -1) ok = false; + } + EXPECT_TRUE(ok); + } + { + auto g = groupByTxid(mk({})); + EXPECT_TRUE(g.first.empty() && g.next.empty()); + auto s = groupByTxid(mk({"X"})); + EXPECT_TRUE(s.first.size() == 1 && s.first[0] == 0 && s.next[0] == -1 && !s.hasSiblings(0)); + } +} + // tx_revision starts at >=1 (a UI cache's 0 sentinel never matches), each helper adds exactly 1, the helpers // never touch last_tx_update, and clear() bumps without resetting, so no earlier revision ever recurs. void testWalletStateTxRevision() @@ -18016,6 +18059,7 @@ int main() testLiteRefreshModelAppliesToWalletState(); testLiteTransactionsAreNewestFirst(); testSameTransactionList(); + testHistoryTxidGroups(); testWalletStateTxRevision(); testTransactionRefreshRevision(); testLiteSendShowsRecipientFromOutgoing();