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
+30 -36
View File
@@ -1373,7 +1373,7 @@ void App::update()
removePendingSendTransactions(successOpids, false);
removePendingSendTransactions(failedOrStaleOpids, true);
// Transaction confirmed by daemon — force immediate data refresh
transactions_dirty_ = true;
markTransactionsDirty();
addresses_dirty_ = true;
last_tx_block_height_ = -1;
network_refresh_.markWalletMutationRefresh();
@@ -1417,21 +1417,11 @@ void App::update()
// Skip balance/tx/address refresh during warmup — RPC calls fail with -28 — and during a
// runtime rescan, when the daemon holds cs_main and these calls would block.
if (!state_.warming_up && !runtime_rescan_active_) {
if (network_refresh_.consumeDue(RefreshTimer::Transactions)) {
if (shouldRunWalletTransactionRefresh() && shouldRefreshTransactions()) {
// Throttle the routine new-block full history rescan (z_listreceivedbyaddress —
// O(mapWallet), holds cs_main) by its measured cost, so a large wallet doesn't
// re-scan every block and starve connection near the tip. Bypass when there's an
// explicit need: a dirty set, an in-progress multi-cycle shielded scan (which must
// continue to completion), or an in-flight send — all need fresh data immediately.
const bool txExplicitNeed = transactions_dirty_ || shielded_history_scan_pending_ ||
hasTransactionSendProgress() || !send_txids_.empty();
if (txExplicitNeed || txRefreshDue()) {
refreshTransactionData();
}
} else if (walletDataPage && shouldRefreshRecentTransactions()) {
refreshRecentTransactionData();
}
// History: refreshTransactionData() decides what this cycle may scan (services::decideTxCycle):
// wallet-wide scans only as their measured cost allows, except explicit work (the first load, an
// invalidation, a new address); sends and other mutations get their rows through Light cycles.
if (network_refresh_.consumeDue(RefreshTimer::Transactions) && shouldRunWalletTransactionRefresh()) {
refreshTransactionData();
}
// 0-conf chat: a DEDICATED ~2.5s mempool scan of the chat address, independent of the page's
// (slower, 10–15s) Transactions cadence — so incoming messages surface in a few seconds. The
@@ -5263,10 +5253,12 @@ void App::refreshNow()
{
// Trigger immediate refresh on all categories
network_refresh_.markImmediateRefresh();
transactions_dirty_ = true; // Force transaction list update
markTransactionsDirty(); // Force transaction list update
addresses_dirty_ = true; // Force address/balance update
last_tx_block_height_ = -1; // Reset tx cache
invalidateShieldedHistoryScanProgress(true);
// A full receive re-scan (explicit, so it runs back to back), but keep the sent sweep's watermark:
// every press would otherwise cost a whole-wallet z_listsentbyaddress.
invalidateShieldedHistoryScanProgress(true, /*keepSentScan=*/true);
}
void App::skipDaemonOutputBacklog()
@@ -5828,7 +5820,7 @@ void App::rescanBlockchain()
user_initiated_rescan_ = true; // user-triggered rescan → its completion should toast
state_.sync.rescan_progress = 0.0f;
state_.sync.rescan_status = decision.status;
transactions_dirty_ = true;
markTransactionsDirty();
last_tx_block_height_ = -1;
invalidateShieldedHistoryScanProgress(true);
@@ -5882,7 +5874,7 @@ void App::repairWallet()
user_initiated_rescan_ = true; // user-triggered repair (implies rescan) → completion should toast
state_.sync.rescan_progress = 0.0f;
state_.sync.rescan_status = decision.status;
transactions_dirty_ = true;
markTransactionsDirty();
last_tx_block_height_ = -1;
invalidateShieldedHistoryScanProgress(true);
@@ -7346,9 +7338,14 @@ std::string App::transactionSendProgressText() const
std::string App::transactionRefreshProgressText() const
{
using Job = services::NetworkRefreshService::Job;
if (!rpc_ || !rpc_->isConnected()) return {}; // Lite, or no node: there's no full-node history scan
bool running = network_refresh_.jobInProgress(Job::Transactions);
bool canRefresh = state_.connected && !state_.warming_up && !state_.isLocked();
if (!running && !(canRefresh && transactions_dirty_)) return {};
const auto work = historyScanWork();
// The first load spans several cycles, and the dirty flag now clears after each: keep the banner up
// between them while addresses are still to scan.
const bool initialLoad = !initial_history_scan_complete_ && work.total > 0;
if (!running && !(canRefresh && (transactions_dirty_ || (initialLoad && work.pending > 0)))) return {};
char buf[128];
@@ -7358,26 +7355,23 @@ std::string App::transactionRefreshProgressText() const
return buf;
}
// Initial bulk history load: the incremental shielded scan streams older transactions in over
// many refresh cycles, so the first batch can appear long before the history is complete. Keep
// the user informed (with a %) even after rows are on screen, so they know it's still loading.
// Progress = fraction of z-addresses scanned at least once (monotonic). Goes quiet for good once
// the first full scan completes; routine per-block re-scans don't re-trigger it.
if (!initial_history_scan_complete_ && !state_.z_addresses.empty()) {
std::size_t total = state_.z_addresses.size();
std::size_t scanned = std::min(shielded_history_scan_heights_.size(), total);
int pct = total > 0 ? static_cast<int>((scanned * 100) / total) : 0;
// Initial bulk history load: the shielded receive scans stream older transactions in over several
// refresh cycles, so the first batch can appear long before the history is complete. Keep the user
// informed (with a %) even after rows are on screen, so they know it's still loading. Progress is the
// share of z-addresses this pass has covered. Goes quiet for good once the first pass completes;
// routine re-scans afterwards don't re-trigger it.
if (initialLoad) {
const std::size_t total = work.total;
const std::size_t done = total - std::min(work.pending, total);
int pct = static_cast<int>((done * 100) / total);
if (pct > 99) pct = 99; // 100% is the "complete" state, which suppresses the banner
snprintf(buf, sizeof(buf), TR("tx_loading_history_progress"), pct);
return buf;
}
// Once history is on screen, stay quiet for routine background refreshes. The incremental
// shielded-receive scan re-dirties transactions_dirty_ on EVERY new block (each block moves the
// "scanned at tip" bar, invalidating prior per-address scans), so for a wallet with more
// z-addresses than the per-cycle scan budget the flag never clears — which left this banner lit
// indefinitely. New receives still appear live as they're scanned; only the INITIAL load
// (nothing displayed yet) and send enrichment (above) warrant a loading banner.
// Once history is on screen, stay quiet for routine background refreshes: new receives appear live
// as they're scanned; only the INITIAL load (nothing displayed yet) and send enrichment (above)
// warrant a loading banner.
if (!state_.transactions.empty()) return {};
if (!running && transactions_dirty_) return TR("tx_loading_queued");