diff --git a/src/app_network.cpp b/src/app_network.cpp index 651d19a..9b225d7 100644 --- a/src/app_network.cpp +++ b/src/app_network.cpp @@ -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::time(nullptr)); } + state_.touchTransactions(); state_.last_tx_update = std::time(nullptr); } @@ -1299,6 +1301,7 @@ void App::removePendingSendTransactions(const std::vector& 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 } diff --git a/src/app_sweep.cpp b/src/app_sweep.cpp index 279c6c1..5429bee 100644 --- a/src/app_sweep.cpp +++ b/src/app_sweep.cpp @@ -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; diff --git a/src/data/wallet_state.h b/src/data/wallet_state.h index 370a389..fc69577 100644 --- a/src/data/wallet_state.h +++ b/src/data/wallet_state.h @@ -9,6 +9,7 @@ #include #include #include +#include #include #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& a, const std::vector& 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&& 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)); + 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 diff --git a/src/services/network_refresh_service.cpp b/src/services/network_refresh_service.cpp index 75f783d..bde967f 100644 --- a/src/services/network_refresh_service.cpp +++ b/src/services/network_refresh_service.cpp @@ -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; diff --git a/src/wallet/lite_wallet_controller.cpp b/src/wallet/lite_wallet_controller.cpp index e352ca5..1f39078 100644 --- a/src/wallet/lite_wallet_controller.cpp +++ b/src/wallet/lite_wallet_controller.cpp @@ -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 nextTransactions; + nextTransactions.reserve(model.transactions.size()); const int64_t chainHeight = model.hasSyncStatus ? static_cast(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(confs) : 0; } - state.transactions.push_back(std::move(tx)); + nextTransactions.push_back(std::move(tx)); } + state.setTransactionsIfChanged(std::move(nextTransactions)); } if (model.hasSyncStatus) { diff --git a/tests/test_phase4.cpp b/tests/test_phase4.cpp index 5325da7..fc20e83 100644 --- a/tests/test_phase4.cpp +++ b/tests/test_phase4.cpp @@ -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 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> 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(8)); + for (const auto& mutate : mutations) { + dragonx::WalletState state; + state.replaceTransactions(std::vector(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(base)); + const std::uint64_t before = state.tx_revision; + const dragonx::TransactionInfo* storage = state.transactions.data(); + EXPECT_FALSE(state.setTransactionsIfChanged(std::vector(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(2)); + EXPECT_TRUE(state.setTransactionsIfChanged(std::vector(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(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(42)); + + std::set 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(0)); + seen.insert(state.tx_revision); + state.replaceTransactions({t}); + EXPECT_EQ(seen.count(state.tx_revision), static_cast(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 sendTxids; + std::vector confirmedCache; + std::unordered_set 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(2)); + EXPECT_EQ(state.last_tx_update, static_cast(100)); + EXPECT_EQ(confirmedCache.size(), static_cast(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(200)); + EXPECT_EQ(lastTxBlock, 222); + EXPECT_EQ(confirmedBlock, 222); + EXPECT_EQ(confirmedCache.size(), static_cast(1)); + EXPECT_EQ(confirmedCache[0].txid, std::string("confirmed")); + EXPECT_EQ(confirmedIds.count("confirmed"), static_cast(1)); + EXPECT_EQ(confirmedIds.size(), static_cast(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(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(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();