diff --git a/src/app.h b/src/app.h index 3462aaa..7f1535a 100644 --- a/src/app.h +++ b/src/app.h @@ -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). diff --git a/src/app_network.cpp b/src/app_network.cpp index dde17eb..c195bff 100644 --- a/src/app_network.cpp +++ b/src/app_network.cpp @@ -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: diff --git a/src/services/history_scan_policy.cpp b/src/services/history_scan_policy.cpp index 78a0511..249f04d 100644 --- a/src/services/history_scan_policy.cpp +++ b/src/services/history_scan_policy.cpp @@ -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 diff --git a/src/services/history_scan_policy.h b/src/services/history_scan_policy.h index b0b5766..a665739 100644 --- a/src/services/history_scan_policy.h +++ b/src/services/history_scan_policy.h @@ -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 diff --git a/src/services/network_refresh_service.cpp b/src/services/network_refresh_service.cpp index bde967f..c5a4068 100644 --- a/src/services/network_refresh_service.cpp +++ b/src/services/network_refresh_service.cpp @@ -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 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()) { diff --git a/tests/test_phase4.cpp b/tests/test_phase4.cpp index cf09f7f..0a8f316 100644 --- a/tests/test_phase4.cpp +++ b/tests/test_phase4.cpp @@ -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(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(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(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();