A full z_listsentbyaddress holds the daemon's cs_main for the whole sweep -- ~140 s on a large wallet on a daemon without the sent-history decrypt cache -- which starves block connection and makes every other RPC (getinfo, balances) wait, so the node looks "busy". This runs the full sweep in depth bands so no single call holds the lock for long. - The full case now covers [0, tip] in bands: z_listsentbyaddress(z, minconf=a, filter-2, maxDepth=b) returns exactly depth [a, b]. Newest band first (its minconf=0 includes the mempool); results dedupe by txid across the overlap. - Each band is a separate call, so cs_main is held only ~5 s at a time and the node stays responsive between bands (block connection isn't starved, the busy canary answers). Total CPU is unchanged -- just not in one long lock. - sentSweepNextBandSize adapts the next band toward ~5 s: grows up to 4x in sparse depth (empty regions covered fast), shrinks to 1/2x in dense depth. - W advances only after every band succeeds; a mid-sweep failure re-sweeps rather than marking a partial result. The windowed sweep is unchanged. - Each band also fits the 120 s timeout on its own, so the seed completes even on a daemon where the single whole-wallet call used to time out. This removes the ~2.5 min hang on the first-run seed, the weekly safety-net re-sweep, and any invalidation. Multi-band + band-sizing covered by tests. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
201 lines
12 KiB
C++
201 lines
12 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 <cstdint>
|
|
#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 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<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
|