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:
@@ -1773,6 +1773,7 @@ private:
|
||||
bool addresses_dirty_ = true; // true → refreshAddresses() will run
|
||||
bool address_validation_cache_dirty_ = true;
|
||||
bool transactions_dirty_ = false; // true → force tx refresh regardless of block height
|
||||
int mining_tx_poll_block_ = -1; // C119: tip of the last dispatched Mining-tab poll
|
||||
bool encryption_state_prefetched_ = false; // suppress duplicate getwalletinfo on connect
|
||||
bool rescan_status_poll_in_progress_ = false;
|
||||
// True once we've actually observed the rescan running (daemon restarted into -rescan warmup).
|
||||
|
||||
+27
-2
@@ -886,6 +886,7 @@ void App::onDisconnected(const std::string& reason)
|
||||
confirmed_tx_ids_.clear();
|
||||
confirmed_cache_block_ = -1;
|
||||
last_tx_block_height_ = -1;
|
||||
mining_tx_poll_block_ = -1;
|
||||
pending_opids_.clear();
|
||||
pending_send_info_.clear();
|
||||
// Resolve any deferred send callbacks so their UI doesn't spin forever on disconnect.
|
||||
@@ -1207,6 +1208,7 @@ bool App::currentPageNeedsWalletDataRefresh() const
|
||||
bool App::shouldRunWalletTransactionRefresh() const
|
||||
{
|
||||
if (currentPageNeedsWalletDataRefresh()) return true;
|
||||
if (current_page_ == ui::NavPage::Mining) return true; // C119: refreshTransactionData() narrows it to the Recent poll
|
||||
if (!send_txids_.empty()) return true;
|
||||
return transactions_dirty_;
|
||||
}
|
||||
@@ -2637,13 +2639,32 @@ bool App::refreshTransactionData()
|
||||
in.routineScanWork = work.routineWork;
|
||||
in.syncing = effectivelySyncing();
|
||||
in.heightKnown = tip > 0;
|
||||
in.recentDue = currentPageNeedsWalletDataRefresh() && shouldRefreshRecentTransactions();
|
||||
const bool walletPage = currentPageNeedsWalletDataRefresh();
|
||||
// C119: the Mining tab shows history-derived earnings but only gets the cheap per-block Recent poll.
|
||||
const bool miningPoll = current_page_ == ui::NavPage::Mining && !walletPage;
|
||||
in.recentDue = walletPage && shouldRefreshRecentTransactions();
|
||||
if (miningPoll) {
|
||||
using Refresh = services::NetworkRefreshService;
|
||||
in = services::shapeTxCycleForMiningPage(in);
|
||||
services::MiningTxPollInputs mp;
|
||||
mp.tip = tip;
|
||||
mp.lastPolledTip = mining_tx_poll_block_;
|
||||
mp.txAgeDue = network_refresh_.isDue(Refresh::Timer::TxAge);
|
||||
mp.historyLoaded = last_tx_block_height_ >= 0;
|
||||
mp.dirty = transactions_dirty_;
|
||||
mp.addressesDirty = addresses_dirty_;
|
||||
in.recentDue = services::miningTxPollDue(mp);
|
||||
}
|
||||
// What this cycle is without and with a routine wallet-wide scan: the scan budget (the governor)
|
||||
// decides between the two.
|
||||
in.gateOpen = false;
|
||||
const TxCycle held = services::decideTxCycle(in);
|
||||
in.gateOpen = true;
|
||||
const TxCycle open = services::decideTxCycle(in);
|
||||
TxCycle open = services::decideTxCycle(in);
|
||||
// Pool payouts are shielded receives (a z_listreceivedbyaddress probe finds them); a solo miner's earnings
|
||||
// are transparent coinbase rows, so on the Mining tab the probe runs only while the pool miner is.
|
||||
if (miningPoll && open == TxCycle::RecentWithProbe && !(xmrig_manager_ && xmrig_manager_->isRunning()))
|
||||
open = TxCycle::Recent;
|
||||
|
||||
const double now = scanClockNow();
|
||||
const auto sig = currentScanSignals();
|
||||
@@ -2669,9 +2690,13 @@ bool App::refreshTransactionData()
|
||||
return heldBack;
|
||||
case TxCycle::Recent:
|
||||
startRecentTransactionPoll(false, {});
|
||||
if (miningPoll && network_refresh_.jobInProgress(services::NetworkRefreshService::Job::Transactions))
|
||||
mining_tx_poll_block_ = tip; // dispatched (the job guard was clear on entry): one poll per block
|
||||
return heldBack;
|
||||
case TxCycle::RecentWithProbe:
|
||||
startRecentTransactionPoll(true, ticket);
|
||||
if (miningPoll && network_refresh_.jobInProgress(services::NetworkRefreshService::Job::Transactions))
|
||||
mining_tx_poll_block_ = tip;
|
||||
return false;
|
||||
case TxCycle::Full:
|
||||
case TxCycle::Light:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()) {
|
||||
|
||||
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user