perf(history): add a transactions revision counter to WalletState

WalletState had no change signal for its transaction list, so a UI
cache had to fingerprint all N transactions every frame to notice an
update. It now carries tx_revision, a counter that only increases;
clear() on disconnect bumps it instead of resetting it, so a cache
built before a reconnect or wallet switch can never match again.

- Every writer of the list bumps it: the full-node refresh, loading the
  cached history, adding, completing and removing a pending send, a
  mining-address relabel, the lite refresh and the screenshot demo
  rows.
- The full-node refresh and the lite refresh (every ~2 s) swap in the
  new list only when it differs field by field, the amount compared
  bit-exactly because -0.0 and +0.0 print differently. An unchanged
  refresh keeps the list and the revision; the compare costs O(N) per
  refresh, not per frame.

Nothing reads the counter yet.

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 4a2a1bdd54
commit 860916d203
6 changed files with 255 additions and 4 deletions
+5 -1
View File
@@ -1252,6 +1252,7 @@ void App::upsertPendingSendTransaction(const std::string& opid,
} else {
state_.transactions.insert(state_.transactions.begin(), std::move(pending));
}
state_.touchTransactions(); // covers both the in-place replace and the insert
if (newPending) {
applyPendingSendDelta(pendingInfo.from, -pendingInfo.amount, /*includeAggregates*/ true);
}
@@ -1276,6 +1277,7 @@ void App::markPendingSendTransactionSucceeded(const std::string& opid,
pending->confirmations = 0;
if (pending->timestamp == 0) pending->timestamp = static_cast<std::int64_t>(std::time(nullptr));
}
state_.touchTransactions();
state_.last_tx_update = std::time(nullptr);
}
@@ -1299,6 +1301,7 @@ void App::removePendingSendTransactions(const std::vector<std::string>& opids,
}),
state_.transactions.end());
for (const auto& opid : opidSet) pending_send_info_.erase(opid);
state_.touchTransactions();
state_.last_tx_update = std::time(nullptr);
}
@@ -1917,7 +1920,7 @@ void App::loadTransactionHistoryCacheIfAvailable()
state_.sync.best_blockhash);
if (!loaded.loaded) return;
state_.transactions = std::move(loaded.transactions);
state_.replaceTransactions(std::move(loaded.transactions));
shielded_history_scan_heights_ = std::move(loaded.shieldedScanHeights);
pruneShieldedHistoryScanProgress();
state_.last_tx_update = loaded.updatedAt;
@@ -3897,6 +3900,7 @@ void App::setMiningAddress(const std::string& addr, bool mining)
changed = true;
}
}
if (changed) state_.touchTransactions(); // rows relabelled receive<->mined in place: History must rebuild
if (changed) storeTransactionHistoryCacheIfAvailable(data::HistoryStoreMode::Now); // user relabel: persist now
}
+2
View File
@@ -201,6 +201,7 @@ void App::installDemoWalletData()
tx("deadbeef000000000000000000000000000000000000000000000000000000000000", "receive", 0.50000000,
1751290000, 0, "zs1demosecondaryshieldedaddressforuisweep0000000000000000000000000", "pending"),
};
state_.touchTransactions();
// Demo portfolio groups so the Market tab's row styles render with real-looking data. Snapshot
// the user's real portfolio first; setPortfolio* only mutate memory (save() is never called
@@ -261,6 +262,7 @@ void App::clearDemoWalletData()
state_.totalBalance = s.totalBalance; state_.unconfirmedBalance = s.unconfirmedBalance;
state_.addresses = s.addresses; state_.z_addresses = s.z_addresses; state_.t_addresses = s.t_addresses;
state_.transactions = s.transactions;
state_.touchTransactions();
state_.market.price_usd = s.market_price_usd;
state_.market.change_24h = s.market_change_24h;
state_.market.price_history = g_marketHistorySnapshot;
+33
View File
@@ -9,6 +9,7 @@
#include <cstdint>
#include <chrono>
#include <ctime>
#include <cstring>
#include <utility>
#include "exchange_info.h"
@@ -62,6 +63,22 @@ struct TransactionInfo {
bool isMature() const { return confirmations >= 100; }
};
// Exact field-wise equality: decides whether a refreshed list really differs. `amount` is compared
// BIT-exactly (like the old History fingerprint): -0.0 and +0.0 render differently ("+-0.0" vs "+0.0").
// Must list EVERY TransactionInfo field (tests mutate each one).
inline bool sameTransactionInfo(const TransactionInfo& a, const TransactionInfo& b) {
return a.timestamp == b.timestamp && a.confirmations == b.confirmations &&
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<TransactionInfo>& a, const std::vector<TransactionInfo>& b) {
if (a.size() != b.size()) return false;
for (std::size_t i = 0; i < a.size(); ++i)
if (!sameTransactionInfo(a[i], b[i])) return false;
return true;
}
/**
* @brief Represents a connected peer
*/
@@ -327,6 +344,21 @@ struct WalletState {
int wallet_tx_count = -1; // getwalletinfo's txcount; -1 = unknown
int64_t last_address_update = 0; // set when an address-list refresh applies; 0 = never loaded yet
int64_t last_tx_update = 0;
// Change counter for `transactions` (main thread only). Starts at 1 so a UI cache's 0 sentinel never matches;
// only ever increases (clear() bumps, never resets) so a wallet switch can't alias an old cache key. EVERY
// write to `transactions` (replace, insert/erase, in-place field edit) must use the helpers below or call
// 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; }
void replaceTransactions(std::vector<TransactionInfo>&& 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<TransactionInfo>&& next) {
if (sameTransactionList(transactions, next)) return false;
replaceTransactions(std::move(next));
return true;
}
int64_t last_peer_update = 0;
int64_t last_mining_update = 0;
@@ -358,6 +390,7 @@ struct WalletState {
z_addresses.clear();
t_addresses.clear();
transactions.clear();
touchTransactions(); // never reset: a post-switch revision must differ from every cached key
peers.clear();
bannedPeers.clear();
// W6-1: reset node-level mining state too — the daemon restarts on a wallet switch (mining
+1 -1
View File
@@ -1911,7 +1911,7 @@ void NetworkRefreshService::applyTransactionRefreshResult(WalletState& state,
TransactionRefreshResult&& result,
std::time_t updatedAt)
{
state.transactions = std::move(result.transactions);
state.setTransactionsIfChanged(std::move(result.transactions)); // bumps tx_revision only on a real change
state.last_tx_update = updatedAt;
cacheUpdate.lastTxBlockHeight = result.blockHeight;
+5 -2
View File
@@ -326,7 +326,9 @@ void applyLiteRefreshModelToWalletState(const LiteWalletAppRefreshModel& model,
}
if (model.hasTransactions) {
state.transactions.clear();
// Build aside and swap in only on a real change: this runs every ~2 s, and a bump rebuilds History.
std::vector<TransactionInfo> nextTransactions;
nextTransactions.reserve(model.transactions.size());
const int64_t chainHeight =
model.hasSyncStatus ? static_cast<int64_t>(model.sync.chainHeight) : 0;
for (const auto& record : model.transactions) {
@@ -356,8 +358,9 @@ void applyLiteRefreshModelToWalletState(const LiteWalletAppRefreshModel& model,
const int64_t confs = chainHeight - *record.blockHeight + 1;
tx.confirmations = confs > 0 ? static_cast<int>(confs) : 0;
}
state.transactions.push_back(std::move(tx));
nextTransactions.push_back(std::move(tx));
}
state.setTransactionsIfChanged(std::move(nextTransactions));
}
if (model.hasSyncStatus) {