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:
@@ -3664,9 +3664,11 @@ void App::maybeFinishTransactionSendProgress()
|
|||||||
using Job = services::NetworkRefreshService::Job;
|
using Job = services::NetworkRefreshService::Job;
|
||||||
if (!send_progress_active_) return;
|
if (!send_progress_active_) return;
|
||||||
if (send_submissions_in_flight_ > 0 || !pending_opids_.empty()) return;
|
if (send_submissions_in_flight_ > 0 || !pending_opids_.empty()) return;
|
||||||
if (addresses_dirty_ || transactions_dirty_) return;
|
// Finish once the spend is reflected in the balance (addresses). Do NOT wait on the transaction
|
||||||
if (network_refresh_.jobInProgress(Job::Addresses) ||
|
// history scan: the sent tx is already shown via the optimistic pending insert, and the shielded
|
||||||
network_refresh_.jobInProgress(Job::Transactions)) return;
|
// 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;
|
send_progress_active_ = false;
|
||||||
}
|
}
|
||||||
void App::restartDaemon()
|
void App::restartDaemon()
|
||||||
|
|||||||
@@ -122,6 +122,19 @@ std::size_t shieldedReceiveScanBudget(ui::NavPage page)
|
|||||||
return page == ui::NavPage::History ? 8u : 4u;
|
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
|
} // namespace
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
@@ -1297,6 +1310,8 @@ void App::refreshTransactionData()
|
|||||||
transactionSnapshot.shieldedScanHeights = shielded_history_scan_heights_;
|
transactionSnapshot.shieldedScanHeights = shielded_history_scan_heights_;
|
||||||
transactionSnapshot.shieldedScanStartIndex = shielded_history_scan_cursor_;
|
transactionSnapshot.shieldedScanStartIndex = shielded_history_scan_cursor_;
|
||||||
transactionSnapshot.maxShieldedReceiveScans = shieldedReceiveScanBudget(current_page_);
|
transactionSnapshot.maxShieldedReceiveScans = shieldedReceiveScanBudget(current_page_);
|
||||||
|
transactionSnapshot.shieldedScanTipTolerance =
|
||||||
|
shieldedScanTipTolerance(transactionSnapshot.shieldedAddresses.size());
|
||||||
ui::NavPage tracePage = current_page_;
|
ui::NavPage tracePage = current_page_;
|
||||||
|
|
||||||
auto enqueued = network_refresh_.enqueue(services::NetworkRefreshService::Job::Transactions, *worker_, [this, currentBlocks,
|
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.shieldedScanHeights = shielded_history_scan_heights_;
|
||||||
transactionSnapshot.shieldedScanStartIndex = shielded_history_scan_cursor_;
|
transactionSnapshot.shieldedScanStartIndex = shielded_history_scan_cursor_;
|
||||||
transactionSnapshot.maxShieldedReceiveScans = 1;
|
transactionSnapshot.maxShieldedReceiveScans = 1;
|
||||||
|
transactionSnapshot.shieldedScanTipTolerance =
|
||||||
|
shieldedScanTipTolerance(transactionSnapshot.shieldedAddresses.size());
|
||||||
ui::NavPage tracePage = current_page_;
|
ui::NavPage tracePage = current_page_;
|
||||||
|
|
||||||
auto enqueued = network_refresh_.enqueue(services::NetworkRefreshService::Job::Transactions, *worker_, [this, currentBlocks,
|
auto enqueued = network_refresh_.enqueue(services::NetworkRefreshService::Job::Transactions, *worker_, [this, currentBlocks,
|
||||||
|
|||||||
@@ -822,10 +822,14 @@ NetworkRefreshService::TransactionRefreshResult NetworkRefreshService::collectTr
|
|||||||
DEBUG_LOGF("listtransactions error: %s\n", e.what());
|
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) {
|
auto scannedAtTip = [&](const std::string& address) {
|
||||||
if (currentBlockHeight < 0) return false;
|
if (currentBlockHeight < 0) return false;
|
||||||
auto it = result.shieldedScanHeights.find(address);
|
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;
|
std::size_t shieldedStart = snapshot.shieldedScanStartIndex;
|
||||||
|
|||||||
@@ -178,6 +178,13 @@ public:
|
|||||||
std::unordered_map<std::string, int> shieldedScanHeights;
|
std::unordered_map<std::string, int> shieldedScanHeights;
|
||||||
std::size_t shieldedScanStartIndex = 0;
|
std::size_t shieldedScanStartIndex = 0;
|
||||||
std::size_t maxShieldedReceiveScans = 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 {
|
struct TransactionRefreshResult {
|
||||||
|
|||||||
@@ -1477,6 +1477,36 @@ void testNetworkRefreshRpcCollectors()
|
|||||||
EXPECT_EQ(pagedResult.transactions.size(), static_cast<size_t>(1001));
|
EXPECT_EQ(pagedResult.transactions.size(), static_cast<size_t>(1001));
|
||||||
EXPECT_EQ(pagedResult.transactions[0].txid, std::string("paged-1000"));
|
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<size_t>(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<size_t>(0)); // both skipped
|
||||||
|
EXPECT_TRUE(tolerantResult.shieldedScanComplete);
|
||||||
|
EXPECT_TRUE(tolerantRpc.methodNames() == std::vector<std::string>({"listtransactions"}));
|
||||||
|
}
|
||||||
|
|
||||||
Refresh::TransactionRefreshSnapshot recentSnapshot;
|
Refresh::TransactionRefreshSnapshot recentSnapshot;
|
||||||
dragonx::TransactionInfo previousShielded;
|
dragonx::TransactionInfo previousShielded;
|
||||||
previousShielded.txid = "shielded-old";
|
previousShielded.txid = "shielded-old";
|
||||||
|
|||||||
Reference in New Issue
Block a user