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:
+328
-101
@@ -99,8 +99,9 @@ std::string unencryptedTransactionHistoryCacheKey(const std::string& walletIdent
|
||||
|
||||
class AppRefreshRpcGateway final : public NetworkRefreshService::RefreshRpcGateway {
|
||||
public:
|
||||
AppRefreshRpcGateway(rpc::RPCClient& rpc, std::string source)
|
||||
: rpc_(rpc), source_(std::move(source)) {}
|
||||
// `maintenanceOwner`: the App whose rpc_ this is, for callLong() (see App::MaintenanceCallScope).
|
||||
AppRefreshRpcGateway(rpc::RPCClient& rpc, std::string source, App* maintenanceOwner = nullptr)
|
||||
: rpc_(rpc), source_(std::move(source)), owner_(maintenanceOwner) {}
|
||||
|
||||
json call(const std::string& method, const json& params) override
|
||||
{
|
||||
@@ -108,9 +109,20 @@ public:
|
||||
return rpc_.call(method, params);
|
||||
}
|
||||
|
||||
// Holds rpc_'s curl lock for up to timeoutSec: as a maintenance call, a main-thread disconnect
|
||||
// aborts it rather than waiting for it (App::abortMaintenanceCalls()).
|
||||
json callLong(const std::string& method, const json& params, long timeoutSec) override
|
||||
{
|
||||
std::optional<App::MaintenanceCallScope> maintenance;
|
||||
if (owner_) maintenance.emplace(*owner_);
|
||||
rpc::RPCClient::TraceScope trace(source_);
|
||||
return rpc_.call(method, params, timeoutSec);
|
||||
}
|
||||
|
||||
private:
|
||||
rpc::RPCClient& rpc_;
|
||||
std::string source_;
|
||||
App* owner_ = nullptr;
|
||||
};
|
||||
|
||||
const char* tracePageName(ui::NavPage page)
|
||||
@@ -150,19 +162,6 @@ std::size_t shieldedReceiveScanBudget(ui::NavPage page)
|
||||
return page == ui::NavPage::History ? 8u : 4u;
|
||||
}
|
||||
|
||||
// How far the tip may drift past an address's last shielded scan before we re-scan it. A full pass
|
||||
// scans ~budget addresses per refresh cycle (≈96 per block of wall time), so a wallet with many
|
||||
// z-addresses takes several blocks to scan fully. With a strict (tolerance 0) "scanned at tip"
|
||||
// check, new blocks arriving mid-pass would invalidate already-scanned addresses and the pass would
|
||||
// never complete — leaving transactions_dirty_ (and its "refreshing history" banner + send-progress
|
||||
// gate) stuck on forever. Scaling the tolerance with the address count lets the pass complete while
|
||||
// keeping shielded-receive latency minimal for small wallets; it's capped for pathological sizes.
|
||||
int shieldedScanTipTolerance(std::size_t shieldedAddressCount)
|
||||
{
|
||||
int t = 2 + static_cast<int>(shieldedAddressCount / 96);
|
||||
return std::min(t, 50);
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
// ============================================================================
|
||||
@@ -841,6 +840,9 @@ void App::onDisconnected(const std::string& reason)
|
||||
opid_poll_in_progress_ = false;
|
||||
opid_filter_unsupported_ = false; // a reconnect may be to a different daemon
|
||||
last_total_balance_zat_.reset();
|
||||
history_transparent_rows_ = 0; // the history's scan state (the pass, epochs and watermark are
|
||||
history_transparent_tip_ = -1; // reset with the cache session below); cost estimates are kept
|
||||
last_tx_scan_at_ = 0;
|
||||
ui::ShieldDialog::invalidateScope();
|
||||
address_validation_cache_dirty_ = true;
|
||||
resetTransactionHistoryCacheSession();
|
||||
@@ -1043,7 +1045,9 @@ bool App::addressRefreshDue() const
|
||||
|
||||
bool App::txRefreshDue() const
|
||||
{
|
||||
return scanRefreshDue(state_.last_tx_update, last_tx_scan_ms_);
|
||||
// Keyed on the last wallet-wide scan, not state_.last_tx_update (pending-send row edits and probe-less
|
||||
// polls stamp that too, which kept pushing the next scan out).
|
||||
return scanRefreshDue(last_tx_scan_at_, last_tx_scan_ms_);
|
||||
}
|
||||
|
||||
bool App::currentPageNeedsWalletDataRefresh() const
|
||||
@@ -1059,7 +1063,7 @@ bool App::shouldRunWalletTransactionRefresh() const
|
||||
{
|
||||
if (currentPageNeedsWalletDataRefresh()) return true;
|
||||
if (hasTransactionSendProgress() || !send_txids_.empty()) return true;
|
||||
return transactions_dirty_ && !shielded_history_scan_pending_;
|
||||
return transactions_dirty_;
|
||||
}
|
||||
|
||||
void App::setCurrentPage(ui::NavPage page)
|
||||
@@ -1083,7 +1087,7 @@ void App::setCurrentPage(ui::NavPage page)
|
||||
network_refresh_.reset(RefreshTimer::Core);
|
||||
break;
|
||||
case NP::History:
|
||||
transactions_dirty_ = true;
|
||||
markTransactionsDirty();
|
||||
refreshTransactionData();
|
||||
network_refresh_.reset(RefreshTimer::Transactions);
|
||||
break;
|
||||
@@ -1108,25 +1112,14 @@ void App::setCurrentPage(ui::NavPage page)
|
||||
|
||||
// Screenshot sweep (tab-only + full-UI) + its demo data live in src/app_sweep.cpp.
|
||||
|
||||
bool App::shouldRefreshTransactions() const
|
||||
{
|
||||
// NOTE: this is block-height / dirty driven, NOT interval-gated. It returns true only when a new
|
||||
// block arrived (currentBlocks != last_tx_block_height_), the history was never fetched, or
|
||||
// something marked it dirty (tab entry, a send, a reorg, etc.). The Transactions timer only
|
||||
// controls how often this CHECK runs; between blocks the lightweight recent-poll
|
||||
// (shouldRefreshRecentTransactions / TxAge) handles mempool + unconfirmed deltas instead.
|
||||
const int currentBlocks = state_.sync.blocks;
|
||||
return network_refresh_.shouldRefreshTransactions(last_tx_block_height_,
|
||||
currentBlocks,
|
||||
transactions_dirty_);
|
||||
}
|
||||
|
||||
bool App::shouldRefreshRecentTransactions() const
|
||||
{
|
||||
// No same-tip condition: at a new tip services::decideTxCycle runs a Full or Light cycle first when it
|
||||
// can, so the cheap poll runs there only while the scan throttle holds the Full cycle back (it keeps
|
||||
// transparent rows and confirmations live meanwhile, and doesn't consume the new tip).
|
||||
using RefreshTimer = services::NetworkRefreshService::Timer;
|
||||
return network_refresh_.isDue(RefreshTimer::TxAge)
|
||||
&& last_tx_block_height_ >= 0
|
||||
&& state_.sync.blocks == last_tx_block_height_
|
||||
&& !state_.transactions.empty()
|
||||
&& !transactions_dirty_
|
||||
&& !addresses_dirty_;
|
||||
@@ -1643,6 +1636,14 @@ void App::resetTransactionHistoryCacheSession()
|
||||
wipePendingTransactionHistoryCachePassphrase();
|
||||
transaction_history_cache_loaded_ = false;
|
||||
invalidateShieldedHistoryScanProgress(false);
|
||||
history_listed_z_.clear();
|
||||
}
|
||||
|
||||
void App::lockTransactionHistoryCacheSession()
|
||||
{
|
||||
transaction_history_cache_.lockKey();
|
||||
wipePendingTransactionHistoryCachePassphrase();
|
||||
transaction_history_cache_loaded_ = false;
|
||||
}
|
||||
|
||||
void App::pruneShieldedHistoryScanProgress()
|
||||
@@ -1662,15 +1663,70 @@ void App::pruneShieldedHistoryScanProgress()
|
||||
}
|
||||
}
|
||||
|
||||
void App::invalidateShieldedHistoryScanProgress(bool persistCache)
|
||||
void App::invalidateShieldedHistoryScanProgress(bool persistCache, bool keepSentScan)
|
||||
{
|
||||
shielded_history_scan_cursor_ = 0;
|
||||
shielded_history_scan_pending_ = false;
|
||||
initial_history_scan_complete_ = false; // a full re-scan is coming — show load progress again
|
||||
shielded_history_scan_heights_.clear();
|
||||
history_pass_.reset();
|
||||
++history_scan_epoch_; // results of scans already in flight no longer apply
|
||||
history_hint_floors_.clear();
|
||||
history_hinted_txids_.clear();
|
||||
if (!keepSentScan) {
|
||||
history_sent_scan_height_ = -1; // the next sweep covers the whole wallet again
|
||||
history_enrich_backlog_.clear();
|
||||
history_full_sweep_failures_ = 0;
|
||||
history_full_sweep_retry_at_ = 0;
|
||||
++history_sent_epoch_;
|
||||
}
|
||||
if (persistCache) storeTransactionHistoryCacheIfAvailable();
|
||||
}
|
||||
|
||||
std::vector<std::string> App::shieldedAddressList() const
|
||||
{
|
||||
// The same order as NetworkRefreshService::buildTransactionRefreshSnapshot.
|
||||
std::vector<std::string> addresses;
|
||||
addresses.reserve(state_.z_addresses.size());
|
||||
for (const auto& address : state_.z_addresses) {
|
||||
if (!address.address.empty()) addresses.push_back(address.address);
|
||||
}
|
||||
return addresses;
|
||||
}
|
||||
|
||||
void App::noteHistoryHintsFromUnspent(
|
||||
const std::vector<services::NetworkRefreshService::UnspentNoteLite>& notes)
|
||||
{
|
||||
const int tip = state_.sync.blocks;
|
||||
if (tip <= 0 || notes.empty()) return;
|
||||
std::unordered_map<std::string, bool> rowConfirmed; // txid -> any row of it is mined
|
||||
for (const auto& transaction : state_.transactions) {
|
||||
if (transaction.txid.empty()) continue;
|
||||
bool& confirmed = rowConfirmed[transaction.txid];
|
||||
confirmed = confirmed || transaction.confirmations >= 1;
|
||||
}
|
||||
for (const auto& note : notes) {
|
||||
if (note.change || note.txid.empty() || note.address.empty()) continue;
|
||||
if (history_hinted_txids_.count(note.txid)) continue; // once per txid: change/merges can't loop
|
||||
const auto row = rowConfirmed.find(note.txid);
|
||||
const bool noRow = row == rowConfirmed.end();
|
||||
const bool staleUnconfirmed = !noRow && !row->second && note.confirmations >= 1;
|
||||
if (!noRow && !staleUnconfirmed) continue;
|
||||
int& floor = history_hint_floors_[note.address];
|
||||
floor = std::max(floor, tip);
|
||||
// A mempool hint doesn't use up the txid: once mined, its row (still at 0 conf) needs one more scan.
|
||||
// No loop: an unmined note with a row matches neither case, and a mined one hints once.
|
||||
if (note.confirmations >= 1) history_hinted_txids_.insert(note.txid);
|
||||
}
|
||||
}
|
||||
|
||||
services::ShieldedHistoryPass::Work App::historyScanWork() const
|
||||
{
|
||||
const auto addresses = shieldedAddressList();
|
||||
return history_pass_.evaluate(state_.sync.blocks, addresses, shielded_history_scan_heights_,
|
||||
services::historyStaleAfterBlocks(addresses.size(), history_addr_scan_ms_),
|
||||
initial_history_scan_complete_);
|
||||
}
|
||||
|
||||
bool App::ensureTransactionHistoryCacheUnlockedFor(const std::string& walletIdentity)
|
||||
{
|
||||
if (walletIdentity.empty()) return false;
|
||||
@@ -1741,7 +1797,7 @@ void App::loadTransactionHistoryCacheIfAvailable()
|
||||
}
|
||||
confirmed_cache_block_ = loaded.tipHeight;
|
||||
transaction_history_cache_loaded_ = true;
|
||||
transactions_dirty_ = true;
|
||||
markTransactionsDirty();
|
||||
network_refresh_.markDue(services::NetworkRefreshService::Timer::Transactions);
|
||||
}
|
||||
|
||||
@@ -1790,15 +1846,12 @@ void App::refreshData()
|
||||
// as each completes, rather than waiting for the slowest phase.
|
||||
refreshCoreData();
|
||||
|
||||
bool addressRefreshNeeded = addresses_dirty_;
|
||||
bool walletDataPage = currentPageNeedsWalletDataRefresh();
|
||||
if (addressRefreshNeeded)
|
||||
// The address list first: the history needs it (the address callback marks the history due).
|
||||
// refreshTransactionData() picks the cycle itself (Full / Light / the between-blocks poll / none).
|
||||
if (addresses_dirty_)
|
||||
refreshAddressData();
|
||||
|
||||
if (!addressRefreshNeeded && shouldRunWalletTransactionRefresh() && shouldRefreshTransactions())
|
||||
else if (shouldRunWalletTransactionRefresh())
|
||||
refreshTransactionData();
|
||||
else if (!addressRefreshNeeded && walletDataPage && shouldRefreshRecentTransactions())
|
||||
refreshRecentTransactionData();
|
||||
|
||||
// Always refresh peers here — refreshData() only runs on one-shot transitions (connect,
|
||||
// warmup-complete, unlock), so this populates the status-bar peer count immediately on open
|
||||
@@ -1857,7 +1910,7 @@ void App::refreshCoreData(bool allowBalance)
|
||||
resetWitnessRescanProgress();
|
||||
state_.sync.rescan_progress = 1.0f;
|
||||
// Notes/witnesses were rebuilt — force a fresh history + balance pull.
|
||||
transactions_dirty_ = true;
|
||||
markTransactionsDirty();
|
||||
last_tx_block_height_ = -1;
|
||||
invalidateShieldedHistoryScanProgress(true);
|
||||
if (user_initiated_rescan_) {
|
||||
@@ -2077,15 +2130,17 @@ void App::refreshAddressData()
|
||||
if (!worker_ || !rpc_ || !state_.connected) return;
|
||||
const std::size_t previousAddressCount = state_.z_addresses.size() + state_.t_addresses.size();
|
||||
const std::string previousWalletIdentity = transactionHistoryCacheWalletIdentity();
|
||||
const auto zList = shieldedAddressList();
|
||||
std::unordered_set<std::string> previousZ(zList.begin(), zList.end());
|
||||
auto addressSnapshot = address_validation_cache_dirty_
|
||||
? NetworkRefreshService::AddressRefreshSnapshot{}
|
||||
: NetworkRefreshService::buildAddressRefreshSnapshot(state_);
|
||||
ui::NavPage tracePage = current_page_;
|
||||
auto enqueued = network_refresh_.enqueue(services::NetworkRefreshService::Job::Addresses, *worker_, [this, previousAddressCount, previousWalletIdentity, addressSnapshot = std::move(addressSnapshot), tracePage]() -> rpc::RPCWorker::MainCb {
|
||||
auto enqueued = network_refresh_.enqueue(services::NetworkRefreshService::Job::Addresses, *worker_, [this, previousAddressCount, previousWalletIdentity, previousZ = std::move(previousZ), addressSnapshot = std::move(addressSnapshot), tracePage]() -> rpc::RPCWorker::MainCb {
|
||||
AppRefreshRpcGateway refreshRpc(*rpc_, traceSource(tracePage, "Address refresh"));
|
||||
auto result = NetworkRefreshService::collectAddressRefreshResult(refreshRpc, addressSnapshot);
|
||||
|
||||
return [this, previousAddressCount, previousWalletIdentity, result = std::move(result)]() mutable {
|
||||
return [this, previousAddressCount, previousWalletIdentity, previousZ, result = std::move(result)]() mutable {
|
||||
const bool addrListOk = result.addressListOk; // capture before the move
|
||||
const double scanMs = result.scanMs; // feed the adaptive throttle (addressRefreshDue)
|
||||
if (scanMs > 0.0) last_address_scan_ms_ = scanMs;
|
||||
@@ -2105,16 +2160,44 @@ void App::refreshAddressData()
|
||||
const std::size_t currentAddressCount = state_.z_addresses.size() + state_.t_addresses.size();
|
||||
const bool addressSetChanged = currentAddressCount != previousAddressCount ||
|
||||
transactionHistoryCacheWalletIdentity() != previousWalletIdentity;
|
||||
if (state_.transactions.empty() || addressSetChanged) {
|
||||
if (addressSetChanged) {
|
||||
invalidateShieldedHistoryScanProgress(false);
|
||||
if (addrListOk) {
|
||||
// Drop removed addresses' scan heights; the others stay valid (a changed set used to wipe
|
||||
// every height, so one new address re-scanned the whole wallet). A z-address we never
|
||||
// scanned that wasn't listed before (a key imported from the Console, say) may bring
|
||||
// older sends with it, which a windowed sweep can't reach: sweep the whole wallet once.
|
||||
pruneShieldedHistoryScanProgress();
|
||||
// New: in neither the list at dispatch (GUI-created addresses are added to it before the
|
||||
// refresh, and have no history) nor the last good listing (a blank list in between proves
|
||||
// nothing), and never scanned.
|
||||
bool newUnscannedZ = false;
|
||||
std::unordered_set<std::string> listedZ;
|
||||
for (const auto& address : state_.z_addresses) {
|
||||
if (address.address.empty()) continue;
|
||||
listedZ.insert(address.address);
|
||||
if (!previousZ.count(address.address) && !history_listed_z_.count(address.address) &&
|
||||
!shielded_history_scan_heights_.count(address.address))
|
||||
newUnscannedZ = true;
|
||||
}
|
||||
transactions_dirty_ = true;
|
||||
if (newUnscannedZ && (!previousZ.empty() || !history_listed_z_.empty())) {
|
||||
history_sent_scan_height_ = -1;
|
||||
history_full_sweep_failures_ = 0;
|
||||
history_full_sweep_retry_at_ = 0;
|
||||
++history_sent_epoch_;
|
||||
}
|
||||
history_listed_z_ = std::move(listedZ);
|
||||
}
|
||||
if (state_.transactions.empty() || addressSetChanged) {
|
||||
markTransactionsDirty();
|
||||
last_tx_block_height_ = -1;
|
||||
network_refresh_.markDue(services::NetworkRefreshService::Timer::Transactions);
|
||||
}
|
||||
if (state_.transactions.empty()) loadTransactionHistoryCacheIfAvailable();
|
||||
else storeTransactionHistoryCacheIfAvailable();
|
||||
else if (addrListOk) storeTransactionHistoryCacheIfAvailable();
|
||||
// Hints compare the notes with the history on screen: only once it's there (the cache loaded
|
||||
// just above, or a pass is done). Before that every note looks missing, and the first pass scans
|
||||
// every address anyway.
|
||||
if (addrListOk && (!state_.transactions.empty() || initial_history_scan_complete_))
|
||||
noteHistoryHintsFromUnspent(unspentNotes);
|
||||
maybeFinishTransactionSendProgress();
|
||||
// Addresses (hence identity + count) are now known — refresh the wallet index's cached
|
||||
// metadata for the active wallet. Throttled: upsert() only writes on a real change.
|
||||
@@ -2130,46 +2213,161 @@ void App::refreshAddressData()
|
||||
|
||||
void App::refreshTransactionData()
|
||||
{
|
||||
if (!worker_ || !rpc_ || !state_.connected) return;
|
||||
if (addresses_dirty_) {
|
||||
refreshAddressData();
|
||||
network_refresh_.markDue(services::NetworkRefreshService::Timer::Transactions);
|
||||
// The one entry point for history refreshes (the periodic timer, connect, tab entry, the address
|
||||
// callback's re-route), so every caller gets the same rules. Wallet-wide scans hold the daemon's
|
||||
// cs_main: never while it can't take them.
|
||||
if (!worker_ || !rpc_ || !rpc_->isConnected() || !state_.connected) return;
|
||||
if (state_.isLocked() || state_.warming_up || runtime_rescan_active_ || rpc_busy_gate_->busy()) return;
|
||||
|
||||
const int tip = state_.sync.blocks;
|
||||
const auto work = historyScanWork();
|
||||
services::TxCycleInputs in;
|
||||
in.neverFetched = last_tx_block_height_ < 0;
|
||||
in.newTip = tip != last_tx_block_height_;
|
||||
in.dirty = transactions_dirty_;
|
||||
in.sendTracking = !send_txids_.empty();
|
||||
in.explicitScanWork = work.explicitWork;
|
||||
in.routineScanWork = work.routineWork;
|
||||
in.gateOpen = txRefreshDue();
|
||||
in.syncing = effectivelySyncing();
|
||||
in.heightKnown = tip > 0;
|
||||
in.recentDue = currentPageNeedsWalletDataRefresh() && shouldRefreshRecentTransactions();
|
||||
const auto cycle = services::decideTxCycle(in);
|
||||
switch (cycle) {
|
||||
case services::TxCycle::None:
|
||||
return;
|
||||
case services::TxCycle::Recent:
|
||||
case services::TxCycle::RecentWithProbe:
|
||||
startRecentTransactionPoll(cycle == services::TxCycle::RecentWithProbe);
|
||||
return;
|
||||
case services::TxCycle::Full:
|
||||
case services::TxCycle::Light:
|
||||
if (addresses_dirty_) {
|
||||
// The history needs the current address list; the address callback marks it due again.
|
||||
refreshAddressData();
|
||||
network_refresh_.markDue(services::NetworkRefreshService::Timer::Transactions);
|
||||
return;
|
||||
}
|
||||
startTransactionCycle(cycle == services::TxCycle::Full, in.gateOpen);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
void App::startTransactionCycle(bool full, bool routineAllowed)
|
||||
{
|
||||
const int currentBlocks = state_.sync.blocks;
|
||||
if (last_tx_block_height_ < 0 || currentBlocks != last_tx_block_height_ ||
|
||||
!shielded_history_scan_pending_) {
|
||||
shielded_history_scan_cursor_ = 0;
|
||||
shielded_history_scan_pending_ = false;
|
||||
}
|
||||
auto transactionSnapshot = NetworkRefreshService::buildTransactionRefreshSnapshot(
|
||||
state_, viewtx_cache_, send_txids_);
|
||||
transactionSnapshot.pendingOpids.insert(pending_opids_.begin(), pending_opids_.end());
|
||||
if (settings_) transactionSnapshot.miningAddresses = settings_->getMiningAddresses();
|
||||
transactionSnapshot.shieldedScanHeights = shielded_history_scan_heights_;
|
||||
transactionSnapshot.shieldedScanStartIndex = shielded_history_scan_cursor_;
|
||||
transactionSnapshot.maxShieldedReceiveScans = shieldedReceiveScanBudget(current_page_);
|
||||
transactionSnapshot.shieldedScanTipTolerance =
|
||||
shieldedScanTipTolerance(transactionSnapshot.shieldedAddresses.size());
|
||||
// When fully synced, widen the re-scan tolerance so the O(mapWallet) per-address history scan runs
|
||||
// every several blocks instead of ~every 2 — the heaviest residual synced-state cs_main cost. The
|
||||
// balance poll still surfaces incoming funds on its own cadence; only the detailed history list lags a
|
||||
// few minutes. (During catch-up the tx refresh is suppressed by kSyncProfile, so this is synced-only.)
|
||||
if (!effectivelySyncing())
|
||||
transactionSnapshot.shieldedScanTipTolerance =
|
||||
std::max(transactionSnapshot.shieldedScanTipTolerance, 8);
|
||||
transactionSnapshot.transparentRowHint = history_transparent_rows_;
|
||||
transactionSnapshot.reuseTransparentRows = !transactions_dirty_ && currentBlocks > 0 &&
|
||||
history_transparent_tip_ == currentBlocks && !state_.transactions.empty();
|
||||
transactionSnapshot.enrichBacklogTxids = history_enrich_backlog_;
|
||||
if (full) {
|
||||
const auto& addresses = transactionSnapshot.shieldedAddresses;
|
||||
const bool wasActive = history_pass_.active();
|
||||
history_pass_.begin(currentBlocks, addresses, shielded_history_scan_heights_,
|
||||
services::historyStaleAfterBlocks(addresses.size(), history_addr_scan_ms_),
|
||||
initial_history_scan_complete_, routineAllowed);
|
||||
if (!wasActive && history_pass_.active()) shielded_history_scan_cursor_ = 0; // a new pass: from the top
|
||||
transactionSnapshot.shieldedScanFloorHeight = history_pass_.scanFloor(currentBlocks, routineAllowed);
|
||||
transactionSnapshot.shieldedScanStartIndex = shielded_history_scan_cursor_;
|
||||
transactionSnapshot.maxShieldedReceiveScans = shieldedReceiveScanBudget(current_page_);
|
||||
auto listed = [&](const std::string& address) {
|
||||
return std::find(addresses.begin(), addresses.end(), address) != addresses.end();
|
||||
};
|
||||
// Never-scanned addresses first, whatever the cursor (the explicit work: a new or imported
|
||||
// address costs one scan, not a pass).
|
||||
for (const auto& address : addresses) {
|
||||
if (!shielded_history_scan_heights_.count(address)) transactionSnapshot.priorityScanFloors[address] = currentBlocks;
|
||||
}
|
||||
if (routineAllowed) {
|
||||
// Addresses holding notes the history doesn't show yet (noteHistoryHintsFromUnspent).
|
||||
for (const auto& [address, floor] : history_hint_floors_) {
|
||||
if (listed(address)) transactionSnapshot.priorityScanFloors.emplace(address, floor);
|
||||
}
|
||||
// The chat reply address once per new tip, so messages also surface off the Chat tab.
|
||||
if (!lite_wallet_ && chat::hushChatFeatureEnabledAtBuild() && chat_service_.hasIdentity() && settings_) {
|
||||
const std::string chatZ = settings_->getChatReplyZaddr();
|
||||
if (!chatZ.empty() && listed(chatZ)) transactionSnapshot.priorityScanFloors[chatZ] = currentBlocks;
|
||||
}
|
||||
}
|
||||
transactionSnapshot.sentScanFromHeight = history_sent_scan_height_;
|
||||
// A windowed sweep mid-pass too, now and then, so an untracked send (the Console, another wallet on
|
||||
// the same seed) doesn't wait for a whole pass on a large wallet: at most every quarter of the pass
|
||||
// cadence, and only in cycles the throttle allows.
|
||||
const int staleAfter = services::historyStaleAfterBlocks(addresses.size(), history_addr_scan_ms_);
|
||||
transactionSnapshot.allowMidPassSentSweep =
|
||||
routineAllowed && history_sent_scan_height_ >= 0 && history_sent_scan_height_ < currentBlocks &&
|
||||
currentBlocks - history_sent_scan_height_ >= std::max(services::kHistoryMinStaleBlocks, staleAfter / 4);
|
||||
// The sweep runs once per tip, when the throttle allows (or the first load is still running),
|
||||
// and backs off after whole-wallet sweeps that failed.
|
||||
transactionSnapshot.skipSentSweep =
|
||||
(!routineAllowed && initial_history_scan_complete_) ||
|
||||
(history_sent_scan_height_ >= 0 && history_sent_scan_height_ == currentBlocks) ||
|
||||
(history_sent_scan_height_ < 0 && std::time(nullptr) < history_full_sweep_retry_at_);
|
||||
} else {
|
||||
// Light: rows now (a send, a mutation), no wallet-wide z-scans.
|
||||
transactionSnapshot.skipShieldedScans = true;
|
||||
transactionSnapshot.skipSentSweep = true;
|
||||
transactionSnapshot.shieldedScanStartIndex = shielded_history_scan_cursor_;
|
||||
}
|
||||
const std::uint64_t scanEpoch = history_scan_epoch_;
|
||||
const std::uint64_t sentEpoch = history_sent_epoch_;
|
||||
const std::uint64_t dirtyGen = tx_dirty_generation_;
|
||||
const bool snapshotHadZ = !transactionSnapshot.shieldedAddresses.empty();
|
||||
ui::NavPage tracePage = current_page_;
|
||||
|
||||
auto enqueued = network_refresh_.enqueue(services::NetworkRefreshService::Job::Transactions, *worker_, [this, currentBlocks,
|
||||
transactionSnapshot = std::move(transactionSnapshot), tracePage]() -> rpc::RPCWorker::MainCb {
|
||||
AppRefreshRpcGateway refreshRpc(*rpc_, traceSource(tracePage, "Transaction refresh"));
|
||||
auto enqueued = network_refresh_.enqueue(services::NetworkRefreshService::Job::Transactions, *worker_, [this, currentBlocks, full,
|
||||
transactionSnapshot = std::move(transactionSnapshot), tracePage, scanEpoch, sentEpoch, dirtyGen,
|
||||
snapshotHadZ]() -> rpc::RPCWorker::MainCb {
|
||||
AppRefreshRpcGateway refreshRpc(*rpc_, traceSource(tracePage, full ? "Transaction refresh" : "Transaction refresh (light)"),
|
||||
this);
|
||||
auto result = NetworkRefreshService::collectTransactionRefreshResult(
|
||||
refreshRpc, transactionSnapshot, currentBlocks, MAX_VIEWTX_PER_CYCLE);
|
||||
|
||||
return [this, result = std::move(result)]() mutable {
|
||||
bool shieldedScanComplete = result.shieldedScanComplete;
|
||||
if (result.scanMs > 0.0) last_tx_scan_ms_ = result.scanMs; // feed the adaptive throttle (txRefreshDue)
|
||||
return [this, full, scanEpoch, sentEpoch, dirtyGen, snapshotHadZ, result = std::move(result)]() mutable {
|
||||
const std::time_t now = std::time(nullptr);
|
||||
const int resultTip = result.blockHeight;
|
||||
const std::size_t attempted = result.shieldedAddressesScanned;
|
||||
const std::size_t succeeded = result.shieldedScansSucceeded;
|
||||
const bool sameScanEpoch = scanEpoch == history_scan_epoch_;
|
||||
// Only history_sent_epoch_ invalidates the sent side (watermark, backoff, backlog): a plain
|
||||
// refresh keeps the sent scan, so it doesn't throw away an in-flight whole-wallet sweep.
|
||||
const bool sameSentEpoch = sentEpoch == history_sent_epoch_;
|
||||
// Feed the throttle (txRefreshDue): Full cycles are the wallet-wide scans it meters. Not the
|
||||
// whole-wallet sweep, which has its own cadence (once per connect or full invalidation, with a
|
||||
// backoff): charging up to 120 s of it held routine scans back for 10x that after every connect.
|
||||
if (full) {
|
||||
double charged = result.scanMs;
|
||||
if (result.sentSweepRan && result.sentSweepFull) charged = std::max(1.0, result.scanMs - result.sentSweepMs);
|
||||
if (charged > 0.0) last_tx_scan_ms_ = charged;
|
||||
last_tx_scan_at_ = now;
|
||||
}
|
||||
if (sameScanEpoch) {
|
||||
if (succeeded > 0) {
|
||||
history_addr_scan_ms_ = services::updateScanCostEstimateMs(
|
||||
history_addr_scan_ms_, result.receiveScanOkMs / static_cast<double>(succeeded));
|
||||
}
|
||||
if (result.transparentChainOk) {
|
||||
history_transparent_rows_ = result.transparentRowCount;
|
||||
history_transparent_tip_ = resultTip;
|
||||
}
|
||||
}
|
||||
if (sameSentEpoch) history_enrich_backlog_ = std::move(result.unenrichedTxids);
|
||||
if (sameSentEpoch) {
|
||||
if (result.sentSweepOk && result.sentSweepHeight > 0) {
|
||||
history_sent_scan_height_ = result.sentSweepHeight;
|
||||
history_full_sweep_failures_ = 0;
|
||||
history_full_sweep_retry_at_ = 0;
|
||||
} else if (result.sentSweepRan && result.sentSweepFull &&
|
||||
services::fullSentSweepFailureCounts(result.sentSweepErrKind)) {
|
||||
++history_full_sweep_failures_;
|
||||
history_full_sweep_retry_at_ = now + services::fullSentSweepRetryDelaySec(history_full_sweep_failures_);
|
||||
}
|
||||
}
|
||||
std::size_t nextShieldedScanStartIndex = result.nextShieldedScanStartIndex;
|
||||
auto shieldedScanHeights = std::move(result.shieldedScanHeights);
|
||||
NetworkRefreshService::TransactionCacheUpdate cacheUpdate{
|
||||
@@ -2207,12 +2405,32 @@ void App::refreshTransactionData()
|
||||
NetworkRefreshService::applyTransactionRefreshResult(
|
||||
state_, cacheUpdate, std::move(result), std::time(nullptr));
|
||||
pruneTrackedSendTxids();
|
||||
shielded_history_scan_heights_ = std::move(shieldedScanHeights);
|
||||
// Pass bookkeeping on the main thread, from the merged heights. Not from a cycle whose address
|
||||
// list came back blank (enumeration failed): it scanned nothing and knows nothing.
|
||||
if (full && sameScanEpoch && (snapshotHadZ || shielded_history_scan_heights_.empty())) {
|
||||
shielded_history_scan_heights_ = std::move(shieldedScanHeights);
|
||||
shielded_history_scan_cursor_ = nextShieldedScanStartIndex;
|
||||
const auto addresses = shieldedAddressList();
|
||||
// A list blanked by a failed enumeration after dispatch proves nothing: don't end the pass
|
||||
// or call the first load complete from it.
|
||||
if (!(snapshotHadZ && addresses.empty())) {
|
||||
history_pass_.finish(resultTip, addresses, shielded_history_scan_heights_, attempted, succeeded);
|
||||
for (auto it = history_hint_floors_.begin(); it != history_hint_floors_.end();) {
|
||||
const auto height = shielded_history_scan_heights_.find(it->first);
|
||||
if (height != shielded_history_scan_heights_.end() && height->second >= it->second)
|
||||
it = history_hint_floors_.erase(it);
|
||||
else
|
||||
++it;
|
||||
}
|
||||
const auto work = historyScanWork();
|
||||
if (work.total > 0 && work.missing == 0 &&
|
||||
!(history_pass_.active() && history_pass_.explicitPass()))
|
||||
initial_history_scan_complete_ = true;
|
||||
}
|
||||
}
|
||||
storeTransactionHistoryCacheIfAvailable();
|
||||
shielded_history_scan_cursor_ = nextShieldedScanStartIndex;
|
||||
shielded_history_scan_pending_ = !shieldedScanComplete;
|
||||
if (shieldedScanComplete) initial_history_scan_complete_ = true;
|
||||
transactions_dirty_ = !shieldedScanComplete;
|
||||
// A mutation that landed while this cycle ran isn't covered by it: stay dirty for it.
|
||||
if (tx_dirty_generation_ == dirtyGen) transactions_dirty_ = false;
|
||||
maybeFinishTransactionSendProgress();
|
||||
};
|
||||
}, 3);
|
||||
@@ -2245,34 +2463,44 @@ void App::pruneTrackedSendTxids()
|
||||
}
|
||||
}
|
||||
|
||||
void App::refreshRecentTransactionData()
|
||||
void App::startRecentTransactionPoll(bool probe)
|
||||
{
|
||||
if (!worker_ || !rpc_ || !state_.connected) return;
|
||||
if (!shouldRefreshRecentTransactions()) return;
|
||||
|
||||
const int currentBlocks = state_.sync.blocks;
|
||||
auto transactionSnapshot = NetworkRefreshService::buildTransactionRefreshSnapshot(
|
||||
state_, viewtx_cache_, send_txids_);
|
||||
transactionSnapshot.pendingOpids.insert(pending_opids_.begin(), pending_opids_.end());
|
||||
if (settings_) transactionSnapshot.miningAddresses = settings_->getMiningAddresses();
|
||||
transactionSnapshot.shieldedScanHeights = shielded_history_scan_heights_;
|
||||
transactionSnapshot.shieldedScanStartIndex = shielded_history_scan_cursor_;
|
||||
// Between blocks: recent listtransactions rows, plus (when the scan throttle allows) one receive scan
|
||||
// of the address scanned longest ago. It no longer advances the pass cursor.
|
||||
transactionSnapshot.skipShieldedScans = !probe;
|
||||
transactionSnapshot.maxShieldedReceiveScans = 1;
|
||||
transactionSnapshot.shieldedScanTipTolerance =
|
||||
shieldedScanTipTolerance(transactionSnapshot.shieldedAddresses.size());
|
||||
if (!effectivelySyncing()) // synced: widen the re-scan tolerance (see refreshTransactionData)
|
||||
transactionSnapshot.shieldedScanTipTolerance =
|
||||
std::max(transactionSnapshot.shieldedScanTipTolerance, 8);
|
||||
transactionSnapshot.shieldedScanStartIndex = probe
|
||||
? services::oldestScannedAddressIndex(transactionSnapshot.shieldedAddresses, shielded_history_scan_heights_)
|
||||
: 0;
|
||||
const std::uint64_t scanEpoch = history_scan_epoch_;
|
||||
ui::NavPage tracePage = current_page_;
|
||||
|
||||
auto enqueued = network_refresh_.enqueue(services::NetworkRefreshService::Job::Transactions, *worker_, [this, currentBlocks,
|
||||
transactionSnapshot = std::move(transactionSnapshot), tracePage]() -> rpc::RPCWorker::MainCb {
|
||||
transactionSnapshot = std::move(transactionSnapshot), tracePage, probe, scanEpoch]() -> rpc::RPCWorker::MainCb {
|
||||
AppRefreshRpcGateway refreshRpc(*rpc_, traceSource(tracePage, "Recent transaction poll"));
|
||||
auto result = NetworkRefreshService::collectRecentTransactionRefreshResult(
|
||||
refreshRpc, transactionSnapshot, currentBlocks);
|
||||
|
||||
return [this, result = std::move(result)]() mutable {
|
||||
std::size_t nextShieldedScanStartIndex = result.nextShieldedScanStartIndex;
|
||||
return [this, probe, scanEpoch, result = std::move(result)]() mutable {
|
||||
// The poll doesn't consume the tip: newTip stays set until a Full or Light cycle applies at it,
|
||||
// so the Full cycle still runs once the throttle allows. (Read here, not at dispatch, so a reset
|
||||
// to -1 meanwhile is kept.)
|
||||
int pollTip = last_tx_block_height_;
|
||||
if (probe && result.shieldedAddressesScanned > 0) {
|
||||
// The probe is a wallet-wide scan: it counts for the throttle.
|
||||
if (result.scanMs > 0.0) last_tx_scan_ms_ = result.scanMs;
|
||||
last_tx_scan_at_ = std::time(nullptr);
|
||||
if (scanEpoch == history_scan_epoch_ && result.shieldedScansSucceeded > 0) {
|
||||
history_addr_scan_ms_ = services::updateScanCostEstimateMs(
|
||||
history_addr_scan_ms_, result.receiveScanOkMs / static_cast<double>(result.shieldedScansSucceeded));
|
||||
}
|
||||
}
|
||||
auto shieldedScanHeights = std::move(result.shieldedScanHeights);
|
||||
NetworkRefreshService::TransactionCacheUpdate cacheUpdate{
|
||||
viewtx_cache_,
|
||||
@@ -2280,7 +2508,7 @@ void App::refreshRecentTransactionData()
|
||||
confirmed_tx_cache_,
|
||||
confirmed_tx_ids_,
|
||||
confirmed_cache_block_,
|
||||
last_tx_block_height_
|
||||
pollTip
|
||||
};
|
||||
// HushChat: decrypt & thread any harvested chat memos BEFORE `result` is moved (see the
|
||||
// full-refresh path above for rationale; the store dedups across both paths).
|
||||
@@ -2307,8 +2535,7 @@ void App::refreshRecentTransactionData()
|
||||
}
|
||||
NetworkRefreshService::applyTransactionRefreshResult(
|
||||
state_, cacheUpdate, std::move(result), std::time(nullptr));
|
||||
shielded_history_scan_heights_ = std::move(shieldedScanHeights);
|
||||
shielded_history_scan_cursor_ = nextShieldedScanStartIndex;
|
||||
if (scanEpoch == history_scan_epoch_) shielded_history_scan_heights_ = std::move(shieldedScanHeights);
|
||||
storeTransactionHistoryCacheIfAvailable();
|
||||
maybeFinishTransactionSendProgress();
|
||||
};
|
||||
@@ -2341,7 +2568,7 @@ bool App::refreshEncryptionState()
|
||||
return [this, result]() {
|
||||
NetworkRefreshService::applyWalletEncryptionResult(state_, result);
|
||||
if (state_.isLocked()) {
|
||||
resetTransactionHistoryCacheSession();
|
||||
lockTransactionHistoryCacheSession();
|
||||
} else if (state_.transactions.empty()) {
|
||||
loadTransactionHistoryCacheIfAvailable();
|
||||
} else {
|
||||
@@ -5855,7 +6082,7 @@ void App::submitZSendMany(const std::string& from, const std::string& to, double
|
||||
// A send changes address balances — refresh on next cycle
|
||||
addresses_dirty_ = true;
|
||||
// Force transaction list refresh so the sent tx appears immediately
|
||||
transactions_dirty_ = true;
|
||||
markTransactionsDirty();
|
||||
last_tx_block_height_ = -1;
|
||||
requestForcedBalanceRefresh(); // the user's own send must update the balance now, past the throttle
|
||||
network_refresh_.markWalletMutationRefresh();
|
||||
@@ -6030,7 +6257,7 @@ void App::runtimeRescan(int startHeight)
|
||||
user_initiated_rescan_ = true; // user clicked Rescan → completion should toast
|
||||
state_.sync.rescan_progress = 0.0f;
|
||||
state_.sync.rescan_status = "Rescanning from block " + std::to_string(startHeight) + "...";
|
||||
transactions_dirty_ = true;
|
||||
markTransactionsDirty();
|
||||
last_tx_block_height_ = -1;
|
||||
invalidateShieldedHistoryScanProgress(true);
|
||||
|
||||
@@ -6052,7 +6279,7 @@ void App::runtimeRescan(int startHeight)
|
||||
resetWitnessRescanProgress();
|
||||
if (ok) {
|
||||
state_.sync.rescan_progress = 1.0f;
|
||||
transactions_dirty_ = true;
|
||||
markTransactionsDirty();
|
||||
last_tx_block_height_ = -1;
|
||||
invalidateShieldedHistoryScanProgress(true);
|
||||
ui::Notifications::instance().success("Blockchain rescan complete");
|
||||
|
||||
Reference in New Issue
Block a user