diff --git a/src/app.cpp b/src/app.cpp index d63a7a6..2eacfb2 100644 --- a/src/app.cpp +++ b/src/app.cpp @@ -3664,9 +3664,11 @@ void App::maybeFinishTransactionSendProgress() using Job = services::NetworkRefreshService::Job; if (!send_progress_active_) return; if (send_submissions_in_flight_ > 0 || !pending_opids_.empty()) return; - if (addresses_dirty_ || transactions_dirty_) return; - if (network_refresh_.jobInProgress(Job::Addresses) || - network_refresh_.jobInProgress(Job::Transactions)) return; + // Finish once the spend is reflected in the balance (addresses). Do NOT wait on the transaction + // history scan: the sent tx is already shown via the optimistic pending insert, and the shielded + // history scan can stay "dirty" for a long time on wallets with many z-addresses — gating on it + // left the send-progress indicator stuck on indefinitely. + if (addresses_dirty_ || network_refresh_.jobInProgress(Job::Addresses)) return; send_progress_active_ = false; } void App::restartDaemon() diff --git a/src/app_network.cpp b/src/app_network.cpp index 75915cc..0468459 100644 --- a/src/app_network.cpp +++ b/src/app_network.cpp @@ -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(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, diff --git a/src/services/network_refresh_service.cpp b/src/services/network_refresh_service.cpp index 2795a17..8c46230 100644 --- a/src/services/network_refresh_service.cpp +++ b/src/services/network_refresh_service.cpp @@ -822,10 +822,14 @@ NetworkRefreshService::TransactionRefreshResult NetworkRefreshService::collectTr 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 >= currentBlockHeight; + return it != result.shieldedScanHeights.end() && it->second >= scanStaleThreshold; }; std::size_t shieldedStart = snapshot.shieldedScanStartIndex; diff --git a/src/services/network_refresh_service.h b/src/services/network_refresh_service.h index 96bb946..3037e88 100644 --- a/src/services/network_refresh_service.h +++ b/src/services/network_refresh_service.h @@ -178,6 +178,13 @@ public: std::unordered_map 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; }; struct TransactionRefreshResult { diff --git a/tests/test_phase4.cpp b/tests/test_phase4.cpp index 1220136..df0c8ab 100644 --- a/tests/test_phase4.cpp +++ b/tests/test_phase4.cpp @@ -1477,6 +1477,36 @@ void testNetworkRefreshRpcCollectors() EXPECT_EQ(pagedResult.transactions.size(), static_cast(1001)); EXPECT_EQ(pagedResult.transactions[0].txid, std::string("paged-1000")); + // shieldedScanTipTolerance: an address scanned a few blocks below the tip is re-scanned when + // strict (tolerance 0) but SKIPPED within tolerance — this is what lets a multi-block pass + // complete (and unsticks the perpetual "refreshing history" state). + { + Refresh::TransactionRefreshSnapshot strictSnapshot; + strictSnapshot.shieldedAddresses = {"zs-a", "zs-b"}; + strictSnapshot.shieldedScanHeights = {{"zs-a", 100}, {"zs-b", 100}}; + strictSnapshot.maxShieldedReceiveScans = 4; + strictSnapshot.shieldedScanTipTolerance = 0; // strict: heights 100 are stale at tip 102 + MockRefreshRpc strictRpc; + strictRpc.addResponse("listtransactions", json::array()); + strictRpc.addResponse("z_listreceivedbyaddress", json::array()); + strictRpc.addResponse("z_listreceivedbyaddress", json::array()); + auto strictResult = Refresh::collectTransactionRefreshResult(strictRpc, strictSnapshot, 102, 4); + EXPECT_EQ(strictResult.shieldedAddressesScanned, static_cast(2)); // both re-scanned + + Refresh::TransactionRefreshSnapshot tolerantSnapshot; + tolerantSnapshot.shieldedAddresses = {"zs-a", "zs-b"}; + tolerantSnapshot.shieldedScanHeights = {{"zs-a", 100}, {"zs-b", 100}}; + tolerantSnapshot.maxShieldedReceiveScans = 4; + tolerantSnapshot.shieldedScanTipTolerance = 5; // 100 >= 102-5 -> still fresh + MockRefreshRpc tolerantRpc; + tolerantRpc.addResponse("listtransactions", json::array()); + // No z_listreceivedbyaddress responses: the addresses must be skipped (else call() throws). + auto tolerantResult = Refresh::collectTransactionRefreshResult(tolerantRpc, tolerantSnapshot, 102, 4); + EXPECT_EQ(tolerantResult.shieldedAddressesScanned, static_cast(0)); // both skipped + EXPECT_TRUE(tolerantResult.shieldedScanComplete); + EXPECT_TRUE(tolerantRpc.methodNames() == std::vector({"listtransactions"})); + } + Refresh::TransactionRefreshSnapshot recentSnapshot; dragonx::TransactionInfo previousShielded; previousShielded.txid = "shielded-old";