fix(core): refresh transaction history on the Mining tab

The Mining tab shows earnings derived from transaction history, but it
was not treated as a wallet-data page, so newly mined or paid-out
earnings did not appear until the user left the tab and came back. A
coinbase row also changes category in place (immature becomes generate
at maturity, or orphan on a reorg), and the Recent merge kept both the
old and the new row, double-counting the earnings.

- The Mining tab now gets the cheap between-blocks Recent poll: one
  listtransactions per new block, bounded by the 15 s transaction-age
  timer, once history has loaded. It never runs a full routine scan and
  never pre-empts an owed dirty cycle; tracked sends still take their
  normal cycle on any page.
- The shielded-payout probe runs only while the pool miner is running,
  since pool payouts are shielded receives while a solo miner's earnings
  are transparent coinbase rows.
- The Recent merge drops the previous row of the other coinbase category
  when a coinbase transitions, so maturing or reorged rewards are no
  longer counted twice. A non-coinbase row sharing the txid is left
  alone.

Behaviour change: mining earnings update on the Mining tab per block
instead of only on tab re-entry, and maturing or reorged coinbase rows
no longer double-count.

Tests: the per-block poll gate (one poll per block, due only after
history loads, held while dirty or addresses are dirty), the Mining-page
cycle shaping, and the coinbase transition merge for immature to
generate, generate to orphan, and a shared-txid non-coinbase row.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-10-12 21:42:23 -05:00
co-authored by Claude Opus 4.8
parent fbe09c6a4c
commit 21dc874988
6 changed files with 154 additions and 2 deletions
+15
View File
@@ -193,5 +193,20 @@ TxCycle decideTxCycle(const TxCycleInputs& in)
return TxCycle::None;
}
TxCycleInputs shapeTxCycleForMiningPage(TxCycleInputs in)
{
if (!in.sendTracking) { // tracked sends need their Light cycle at a new tip, wherever the user is
in.newTip = false;
in.routineScanWork = false;
}
return in;
}
bool miningTxPollDue(const MiningTxPollInputs& in)
{
return in.historyLoaded && in.tip > 0 && in.tip != in.lastPolledTip && in.txAgeDue && !in.dirty &&
!in.addressesDirty;
}
} // namespace services
} // namespace dragonx
+18
View File
@@ -148,5 +148,23 @@ struct TxCycleInputs {
// Light cycle, so it can't loop them at one tip.
TxCycle decideTxCycle(const TxCycleInputs& in);
// The Mining tab shows history-derived earnings but is not a wallet-data page: it gets the cheap between-blocks
// poll (recent listtransactions rows) and nothing routine. Explicit work (first load, dirty, new addresses) and
// tracked sends still go through the normal cycles on any page.
TxCycleInputs shapeTxCycleForMiningPage(TxCycleInputs in);
struct MiningTxPollInputs {
int tip = 0; // current chain height
int lastPolledTip = -1; // the tip the last Mining poll was dispatched at (-1 = none)
bool txAgeDue = false; // RefreshTimer::TxAge due (15 s since the last Recent poll / full cycle)
bool historyLoaded = false; // last_tx_block_height_ >= 0
bool dirty = false; // transactions_dirty_
bool addressesDirty = false; // addresses_dirty_
};
// One poll per new block (confirmation counts and new coinbase rows only change with a block), after the history
// has loaded, never while a dirty cycle is already owed. Unlike the wallet pages, an EMPTY history may poll (a fresh
// miner's first block must appear).
bool miningTxPollDue(const MiningTxPollInputs& in);
} // namespace services
} // namespace dragonx
+28
View File
@@ -1558,6 +1558,34 @@ NetworkRefreshService::TransactionRefreshResult NetworkRefreshService::collectRe
json transactions = rpc.call("listtransactions", json::array({"", pageSize, 0}));
appendTransparentTransactions(recentTransactions, recentTxids, transactions, snapshot.miningAddresses);
// A coinbase changes category in place (immature -> generate at maturity, or -> orphan on a reorg): drop
// the previous row of the OTHER category before merging, or both stay and the earnings double count
// (C119). Note "mined" (a transparent receive remapped to a mining address) is NOT a coinbase category
// and never transitions, so it is intentionally excluded.
{
const auto coinbaseType = [](const std::string& t) {
return t == "generate" || t == "immature" || t == "orphan";
};
std::unordered_map<std::string, std::string> freshCoinbase; // txid -> its fresh category
for (const auto& recent : recentTransactions)
if (coinbaseType(recent.type)) freshCoinbase[recent.txid] = recent.type;
if (!freshCoinbase.empty()) {
const auto before = result.transactions.size();
result.transactions.erase(
std::remove_if(result.transactions.begin(), result.transactions.end(),
[&](const TransactionInfo& t) {
if (!coinbaseType(t.type)) return false;
const auto it = freshCoinbase.find(t.txid);
return it != freshCoinbase.end() && it->second != t.type;
}),
result.transactions.end());
if (result.transactions.size() != before) {
byKey.clear();
for (std::size_t i = 0; i < result.transactions.size(); ++i)
byKey.emplace(txKey(result.transactions[i]), i);
}
}
}
for (auto& recent : recentTransactions) {
auto it = byKey.find(txKey(recent));
if (it != byKey.end()) {