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>
181 lines
6.5 KiB
C++
181 lines
6.5 KiB
C++
// DragonX Wallet - ImGui Edition
|
|
// Copyright 2024-2026 The Hush Developers
|
|
// Released under the GPLv3
|
|
|
|
#include "history_scan_policy.h"
|
|
|
|
#include <algorithm>
|
|
#include <cmath>
|
|
|
|
namespace dragonx {
|
|
namespace services {
|
|
|
|
int historyStaleAfterBlocks(std::size_t addressCount, double perAddressScanMs)
|
|
{
|
|
if (addressCount == 0 || !(perAddressScanMs > 0.0)) return kHistoryMinStaleBlocks;
|
|
const double blocks = std::ceil((static_cast<double>(addressCount) * perAddressScanMs / 1000.0) /
|
|
(kHistoryBlockSeconds * kHistoryScanDuty));
|
|
if (!(blocks < kHistoryMaxStaleBlocks)) return kHistoryMaxStaleBlocks; // also NaN / inf
|
|
return std::max(kHistoryMinStaleBlocks, static_cast<int>(blocks));
|
|
}
|
|
|
|
double updateScanCostEstimateMs(double previousMs, double sampleMs)
|
|
{
|
|
if (!(sampleMs > 0.0)) return previousMs;
|
|
if (!(previousMs > 0.0) || sampleMs > previousMs) return sampleMs;
|
|
return 0.75 * previousMs + 0.25 * sampleMs;
|
|
}
|
|
|
|
int transparentPageSize(std::size_t previousRowCount)
|
|
{
|
|
const std::size_t want = previousRowCount + std::max<std::size_t>(200, previousRowCount / 10);
|
|
return static_cast<int>(std::clamp<std::size_t>(want, kTransparentPageMin, kTransparentPageMax));
|
|
}
|
|
|
|
bool sentSweepIsFull(int tip, int fromHeight)
|
|
{
|
|
return tip <= 0 || fromHeight < 0 || fromHeight > tip;
|
|
}
|
|
|
|
nlohmann::json sentSweepParams(const std::string& anyZAddress, int tip, int fromHeight)
|
|
{
|
|
nlohmann::json params = nlohmann::json::array();
|
|
params.push_back(anyZAddress);
|
|
params.push_back(0);
|
|
if (sentSweepIsFull(tip, fromHeight)) return params;
|
|
const int maxDepth = (tip - fromHeight) + kSentSweepReorgMarginBlocks;
|
|
params.push_back(2); // filter type 2: depth
|
|
params.push_back(maxDepth); // at most this deep (mempool txs, depth 0, always pass)
|
|
return params;
|
|
}
|
|
|
|
long fullSentSweepRetryDelaySec(int consecutiveFailures)
|
|
{
|
|
if (consecutiveFailures <= 0) return 0;
|
|
if (consecutiveFailures == 1) return 600;
|
|
if (consecutiveFailures == 2) return 3600;
|
|
return 21600;
|
|
}
|
|
|
|
bool fullSentSweepFailureCounts(rpc::RpcFailureKind kind)
|
|
{
|
|
using K = rpc::RpcFailureKind;
|
|
return kind == K::ReplyTimeout || kind == K::AppError || kind == K::Other;
|
|
}
|
|
|
|
std::size_t oldestScannedAddressIndex(const std::vector<std::string>& addresses,
|
|
const std::unordered_map<std::string, int>& heights)
|
|
{
|
|
std::size_t best = 0;
|
|
int bestHeight = 0;
|
|
bool haveBest = false;
|
|
for (std::size_t i = 0; i < addresses.size(); ++i) {
|
|
const auto it = heights.find(addresses[i]);
|
|
if (it == heights.end()) return i; // never scanned: first
|
|
if (!haveBest || it->second < bestHeight) {
|
|
best = i;
|
|
bestHeight = it->second;
|
|
haveBest = true;
|
|
}
|
|
}
|
|
return best;
|
|
}
|
|
|
|
int ShieldedHistoryPass::floorAt(int tip) const
|
|
{
|
|
return tip > 0 ? std::min(startHeight_, tip) : startHeight_;
|
|
}
|
|
|
|
ShieldedHistoryPass::Work ShieldedHistoryPass::evaluate(int tip, const std::vector<std::string>& addresses,
|
|
const std::unordered_map<std::string, int>& heights,
|
|
int staleAfter, bool initialComplete) const
|
|
{
|
|
Work work;
|
|
work.total = addresses.size();
|
|
const int floor = active_ ? floorAt(tip) : -1;
|
|
std::size_t below = 0;
|
|
bool staleDue = false;
|
|
for (const auto& address : addresses) {
|
|
const auto it = heights.find(address);
|
|
if (it == heights.end()) {
|
|
++work.missing;
|
|
continue;
|
|
}
|
|
if (active_) {
|
|
if (it->second < floor) ++below;
|
|
} else if (tip > 0 && it->second < tip - staleAfter) {
|
|
staleDue = true;
|
|
}
|
|
}
|
|
work.pending = work.missing + (active_ ? below : (staleDue ? work.total - work.missing : 0));
|
|
work.explicitWork = !stalled() &&
|
|
(work.missing > 0 || (active_ ? (explicit_ && below > 0) : (staleDue && !initialComplete)));
|
|
work.routineWork = !work.explicitWork && work.pending > 0;
|
|
return work;
|
|
}
|
|
|
|
void ShieldedHistoryPass::begin(int tip, const std::vector<std::string>& addresses,
|
|
const std::unordered_map<std::string, int>& heights, int staleAfter,
|
|
bool initialComplete, bool routineAllowed)
|
|
{
|
|
if (active_) {
|
|
if (tip > 0 && startHeight_ > tip) startHeight_ = tip; // the chain reorganised below the floor
|
|
return;
|
|
}
|
|
if (tip <= 0 || !(routineAllowed || !initialComplete)) return;
|
|
bool staleDue = false;
|
|
for (const auto& address : addresses) {
|
|
const auto it = heights.find(address);
|
|
if (it != heights.end() && it->second < tip - staleAfter) {
|
|
staleDue = true;
|
|
break;
|
|
}
|
|
}
|
|
if (!staleDue) return;
|
|
active_ = true;
|
|
explicit_ = !initialComplete;
|
|
startHeight_ = tip;
|
|
}
|
|
|
|
int ShieldedHistoryPass::scanFloor(int tip, bool routineAllowed) const
|
|
{
|
|
return (active_ && (explicit_ || routineAllowed)) ? floorAt(tip) : -1;
|
|
}
|
|
|
|
void ShieldedHistoryPass::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)
|
|
{
|
|
if (attempted > 0) stalledCycles_ = succeeded > 0 ? 0 : stalledCycles_ + 1;
|
|
// An empty list proves nothing (a failed enumeration blanks it); a real wallet change resets the pass.
|
|
if (!active_ || addresses.empty()) return;
|
|
const int floor = floorAt(tip);
|
|
for (const auto& address : addresses) {
|
|
const auto it = heights.find(address);
|
|
if (it == heights.end() || it->second < floor) return; // still work left in this pass
|
|
}
|
|
active_ = false;
|
|
explicit_ = false;
|
|
}
|
|
|
|
void ShieldedHistoryPass::reset()
|
|
{
|
|
*this = ShieldedHistoryPass{};
|
|
}
|
|
|
|
TxCycle decideTxCycle(const TxCycleInputs& in)
|
|
{
|
|
const bool wanted = in.neverFetched || in.newTip || in.dirty || in.explicitScanWork || in.routineScanWork;
|
|
const bool scansAllowed = !in.syncing && in.heightKnown;
|
|
if (wanted) {
|
|
if (scansAllowed && (in.explicitScanWork || in.gateOpen)) return TxCycle::Full;
|
|
// Rows now (a mutation, a tracked send at a new tip); wallet-wide scans wait for the throttle.
|
|
if (in.neverFetched || in.dirty || (in.sendTracking && in.newTip)) return TxCycle::Light;
|
|
}
|
|
if (in.recentDue) return (scansAllowed && in.gateOpen) ? TxCycle::RecentWithProbe : TxCycle::Recent;
|
|
return TxCycle::None;
|
|
}
|
|
|
|
} // namespace services
|
|
} // namespace dragonx
|