diff --git a/src/ui/windows/mining_earnings.cpp b/src/ui/windows/mining_earnings.cpp index eba9ad6..94f1d4e 100644 --- a/src/ui/windows/mining_earnings.cpp +++ b/src/ui/windows/mining_earnings.cpp @@ -21,7 +21,9 @@ #include "imgui_internal.h" #include +#include #include +#include #include #include #include @@ -43,66 +45,28 @@ void RenderMiningEarnings(App* app, const WalletState& state, const MiningInfo& { const bool s_pool_mode = poolMode; // alias: keep the moved body's identifier verbatim char buf[128]; // section-local scratch (was shared in the parent) - // Gather mining transactions from state - double minedToday = 0.0, minedYesterday = 0.0, minedAllTime = 0.0; - int minedTodayCount = 0, minedYesterdayCount = 0, minedAllTimeCount = 0; + // Mining earnings totals + the 4 most recent rows, memoised (MiningEarningsCache): the O(transactions) + // pass reruns only when the tx list (WalletState::tx_revision), the filter pill, the Solo/Pool mode, the + // solo capability or the local day changes, or at most every 2 s as a safety net - not every frame. + // `now` stays per frame (the "x ago" labels below use it). int64_t now = (int64_t)std::time(nullptr); - - // Calendar-day boundaries (local time) - time_t nowT = (time_t)now; - struct tm local; -#ifdef _WIN32 - localtime_s(&local, &nowT); -#else - localtime_r(&nowT, &local); -#endif - local.tm_hour = 0; - local.tm_min = 0; - local.tm_sec = 0; - int64_t todayStart = (int64_t)mktime(&local); - int64_t yesterdayStart = todayStart - 86400; - - struct MinedTx { - int64_t timestamp; - double amount; - int confirmations; - bool mature; - std::string txid; - bool isPoolPayout; - }; - std::vector recentMined; - - for (const auto& tx : state.transactions) { - bool isSoloMined = (tx.type == "generate" || tx.type == "immature" || tx.type == "mined"); - bool isPoolPayout = (tx.type == "receive" - && !tx.memo.empty() - && tx.memo.find("Mining Pool payout") != std::string::npos); - if (isSoloMined || isPoolPayout) { - // Apply earnings filter - if (!app->supportsSoloMining()) { - if (s_earnings_filter == 1 && !isPoolPayout) continue; - } else { - if (s_earnings_filter == 1 && !isSoloMined) continue; - if (s_earnings_filter == 2 && !isPoolPayout) continue; - } - - double amt = std::abs(tx.amount); - minedAllTime += amt; - minedAllTimeCount++; - if (tx.timestamp >= todayStart) { - minedToday += amt; - minedTodayCount++; - } else if (tx.timestamp >= yesterdayStart) { - minedYesterday += amt; - minedYesterdayCount++; - } - // Separate solo blocks from pool payouts based on current mode - bool showInCurrentMode = s_pool_mode ? isPoolPayout : isSoloMined; - if (showInCurrentMode && recentMined.size() < 4) { - recentMined.push_back({tx.timestamp, amt, tx.confirmations, tx.confirmations >= 100, tx.txid, isPoolPayout}); - } - } - } + static MiningEarningsCache s_earningsCache; + static LocalDayStartMemo s_dayStart; + MiningEarningsKey earnKey; + earnKey.txRevision = state.tx_revision; + earnKey.txCount = state.transactions.size(); + earnKey.filter = s_earnings_filter; + earnKey.soloSupported = app->supportsSoloMining(); + earnKey.poolMode = s_pool_mode; + earnKey.todayStart = s_dayStart.get(now); + const int64_t nowMs = (int64_t)std::chrono::duration_cast( + std::chrono::steady_clock::now().time_since_epoch()).count(); + const MiningEarningsTotals& earn = s_earningsCache.get(earnKey, state.transactions, nowMs); + // The names the (unchanged) rendering code below already uses. + const double minedToday = earn.today, minedYesterday = earn.yesterday, minedAllTime = earn.allTime; + const int minedTodayCount = earn.todayCount, minedYesterdayCount = earn.yesterdayCount, + minedAllTimeCount = earn.allTimeCount; + const std::vector& recentMined = earn.recent; // Est. Daily = expected reward for YOUR own hashrate share of the network. In pool mode this uses // your local miner rate (pool_mining.hashrate_10s), NOT the pool's aggregate rate: pool payouts are diff --git a/src/ui/windows/mining_tab_helpers.cpp b/src/ui/windows/mining_tab_helpers.cpp index 801e1b7..9942f4b 100644 --- a/src/ui/windows/mining_tab_helpers.cpp +++ b/src/ui/windows/mining_tab_helpers.cpp @@ -1,7 +1,11 @@ #include "mining_tab_helpers.h" +#include "../../util/local_time.h" + #include +#include #include +#include #include namespace dragonx { @@ -83,5 +87,74 @@ std::string FormatEstTime(double estimatedHours) return std::string(buffer); } +MiningEarningsTotals ComputeMiningEarnings(const std::vector& txs, int filter, + bool soloSupported, bool poolMode, + std::int64_t todayStart, std::int64_t yesterdayStart) +{ + MiningEarningsTotals t; + for (const auto& tx : txs) { + bool isSoloMined = (tx.type == "generate" || tx.type == "immature" || tx.type == "mined"); + bool isPoolPayout = (tx.type == "receive" + && !tx.memo.empty() + && tx.memo.find("Mining Pool payout") != std::string::npos); + if (!(isSoloMined || isPoolPayout)) continue; + // Apply earnings filter + if (!soloSupported) { + if (filter == 1 && !isPoolPayout) continue; + } else { + if (filter == 1 && !isSoloMined) continue; + if (filter == 2 && !isPoolPayout) continue; + } + double amt = std::abs(tx.amount); + t.allTime += amt; + t.allTimeCount++; + if (tx.timestamp >= todayStart) { + t.today += amt; + t.todayCount++; + } else if (tx.timestamp >= yesterdayStart) { + t.yesterday += amt; + t.yesterdayCount++; + } + // Separate solo blocks from pool payouts based on current mode + bool showInCurrentMode = poolMode ? isPoolPayout : isSoloMined; + if (showInCurrentMode && t.recent.size() < 4) + t.recent.push_back({tx.timestamp, amt, tx.confirmations, tx.confirmations >= 100, tx.txid, isPoolPayout}); + } + return t; +} + +const MiningEarningsTotals& MiningEarningsCache::get(const MiningEarningsKey& key, + const std::vector& txs, + std::int64_t nowMs) +{ + if (!valid_ || !(key == key_) || IntervalElapsed(nowMs, computedAtMs_, kMaxAgeMs)) { + totals_ = ComputeMiningEarnings(txs, key.filter, key.soloSupported, key.poolMode, + key.todayStart, key.todayStart - 86400); // 86400: as before + key_ = key; + computedAtMs_ = nowMs; + valid_ = true; + ++computes_; + } + return totals_; +} + +bool IntervalElapsed(std::int64_t nowMs, std::int64_t lastMs, std::int64_t minMs) +{ + return nowMs < lastMs || nowMs - lastMs >= minMs; +} + +std::int64_t LocalDayStartMemo::get(std::int64_t now) +{ + if (now == now_) return start_; + std::tm local{}; + util::localTime(static_cast(now), local); // same localtime_r / localtime_s as before + local.tm_hour = 0; + local.tm_min = 0; + local.tm_sec = 0; + start_ = (std::int64_t)mktime(&local); + now_ = now; + return start_; +} + } // namespace ui } // namespace dragonx diff --git a/src/ui/windows/mining_tab_helpers.h b/src/ui/windows/mining_tab_helpers.h index 60add69..a5b3313 100644 --- a/src/ui/windows/mining_tab_helpers.h +++ b/src/ui/windows/mining_tab_helpers.h @@ -1,6 +1,11 @@ #pragma once +#include +#include #include +#include + +#include "data/wallet_state.h" // TransactionInfo namespace dragonx { namespace ui { @@ -13,5 +18,61 @@ std::string FormatFeePercent(double feePercent); double EstimateHoursToBlock(double localHashrate, double networkHashrate, double difficulty); std::string FormatEstTime(double estimatedHours); +// ---- Mining earnings card (Today / Yesterday / All time + the 4 most recent rows) ---- +// Stored BY VALUE: never point into WalletState::transactions (a refresh replaces / reallocates it). +struct MiningEarningsRecentTx { + std::int64_t timestamp = 0; + double amount = 0.0; + int confirmations = 0; + bool mature = false; + std::string txid; + bool isPoolPayout = false; +}; +struct MiningEarningsTotals { + double today = 0.0, yesterday = 0.0, allTime = 0.0; + int todayCount = 0, yesterdayCount = 0, allTimeCount = 0; + std::vector recent; // <= 4, in tx-list order +}; +// The earnings card's former inline loop, verbatim. filter: 0 All, 1 Solo, 2 Pool (the card's filter pill). +MiningEarningsTotals ComputeMiningEarnings(const std::vector& txs, int filter, + bool soloSupported, bool poolMode, + std::int64_t todayStart, std::int64_t yesterdayStart); +// Every input the totals depend on: equal keys => the cached totals are still valid. +struct MiningEarningsKey { + std::uint64_t txRevision = 0; // WalletState::tx_revision (bumped by every write to `transactions`) + std::size_t txCount = 0; // belt: a future writer that skips the bump but changes the row count + int filter = 0; + bool soloSupported = false; + bool poolMode = false; // picks the recent-4 rows (solo blocks vs pool payouts) + std::int64_t todayStart = 0; // local midnight; yesterday = todayStart - 86400, as before + bool operator==(const MiningEarningsKey& o) const { + return txRevision == o.txRevision && txCount == o.txCount && filter == o.filter && + soloSupported == o.soloSupported && poolMode == o.poolMode && todayStart == o.todayStart; + } +}; +class MiningEarningsCache { +public: + static constexpr std::int64_t kMaxAgeMs = 2000; // bounds staleness if a future writer skips the bump + const MiningEarningsTotals& get(const MiningEarningsKey& key, const std::vector& txs, + std::int64_t nowMs); + int computeCount() const { return computes_; } // tests +private: + bool valid_ = false; + MiningEarningsKey key_; + std::int64_t computedAtMs_ = 0; + MiningEarningsTotals totals_; + int computes_ = 0; +}; +// True when a sample taken at lastMs is at least minMs old (or the clock went backwards). +bool IntervalElapsed(std::int64_t nowMs, std::int64_t lastMs, std::int64_t minMs); +// Local midnight for `now` (the same localtime + mktime as before). A pure function of the second, so it is +// recomputed only when `now` changes instead of on every frame (bit-identical result, DST quirks included). +class LocalDayStartMemo { +public: + std::int64_t get(std::int64_t now); +private: + std::int64_t now_ = INT64_MIN, start_ = 0; +}; + } // namespace ui } // namespace dragonx diff --git a/tests/test_phase4.cpp b/tests/test_phase4.cpp index b1296f8..2c3b470 100644 --- a/tests/test_phase4.cpp +++ b/tests/test_phase4.cpp @@ -6318,6 +6318,106 @@ void testMiningThreadCommit() EXPECT_EQ(effectivePoolThreads(0, 0, 0), 0); } +void testMiningEarningsCore() +{ + using dragonx::TransactionInfo; + using namespace dragonx::ui; + auto mk = [](const char* type, double amt, std::int64_t ts, const char* memo, int conf, const char* txid) { + TransactionInfo t; t.type = type; t.amount = amt; t.timestamp = ts; t.memo = memo; + t.confirmations = conf; t.txid = txid; return t; + }; + const std::int64_t today = 1000000, yest = today - 86400; + std::vector txs = { + mk("mined", 3.0, today + 10, "", 150, "a"), + mk("receive", 2.0, today + 5, "Mining Pool payout #7", 3, "b"), + mk("receive", 9.0, today + 5, "thanks", 3, "c"), // not mining + mk("generate", -1.5, yest + 1, "", 20, "d"), // abs(amount) + mk("immature", 1.0, yest - 50, "", 0, "e"), // older than yesterday + mk("send", 4.0, today, "", 9, "f"), // not mining + }; + auto all = ComputeMiningEarnings(txs, 0, true, false, today, yest); + EXPECT_EQ(all.allTimeCount, 4); EXPECT_NEAR(all.allTime, 7.5, 1e-9); + EXPECT_EQ(all.todayCount, 2); EXPECT_NEAR(all.today, 5.0, 1e-9); + EXPECT_EQ(all.yesterdayCount, 1); EXPECT_NEAR(all.yesterday, 1.5, 1e-9); + EXPECT_EQ(all.recent.size(), static_cast(3)); // solo mode: a, d, e + EXPECT_TRUE(all.recent[0].mature); EXPECT_FALSE(all.recent[1].mature); + EXPECT_EQ(all.recent[0].txid, std::string("a")); + EXPECT_NEAR(all.recent[1].amount, 1.5, 1e-12); + auto pool = ComputeMiningEarnings(txs, 0, true, true, today, yest); + EXPECT_EQ(pool.recent.size(), static_cast(1)); // pool mode: b only + EXPECT_TRUE(pool.recent[0].isPoolPayout); + EXPECT_EQ(ComputeMiningEarnings(txs, 1, true, false, today, yest).allTimeCount, 3); // Solo + EXPECT_EQ(ComputeMiningEarnings(txs, 2, true, false, today, yest).allTimeCount, 1); // Pool + EXPECT_EQ(ComputeMiningEarnings(txs, 1, false, false, today, yest).allTimeCount, 1); // no solo: 1 = Pool + EXPECT_EQ(ComputeMiningEarnings(txs, 2, false, false, today, yest).allTimeCount, 4); // no solo: 2 = no skip + std::vector many; + for (int i = 0; i < 10; ++i) many.push_back(mk("mined", 1.0, today, "", 1, "m")); + EXPECT_EQ(ComputeMiningEarnings(many, 0, true, false, today, yest).recent.size(), static_cast(4)); + EXPECT_EQ(ComputeMiningEarnings({}, 0, true, false, today, yest).allTimeCount, 0); +} + +void testMiningEarningsCache() +{ + using dragonx::TransactionInfo; + using namespace dragonx::ui; + TransactionInfo t; t.type = "mined"; t.amount = 1.0; t.timestamp = 2000000; t.txid = "x"; + std::vector txs = {t}; + MiningEarningsCache cache; + MiningEarningsKey k; k.txRevision = 7; k.txCount = txs.size(); k.todayStart = 1000000; k.soloSupported = true; + EXPECT_NEAR(cache.get(k, txs, 0).allTime, 1.0, 1e-9); + EXPECT_EQ(cache.computeCount(), 1); + cache.get(k, txs, 500); cache.get(k, txs, 1999); // hits + EXPECT_EQ(cache.computeCount(), 1); + txs[0].amount = 5.0; // write WITHOUT a revision bump: + EXPECT_NEAR(cache.get(k, txs, 1999).allTime, 1.0, 1e-9); // stale until the 2 s max age... + EXPECT_NEAR(cache.get(k, txs, 2000).allTime, 5.0, 1e-9); // ...then recomputed + int c = cache.computeCount(); EXPECT_EQ(c, 2); + k.txRevision = 8; cache.get(k, txs, 2001); EXPECT_EQ(cache.computeCount(), ++c); + k.filter = 1; cache.get(k, txs, 2002); EXPECT_EQ(cache.computeCount(), ++c); + k.poolMode = true; cache.get(k, txs, 2003); EXPECT_EQ(cache.computeCount(), ++c); + k.soloSupported = false; cache.get(k, txs, 2004); EXPECT_EQ(cache.computeCount(), ++c); + k.todayStart += 86400; cache.get(k, txs, 2005); EXPECT_EQ(cache.computeCount(), ++c); + k.txCount += 1; cache.get(k, txs, 2006); EXPECT_EQ(cache.computeCount(), ++c); + cache.get(k, txs, 2007); EXPECT_EQ(cache.computeCount(), c); // hit + EXPECT_TRUE(IntervalElapsed(100, 200, 1000)); // clock went backwards -> resample + EXPECT_FALSE(IntervalElapsed(1199, 200, 1000)); EXPECT_TRUE(IntervalElapsed(1200, 200, 1000)); +} + +void testMiningEarningsMidnightRollover() +{ +#ifndef _WIN32 + using dragonx::TransactionInfo; + using namespace dragonx::ui; + const char* prev = std::getenv("TZ"); + const bool hadTz = prev != nullptr; + const std::string savedTz = hadTz ? prev : ""; + setenv("TZ", "XXX-3", 1); dragonx::util::refreshTimezone(); // UTC+3: local midnight is 21:00 UTC + const std::int64_t midnight = 86400LL * 20000 - 3 * 3600; // 2024-10-04 00:00:00 local + LocalDayStartMemo day; + EXPECT_EQ(day.get(midnight - 2), midnight - 86400); // 23:59:58 local: still the previous day + EXPECT_EQ(day.get(midnight - 2), midnight - 86400); // same second: memo hit + EXPECT_EQ(day.get(midnight - 1), midnight - 86400); + EXPECT_EQ(day.get(midnight), midnight); // 00:00:00 local: rolled over + EXPECT_EQ(day.get(midnight + 3599), midnight); + + TransactionInfo t; t.type = "mined"; t.amount = 2.0; t.timestamp = midnight - 600; t.txid = "m"; // 23:50 + const std::vector txs = {t}; + LocalDayStartMemo memo; + MiningEarningsCache cache; + MiningEarningsKey k; k.txRevision = 5; k.txCount = txs.size(); k.soloSupported = true; + k.todayStart = memo.get(midnight - 1); + const MiningEarningsTotals before = cache.get(k, txs, 10000); + EXPECT_EQ(before.todayCount, 1); EXPECT_EQ(before.yesterdayCount, 0); + k.todayStart = memo.get(midnight + 1); // the clock passes midnight, no tx change + const MiningEarningsTotals after = cache.get(k, txs, 10500); // 0.5 s later: well inside the max age + EXPECT_EQ(cache.computeCount(), 2); + EXPECT_EQ(after.todayCount, 0); EXPECT_EQ(after.yesterdayCount, 1); + EXPECT_NEAR(after.today, 0.0, 1e-9); EXPECT_NEAR(after.yesterday, 2.0, 1e-9); + if (hadTz) setenv("TZ", savedTz.c_str(), 1); else unsetenv("TZ"); + dragonx::util::refreshTimezone(); +#endif +} + void testMiningBenchmarkModel() { dragonx::ui::ThreadBenchmark benchmark; @@ -14645,6 +14745,9 @@ int main() testConsoleInputModel(); testMiningBenchmarkModel(); testMiningThreadCommit(); + testMiningEarningsCore(); + testMiningEarningsCache(); + testMiningEarningsMidnightRollover(); testBalanceAddressListModel(); testExplorerBlockCache(); testTransactionHistoryCache();