// 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 #include #include #include #include #include #include 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 int kSentSweepFirstLoadExtraBlocks = 1000; // E: extra margin on the first sweep after a load, // to absorb wallet-notifier lag at the last save constexpr int kSentSweepLongCallBlocks = 2400; // tip - W beyond this: use callLong + count failures constexpr long kSentSweepSafetyNetSec = 7 * 24 * 3600; // ~weekly full re-sweep: catches external wallet // changes the per-event guards can't see constexpr long kFullSentSweepTimeoutSec = 120; // a whole-wallet sweep must be able to finish // Banded full sweep: cover [0, tip] in depth bands (minconf=a + filter-2 maxDepth=b) so each cs_main hold is // short (~kSentSweepBandTargetMs), instead of one call that holds it for the whole sweep. The band grows in // sparse depth (no sends) and shrinks in dense depth, so it stays near the target duration either way. constexpr long kSentSweepBandTargetMs = 5000; constexpr int kSentSweepBandInitialBlocks = 2000; constexpr int kSentSweepBandMinBlocks = 500; constexpr int kSentSweepBandMaxBlocks = 100000; constexpr int kSentSweepBandOverlap = 20; // overlap adjacent bands; dedupe by txid 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). `margin` widens the window (the first sweep after a load uses a bigger one). nlohmann::json sentSweepParams(const std::string& anyZAddress, int tip, int fromHeight, int margin = kSentSweepReorgMarginBlocks); // Window margin: steady-state kSentSweepReorgMarginBlocks, bigger on the first sweep after a load. int sentSweepMargin(bool firstAfterLoad); // A: the bottom edge the window covers (W - margin), clamped to >= 1. The anchor whose block hash is stored // (H_A) and re-verified on the next load. int sentSweepAnchor(int fromHeight, bool firstAfterLoad); // A watermark saved long ago makes a huge "windowed" call: past this gap, use callLong and count failures // toward the backoff, so it doesn't time out on every block while the daemon keeps working. bool sentSweepNeedsLongCall(int tip, int fromHeight); // A watermarked wallet is due a periodic full re-sweep to catch changes made outside the GUI (a Console // import, an external same-seed restore) that the per-event guards miss. lastFullSec == 0 (no full sweep // recorded yet) never triggers one: the seeding full sweep records it. bool sentSweepSafetyNetDue(std::int64_t nowSec, std::int64_t lastFullSec); // Next banded-sweep depth size, adapting the previous band's block count toward kSentSweepBandTargetMs (at // most 2x down / 4x up per step), clamped to [min, max]. prevMs <= 0 (unmeasured) keeps the size. int sentSweepNextBandSize(int prevSize, double prevMs); // 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& addresses, const std::unordered_map& 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& addresses, const std::unordered_map& heights); void probed(const std::string& address) { left_.erase(address); } bool complete() const { return left_.empty(); } void reset() { left_.clear(); } private: std::unordered_set 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& addresses, const std::unordered_map& 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& addresses, const std::unordered_map& 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& addresses, const std::unordered_map& 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