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:
+5
-1
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -8285,6 +8285,194 @@ void testLiteWalletControllerAsyncAppliesPassphrase()
|
||||
dragonx::test::g_liteFakeWarmupServerSubstr.clear();
|
||||
}
|
||||
|
||||
// WalletState::tx_revision (the History / Mining caches key on it): sameTransactionInfo covers every
|
||||
// TransactionInfo field and compares `amount` bit-exactly; setTransactionsIfChanged bumps only on a real change
|
||||
// and otherwise keeps the old list.
|
||||
void testSameTransactionList()
|
||||
{
|
||||
auto row = [](const char* txid, const char* type, double amount, std::int64_t ts, int conf,
|
||||
const char* addr, const char* from, const char* memo) {
|
||||
dragonx::TransactionInfo t;
|
||||
t.txid = txid; t.type = type; t.amount = amount; t.timestamp = ts; t.confirmations = conf;
|
||||
t.address = addr; t.from_address = from; t.memo = memo;
|
||||
return t;
|
||||
};
|
||||
const std::vector<dragonx::TransactionInfo> base = {
|
||||
row("tx-a", "receive", 1.5, 1000, 12, "zs-a", "", "hello"),
|
||||
row("tx-b", "send", -0.25, 1001, 3, "zs-b", "zs-own", "pay"),
|
||||
row("tx-c", "mined", 3.0, 1002, 120, "R-mine", "", ""),
|
||||
};
|
||||
EXPECT_TRUE(dragonx::sameTransactionList(base, base));
|
||||
|
||||
// Each of the 8 fields, mutated alone in the middle row, is a real change: true and exactly +1.
|
||||
const std::vector<std::function<void(dragonx::TransactionInfo&)>> mutations = {
|
||||
[](dragonx::TransactionInfo& t) { t.txid += "x"; },
|
||||
[](dragonx::TransactionInfo& t) { t.type = "shield"; },
|
||||
[](dragonx::TransactionInfo& t) { t.amount = -0.26; },
|
||||
[](dragonx::TransactionInfo& t) { t.timestamp += 1; },
|
||||
[](dragonx::TransactionInfo& t) { t.confirmations += 1; },
|
||||
[](dragonx::TransactionInfo& t) { t.address = "zs-other"; },
|
||||
[](dragonx::TransactionInfo& t) { t.from_address = "zs-else"; },
|
||||
[](dragonx::TransactionInfo& t) { t.memo = "pay!"; },
|
||||
};
|
||||
EXPECT_EQ(mutations.size(), static_cast<size_t>(8));
|
||||
for (const auto& mutate : mutations) {
|
||||
dragonx::WalletState state;
|
||||
state.replaceTransactions(std::vector<dragonx::TransactionInfo>(base));
|
||||
const std::uint64_t before = state.tx_revision;
|
||||
auto next = base;
|
||||
mutate(next[1]);
|
||||
EXPECT_FALSE(dragonx::sameTransactionInfo(base[1], next[1]));
|
||||
EXPECT_TRUE(state.setTransactionsIfChanged(std::move(next)));
|
||||
EXPECT_EQ(state.tx_revision, before + 1);
|
||||
EXPECT_FALSE(dragonx::sameTransactionList(state.transactions, base));
|
||||
}
|
||||
|
||||
// An identical copy keeps the old list (same storage), the revision and the contents.
|
||||
{
|
||||
dragonx::WalletState state;
|
||||
state.replaceTransactions(std::vector<dragonx::TransactionInfo>(base));
|
||||
const std::uint64_t before = state.tx_revision;
|
||||
const dragonx::TransactionInfo* storage = state.transactions.data();
|
||||
EXPECT_FALSE(state.setTransactionsIfChanged(std::vector<dragonx::TransactionInfo>(base)));
|
||||
EXPECT_EQ(state.tx_revision, before);
|
||||
EXPECT_TRUE(state.transactions.data() == storage);
|
||||
EXPECT_TRUE(dragonx::sameTransactionList(state.transactions, base));
|
||||
|
||||
// A size change is a change.
|
||||
auto shorter = base;
|
||||
shorter.pop_back();
|
||||
EXPECT_TRUE(state.setTransactionsIfChanged(std::move(shorter)));
|
||||
EXPECT_EQ(state.tx_revision, before + 1);
|
||||
EXPECT_EQ(state.transactions.size(), static_cast<size_t>(2));
|
||||
EXPECT_TRUE(state.setTransactionsIfChanged(std::vector<dragonx::TransactionInfo>(base)));
|
||||
EXPECT_EQ(state.tx_revision, before + 2);
|
||||
}
|
||||
|
||||
// `amount` is compared bit-exactly: 0.1+0.2 is not 0.3, and -0.0 (a zero-amount pending send) is not +0.0.
|
||||
{
|
||||
dragonx::TransactionInfo a = base[0];
|
||||
dragonx::TransactionInfo b = base[0];
|
||||
double sum = 0.1;
|
||||
sum += 0.2;
|
||||
a.amount = sum;
|
||||
b.amount = 0.3;
|
||||
EXPECT_FALSE(dragonx::sameTransactionInfo(a, b));
|
||||
a.amount = 0.0;
|
||||
b.amount = -0.0;
|
||||
EXPECT_TRUE(a.amount == b.amount); // numerically equal...
|
||||
EXPECT_FALSE(dragonx::sameTransactionInfo(a, b)); // ...but rendered "+0.0" vs "+-0.0"
|
||||
b.amount = 0.0;
|
||||
EXPECT_TRUE(dragonx::sameTransactionInfo(a, b));
|
||||
|
||||
dragonx::WalletState state;
|
||||
state.replaceTransactions({a});
|
||||
const std::uint64_t before = state.tx_revision;
|
||||
b.amount = -0.0;
|
||||
EXPECT_TRUE(state.setTransactionsIfChanged({b}));
|
||||
EXPECT_EQ(state.tx_revision, before + 1);
|
||||
}
|
||||
}
|
||||
|
||||
// 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()
|
||||
{
|
||||
dragonx::WalletState state;
|
||||
EXPECT_TRUE(state.tx_revision >= 1);
|
||||
state.last_tx_update = 42;
|
||||
|
||||
const std::uint64_t rev = state.tx_revision;
|
||||
dragonx::TransactionInfo t;
|
||||
t.txid = "tx-1";
|
||||
t.type = "receive";
|
||||
t.amount = 1.0;
|
||||
state.replaceTransactions({t});
|
||||
EXPECT_EQ(state.tx_revision, rev + 1);
|
||||
EXPECT_EQ(state.transactions.size(), static_cast<size_t>(1));
|
||||
state.touchTransactions();
|
||||
EXPECT_EQ(state.tx_revision, rev + 2);
|
||||
EXPECT_FALSE(state.setTransactionsIfChanged({t}));
|
||||
EXPECT_EQ(state.tx_revision, rev + 2);
|
||||
EXPECT_EQ(state.last_tx_update, static_cast<int64_t>(42));
|
||||
|
||||
std::set<std::uint64_t> seen{rev, rev + 1, rev + 2};
|
||||
for (int i = 0; i < 3; ++i) {
|
||||
const std::uint64_t beforeClear = state.tx_revision;
|
||||
state.clear();
|
||||
EXPECT_TRUE(state.transactions.empty());
|
||||
EXPECT_TRUE(state.tx_revision > beforeClear);
|
||||
EXPECT_EQ(seen.count(state.tx_revision), static_cast<size_t>(0));
|
||||
seen.insert(state.tx_revision);
|
||||
state.replaceTransactions({t});
|
||||
EXPECT_EQ(seen.count(state.tx_revision), static_cast<size_t>(0));
|
||||
seen.insert(state.tx_revision);
|
||||
}
|
||||
}
|
||||
|
||||
// Full-node refresh: re-applying identical content keeps the list and the revision (only the wall-clock
|
||||
// stamp and the confirmed cache are refreshed); a changed field bumps the revision once.
|
||||
void testTransactionRefreshRevision()
|
||||
{
|
||||
using Refresh = dragonx::services::NetworkRefreshService;
|
||||
auto makeResult = [](const std::string& memo) {
|
||||
Refresh::TransactionRefreshResult result;
|
||||
dragonx::TransactionInfo pendingTx;
|
||||
pendingTx.txid = "pending";
|
||||
pendingTx.type = "send";
|
||||
pendingTx.amount = -0.5;
|
||||
pendingTx.confirmations = 2;
|
||||
pendingTx.timestamp = 1001;
|
||||
pendingTx.address = "zs-dest";
|
||||
dragonx::TransactionInfo confirmedTx;
|
||||
confirmedTx.txid = "confirmed";
|
||||
confirmedTx.type = "receive";
|
||||
confirmedTx.amount = 1.25;
|
||||
confirmedTx.confirmations = 12;
|
||||
confirmedTx.timestamp = 1000;
|
||||
confirmedTx.address = "zs-own";
|
||||
confirmedTx.memo = memo;
|
||||
result.transactions = {pendingTx, confirmedTx};
|
||||
result.blockHeight = 222;
|
||||
return result;
|
||||
};
|
||||
|
||||
dragonx::WalletState state;
|
||||
Refresh::TransactionViewCache viewCache;
|
||||
std::unordered_set<std::string> sendTxids;
|
||||
std::vector<dragonx::TransactionInfo> confirmedCache;
|
||||
std::unordered_set<std::string> confirmedIds;
|
||||
int confirmedBlock = -1;
|
||||
int lastTxBlock = -1;
|
||||
Refresh::TransactionCacheUpdate cacheUpdate{viewCache, sendTxids, confirmedCache, confirmedIds,
|
||||
confirmedBlock, lastTxBlock};
|
||||
|
||||
const std::uint64_t initial = state.tx_revision;
|
||||
Refresh::applyTransactionRefreshResult(state, cacheUpdate, makeResult("hi"), 100);
|
||||
const std::uint64_t rev = state.tx_revision;
|
||||
EXPECT_EQ(rev, initial + 1);
|
||||
EXPECT_EQ(state.transactions.size(), static_cast<size_t>(2));
|
||||
EXPECT_EQ(state.last_tx_update, static_cast<int64_t>(100));
|
||||
EXPECT_EQ(confirmedCache.size(), static_cast<size_t>(1));
|
||||
|
||||
const dragonx::TransactionInfo* storage = state.transactions.data();
|
||||
Refresh::applyTransactionRefreshResult(state, cacheUpdate, makeResult("hi"), 200);
|
||||
EXPECT_EQ(state.tx_revision, rev);
|
||||
EXPECT_TRUE(state.transactions.data() == storage);
|
||||
EXPECT_EQ(state.last_tx_update, static_cast<int64_t>(200));
|
||||
EXPECT_EQ(lastTxBlock, 222);
|
||||
EXPECT_EQ(confirmedBlock, 222);
|
||||
EXPECT_EQ(confirmedCache.size(), static_cast<size_t>(1));
|
||||
EXPECT_EQ(confirmedCache[0].txid, std::string("confirmed"));
|
||||
EXPECT_EQ(confirmedIds.count("confirmed"), static_cast<size_t>(1));
|
||||
EXPECT_EQ(confirmedIds.size(), static_cast<size_t>(1));
|
||||
|
||||
Refresh::applyTransactionRefreshResult(state, cacheUpdate, makeResult("hi there"), 300);
|
||||
EXPECT_EQ(state.tx_revision, rev + 1);
|
||||
EXPECT_EQ(state.transactions[1].memo, std::string("hi there"));
|
||||
EXPECT_EQ(state.last_tx_update, static_cast<int64_t>(300));
|
||||
}
|
||||
|
||||
// M2: a parsed lite refresh bundle maps through to the app's WalletState (the last hop
|
||||
// the Balance/Receive/Transactions tabs read), with zatoshi->DRGX conversion, z/t address
|
||||
// split, transaction typing, confirmations, and sync progress.
|
||||
@@ -8347,6 +8535,24 @@ void testLiteRefreshModelAppliesToWalletState()
|
||||
EXPECT_EQ(state.sync.blocks, 1000);
|
||||
EXPECT_EQ(state.sync.headers, 1000);
|
||||
EXPECT_FALSE(state.sync.syncing);
|
||||
|
||||
// tx_revision: lite applies every ~2 s, so re-applying an identical model must keep the list and the
|
||||
// revision; a real change (a new chain tip moves every confirmation count) bumps it exactly once.
|
||||
auto model = mapped.model;
|
||||
const std::uint64_t revAfterFirst = state.tx_revision;
|
||||
applyLiteRefreshModelToWalletState(model, state);
|
||||
applyLiteRefreshModelToWalletState(model, state);
|
||||
EXPECT_EQ(state.tx_revision, revAfterFirst);
|
||||
model.sync.chainHeight += 1;
|
||||
applyLiteRefreshModelToWalletState(model, state);
|
||||
EXPECT_EQ(state.tx_revision, revAfterFirst + 1);
|
||||
model.hasTransactions = false;
|
||||
applyLiteRefreshModelToWalletState(model, state);
|
||||
EXPECT_EQ(state.tx_revision, revAfterFirst + 1);
|
||||
EXPECT_EQ(static_cast<int>(state.transactions.size()), 1);
|
||||
EXPECT_EQ(state.transactions[0].type, std::string("receive"));
|
||||
EXPECT_NEAR(state.transactions[0].amount, 1.5, 1e-9);
|
||||
EXPECT_EQ(state.transactions[0].confirmations, 12); // chain 1001 - block 990 + 1
|
||||
}
|
||||
|
||||
// A Send record carries its recipient in outgoing_metadata, not the top-level address/memo —
|
||||
@@ -14274,6 +14480,9 @@ int main()
|
||||
testLiteWalletControllerAsyncAppliesPassphrase();
|
||||
testLiteChainNameMigration();
|
||||
testLiteRefreshModelAppliesToWalletState();
|
||||
testSameTransactionList();
|
||||
testWalletStateTxRevision();
|
||||
testTransactionRefreshRevision();
|
||||
testLiteSendShowsRecipientFromOutgoing();
|
||||
testLitePartialRefreshKeepsPriorAddressBalances();
|
||||
testLitePerAddressBalances();
|
||||
|
||||
Reference in New Issue
Block a user