perf(history): scan shielded history in bounded passes, not every block

Every block re-ran z_listreceivedbyaddress for every z-address (O(wallet)
each, under cs_main) and a whole-wallet z_listsentbyaddress. On a 40-address
wallet that was ~80% of wall-clock. The history now advances in passes:

- an address is rescanned when it is stale by a number of blocks scaled to
  its measured cost (8 to 500), never-scanned and hinted addresses first
  (a hint: z_listunspent shows a note the history doesn't);
- z_listsentbyaddress covers only the blocks since the last good sweep
  (+100 reorg margin); the whole-wallet sweep runs once per connect, with
  a 10 min / 1 h / 6 h backoff after real failures;
- listtransactions pages are sized from last time, and an unchanged tip
  reuses the transparent rows;
- found-but-unenriched txids carry over as a backlog; only -5 is a
  definitive "not ours" for z_viewtransaction;
- between blocks, a cheap recent poll plus a one-address probe.

Duty model for a 40-address wallet at 10 s per scan: ~9% instead of ~80%.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-09 11:06:34 -05:00
co-authored by Claude Opus 5.5
parent 8b9a498eee
commit 879de37d18
10 changed files with 1645 additions and 233 deletions
+135
View File
@@ -0,0 +1,135 @@
// 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 <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);
// 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);
} // namespace services
} // namespace dragonx