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
+103
View File
@@ -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<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()
{
dragonx::ui::ThreadBenchmark benchmark;
@@ -14645,6 +14745,9 @@ int main()
testConsoleInputModel();
testMiningBenchmarkModel();
testMiningThreadCommit();
testMiningEarningsCore();
testMiningEarningsCache();
testMiningEarningsMidnightRollover();
testBalanceAddressListModel();
testExplorerBlockCache();
testTransactionHistoryCache();