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:
@@ -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
|
||||
@@ -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
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user