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
+209
View File
@@ -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();