diff --git a/CMakeLists.txt b/CMakeLists.txt index 13f0e8c..d28b6fd 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -496,6 +496,7 @@ set(APP_SOURCES src/wallet/lite_wallet_lifecycle_service.cpp src/data/wallet_state.cpp src/data/transaction_history_cache.cpp + src/data/transaction_history_store_policy.cpp src/ui/theme.cpp src/ui/theme_loader.cpp src/ui/explorer/explorer_block_cache.cpp @@ -651,6 +652,7 @@ set(APP_HEADERS src/config/version.h src/data/wallet_state.h src/data/transaction_history_cache.h + src/data/transaction_history_store_policy.h src/ui/theme.h src/ui/theme_loader.h src/ui/explorer/explorer_block_cache.h @@ -1207,6 +1209,7 @@ if(BUILD_TESTING) src/util/text_format.cpp src/data/wallet_state.cpp src/data/transaction_history_cache.cpp + src/data/transaction_history_store_policy.cpp src/data/address_book.cpp src/data/wallet_index.cpp src/daemon/lifecycle_adapters.cpp diff --git a/src/app.cpp b/src/app.cpp index 51bf293..42d6911 100644 --- a/src/app.cpp +++ b/src/app.cpp @@ -849,6 +849,10 @@ void App::update() } } + // Release a deferred history-snapshot store / retry a failed write (O(1) when idle). After the drains, so + // a store a callback deferred this tick is seen; capture_mode_ returned earlier, so never during a sweep. + pumpTransactionHistoryStore(); + // Apply any lite-wallet refresh the controller's background worker produced (main thread). if (lite_wallet_) { // Finalize any completed async open on the main thread (flips walletOpen / surfaces failure). @@ -6205,6 +6209,8 @@ void App::beginShutdown() stop_user_choice_ = 0; stop_kill_offered_ = false; shutdown_start_time_ = std::chrono::steady_clock::now(); + // Queue the final tx-history snapshot now so its write overlaps the daemon stop; shutdown() drains it. + storeTransactionHistoryCacheIfAvailable(data::HistoryStoreMode::Now); async_tasks_.cancelAll(); // The seed-migration adopt task drives daemon stop/start and swaps wallet.dat; let it finish // before shutdown touches the daemon, so the two never race. shutting_down_ is already set, so @@ -7317,6 +7323,14 @@ void App::renderLoadingOverlay(float contentH) void App::shutdown() { + // Full node: persist whatever a refresh changed since beginShutdown() (everything, on the direct-exit path), + // then drain + join the history writer. Bounded like the settings flush: on a wedged disk the in-flight write + // is left running (one autocommit INSERT in WAL mode: a cut-off write keeps the previous snapshot) and _Exit + // ends it. Lite: the store returns at once and the writer never started. + storeTransactionHistoryCacheIfAvailable(data::HistoryStoreMode::Now); + if (history_writer_.flush(std::chrono::seconds(3))) history_writer_.stop(); + else DEBUG_LOGF("[History] snapshot write still running at exit - left to finish (WAL keeps the old one)\n"); + // Ensure the mining-control thread is stopped + joined (idempotent; beginShutdown already did it on the // normal quit path, but shutdown() can also run without it). Must precede xmrig_manager_ teardown. stopMiningControlThread(); diff --git a/src/app.h b/src/app.h index cda73cb..87f29e5 100644 --- a/src/app.h +++ b/src/app.h @@ -19,6 +19,7 @@ #include #include #include "data/transaction_history_cache.h" +#include "data/transaction_history_store_policy.h" #include "data/address_book.h" #include "data/wallet_index.h" #include "data/wallet_state.h" @@ -864,7 +865,12 @@ private: // rescan-completion sites so a new witness field can't be forgotten in one copy. void resetWitnessRescanProgress(); void loadTransactionHistoryCacheIfAvailable(); - void storeTransactionHistoryCacheIfAvailable(); + // Persist the tx-history snapshot. Coalesce: only a change that matters, rate-limited (content <= every + // 20 s, scan heights/confirmations/tip <= every 10 min). Now: anything not yet on disk, immediately (user + // action, disconnect, lock, exit). The write itself runs on history_writer_; this only copies the rows. + void storeTransactionHistoryCacheIfAvailable( + data::HistoryStoreMode mode = data::HistoryStoreMode::Coalesce); + void pumpTransactionHistoryStore(); // update(): release a deferred store, retry a failed write void wipePendingTransactionHistoryCachePassphrase(); void resetTransactionHistoryCacheSession(); // The wallet locked: drop the cache key, keep the history and its scan progress (an unlock used to @@ -1785,6 +1791,17 @@ private: data::WalletIndex wallet_index_; // per-wallet metadata cache (wallets.json); populated after each load std::string pending_transaction_history_cache_passphrase_; bool transaction_history_cache_loaded_ = false; + // Background writer for the history snapshot. Declared AFTER transaction_history_cache_ (destroyed first, + // so a destructor-only teardown joins it before the cache goes); its jobs reference only that cache. + // App::shutdown() drains it (bounded) and joins it once idle; that is the real join (main.cpp _Exit()s past + // ~App()). + util::CoalescingWriter history_writer_; + struct HistoryStoreState { + bool have = false; // a snapshot of this session is on disk (submitted or loaded) + data::HistorySnapshotSig sig; // its signature + std::chrono::steady_clock::time_point lastSubmit{}; + } history_store_; // UI thread only + bool history_store_pending_ = false; // a content change waits out kHistoryMinStoreIntervalSec // Lock screen state bool lock_screen_was_visible_ = false; // tracks lock→unlock transitions for auto-focus diff --git a/src/app_network.cpp b/src/app_network.cpp index e45dd51..9781e56 100644 --- a/src/app_network.cpp +++ b/src/app_network.cpp @@ -802,6 +802,10 @@ void App::onConnected() void App::onDisconnected(const std::string& reason) { + // Persist what the history snapshot is still missing while the rows, the address set (the cache identity), + // the pending-send filter and the cache key are all live: state_.clear(), pending_opids_.clear() and the + // session reset below empty them, which would turn a later flush into a silent no-op. + storeTransactionHistoryCacheIfAvailable(data::HistoryStoreMode::Now); state_.connected = false; state_.warming_up = false; state_.warmup_status.clear(); @@ -1753,15 +1757,22 @@ void App::wipePendingTransactionHistoryCachePassphrase() void App::resetTransactionHistoryCacheSession() { + storeTransactionHistoryCacheIfAvailable(data::HistoryStoreMode::Now); // while the key is still live transaction_history_cache_.lockKey(); wipePendingTransactionHistoryCachePassphrase(); transaction_history_cache_loaded_ = false; + history_store_ = {}; // a new session: nothing of it is known to be on disk yet + history_store_pending_ = false; invalidateShieldedHistoryScanProgress(false); history_listed_z_.clear(); } void App::lockTransactionHistoryCacheSession() { + // Write what is not on disk yet while the key is live. The queued job carries its own key copy, so the + // lockKey() below cannot lose it. history_store_ is kept: the snapshot stays valid, and re-unlocking with + // the same passphrase gives the same keyId(), so the unlock does not rewrite it. + storeTransactionHistoryCacheIfAvailable(data::HistoryStoreMode::Now); transaction_history_cache_.lockKey(); wipePendingTransactionHistoryCachePassphrase(); transaction_history_cache_loaded_ = false; @@ -1801,7 +1812,7 @@ void App::invalidateShieldedHistoryScanProgress(bool persistCache, bool keepSent history_full_sweep_retry_at_ = 0; ++history_sent_epoch_; } - if (persistCache) storeTransactionHistoryCacheIfAvailable(); + if (persistCache) storeTransactionHistoryCacheIfAvailable(data::HistoryStoreMode::Now); // the cleared heights must reach disk } std::vector App::shieldedAddressList() const @@ -1919,38 +1930,104 @@ void App::loadTransactionHistoryCacheIfAvailable() } confirmed_cache_block_ = loaded.tipHeight; transaction_history_cache_loaded_ = true; + // What is on disk is exactly what was just loaded: the first identical refresh must not rewrite it. + history_store_.sig = data::historySnapshotSignature( + state_.transactions, {}, data::TransactionHistoryCache::walletIdentityHash(walletIdentity), + transaction_history_cache_.keyId(), shielded_history_scan_heights_, loaded.tipHeight, loaded.tipHash); + history_store_.have = true; + history_store_.lastSubmit = std::chrono::steady_clock::now(); + history_store_pending_ = false; markTransactionsDirty(); network_refresh_.markDue(services::NetworkRefreshService::Timer::Transactions); } -void App::storeTransactionHistoryCacheIfAvailable() +void App::storeTransactionHistoryCacheIfAvailable(data::HistoryStoreMode mode) { - if (state_.transactions.empty()) return; + // Re-armed below only while a content change is still owed, so the pump can never spin on it. + history_store_pending_ = false; + // Lite never reads this cache (every load path is full-node RPC); the screenshot sweep's demo rows must + // never reach it. + if (isLiteBuild() || capture_mode_ || state_.transactions.empty()) return; - std::string walletIdentity = transactionHistoryCacheWalletIdentity(); - if (walletIdentity.empty()) return; + try { + std::string walletIdentity = transactionHistoryCacheWalletIdentity(); + if (walletIdentity.empty()) return; - if (!ensureTransactionHistoryCacheUnlockedFor(walletIdentity)) return; - pruneShieldedHistoryScanProgress(); + if (!ensureTransactionHistoryCacheUnlockedFor(walletIdentity)) return; + pruneShieldedHistoryScanProgress(); - std::unordered_set pendingOpids(pending_opids_.begin(), pending_opids_.end()); - std::vector cacheTransactions; - cacheTransactions.reserve(state_.transactions.size()); - for (const auto& transaction : state_.transactions) { - if (pendingOpids.find(transaction.txid) != pendingOpids.end()) continue; - cacheTransactions.push_back(transaction); + // What would be persisted: every row except the pending sends' provisional ones. + const std::unordered_set pendingOpids(pending_opids_.begin(), pending_opids_.end()); + const auto sig = data::historySnapshotSignature( + state_.transactions, pendingOpids, + data::TransactionHistoryCache::walletIdentityHash(walletIdentity), + transaction_history_cache_.keyId(), shielded_history_scan_heights_, + state_.sync.blocks, state_.sync.best_blockhash); + if (sig.rows == 0) return; + + const auto now = std::chrono::steady_clock::now(); + data::HistoryStoreInput in; + in.mode = mode; + in.haveStored = history_store_.have; + in.stored = history_store_.sig; + in.current = sig; + in.sinceLastSubmitSec = history_store_.lastSubmit == std::chrono::steady_clock::time_point{} + ? 1e9 + : std::chrono::duration(now - history_store_.lastSubmit).count(); + switch (data::decideHistoryStore(in)) { + case data::HistoryStoreAction::Skip: + return; + case data::HistoryStoreAction::Defer: + history_store_pending_ = true; // pumpTransactionHistoryStore() releases it from update() + return; + case data::HistoryStoreAction::Store: + break; + } + + // A self-contained job: the writer thread never touches App state. + auto job = std::make_shared(); + if (!transaction_history_cache_.captureKey(walletIdentity, job->key)) return; + job->walletIdentity = std::move(walletIdentity); + job->tipHeight = state_.sync.blocks; + job->tipHash = state_.sync.best_blockhash; + job->updatedAt = state_.last_tx_update != 0 + ? static_cast(state_.last_tx_update) + : std::time(nullptr); + job->shieldedScanHeights = shielded_history_scan_heights_; + job->transactions.reserve(sig.rows); + for (const auto& transaction : state_.transactions) { + if (pendingOpids.find(transaction.txid) != pendingOpids.end()) continue; + job->transactions.push_back(transaction); + } + + auto* cache = &transaction_history_cache_; // App member: outlives every job (see app.h) + history_writer_.submit("tx-history", [cache, job]() { return cache->writeSnapshot(std::move(*job)); }); + // Only once the job is queued: a throwing submit must not leave the session believing it is on disk. + history_store_.have = true; + history_store_.sig = sig; + history_store_.lastSubmit = now; + } catch (const std::exception& e) { + // Never let a cache write break its caller (beginShutdown, onDisconnected, a refresh callback). + DEBUG_LOGF("[History] snapshot store skipped: %s\n", e.what()); + } catch (...) { + DEBUG_LOGF("[History] snapshot store skipped\n"); } - if (cacheTransactions.empty()) return; +} - std::time_t updatedAt = state_.last_tx_update != 0 - ? static_cast(state_.last_tx_update) - : std::time(nullptr); - transaction_history_cache_.replace(walletIdentity, - state_.sync.blocks, - state_.sync.best_blockhash, - cacheTransactions, - updatedAt, - shielded_history_scan_heights_); +void App::pumpTransactionHistoryStore() +{ + // A failed write (disk full, locked DB) is not on disk: the next store rewrites everything, paced like + // any content change. + if (history_writer_.takeFailure("tx-history")) { + history_store_.have = false; + history_store_pending_ = true; + } + if (!history_store_pending_) return; + if (history_store_.lastSubmit != std::chrono::steady_clock::time_point{} && + std::chrono::duration(std::chrono::steady_clock::now() - history_store_.lastSubmit).count() + < data::kHistoryMinStoreIntervalSec) + return; // O(1) until the deadline + storeTransactionHistoryCacheIfAvailable(data::HistoryStoreMode::Coalesce); } void App::refreshData() @@ -2448,8 +2525,10 @@ void App::refreshAddressData(services::ScanTicket ticket) last_tx_block_height_ = -1; network_refresh_.markDue(services::NetworkRefreshService::Timer::Transactions); } + // An address refresh changes what the snapshot holds only through the wallet identity: re-key it + // under the new one at once when the set changed (the rows are the history refresh's business). if (state_.transactions.empty()) loadTransactionHistoryCacheIfAvailable(); - else storeTransactionHistoryCacheIfAvailable(); + else if (addressSetChanged) storeTransactionHistoryCacheIfAvailable(data::HistoryStoreMode::Now); // Hints compare the notes with the history on screen: only once it's there (the cache loaded // just above, or a pass is done). Before that every note looks missing, and the first pass scans // every address anyway. @@ -3807,7 +3886,7 @@ void App::setMiningAddress(const std::string& addr, bool mining) changed = true; } } - if (changed) storeTransactionHistoryCacheIfAvailable(); + if (changed) storeTransactionHistoryCacheIfAvailable(data::HistoryStoreMode::Now); // user relabel: persist now } void App::invalidateAddressValidationCache() diff --git a/src/app_security.cpp b/src/app_security.cpp index c59fbae..acac134 100644 --- a/src/app_security.cpp +++ b/src/app_security.cpp @@ -599,7 +599,9 @@ void App::changePassphrase(const std::string& oldPass, const std::string& newPas memset(change_new_pass_buf_, 0, sizeof(change_new_pass_buf_)); memset(change_confirm_buf_, 0, sizeof(change_confirm_buf_)); unlockTransactionHistoryCacheWithPassphrase(newPass); - storeTransactionHistoryCacheIfAvailable(); + // New key: the old ciphertext is unreadable with it. Write now unless the unlock just did + // (keyId() is part of the content signature, so Now skips only if the same key is submitted). + storeTransactionHistoryCacheIfAvailable(data::HistoryStoreMode::Now); ui::Notifications::instance().info(TR("sec_passphrase_changed_successfully")); } else { encrypt_status_ = std::string(TR("sec_failed_prefix")) + err_msg; diff --git a/src/data/transaction_history_store_policy.cpp b/src/data/transaction_history_store_policy.cpp new file mode 100644 index 0000000..72fb396 --- /dev/null +++ b/src/data/transaction_history_store_policy.cpp @@ -0,0 +1,130 @@ +// DragonX Wallet - ImGui Edition +// Copyright 2024-2026 The Hush Developers +// Released under the GPLv3 + +#include "transaction_history_store_policy.h" + +#include + +namespace dragonx { +namespace data { + +namespace { + +constexpr std::uint64_t kOffset = 0xcbf29ce484222325ULL; +constexpr std::uint64_t kMul = 0x9E3779B97F4A7C15ULL; // odd + +static_assert(sizeof(double) == sizeof(std::uint64_t), "amount bit pattern is hashed as 64 bits"); + +// 8 bytes per step: a 5k-row wallet (~1.5 MB of row text) hashes in about a millisecond. Endianness-dependent +// on purpose: the value never leaves the process. +inline void mixBytes(std::uint64_t& h, const void* data, std::size_t n) +{ + const auto* b = static_cast(data); + while (n >= 8) { + std::uint64_t w; + std::memcpy(&w, b, 8); + h = (h ^ w) * kMul; + h ^= h >> 32; + b += 8; + n -= 8; + } + if (n) { + std::uint64_t w = 0; + std::memcpy(&w, b, n); + h = (h ^ w ^ (static_cast(n) << 56)) * kMul; + h ^= h >> 32; + } +} +inline void mixU64(std::uint64_t& h, std::uint64_t v) { mixBytes(h, &v, sizeof(v)); } +inline void mixStr(std::uint64_t& h, const std::string& s) +{ + mixU64(h, s.size()); // length first: ("ab","c") != ("a","bc") + mixBytes(h, s.data(), s.size()); +} +inline std::uint64_t finalize(std::uint64_t x) // splitmix64 finalizer +{ + x += 0x9e3779b97f4a7c15ULL; + x = (x ^ (x >> 30)) * 0xbf58476d1ce4e5b9ULL; + x = (x ^ (x >> 27)) * 0x94d049bb133111ebULL; + return x ^ (x >> 31); +} +// The thresholds a stored row is read at: transactions_tab.cpp:788-793 / transaction_details_dialog.cpp:80-85 +// (0 pending, < 10 counted, >= 100 mature for mined), mining_earnings.cpp:102 (>= 100), the confirmed-tx cache +// (>= 10, app_network.cpp:1915) and conflicted (< 0, network_refresh_service.cpp:1470). +inline std::uint64_t confirmationBucket(int c) +{ + return c < 0 ? 0 : c == 0 ? 1 : c < 10 ? 2 : c < 100 ? 3 : 4; +} + +} // namespace + +HistorySnapshotSig historySnapshotSignature(const std::vector& transactions, + const std::unordered_set& skipTxids, + const std::string& walletHash, + std::uint64_t keyId, + const std::unordered_map& shieldedScanHeights, + int tipHeight, + const std::string& tipHash) +{ + HistorySnapshotSig sig; + std::uint64_t rowSum = 0; // sums: commutative, so row order does not matter; duplicates still count + std::uint64_t exactSum = 0; + for (const auto& tx : transactions) { + if (!skipTxids.empty() && skipTxids.count(tx.txid)) continue; + std::uint64_t h = kOffset; + mixStr(h, tx.txid); + mixStr(h, tx.type); + std::uint64_t amountBits = 0; + std::memcpy(&amountBits, &tx.amount, sizeof(amountBits)); + mixU64(h, amountBits); + mixU64(h, static_cast(tx.timestamp)); + mixStr(h, tx.address); + mixStr(h, tx.from_address); + mixStr(h, tx.memo); + std::uint64_t e = h; + mixU64(h, confirmationBucket(tx.confirmations)); + mixU64(e, static_cast(static_cast(tx.confirmations))); + rowSum += finalize(h); + exactSum += finalize(e); + ++sig.rows; + } + std::uint64_t c = kOffset; + mixU64(c, rowSum); + mixU64(c, sig.rows); + mixStr(c, walletHash); + mixU64(c, keyId); + sig.content = finalize(c); + + std::uint64_t heightSum = 0; + for (const auto& [address, height] : shieldedScanHeights) { + if (address.empty() || height < 0) continue; // exactly what replace() persists + std::uint64_t h = kOffset; + mixStr(h, address); + mixU64(h, static_cast(height)); + heightSum += finalize(h); + } + std::uint64_t s = kOffset; + mixU64(s, heightSum); + mixU64(s, exactSum); + mixU64(s, static_cast(static_cast(tipHeight))); + mixStr(s, tipHash); + sig.slow = finalize(s); + return sig; +} + +HistoryStoreAction decideHistoryStore(const HistoryStoreInput& in) +{ + const bool contentDirty = !in.haveStored || in.stored.content != in.current.content; + const bool slowDirty = in.haveStored && in.stored.slow != in.current.slow; + if (in.mode == HistoryStoreMode::Now) + return (contentDirty || slowDirty) ? HistoryStoreAction::Store : HistoryStoreAction::Skip; + if (contentDirty) + return in.sinceLastSubmitSec >= kHistoryMinStoreIntervalSec ? HistoryStoreAction::Store + : HistoryStoreAction::Defer; + if (slowDirty && in.sinceLastSubmitSec >= kHistorySlowStoreIntervalSec) return HistoryStoreAction::Store; + return HistoryStoreAction::Skip; +} + +} // namespace data +} // namespace dragonx diff --git a/src/data/transaction_history_store_policy.h b/src/data/transaction_history_store_policy.h new file mode 100644 index 0000000..a23f69b --- /dev/null +++ b/src/data/transaction_history_store_policy.h @@ -0,0 +1,64 @@ +// DragonX Wallet - ImGui Edition +// Copyright 2024-2026 The Hush Developers +// Released under the GPLv3 +// +// Pure decision logic for persisting the tx-history snapshot: what counts as "changed", and when a write is +// due. No I/O, no clock, no sodium: unit-tested directly. + +#pragma once + +#include "wallet_state.h" + +#include +#include +#include +#include +#include +#include + +namespace dragonx { +namespace data { + +// 64-bit fingerprints of what a snapshot would contain. +struct HistorySnapshotSig { + std::uint64_t content = 0; // rows (confirmations by UI bucket) + wallet hash + key id + std::uint64_t slow = 0; // shielded scan heights + raw confirmations + tip: drift every block + std::size_t rows = 0; // rows that would be persisted (pending sends excluded) +}; + +// Order-independent over rows. `content` covers the persisted row fields (txid, type, amount bit pattern, +// timestamp, address, from_address, memo) with confirmations bucketed at the thresholds the UI and the refresh +// read off a stored row (< 0 conflicted, 0 pending, 1-9 counted, >= 10 confirmed, >= 100 mature), plus the +// wallet hash and `keyId` (a passphrase change must rewrite: the old ciphertext is unreadable with the new key). +// `slow` covers what changes every block: the per-address scan heights (entries replace() would persist), each +// row's raw confirmations, and the tip. +HistorySnapshotSig historySnapshotSignature(const std::vector& transactions, + const std::unordered_set& skipTxids, + const std::string& walletHash, + std::uint64_t keyId, + const std::unordered_map& shieldedScanHeights, + int tipHeight, + const std::string& tipHash); + +enum class HistoryStoreMode { + Coalesce, // routine refresh: content changes at most every kHistoryMinStoreIntervalSec, slow drift every + // kHistorySlowStoreIntervalSec + Now // user action / disconnect / lock / exit: store now if anything differs from the last submit +}; +enum class HistoryStoreAction { Skip, Defer, Store }; + +constexpr double kHistoryMinStoreIntervalSec = 20.0; +constexpr double kHistorySlowStoreIntervalSec = 600.0; + +struct HistoryStoreInput { + HistoryStoreMode mode = HistoryStoreMode::Coalesce; + bool haveStored = false; // a snapshot of this session (submitted, or the loaded one) is on disk + HistorySnapshotSig stored; // its signature + HistorySnapshotSig current; + double sinceLastSubmitSec = 1e9; // 1e9 = never submitted this session +}; + +HistoryStoreAction decideHistoryStore(const HistoryStoreInput& input); + +} // namespace data +} // namespace dragonx diff --git a/tests/test_phase4.cpp b/tests/test_phase4.cpp index eab962c..d04fd59 100644 --- a/tests/test_phase4.cpp +++ b/tests/test_phase4.cpp @@ -18,6 +18,7 @@ #include "util/fetch_backoff.h" #include "util/logger.h" #include "data/transaction_history_cache.h" +#include "data/transaction_history_store_policy.h" #include "util/coalescing_writer.h" #include "data/address_book.h" #include "data/wallet_index.h" @@ -6102,6 +6103,103 @@ void testTransactionHistoryCachePrunesOldWallets() removeTree(dir); } +void testHistorySnapshotSignature() +{ + using namespace dragonx::data; + using dragonx::TransactionInfo; + auto mk = [](const char* id, double amt, int conf, const char* memo = "") { + return TransactionInfo{id, "receive", amt, 1700000000, conf, "zs-a", "", memo}; + }; + const std::unordered_set none; + const std::unordered_map h{{"zs-a", 100}}; + const std::vector a{mk("t1", 1.0, 3), mk("t2", 2.0, 40)}; + const auto base = historySnapshotSignature(a, none, "w", 7, h, 500, "tip"); + EXPECT_EQ(base.rows, static_cast(2)); + + // row order is irrelevant + const std::vector reordered{a[1], a[0]}; + const auto r = historySnapshotSignature(reordered, none, "w", 7, h, 500, "tip"); + EXPECT_EQ(r.content, base.content); + EXPECT_EQ(r.slow, base.slow); + + // confirmations inside a bucket move only the slow axis + auto c7 = a; c7[0].confirmations = 7; + const auto s7 = historySnapshotSignature(c7, none, "w", 7, h, 500, "tip"); + EXPECT_EQ(s7.content, base.content); + EXPECT_TRUE(s7.slow != base.slow); + // crossing a UI threshold is a content change: <0|0, 0|1, 9|10, 99|100 + auto contentAt = [&](int conf) { + auto v = a; v[0].confirmations = conf; + return historySnapshotSignature(v, none, "w", 7, h, 500, "tip").content; + }; + EXPECT_TRUE(contentAt(-1) != contentAt(0)); + EXPECT_TRUE(contentAt(0) != contentAt(1)); + EXPECT_TRUE(contentAt(9) != contentAt(10)); + EXPECT_EQ(contentAt(10), contentAt(99)); + EXPECT_TRUE(contentAt(99) != contentAt(100)); + EXPECT_EQ(contentAt(100), contentAt(5000)); + + // every persisted field, the wallet hash and the key id are content + auto m = a; m[0].memo = "x"; + EXPECT_TRUE(historySnapshotSignature(m, none, "w", 7, h, 500, "tip").content != base.content); + auto ty = a; ty[0].type = "mined"; + EXPECT_TRUE(historySnapshotSignature(ty, none, "w", 7, h, 500, "tip").content != base.content); + auto am = a; am[0].amount = 1.0000001; + EXPECT_TRUE(historySnapshotSignature(am, none, "w", 7, h, 500, "tip").content != base.content); + auto fa = a; fa[0].from_address = "zs-other"; + EXPECT_TRUE(historySnapshotSignature(fa, none, "w", 7, h, 500, "tip").content != base.content); + EXPECT_TRUE(historySnapshotSignature(a, none, "w2", 7, h, 500, "tip").content != base.content); + EXPECT_TRUE(historySnapshotSignature(a, none, "w", 8, h, 500, "tip").content != base.content); + + // the tip only moves the slow axis + const auto t = historySnapshotSignature(a, none, "w", 7, h, 501, "tip2"); + EXPECT_EQ(t.content, base.content); + EXPECT_TRUE(t.slow != base.slow); + + // pending sends are excluded + auto withPending = a; withPending.push_back(mk("opid-1", -1.0, 0)); + const auto p = historySnapshotSignature(withPending, {"opid-1"}, "w", 7, h, 500, "tip"); + EXPECT_EQ(p.content, base.content); + EXPECT_EQ(p.slow, base.slow); + EXPECT_EQ(p.rows, static_cast(2)); + + // heights are slow; entries replace() would not persist are ignored + auto h2 = h; h2["zs-a"] = 101; + const auto hs = historySnapshotSignature(a, none, "w", 7, h2, 500, "tip"); + EXPECT_EQ(hs.content, base.content); + EXPECT_TRUE(hs.slow != base.slow); + auto h3 = h; h3[""] = 5; h3["zs-neg"] = -1; + EXPECT_EQ(historySnapshotSignature(a, none, "w", 7, h3, 500, "tip").slow, base.slow); +} + +void testHistoryStorePolicy() +{ + using namespace dragonx::data; + using M = HistoryStoreMode; + using A = HistoryStoreAction; + const HistorySnapshotSig s1{1, 10, 5}, s2{2, 10, 5}, s1slow{1, 11, 5}; + auto act = [](M mode, bool have, HistorySnapshotSig stored, HistorySnapshotSig current, double since) { + HistoryStoreInput in; + in.mode = mode; + in.haveStored = have; + in.stored = stored; + in.current = current; + in.sinceLastSubmitSec = since; + return decideHistoryStore(in); + }; + EXPECT_TRUE(act(M::Coalesce, true, s1, s1, 1e9) == A::Skip); // nothing changed + EXPECT_TRUE(act(M::Coalesce, true, s1, s2, 5.0) == A::Defer); // changed, too soon + EXPECT_TRUE(act(M::Coalesce, true, s1, s2, kHistoryMinStoreIntervalSec) == A::Store); + EXPECT_TRUE(act(M::Coalesce, false, s1, s1, 1e9) == A::Store); // nothing on disk yet + EXPECT_TRUE(act(M::Coalesce, false, s1, s1, 3.0) == A::Defer); // retry is paced + EXPECT_TRUE(act(M::Coalesce, true, s1, s1slow, 100.0) == A::Skip); // slow drift: not yet + EXPECT_TRUE(act(M::Coalesce, true, s1, s1slow, kHistorySlowStoreIntervalSec) == A::Store); + EXPECT_TRUE(act(M::Now, true, s1, s1, 0.0) == A::Skip); // clean: no write + EXPECT_TRUE(act(M::Now, true, s1, s1slow, 0.0) == A::Store); // slow drift flushed + EXPECT_TRUE(act(M::Now, true, s1, s2, 0.0) == A::Store); // ignores pacing + EXPECT_TRUE(act(M::Now, false, s1, s1, 0.0) == A::Store); +} + // The background writer's path: a job carries its own key copy (a lockKey() after queueing cannot drop it), // the copy is wiped, a keyless job is refused, keyId() tracks the key across lock/unlock, and a late job for the // previous wallet keeps the salt of the wallet unlocked since. @@ -12800,6 +12898,8 @@ int main() testExplorerBlockCache(); testTransactionHistoryCache(); testTransactionHistoryCachePrunesOldWallets(); + testHistorySnapshotSignature(); + testHistoryStorePolicy(); testTransactionHistoryCacheBackgroundWrite(); testAddressBookScope(); testWalletIndex();