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
+65
View File
@@ -1240,6 +1240,70 @@ void testRefreshSchedulerDeferFor()
}
}
// C119: the Mining tab's per-block Recent poll and the coinbase category transition in the Recent merge.
void testMiningTabTxPoll()
{
using namespace dragonx::services;
using Refresh = dragonx::services::NetworkRefreshService;
using json = nlohmann::json;
MiningTxPollInputs p; p.tip = 100; p.lastPolledTip = 99; p.txAgeDue = true; p.historyLoaded = true;
EXPECT_TRUE(miningTxPollDue(p));
p.lastPolledTip = 100; EXPECT_FALSE(miningTxPollDue(p)); // one poll per block
p.lastPolledTip = 99; p.txAgeDue = false; EXPECT_FALSE(miningTxPollDue(p));
p.txAgeDue = true; p.historyLoaded = false; EXPECT_FALSE(miningTxPollDue(p));
p.historyLoaded = true; p.dirty = true; EXPECT_FALSE(miningTxPollDue(p));
p.dirty = false; p.addressesDirty = true; EXPECT_FALSE(miningTxPollDue(p));
TxCycleInputs in; in.newTip = true; in.routineScanWork = true; in.heightKnown = true; in.gateOpen = true;
EXPECT_TRUE(decideTxCycle(in) == TxCycle::Full);
TxCycleInputs shaped = shapeTxCycleForMiningPage(in);
shaped.recentDue = true;
EXPECT_TRUE(decideTxCycle(shaped) == TxCycle::RecentWithProbe);
shaped.gateOpen = false;
EXPECT_TRUE(decideTxCycle(shaped) == TxCycle::Recent);
TxCycleInputs tracked; tracked.newTip = true; tracked.sendTracking = true; tracked.heightKnown = true;
EXPECT_TRUE(decideTxCycle(shapeTxCycleForMiningPage(tracked)) == TxCycle::Light);
TxCycleInputs expl; expl.explicitScanWork = true; expl.heightKnown = true;
EXPECT_TRUE(decideTxCycle(shapeTxCycleForMiningPage(expl)) == TxCycle::Full);
{ // immature -> generate at maturity: one row, the fresh category
Refresh::TransactionRefreshSnapshot snap;
dragonx::TransactionInfo imm; imm.txid = "cb1"; imm.type = "immature"; imm.amount = 3.0; imm.timestamp = 50; imm.confirmations = 99;
snap.previousTransactions = {imm};
MockRefreshRpc rpc;
rpc.addResponse("listtransactions", json::array({
json{{"txid", "cb1"}, {"category", "generate"}, {"amount", 3.0}, {"time", 50}, {"confirmations", 100}} }));
auto r = Refresh::collectRecentTransactionRefreshResult(rpc, snap, 200);
EXPECT_EQ(r.transactions.size(), static_cast<size_t>(1));
EXPECT_EQ(r.transactions[0].type, std::string("generate"));
}
{ // generate -> orphan on a reorg
Refresh::TransactionRefreshSnapshot snap;
dragonx::TransactionInfo gen; gen.txid = "cb2"; gen.type = "generate"; gen.amount = 3.0; gen.timestamp = 50; gen.confirmations = 100;
snap.previousTransactions = {gen};
MockRefreshRpc rpc;
rpc.addResponse("listtransactions", json::array({
json{{"txid", "cb2"}, {"category", "orphan"}, {"amount", 3.0}, {"time", 50}, {"confirmations", 0}} }));
auto r = Refresh::collectRecentTransactionRefreshResult(rpc, snap, 200);
EXPECT_EQ(r.transactions.size(), static_cast<size_t>(1));
EXPECT_EQ(r.transactions[0].type, std::string("orphan"));
}
{ // a non-coinbase row sharing the txid is left alone
Refresh::TransactionRefreshSnapshot snap;
dragonx::TransactionInfo rec; rec.txid = "cb3"; rec.type = "receive"; rec.amount = 1.0; rec.timestamp = 40; rec.address = "zs-x";
dragonx::TransactionInfo imm; imm.txid = "cb3"; imm.type = "immature"; imm.amount = 3.0; imm.timestamp = 50;
snap.previousTransactions = {rec, imm};
MockRefreshRpc rpc;
rpc.addResponse("listtransactions", json::array({
json{{"txid", "cb3"}, {"category", "generate"}, {"amount", 3.0}, {"time", 50}, {"confirmations", 100}} }));
auto r = Refresh::collectRecentTransactionRefreshResult(rpc, snap, 200);
EXPECT_EQ(r.transactions.size(), static_cast<size_t>(2));
int receives = 0, gens = 0;
for (const auto& t : r.transactions) { receives += t.type == "receive"; gens += t.type == "generate"; }
EXPECT_EQ(receives, 1);
EXPECT_EQ(gens, 1);
}
}
void testRefreshScheduler()
{
using dragonx::services::RefreshScheduler;
@@ -17532,6 +17596,7 @@ int main()
testAmountFormatting();
testSpendableFiltering();
testRefreshScheduler();
testMiningTabTxPoll();
testIdleTracker();
testEncryptionPendingPolicy();
testWalletLockReconcilePolicy();