perf(mining): memoize the earnings card on tx_revision

The Mining tab's earnings card walked all N wallet transactions on
every frame to total Today / Yesterday / All time and pick the 4 most
recent rows: four string compares per transaction plus a memo search
on receives. It also ran localtime and mktime every frame to find
local midnight.

The pass is now a pure function behind a one-slot cache keyed on
WalletState::tx_revision, the transaction count, the filter pill, the
Solo/Pool mode, solo support and local midnight, with a 2 s maximum
age as a safety net. It reruns only when one of those changes or the
2 s age runs out, instead of every frame. The totals are bit-identical
(same loop, same summation order), and crossing local midnight still
moves Today into Yesterday at once.

Local midnight is recomputed only when the clock second changes,
through the shared util::localTime; a failed localtime now yields a
zeroed struct instead of an uninitialised one. The "x ago" labels and
Est. Daily are still computed per frame.

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 3069151e7f
commit 888a41ddc2
4 changed files with 260 additions and 59 deletions
+23 -59
View File
@@ -21,7 +21,9 @@
#include "imgui_internal.h" #include "imgui_internal.h"
#include <algorithm> #include <algorithm>
#include <chrono>
#include <cmath> #include <cmath>
#include <cstdint>
#include <cstdio> #include <cstdio>
#include <ctime> #include <ctime>
#include <string> #include <string>
@@ -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 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) char buf[128]; // section-local scratch (was shared in the parent)
// Gather mining transactions from state // Mining earnings totals + the 4 most recent rows, memoised (MiningEarningsCache): the O(transactions)
double minedToday = 0.0, minedYesterday = 0.0, minedAllTime = 0.0; // pass reruns only when the tx list (WalletState::tx_revision), the filter pill, the Solo/Pool mode, the
int minedTodayCount = 0, minedYesterdayCount = 0, minedAllTimeCount = 0; // 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); int64_t now = (int64_t)std::time(nullptr);
static MiningEarningsCache s_earningsCache;
// Calendar-day boundaries (local time) static LocalDayStartMemo s_dayStart;
time_t nowT = (time_t)now; MiningEarningsKey earnKey;
struct tm local; earnKey.txRevision = state.tx_revision;
#ifdef _WIN32 earnKey.txCount = state.transactions.size();
localtime_s(&local, &nowT); earnKey.filter = s_earnings_filter;
#else earnKey.soloSupported = app->supportsSoloMining();
localtime_r(&nowT, &local); earnKey.poolMode = s_pool_mode;
#endif earnKey.todayStart = s_dayStart.get(now);
local.tm_hour = 0; const int64_t nowMs = (int64_t)std::chrono::duration_cast<std::chrono::milliseconds>(
local.tm_min = 0; std::chrono::steady_clock::now().time_since_epoch()).count();
local.tm_sec = 0; const MiningEarningsTotals& earn = s_earningsCache.get(earnKey, state.transactions, nowMs);
int64_t todayStart = (int64_t)mktime(&local); // The names the (unchanged) rendering code below already uses.
int64_t yesterdayStart = todayStart - 86400; const double minedToday = earn.today, minedYesterday = earn.yesterday, minedAllTime = earn.allTime;
const int minedTodayCount = earn.todayCount, minedYesterdayCount = earn.yesterdayCount,
struct MinedTx { minedAllTimeCount = earn.allTimeCount;
int64_t timestamp; const std::vector<MiningEarningsRecentTx>& recentMined = earn.recent;
double amount;
int confirmations;
bool mature;
std::string txid;
bool isPoolPayout;
};
std::vector<MinedTx> 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});
}
}
}
// Est. Daily = expected reward for YOUR own hashrate share of the network. In pool mode this uses // 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 // your local miner rate (pool_mining.hashrate_10s), NOT the pool's aggregate rate: pool payouts are
+73
View File
@@ -1,7 +1,11 @@
#include "mining_tab_helpers.h" #include "mining_tab_helpers.h"
#include "../../util/local_time.h"
#include <algorithm> #include <algorithm>
#include <cmath>
#include <cstdio> #include <cstdio>
#include <ctime>
#include <thread> #include <thread>
namespace dragonx { namespace dragonx {
@@ -83,5 +87,74 @@ std::string FormatEstTime(double estimatedHours)
return std::string(buffer); return std::string(buffer);
} }
MiningEarningsTotals ComputeMiningEarnings(const std::vector<TransactionInfo>& 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<TransactionInfo>& 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<std::time_t>(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 ui
} // namespace dragonx } // namespace dragonx
+61
View File
@@ -1,6 +1,11 @@
#pragma once #pragma once
#include <cstddef>
#include <cstdint>
#include <string> #include <string>
#include <vector>
#include "data/wallet_state.h" // TransactionInfo
namespace dragonx { namespace dragonx {
namespace ui { namespace ui {
@@ -13,5 +18,61 @@ std::string FormatFeePercent(double feePercent);
double EstimateHoursToBlock(double localHashrate, double networkHashrate, double difficulty); double EstimateHoursToBlock(double localHashrate, double networkHashrate, double difficulty);
std::string FormatEstTime(double estimatedHours); 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<MiningEarningsRecentTx> 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<TransactionInfo>& 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<TransactionInfo>& 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 ui
} // namespace dragonx } // namespace dragonx
+103
View File
@@ -6318,6 +6318,106 @@ void testMiningThreadCommit()
EXPECT_EQ(effectivePoolThreads(0, 0, 0), 0); 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<TransactionInfo> 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<size_t>(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<size_t>(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<TransactionInfo> 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<size_t>(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<TransactionInfo> 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<TransactionInfo> 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() void testMiningBenchmarkModel()
{ {
dragonx::ui::ThreadBenchmark benchmark; dragonx::ui::ThreadBenchmark benchmark;
@@ -14645,6 +14745,9 @@ int main()
testConsoleInputModel(); testConsoleInputModel();
testMiningBenchmarkModel(); testMiningBenchmarkModel();
testMiningThreadCommit(); testMiningThreadCommit();
testMiningEarningsCore();
testMiningEarningsCache();
testMiningEarningsMidnightRollover();
testBalanceAddressListModel(); testBalanceAddressListModel();
testExplorerBlockCache(); testExplorerBlockCache();
testTransactionHistoryCache(); testTransactionHistoryCache();