diff --git a/src/data/wallet_state.h b/src/data/wallet_state.h index 743955f..93eeca3 100644 --- a/src/data/wallet_state.h +++ b/src/data/wallet_state.h @@ -86,6 +86,15 @@ inline bool sameTransactionInfo(const TransactionInfo& a, const TransactionInfo& a.txid == b.txid && a.type == b.type && a.address == b.address && a.from_address == b.from_address && a.memo == b.memo; } +// Same row except the confirmation COUNT: the pending (confirmations==0) state must still match because it drives the +// History tab's pending-first sort and shield-leg merge. A delta for which every row passes this changes nothing the +// tab's merged/sorted display list is built from, so it can patch confirmations in place instead of rebuilding. +inline bool sameTransactionLayout(const TransactionInfo& a, const TransactionInfo& b) { + return a.timestamp == b.timestamp && (a.confirmations == 0) == (b.confirmations == 0) && + std::memcmp(&a.amount, &b.amount, sizeof(double)) == 0 && + a.txid == b.txid && a.type == b.type && a.address == b.address && + a.from_address == b.from_address && a.memo == b.memo; +} inline bool sameTransactionList(const std::vector& a, const std::vector& b) { if (a.size() != b.size()) return false; for (std::size_t i = 0; i < a.size(); ++i) @@ -370,12 +379,21 @@ struct WalletState { // touchTransactions() afterwards. UI caches (History tab, ...) key on this, NOT on last_tx_update (a // wall-clock refresh stamp that lite never sets) nor App::tx_dirty_generation_ (a refresh-needed flag). std::uint64_t tx_revision = 1; - void touchTransactions() { ++tx_revision; } + // Bumps with tx_revision EXCEPT for a pure confirmation-count change (see sameTransactionLayout): the History tab's + // O(N) merge+sort cache keys on this and just patches confirmations when only tx_revision moved. Every writer that + // is not setTransactionsIfChanged goes through touchTransactions()/replaceTransactions() and bumps both (safe default). + std::uint64_t tx_structure_revision = 1; + void touchTransactions() { ++tx_revision; ++tx_structure_revision; } void replaceTransactions(std::vector&& next) { transactions = std::move(next); touchTransactions(); } // Replace only when the content differs; returns true (and bumps) if it did. Keeps the old list when identical. bool setTransactionsIfChanged(std::vector&& next) { if (sameTransactionList(transactions, next)) return false; - replaceTransactions(std::move(next)); + bool layoutSame = transactions.size() == next.size(); + for (std::size_t i = 0; layoutSame && i < next.size(); ++i) + layoutSame = sameTransactionLayout(transactions[i], next[i]); + transactions = std::move(next); + ++tx_revision; + if (!layoutSame) ++tx_structure_revision; return true; } int64_t last_peer_update = 0; diff --git a/src/ui/windows/transactions_tab.cpp b/src/ui/windows/transactions_tab.cpp index 2ef854a..6ef4410 100644 --- a/src/ui/windows/transactions_tab.cpp +++ b/src/ui/windows/transactions_tab.cpp @@ -219,10 +219,11 @@ void RenderTransactionsTab(App* app) // 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) { + static std::uint64_t s_groups_key = 0; // 0 never matches: tx_structure_revision starts at 1 and only grows + const std::uint64_t structKey = state.tx_structure_revision; // ignores pure confirmation-count bumps + if (s_groups_key != structKey) { s_groups = history::groupByTxid(state.transactions); - s_groups_key = contentKey; + s_groups_key = structKey; } // Summary-card totals: Received / Sent / Mined (counts + amounts). These are three static @@ -415,13 +416,14 @@ void RenderTransactionsTab(App* app) // 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; // tx_revision the cache was built from + static std::uint64_t s_display_cache_key = 0; // tx_structure_revision the cache was built from + static std::uint64_t s_display_conf_rev = 0; // tx_revision the cached confirmations were last synced to 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 // 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) { + if (!s_display_cache_valid || structKey != 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; @@ -524,9 +526,27 @@ void RenderTransactionsTab(App* app) } }); - s_display_cache_key = contentKey; + s_display_cache_key = structKey; + s_display_conf_rev = contentKey; // freshly built from the current list s_display_cache_sort = s_sort_mode; s_display_cache_valid = true; + } else if (s_display_conf_rev != contentKey) { + // Confirmation-count-only delta (setTransactionsIfChanged kept tx_structure_revision): the merged rows, their + // order and the indices into state.transactions are unchanged, so refresh just the count (same rule as the + // build above: a shield shows the larger of its two legs). Sort order cannot change: mode 0 only reads + // conf==0, which the layout check holds constant, and the other modes ignore confirmations. + for (DisplayTx& dtx : s_display_cache) { + if (dtx.is_shield) { + dtx.confirmations = std::max(state.transactions[dtx.send_idx].confirmations, + state.transactions[dtx.recv_idx].confirmations); + } else { + dtx.confirmations = state.transactions[dtx.orig_idx].confirmations; + } + } + // The filtered-index cache keys on s_display_gen (unchanged, correct: filters ignore confirmations) and + // the snapshot's indices remain valid; only re-stamp it so 'export current filter' stays usable. + if (s_filter_snapshot.valid) s_filter_snapshot.txRevision = contentKey; + s_display_conf_rev = contentKey; } // The merged + sorted list (rebuilt above only on change). Pagination below runs per-frame (it only diff --git a/tests/test_phase4.cpp b/tests/test_phase4.cpp index d6148f7..900bab6 100644 --- a/tests/test_phase4.cpp +++ b/tests/test_phase4.cpp @@ -11286,6 +11286,71 @@ void testSameTransactionList() } } +// tx_structure_revision: a pure confirmation-count bump moves tx_revision only; any other change, a pending<->confirmed +// flip, or a size change moves both; legacy writers (replace/touch) always move both. +void testTxStructureRevision() +{ + auto row = [](const char* txid, const char* type, double amount, std::int64_t ts, int conf) { + dragonx::TransactionInfo t; + t.txid = txid; t.type = type; t.amount = amount; t.timestamp = ts; t.confirmations = conf; + t.address = "zs-a"; t.from_address = ""; t.memo = "m"; + return t; + }; + const std::vector base = { + row("tx-a", "receive", 1.5, 1000, 12), + row("tx-b", "send", -0.25, 1001, 3), + row("tx-c", "mined", 3.0, 1002, 120), + }; + EXPECT_TRUE(dragonx::sameTransactionLayout(base[0], base[0])); + { // confirmation count only: tx_revision +1, structure unchanged, new count stored + dragonx::WalletState state; + state.replaceTransactions(std::vector(base)); + const std::uint64_t rev = state.tx_revision, srev = state.tx_structure_revision; + auto next = base; + next[0].confirmations += 1; + next[1].confirmations += 5; + EXPECT_TRUE(state.setTransactionsIfChanged(std::move(next))); + EXPECT_EQ(state.tx_revision, rev + 1); + EXPECT_EQ(state.tx_structure_revision, srev); + EXPECT_EQ(state.transactions[1].confirmations, 8); + } + { // pending (0) -> confirmed (1) flips the sort state: structural + dragonx::WalletState state; + auto pend = base; + pend[1].confirmations = 0; + state.replaceTransactions(std::move(pend)); + const std::uint64_t rev = state.tx_revision, srev = state.tx_structure_revision; + EXPECT_TRUE(state.setTransactionsIfChanged(std::vector(base))); + EXPECT_EQ(state.tx_revision, rev + 1); + EXPECT_EQ(state.tx_structure_revision, srev + 1); + } + { // a non-confirmation field change, and a size change, are structural + dragonx::WalletState state; + state.replaceTransactions(std::vector(base)); + std::uint64_t srev = state.tx_structure_revision; + auto next = base; + next[2].memo = "changed"; + EXPECT_TRUE(state.setTransactionsIfChanged(std::move(next))); + EXPECT_EQ(state.tx_structure_revision, srev + 1); + srev = state.tx_structure_revision; + auto shorter = base; + shorter.pop_back(); + EXPECT_TRUE(state.setTransactionsIfChanged(std::move(shorter))); + EXPECT_EQ(state.tx_structure_revision, srev + 1); + } + { // identical list: neither counter moves; legacy touch moves both + dragonx::WalletState state; + state.replaceTransactions(std::vector(base)); + const std::uint64_t rev = state.tx_revision, srev = state.tx_structure_revision; + EXPECT_FALSE(state.setTransactionsIfChanged(std::vector(base))); + EXPECT_EQ(state.tx_revision, rev); + EXPECT_EQ(state.tx_structure_revision, srev); + state.touchTransactions(); + EXPECT_EQ(state.tx_revision, rev + 1); + EXPECT_EQ(state.tx_structure_revision, srev + 1); + } +} + void testHistoryTxidGroups() { using dragonx::ui::history::groupByTxid; @@ -19432,6 +19497,7 @@ int main() testLiteRefreshModelAppliesToWalletState(); testLiteTransactionsAreNewestFirst(); testSameTransactionList(); + testTxStructureRevision(); testHistoryTxidGroups(); testHistoryFilterInputs(); testWalletStateTxRevision();