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>
171 lines
9.4 KiB
C++
171 lines
9.4 KiB
C++
// DragonX Wallet - ImGui Edition
|
|
// Copyright 2024-2026 The Hush Developers
|
|
// Released under the GPLv3
|
|
|
|
#pragma once
|
|
|
|
// When the transaction history refresh may scan what. The history comes from three wallet-wide RPCs, each
|
|
// holding the daemon's cs_main for its whole run (seconds per call on a large wallet): listtransactions,
|
|
// z_listreceivedbyaddress (once per z-address) and z_listsentbyaddress. This decides, per refresh cycle,
|
|
// which of them run, so the cost stays bounded and every pass over the z-addresses finishes. Pure and
|
|
// main-thread only; App::refreshTransactionData() feeds it and the test target drives it directly.
|
|
|
|
#include "rpc/rpc_failure.h"
|
|
|
|
#include <nlohmann/json.hpp>
|
|
|
|
#include <cstddef>
|
|
#include <string>
|
|
#include <unordered_map>
|
|
#include <unordered_set>
|
|
#include <vector>
|
|
|
|
namespace dragonx {
|
|
namespace services {
|
|
|
|
constexpr int kHistoryBlockSeconds = 36; // -ac_blocktime=36
|
|
constexpr double kHistoryScanDuty = 0.10; // the same 10% as the other scan throttles
|
|
constexpr int kHistoryMinStaleBlocks = 8; // re-scan an address at most every 8 blocks...
|
|
constexpr int kHistoryMaxStaleBlocks = 500; // ...and at least every 500 (a safety net)
|
|
constexpr int kSentSweepReorgMarginBlocks = 100; // > MAX_REORG_LENGTH (99), plus tip drift
|
|
constexpr long kFullSentSweepTimeoutSec = 120; // a whole-wallet sweep must be able to finish
|
|
constexpr int kTransparentPageMin = 1000;
|
|
constexpr int kTransparentPageMax = 5000;
|
|
constexpr int kStallCyclesBeforeGating = 1;
|
|
|
|
// Blocks after which an address's last receive scan counts as stale and a new routine pass is due: the
|
|
// pass then costs about kHistoryScanDuty of wall-clock (one z_listreceivedbyaddress of perAddressScanMs per
|
|
// address, spread over that many blocks), within [kHistoryMinStaleBlocks, kHistoryMaxStaleBlocks].
|
|
int historyStaleAfterBlocks(std::size_t addressCount, double perAddressScanMs);
|
|
|
|
// Running cost estimate: a higher sample replaces it at once, a lower one pulls it down 25%. A sample of 0
|
|
// or less (nothing measured) leaves it as it is.
|
|
double updateScanCostEstimateMs(double previousMs, double sampleMs);
|
|
|
|
// listtransactions rows to ask for in the first page: last time's count plus headroom, so most wallets
|
|
// get every row in one call instead of re-paging the whole history in 1000-row pages.
|
|
int transparentPageSize(std::size_t previousRowCount);
|
|
|
|
// z_listsentbyaddress: the whole wallet when there's no usable watermark (none yet, or it is above the
|
|
// tip after a reorg), else only txs at most (tip - fromHeight) + kSentSweepReorgMarginBlocks deep.
|
|
bool sentSweepIsFull(int tip, int fromHeight);
|
|
// [z, 0] for the full sweep, [z, 0, 2, maxDepth] for the window: JSON integers, and never 3 params (the
|
|
// daemon rejects that count).
|
|
nlohmann::json sentSweepParams(const std::string& anyZAddress, int tip, int fromHeight);
|
|
// Backoff after full sweeps that failed in a way that cost the daemon the work: 0, 10 min, 1 h, then 6 h.
|
|
long fullSentSweepRetryDelaySec(int consecutiveFailures);
|
|
// Whether a failed full sweep counts toward that backoff: only when the daemon did the work (reply timeout,
|
|
// an error answer, anything unclassified), not when the call never ran (held back, aborted, not listening).
|
|
bool fullSentSweepFailureCounts(rpc::RpcFailureKind kind);
|
|
|
|
// The address scanned longest ago (never-scanned first), for the between-blocks probe.
|
|
std::size_t oldestScannedAddressIndex(const std::vector<std::string>& addresses,
|
|
const std::unordered_map<std::string, int>& heights);
|
|
|
|
// The between-blocks probe scans one address per run. After what it watches changed (the mempool, a wallet
|
|
// mutation) it isn't done until it has probed every z-address once: the change may be any of them. Tracks
|
|
// which are left, by address (heights can't tell: a rotation at one tip leaves them all equal).
|
|
class ProbeRotation {
|
|
public:
|
|
// The next address to probe: the oldest-scanned of those left. Drops addresses no longer listed, and
|
|
// starts a new rotation when none are left. Empty when there are no addresses.
|
|
std::string next(const std::vector<std::string>& addresses, const std::unordered_map<std::string, int>& heights);
|
|
void probed(const std::string& address) { left_.erase(address); }
|
|
bool complete() const { return left_.empty(); }
|
|
void reset() { left_.clear(); }
|
|
|
|
private:
|
|
std::unordered_set<std::string> left_;
|
|
};
|
|
|
|
// One pass of receive scans over every z-address. An address is done once it was scanned at or above the
|
|
// pass's start height (its floor), so a pass ends after ceil(A / budget) cycles however the tip moves. An
|
|
// explicit pass (the first load of a wallet, or after an invalidation) may run back to back; a routine
|
|
// re-pass (addresses gone stale) runs only as the scan throttle allows.
|
|
class ShieldedHistoryPass {
|
|
public:
|
|
struct Work {
|
|
std::size_t total = 0; // z-addresses
|
|
std::size_t missing = 0; // never scanned
|
|
std::size_t pending = 0; // still to scan in this pass (or in the pass that is due)
|
|
bool explicitWork = false; // may skip the scan throttle
|
|
bool routineWork = false; // waits for the scan throttle
|
|
};
|
|
|
|
Work evaluate(int tip, const std::vector<std::string>& addresses,
|
|
const std::unordered_map<std::string, int>& heights, int staleAfter, bool initialComplete) const;
|
|
// Start a pass if one is due and allowed (routine passes need routineAllowed, unless the first load
|
|
// isn't complete yet). Clamps an active pass's floor after a reorg below it.
|
|
void begin(int tip, const std::vector<std::string>& addresses, const std::unordered_map<std::string, int>& heights,
|
|
int staleAfter, bool initialComplete, bool routineAllowed);
|
|
// The floor a cycle scans up to: -1 means only never-scanned (and priority) addresses, which is all an
|
|
// explicit-only cycle may do while a routine pass is active.
|
|
int scanFloor(int tip, bool routineAllowed) const;
|
|
// After a cycle's heights are merged: count stalls (scans attempted, none succeeded), end the pass once
|
|
// no address is missing or below its floor.
|
|
void finish(int tip, const std::vector<std::string>& addresses, const std::unordered_map<std::string, int>& heights,
|
|
std::size_t attempted, std::size_t succeeded);
|
|
void reset();
|
|
|
|
bool active() const { return active_; }
|
|
bool explicitPass() const { return explicit_; }
|
|
int startHeight() const { return startHeight_; }
|
|
// Scans keep failing: explicit work is treated as routine (gated) until one succeeds again.
|
|
bool stalled() const { return stalledCycles_ >= kStallCyclesBeforeGating; }
|
|
|
|
private:
|
|
int floorAt(int tip) const;
|
|
|
|
bool active_ = false;
|
|
bool explicit_ = false;
|
|
int startHeight_ = -1;
|
|
int stalledCycles_ = 0;
|
|
};
|
|
|
|
enum class TxCycle {
|
|
None,
|
|
Full, // listtransactions, receive scans, the sent sweep, enrichment
|
|
Light, // listtransactions and enrichment only: no wallet-wide z-scans
|
|
Recent, // the between-blocks poll: recent listtransactions rows
|
|
RecentWithProbe, // ...plus one receive scan of the address scanned longest ago
|
|
};
|
|
|
|
struct TxCycleInputs {
|
|
bool neverFetched = false; // no history loaded yet this session
|
|
bool newTip = false; // the tip moved since the last history apply
|
|
bool dirty = false; // something changed the wallet (a send, an import, ...)
|
|
bool sendTracking = false; // tracked sends still waiting for their view
|
|
bool explicitScanWork = false; // ShieldedHistoryPass::Work::explicitWork
|
|
bool routineScanWork = false; // ShieldedHistoryPass::Work::routineWork
|
|
bool gateOpen = false; // the scan throttle allows a wallet-wide scan now
|
|
bool syncing = false; // the node is catching up (or settling after it)
|
|
bool heightKnown = false; // the tip height is known
|
|
bool recentDue = false; // the between-blocks poll is due on a wallet page
|
|
};
|
|
|
|
// Only explicit work skips the throttle with scans; sends and other mutations get rows now through Light
|
|
// cycles; nothing scans while syncing or before the tip is known. Tracking alone needs a new tip for a
|
|
// 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
|