fix(history): let the shielded scan complete + unstick send-progress on many-z-addr wallets

Two issues shared one root cause: the shielded-receive scan marked each z-address "scanned
at the EXACT current tip," but a new block (~36s on DRGX) advances the tip and invalidates
every prior per-address scan. A wallet with more z-addresses than one refresh cycle can
scan therefore never reached "all scanned at tip" — so shieldedScanComplete stayed false
and transactions_dirty_ stayed true forever, which (a) kept the history-refresh banner lit
and the full rescan churning every cycle, and (b) blocked maybeFinishTransactionSendProgress
(it waited on transactions_dirty_), leaving the send-progress indicator stuck on.

Fix 1 — completion tolerance. Add TransactionRefreshSnapshot::shieldedScanTipTolerance: an
address counts as fresh if its last scan is within N blocks of the tip (0 = old strict
behavior, so existing tests are unchanged). The app scales N with the z-address count
(2 + count/96, capped at 50), so a multi-block pass can COMPLETE before its earliest scan
goes stale. This also throttles full rescans to ~N blocks instead of every block —
transactions_dirty_ clears, the banner stops, and CPU/RPC churn drops. Already-fresh
addresses are skipped, so the per-block cost falls back to just the (cheap) transparent
listtransactions.

Fix 2 — send-progress gate. maybeFinishTransactionSendProgress() no longer waits on the
transaction history scan (transactions_dirty_ / Transactions job): the sent tx is already
shown via the optimistic pending insert, and the spend is reflected once the balance
refresh lands, so it now finishes on the address/balance signal alone.

Test: a tolerant snapshot skips recently-scanned addresses (shieldedAddressesScanned == 0,
shieldedScanComplete) while a strict one re-scans them.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-09 22:55:30 -05:00
parent cf77c6cbe0
commit 63b3a04716
5 changed files with 64 additions and 4 deletions

View File

@@ -122,6 +122,19 @@ 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
// ============================================================================
@@ -1297,6 +1310,8 @@ void App::refreshTransactionData()
transactionSnapshot.shieldedScanHeights = shielded_history_scan_heights_;
transactionSnapshot.shieldedScanStartIndex = shielded_history_scan_cursor_;
transactionSnapshot.maxShieldedReceiveScans = shieldedReceiveScanBudget(current_page_);
transactionSnapshot.shieldedScanTipTolerance =
shieldedScanTipTolerance(transactionSnapshot.shieldedAddresses.size());
ui::NavPage tracePage = current_page_;
auto enqueued = network_refresh_.enqueue(services::NetworkRefreshService::Job::Transactions, *worker_, [this, currentBlocks,
@@ -1345,6 +1360,8 @@ void App::refreshRecentTransactionData()
transactionSnapshot.shieldedScanHeights = shielded_history_scan_heights_;
transactionSnapshot.shieldedScanStartIndex = shielded_history_scan_cursor_;
transactionSnapshot.maxShieldedReceiveScans = 1;
transactionSnapshot.shieldedScanTipTolerance =
shieldedScanTipTolerance(transactionSnapshot.shieldedAddresses.size());
ui::NavPage tracePage = current_page_;
auto enqueued = network_refresh_.enqueue(services::NetworkRefreshService::Job::Transactions, *worker_, [this, currentBlocks,