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
+180
View File
@@ -0,0 +1,180 @@
// 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
+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
+230 -70
View File
@@ -1,4 +1,5 @@
#include "network_refresh_service.h"
#include "history_scan_policy.h"
#include "rpc/rpc_busy_gate.h"
#include "../util/logger.h"
@@ -134,20 +135,43 @@ void appendTrackedSendPlaceholder(std::vector<TransactionInfo>& transactions,
transactions.push_back(std::move(info));
}
// A row the shielded scans (or z_viewtransaction) produce rather than listtransactions.
bool isShieldedRelatedRow(const TransactionInfo& transaction)
{
return (!transaction.address.empty() && transaction.address[0] == 'z') ||
(!transaction.from_address.empty() && transaction.from_address[0] == 'z') ||
!transaction.memo.empty();
}
void appendMissingPreviousTransactions(std::vector<TransactionInfo>& transactions,
const std::vector<TransactionInfo>& previousTransactions,
bool includeAll,
const std::unordered_set<std::string>& pinnedTxids)
{
// Presence is decided against the rows built this cycle, before any is carried: deduping against rows
// just appended kept only the first leg of a multi-recipient send.
std::set<std::pair<std::string, std::string>> present;
for (const auto& transaction : transactions) present.emplace(transaction.txid, transaction.type);
for (const auto& transaction : previousTransactions) {
bool pinned = pinnedTxids.find(transaction.txid) != pinnedTxids.end();
if (!includeAll && !pinned) {
bool shieldedRelated = (!transaction.address.empty() && transaction.address[0] == 'z') ||
(!transaction.from_address.empty() && transaction.from_address[0] == 'z') ||
!transaction.memo.empty();
if (!shieldedRelated) continue;
}
if (hasTransactionType(transactions, transaction.txid, transaction.type)) continue;
if (!includeAll && !pinned && !isShieldedRelatedRow(transaction)) continue;
if (present.count({transaction.txid, transaction.type})) continue;
transactions.push_back(transaction);
}
}
// On a full rebuild, keep the stored rows of sends still waiting to be re-enriched. Presence is decided
// against the rows built this cycle, before any is carried, so every row of a multi-recipient send is kept
// (deduping against rows just appended would keep only the first).
void appendPinnedPreviousTransactions(std::vector<TransactionInfo>& transactions,
const std::vector<TransactionInfo>& previousTransactions,
const std::unordered_set<std::string>& pinnedTxids)
{
std::set<std::pair<std::string, std::string>> present;
for (const auto& transaction : transactions) present.emplace(transaction.txid, transaction.type);
for (const auto& transaction : previousTransactions) {
if (!pinnedTxids.count(transaction.txid)) continue;
if (present.count({transaction.txid, transaction.type})) continue;
transactions.push_back(transaction);
}
}
@@ -767,6 +791,9 @@ NetworkRefreshService::AddressRefreshResult NetworkRefreshService::collectAddres
lite.confirmations = (nz.contains("rawconfirmations") && nz["rawconfirmations"].is_number_integer())
? nz["rawconfirmations"].get<int>()
: nz.value("confirmations", 0);
if (nz.contains("address") && nz["address"].is_string()) lite.address = nz["address"].get<std::string>();
if (nz.contains("txid") && nz["txid"].is_string()) lite.txid = nz["txid"].get<std::string>();
if (nz.contains("change") && nz["change"].is_boolean()) lite.change = nz["change"].get<bool>();
result.unspentNotes.push_back(lite);
}
}
@@ -1044,10 +1071,13 @@ NetworkRefreshService::TransactionRefreshResult NetworkRefreshService::collectTr
result.blockHeight = currentBlockHeight;
result.shieldedAddressCount = snapshot.shieldedAddresses.size();
result.shieldedScanHeights = snapshot.shieldedScanHeights;
// Time the whole scan — the per-address z_listreceivedbyaddress pass is O(mapWallet) and holds the
// daemon's cs_main. The measured cost feeds the caller's adaptive throttle (txRefreshDue()) so the
// routine full history rescan can't starve block connection on a large wallet.
// Time the whole cycle: listtransactions, the per-address z_listreceivedbyaddress scans and
// z_listsentbyaddress are each O(mapWallet) and hold the daemon's cs_main. The measured cost feeds the
// caller's throttle (txRefreshDue()) so routine scans can't starve block connection on a large wallet.
const auto scanStart = std::chrono::steady_clock::now();
auto msSince = [](std::chrono::steady_clock::time_point from) {
return std::chrono::duration<double, std::milli>(std::chrono::steady_clock::now() - from).count();
};
std::set<std::string> knownTxids;
HushChatMemoOutputMap hushChatReceivedOutputs;
@@ -1056,47 +1086,62 @@ NetworkRefreshService::TransactionRefreshResult NetworkRefreshService::collectTr
: nullptr;
bool transactionRpcError = false;
try {
constexpr int pageSize = 1000;
int skip = 0;
while (true) {
json transactions = rpc.call("listtransactions", json::array({"", pageSize, skip}));
appendTransparentTransactions(result.transactions, knownTxids, transactions, snapshot.miningAddresses);
if (!transactions.is_array() || transactions.size() < static_cast<std::size_t>(pageSize)) break;
skip += pageSize;
// ── Transparent rows (listtransactions) ─────────────────────────────────────────────────────────
if (snapshot.reuseTransparentRows && !snapshot.previousTransactions.empty()) {
// Same tip and nothing changed since the rows on screen were fetched: keep their transparent rows
// instead of paging the whole history again. Shielded rows come from the scans below, and rows of
// tracked sends and pending ops are rebuilt by enrichment.
for (const auto& transaction : snapshot.previousTransactions) {
if (isShieldedRelatedRow(transaction)) continue;
if (snapshot.sendTxids.count(transaction.txid) || snapshot.pendingOpids.count(transaction.txid)) continue;
result.transactions.push_back(transaction);
if (!transaction.txid.empty()) knownTxids.insert(transaction.txid);
}
result.transparentRowsReused = true;
} else {
try {
// The first page holds last time's count plus headroom, so most wallets get every row in one
// call (re-paging 1000 rows at a time formats ~rows^2/2000 rows); skip-paging stays the fallback.
const int pageSize = transparentPageSize(snapshot.transparentRowHint);
for (int skip = 0;; skip += pageSize) {
json page = rpc.call("listtransactions", json::array({"", pageSize, skip}));
appendTransparentTransactions(result.transactions, knownTxids, page, snapshot.miningAddresses);
const std::size_t got = page.is_array() ? page.size() : 0;
result.transparentRowCount += got;
if (got < static_cast<std::size_t>(pageSize)) break;
}
result.transparentChainOk = true;
} catch (const std::exception& e) {
transactionRpcError = true;
DEBUG_LOGF("listtransactions error: %s\n", e.what());
}
} catch (const std::exception& e) {
transactionRpcError = true;
DEBUG_LOGF("listtransactions error: %s\n", e.what());
}
// An address counts as "scanned at tip" if its last scan is within shieldedScanTipTolerance
// blocks of the current tip. Tolerance 0 means strict (exact tip); a small tolerance lets a
// multi-cycle pass over many addresses complete even as the tip advances during it.
const int scanStaleThreshold = currentBlockHeight - std::max(0, snapshot.shieldedScanTipTolerance);
auto scannedAtTip = [&](const std::string& address) {
if (currentBlockHeight < 0) return false;
auto it = result.shieldedScanHeights.find(address);
return it != result.shieldedScanHeights.end() && it->second >= scanStaleThreshold;
// ── Shielded receives (z_listreceivedbyaddress, one per z-address) ──────────────────────────────
// A pass re-scans each address once: one scanned below the pass floor (or never) needs a scan, so a
// pass over many addresses completes however the tip moves meanwhile. Priority addresses go first.
const int scanFloor = snapshot.shieldedScanFloorHeight;
auto needsPassScan = [&](const std::string& address) {
const auto it = result.shieldedScanHeights.find(address);
if (it == result.shieldedScanHeights.end()) return true;
return scanFloor >= 0 && it->second < scanFloor;
};
std::size_t shieldedStart = snapshot.shieldedScanStartIndex;
if (shieldedStart >= snapshot.shieldedAddresses.size()) shieldedStart = 0;
std::size_t nextSearchIndex = shieldedStart;
for (std::size_t index = shieldedStart; index < snapshot.shieldedAddresses.size(); ++index) {
const auto& address = snapshot.shieldedAddresses[index];
if (scannedAtTip(address)) {
nextSearchIndex = index + 1;
continue;
}
if (snapshot.maxShieldedReceiveScans > 0 &&
result.shieldedAddressesScanned >= snapshot.maxShieldedReceiveScans) {
nextSearchIndex = index;
break;
}
auto needsPriorityScan = [&](const std::string& address) {
const auto priority = snapshot.priorityScanFloors.find(address);
if (priority == snapshot.priorityScanFloors.end()) return false;
const auto it = result.shieldedScanHeights.find(address);
return it == result.shieldedScanHeights.end() || it->second < priority->second;
};
auto budgetLeft = [&]() {
return snapshot.maxShieldedReceiveScans == 0 ||
result.shieldedAddressesScanned < snapshot.maxShieldedReceiveScans;
};
std::unordered_set<std::string> scannedNow;
auto scanAddress = [&](const std::string& address) {
++result.shieldedAddressesScanned;
nextSearchIndex = index + 1;
scannedNow.insert(address);
const auto callStart = std::chrono::steady_clock::now();
try {
json received = rpc.call("z_listreceivedbyaddress", json::array({address, 0}));
appendShieldedReceivedTransactions(result.transactions,
@@ -1105,11 +1150,36 @@ NetworkRefreshService::TransactionRefreshResult NetworkRefreshService::collectTr
received,
snapshot.miningAddresses,
hushChatReceivedOutputsPtr);
++result.shieldedScansSucceeded;
result.receiveScanOkMs += msSince(callStart);
if (currentBlockHeight >= 0) result.shieldedScanHeights[address] = currentBlockHeight;
} catch (const std::exception& e) {
transactionRpcError = true;
DEBUG_LOGF("z_listreceivedbyaddress error: %s\n", e.what());
}
};
std::size_t shieldedStart = snapshot.shieldedScanStartIndex;
if (shieldedStart >= snapshot.shieldedAddresses.size()) shieldedStart = 0;
std::size_t nextSearchIndex = shieldedStart;
if (!snapshot.skipShieldedScans) {
for (const auto& address : snapshot.shieldedAddresses) {
if (!budgetLeft()) break;
if (needsPriorityScan(address)) scanAddress(address);
}
for (std::size_t index = shieldedStart; index < snapshot.shieldedAddresses.size(); ++index) {
const auto& address = snapshot.shieldedAddresses[index];
if (!needsPassScan(address) || scannedNow.count(address)) {
nextSearchIndex = index + 1;
continue;
}
if (!budgetLeft()) {
nextSearchIndex = index;
break;
}
nextSearchIndex = index + 1;
scanAddress(address);
}
}
auto findPendingShieldedIndex = [&]() -> std::optional<std::size_t> {
@@ -1117,7 +1187,7 @@ NetworkRefreshService::TransactionRefreshResult NetworkRefreshService::collectTr
std::size_t start = nextSearchIndex < snapshot.shieldedAddresses.size() ? nextSearchIndex : 0;
for (std::size_t offset = 0; offset < snapshot.shieldedAddresses.size(); ++offset) {
std::size_t index = (start + offset) % snapshot.shieldedAddresses.size();
if (!scannedAtTip(snapshot.shieldedAddresses[index])) return index;
if (needsPassScan(snapshot.shieldedAddresses[index])) return index;
}
return std::nullopt;
};
@@ -1126,11 +1196,11 @@ NetworkRefreshService::TransactionRefreshResult NetworkRefreshService::collectTr
result.shieldedScanComplete = !pendingShieldedIndex.has_value();
result.nextShieldedScanStartIndex = pendingShieldedIndex.value_or(0);
// Discover outgoing shielded (z->z) sends. A pure z->z send moves no value in or out of the
// transparent pool (valueBalance == 0), so the daemon's `listtransactions` never reports it,
// and — being a send rather than a receive — it never shows up in `z_listreceivedbyaddress`
// either. Its txid therefore never reaches `knownTxids`, so the z_viewtransaction enrichment
// below is never run for it and the send is invisible in History (the reported bug).
// ── Outgoing shielded sends (z_listsentbyaddress) ───────────────────────────────────────────────
// A pure z->z send moves no value in or out of the transparent pool (valueBalance == 0), so the
// daemon's `listtransactions` never reports it, and — being a send rather than a receive — it never
// shows up in `z_listreceivedbyaddress` either. Its txid only reaches `knownTxids`, and so the
// z_viewtransaction enrichment below, through this call.
//
// `z_listsentbyaddress` scans the whole wallet for sends — the address argument is NOT used to
// filter (the daemon iterates every wallet tx against "*"), so a single call yields all send
@@ -1144,13 +1214,22 @@ NetworkRefreshService::TransactionRefreshResult NetworkRefreshService::collectTr
// deduped (by txid+address+amount) against listtransactions-derived t->t sends above, so a
// purely-transparent (t->t) send — which the payload ALSO reports — is not double-counted.
//
// Gated on shieldedScanComplete so it adds no cost during the multi-cycle initial received
// scan: it is one more O(mapWallet) pass holding cs_main (comparable to the listtransactions
// pass), and the caller's adaptive throttle (txRefreshDue) already accounts for it via scanMs.
if (result.shieldedScanComplete && !snapshot.shieldedAddresses.empty()) {
// It trial-decrypts every send it returns under cs_main, the heaviest call here on a wallet with many
// sends. So it runs once a pass is complete and, past a successful sweep (the caller's watermark), only
// over the txs at most a little deeper than the blocks since then (filter type 2). The whole-wallet
// sweep, needed when there's no watermark, gets a long timeout so it can finish on a large wallet.
const bool sweepIsFull = sentSweepIsFull(currentBlockHeight, snapshot.sentScanFromHeight);
if (!snapshot.shieldedAddresses.empty() && !snapshot.skipSentSweep &&
(result.shieldedScanComplete || (snapshot.allowMidPassSentSweep && !sweepIsFull))) {
result.sentSweepRan = true;
result.sentSweepFull = sweepIsFull;
const json params = sentSweepParams(snapshot.shieldedAddresses.front(), currentBlockHeight,
snapshot.sentScanFromHeight);
const auto sweepStart = std::chrono::steady_clock::now();
try {
json sent = rpc.call("z_listsentbyaddress",
json::array({snapshot.shieldedAddresses.front(), 0}));
json sent = result.sentSweepFull
? rpc.callLong("z_listsentbyaddress", params, kFullSentSweepTimeoutSec)
: rpc.call("z_listsentbyaddress", params);
if (sent.is_array()) {
for (const auto& sentTx : sent) {
auto txid = readOptional<std::string>(sentTx, "txid");
@@ -1158,11 +1237,15 @@ NetworkRefreshService::TransactionRefreshResult NetworkRefreshService::collectTr
appendSentTransparentOutputs(result.transactions, *txid, sentTx);
knownTxids.insert(*txid); // shielded (z->z) legs enriched via z_viewtransaction
}
result.sentSweepOk = true;
result.sentSweepHeight = currentBlockHeight;
}
} catch (const std::exception& e) {
transactionRpcError = true;
result.sentSweepErrKind = rpc::rpcFailureKindOf(e);
DEBUG_LOGF("z_listsentbyaddress error: %s\n", e.what());
}
result.sentSweepMs = msSince(sweepStart);
}
for (const auto& txid : snapshot.sendTxids) {
@@ -1173,17 +1256,41 @@ NetworkRefreshService::TransactionRefreshResult NetworkRefreshService::collectTr
if (!cachedView.outgoing_outputs.empty()) knownTxids.insert(txid);
}
// Shielded send rows from an earlier session (the history cache) whose view isn't cached: they count as
// enriched by their age, so nothing would rebuild them, and a windowed sweep never rediscovers an old
// send. Re-enrich them, and keep their stored rows ("pinned") until the daemon has answered.
std::unordered_set<std::string> rowBacklog;
// A settled stored row's block time and confirmations (>= 10, the History's "confirmed"): reused for
// its re-enrichment instead of a gettransaction per row. An unsettled one still asks, or its stale
// count would stick for the session (a cached view keeps the metadata it was built with).
std::unordered_map<std::string, std::pair<std::int64_t, int>> settledStoredMeta;
for (const auto& transaction : snapshot.previousTransactions) {
if (transaction.type != "send" || transaction.txid.empty() || !isShieldedRelatedRow(transaction)) continue;
if (snapshot.viewTxCache.count(transaction.txid) || snapshot.sendTxids.count(transaction.txid) ||
snapshot.pendingOpids.count(transaction.txid)) continue;
rowBacklog.insert(transaction.txid);
if (transaction.timestamp != 0 && transaction.confirmations >= 10)
settledStoredMeta.emplace(transaction.txid, std::make_pair(transaction.timestamp, transaction.confirmations));
}
std::set<std::string> backlog(snapshot.enrichBacklogTxids.begin(), snapshot.enrichBacklogTxids.end());
backlog.insert(rowBacklog.begin(), rowBacklog.end());
std::unordered_set<std::string> pinnedRows = rowBacklog;
// Tracked sends go first so they get the per-cycle z_viewtransaction budget ahead of routine
// enrichment, and a spent budget only skips further RPCs: cached rows later in the order are still
// appended (stopping the loop at the cap dropped them from History).
// enrichment, then this cycle's discoveries, then the backlog. A spent budget only skips further
// RPCs: cached rows later in the order are still appended (stopping the loop at the cap dropped them
// from History), and what was skipped is handed back as unenrichedTxids.
std::vector<const std::string*> enrichOrder;
enrichOrder.reserve(knownTxids.size());
enrichOrder.reserve(knownTxids.size() + backlog.size());
for (const auto& txid : knownTxids) {
if (snapshot.sendTxids.count(txid)) enrichOrder.push_back(&txid);
}
for (const auto& txid : knownTxids) {
if (!snapshot.sendTxids.count(txid)) enrichOrder.push_back(&txid);
}
for (const auto& txid : backlog) {
if (!knownTxids.count(txid) && !snapshot.sendTxids.count(txid)) enrichOrder.push_back(&txid);
}
int viewTxCount = 0;
for (const std::string* txidPtr : enrichOrder) {
@@ -1202,9 +1309,12 @@ NetworkRefreshService::TransactionRefreshResult NetworkRefreshService::collectTr
}
}
if (!trackedSend && snapshot.fullyEnrichedTxids.count(txid)) continue;
if (!trackedSend && snapshot.fullyEnrichedTxids.count(txid) && !rowBacklog.count(txid)) continue;
if (viewTxCount >= maxViewTransactionsPerCycle) continue;
if (viewTxCount >= maxViewTransactionsPerCycle) {
if (!trackedSend) result.unenrichedTxids.insert(txid);
continue;
}
++viewTxCount;
try {
@@ -1216,11 +1326,22 @@ NetworkRefreshService::TransactionRefreshResult NetworkRefreshService::collectTr
appendTrackedSendPlaceholder(result.transactions,
txid,
hasRawTransaction ? &rawTransaction : nullptr);
} else {
result.unenrichedTxids.insert(txid);
}
continue;
}
auto entry = parseViewTransactionCacheEntry(viewTransaction);
const bool storedRow = rowBacklog.count(txid) > 0;
if (storedRow && entry.timestamp == 0) {
const auto settled = settledStoredMeta.find(txid);
if (settled != settledStoredMeta.end()) {
entry.timestamp = settled->second.first;
entry.confirmations = settled->second.second;
}
}
const std::size_t rowsBeforeView = result.transactions.size();
appendViewTransactionOutputs(result.transactions, txid, entry);
// (Chat metadata is harvested only from RECEIVED notes, not our own outgoing memos — see
// the cached-view branch above.)
@@ -1246,6 +1367,21 @@ NetworkRefreshService::TransactionRefreshResult NetworkRefreshService::collectTr
}
}
if (storedRow) {
// A stored send rebuilt without a date (gettransaction failed): keep its stored rows (still
// pinned) and retry next cycle, rather than cache an undated view for the session.
bool undated = false;
for (std::size_t i = rowsBeforeView; i < result.transactions.size(); ++i) {
if (result.transactions[i].txid == txid && result.transactions[i].timestamp == 0) undated = true;
}
if (undated) {
result.transactions.resize(rowsBeforeView);
result.unenrichedTxids.insert(txid);
continue;
}
pinnedRows.erase(txid);
}
if (trackedSend && !hasTransactionType(result.transactions, txid, "send")) {
if (!hasRawTransaction) hasRawTransaction = tryFetchRawTransaction(rpc, txid, rawTransaction);
appendTrackedSendPlaceholder(result.transactions,
@@ -1275,13 +1411,20 @@ NetworkRefreshService::TransactionRefreshResult NetworkRefreshService::collectTr
}
}
} catch (const std::exception& e) {
(void)e;
if (trackedSend) {
json rawTransaction;
bool hasRawTransaction = tryFetchRawTransaction(rpc, txid, rawTransaction);
appendTrackedSendPlaceholder(result.transactions,
txid,
hasRawTransaction ? &rawTransaction : nullptr);
} else if (const auto* rpcError = dynamic_cast<const rpc::RpcError*>(&e);
rpcError && rpcError->code == -5) {
// "Invalid or non-wallet transaction id": definitive. Cache an empty view so it isn't
// asked again, and let a stored row for it go.
result.newViewTxEntries[txid] = TransactionViewCacheEntry{};
pinnedRows.erase(txid);
} else {
result.unenrichedTxids.insert(txid); // anything else may be transient: retry
}
}
}
@@ -1289,17 +1432,21 @@ NetworkRefreshService::TransactionRefreshResult NetworkRefreshService::collectTr
appendExtractedHushChatMetadata(result.hushChatMetadata, hushChatReceivedOutputs);
if (!snapshot.previousTransactions.empty()) {
std::unordered_set<std::string> pinned = snapshot.pendingOpids;
pinned.insert(pinnedRows.begin(), pinnedRows.end());
if (transactionRpcError) {
appendMissingPreviousTransactions(result.transactions, snapshot.previousTransactions, true, snapshot.pendingOpids);
appendMissingPreviousTransactions(result.transactions, snapshot.previousTransactions, true, pinned);
} else if (snapshot.shieldedAddresses.empty() || !result.shieldedScanComplete ||
result.shieldedAddressesScanned < result.shieldedAddressCount) {
appendMissingPreviousTransactions(result.transactions, snapshot.previousTransactions, false, snapshot.pendingOpids);
appendMissingPreviousTransactions(result.transactions, snapshot.previousTransactions, false, pinned);
} else if (!pinnedRows.empty()) {
// A full rebuild, except the stored rows of sends still waiting to be re-enriched.
appendPinnedPreviousTransactions(result.transactions, snapshot.previousTransactions, pinnedRows);
}
}
sortTransactionsNewestFirst(result.transactions);
result.scanMs = std::chrono::duration<double, std::milli>(
std::chrono::steady_clock::now() - scanStart).count();
result.scanMs = msSince(scanStart);
return result;
}
@@ -1310,6 +1457,7 @@ NetworkRefreshService::TransactionRefreshResult NetworkRefreshService::collectRe
int pageSize)
{
TransactionRefreshResult result;
const auto scanStart = std::chrono::steady_clock::now();
result.blockHeight = currentBlockHeight;
result.transactions = snapshot.previousTransactions;
result.shieldedAddressCount = snapshot.shieldedAddresses.size();
@@ -1360,9 +1508,12 @@ NetworkRefreshService::TransactionRefreshResult NetworkRefreshService::collectRe
if (snapshot.maxShieldedReceiveScans > 0) {
shieldedLimit = std::min(shieldedLimit, snapshot.maxShieldedReceiveScans);
}
// Without the probe (the scan throttle is closed) the poll is listtransactions only.
if (snapshot.skipShieldedScans) shieldedLimit = 0;
for (std::size_t index = shieldedStart; index < shieldedStart + shieldedLimit; ++index) {
const auto& address = snapshot.shieldedAddresses[index];
const auto callStart = std::chrono::steady_clock::now();
try {
std::vector<TransactionInfo> scannedTransactions;
std::set<std::string> scannedTxids;
@@ -1384,18 +1535,27 @@ NetworkRefreshService::TransactionRefreshResult NetworkRefreshService::collectRe
}
if (currentBlockHeight >= 0) result.shieldedScanHeights[address] = currentBlockHeight;
++result.shieldedAddressesScanned;
++result.shieldedScansSucceeded;
result.receiveScanOkMs += std::chrono::duration<double, std::milli>(
std::chrono::steady_clock::now() - callStart).count();
} catch (const std::exception& e) {
++result.shieldedAddressesScanned;
DEBUG_LOGF("recent z_listreceivedbyaddress error: %s\n", e.what());
}
}
result.nextShieldedScanStartIndex =
(shieldedStart + shieldedLimit >= snapshot.shieldedAddresses.size()) ? 0 : shieldedStart + shieldedLimit;
if (snapshot.skipShieldedScans) {
result.nextShieldedScanStartIndex = shieldedStart;
} else {
result.nextShieldedScanStartIndex =
(shieldedStart + shieldedLimit >= snapshot.shieldedAddresses.size()) ? 0 : shieldedStart + shieldedLimit;
}
result.shieldedScanComplete = true;
appendExtractedHushChatMetadata(result.hushChatMetadata, hushChatReceivedOutputs);
sortTransactionsNewestFirst(result.transactions);
result.scanMs = std::chrono::duration<double, std::milli>(std::chrono::steady_clock::now() - scanStart).count();
return result;
}
+45 -10
View File
@@ -35,6 +35,13 @@ public:
virtual ~RefreshRpcGateway() = default;
virtual nlohmann::json call(const std::string& method,
const nlohmann::json& params) = 0;
// A call allowed to outlast the default timeout (a whole-wallet sweep on a large wallet).
// Gateways without a separate timeout just call().
virtual nlohmann::json callLong(const std::string& method, const nlohmann::json& params, long timeoutSec)
{
(void)timeoutSec;
return call(method, params);
}
};
enum class Job {
@@ -175,6 +182,9 @@ public:
double amount = 0.0; // note value, DRGX
bool locked = false; // tied up by an in-flight send
int confirmations = 0; // TRUE depth (rawconfirmations when present, else confirmations)
std::string address; // the z-address holding it
std::string txid; // the transaction that created it
bool change = false; // change from the wallet's own spend (not a receive)
};
struct AddressRefreshResult {
@@ -221,14 +231,25 @@ public:
std::set<std::string> miningAddresses;
std::unordered_map<std::string, int> shieldedScanHeights;
std::size_t shieldedScanStartIndex = 0;
std::size_t maxShieldedReceiveScans = 0;
// How many blocks the tip may advance past an address's last scan before it counts as stale
// and needs re-scanning. 0 = strict (must be scanned at the exact current tip). A small
// tolerance lets a multi-cycle pass over many shielded addresses COMPLETE even though new
// blocks arrive mid-pass — otherwise the "scanned at tip" bar moves every block and the scan
// (and the transactions_dirty_ flag it drives) never finishes. It also naturally throttles
// full rescans to roughly once per `tolerance` blocks.
int shieldedScanTipTolerance = 0;
std::size_t maxShieldedReceiveScans = 0; // receive scans this cycle; 0 = no limit
// The pass's floor (services::ShieldedHistoryPass): an address scanned below it is scanned again.
// -1: only never-scanned addresses (and priority ones) are.
int shieldedScanFloorHeight = -1;
// Scanned first, whatever the cursor, when never scanned or scanned below their floor: new
// addresses, the chat reply address, addresses holding notes the history doesn't show yet.
std::unordered_map<std::string, int> priorityScanFloors;
bool skipShieldedScans = false; // a Light cycle (or a probe-less recent poll): no receive scans
bool skipSentSweep = false;
int sentScanFromHeight = -1; // the sweep's watermark; -1 = sweep the whole wallet
std::size_t transparentRowHint = 0; // listtransactions rows last time: sizes the first page
// Same tip and nothing changed since the rows on screen were fetched: reuse their transparent rows
// instead of paging listtransactions again.
bool reuseTransparentRows = false;
std::unordered_set<std::string> enrichBacklogTxids; // discovered earlier, not enriched yet
// Run a windowed sent sweep even though the receive pass isn't complete (the caller rate-limits
// this), so untracked sends (from the Console, or another wallet on the same seed) don't wait for a
// whole pass. The whole-wallet sweep stays tied to a completed pass.
bool allowMidPassSentSweep = false;
};
struct TransactionRefreshResult {
@@ -245,9 +266,23 @@ public:
std::size_t shieldedAddressCount = 0;
std::unordered_map<std::string, int> shieldedScanHeights;
bool shieldedScanComplete = true;
// Wall-clock spent in the history scan (z_listreceivedbyaddress — O(mapWallet), holds cs_main).
// Lets the caller throttle the routine full rescan by its measured cost (txRefreshDue()).
// Wall-clock spent in the whole cycle: listtransactions, the receive scans and the sent sweep each
// hold cs_main. Lets the caller throttle wallet-wide scans by their measured cost (txRefreshDue()).
double scanMs = 0.0;
std::size_t shieldedScansSucceeded = 0; // shieldedAddressesScanned counts attempts
double receiveScanOkMs = 0.0; // time in the receive scans that succeeded
std::size_t transparentRowCount = 0; // rows the listtransactions chain returned
bool transparentChainOk = false;
bool transparentRowsReused = false;
bool sentSweepRan = false;
bool sentSweepOk = false;
bool sentSweepFull = false;
int sentSweepHeight = -1; // the tip a successful sweep covered
double sentSweepMs = 0.0; // time in the sweep (part of scanMs)
rpc::RpcFailureKind sentSweepErrKind = rpc::RpcFailureKind::None;
// Txids found but not enriched (over the per-cycle cap, or the view failed transiently): the caller
// keeps them and feeds them back as enrichBacklogTxids.
std::unordered_set<std::string> unenrichedTxids;
};
struct OperationStatusPollResult {