perf(net): hold wallet scans at connect, in the background and on tab entry

- Connect gate: after the node starts answering, wallet scans wait until the
  lock state is known and the node knows its tip (or the wallet has fewer
  than 500 transactions), at most 20 s. A node already behind latches the
  sync profile from the connect's getinfo. Meanwhile a list-only pass keeps
  the address list and the cached history up, and the session's first
  balance and per-address balances are read once even while catching up,
  so the wallet shows its last-synced values instead of 0.
- Background: minimized, hidden, or covered without focus (3 s debounce,
  any input cancels) polls only the chain state every 15 s. Wallet scans,
  the mining poll, price, nudges and auto-lock interaction tracking pause;
  auto-lock now fires while minimized. Leaving a held profile marks every
  timer due again.
- Tab entry marks the timers due instead of forcing scans; a stale list or
  a never-loaded history still loads on entry.
- The scan entry points own the hard gates, failed enumerations keep the
  previous lists behind a retry hold, and failed balance calls never
  overwrite known balances with 0.

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 323143e111
commit f86cc4d6b7
26 changed files with 1083 additions and 159 deletions
+2
View File
@@ -619,6 +619,8 @@ set(APP_HEADERS
src/services/network_refresh_service.h
src/services/refresh_scheduler.h
src/services/wallet_scan_governor.h
src/services/connect_scan_gate.h
src/services/scan_policy.h
src/services/wallet_security_controller.h
src/services/wallet_security_workflow.h
src/services/wallet_security_workflow_executor.h
+2
View File
@@ -1561,6 +1561,7 @@
"reduce_motion": "Bewegung reduzieren",
"refresh": "Aktualisieren",
"refresh_now": "Jetzt aktualisieren",
"refresh_waits_for_sync": "Der Knoten synchronisiert noch. Die Wallet-Daten werden aktualisiert, sobald er fertig ist.",
"remove_favorite": "Favorit entfernen",
"repair_wallet": "Wallet reparieren",
"report_bug": "Fehler melden",
@@ -2127,6 +2128,7 @@
"tx_loading_queued": "Transaktionsaktualisierung in der Warteschlange",
"tx_loading_refreshing_cached": "Wallet-Verlauf wird aktualisiert (%d zwischengespeichert)",
"tx_loading_scanning_shielded": "Abgeschirmter Verlauf wird gescannt (%d Adressen)",
"tx_loading_waits_for_sync": "Der Verlauf wird aktualisiert, sobald der Knoten fertig synchronisiert ist",
"tx_mined": "GEMINED",
"tx_progress_balances": "Guthaben wird aktualisiert",
"tx_progress_finalizing": "Transaktion wird abgeschlossen",
+2
View File
@@ -1561,6 +1561,7 @@
"reduce_motion": "Reducir Movimiento",
"refresh": "Actualizar",
"refresh_now": "Actualizar Ahora",
"refresh_waits_for_sync": "El nodo aún se está sincronizando. Los datos de la cartera se actualizarán cuando termine.",
"remove_favorite": "Quitar favorito",
"repair_wallet": "Reparar cartera",
"report_bug": "Reportar Error",
@@ -2127,6 +2128,7 @@
"tx_loading_queued": "Actualización de transacciones en cola",
"tx_loading_refreshing_cached": "Actualizando el historial de la cartera (%d en caché)",
"tx_loading_scanning_shielded": "Escaneando historial protegido (%d direcciones)",
"tx_loading_waits_for_sync": "El historial se actualizará cuando el nodo termine de sincronizarse",
"tx_mined": "MINADO",
"tx_progress_balances": "Actualizando saldos",
"tx_progress_finalizing": "Finalizando transacción",
+2
View File
@@ -1561,6 +1561,7 @@
"reduce_motion": "Réduire les animations",
"refresh": "Actualiser",
"refresh_now": "Actualiser maintenant",
"refresh_waits_for_sync": "Le nœud est encore en cours de synchronisation. Les données du portefeuille seront actualisées une fois celle-ci terminée.",
"remove_favorite": "Retirer des favoris",
"repair_wallet": "Réparer le portefeuille",
"report_bug": "Signaler un bug",
@@ -2127,6 +2128,7 @@
"tx_loading_queued": "Actualisation des transactions en file d'attente",
"tx_loading_refreshing_cached": "Actualisation de l'historique du portefeuille (%d en cache)",
"tx_loading_scanning_shielded": "Analyse de l'historique blindé (%d adresses)",
"tx_loading_waits_for_sync": "L'historique sera mis à jour une fois le nœud synchronisé",
"tx_mined": "MINÉ",
"tx_progress_balances": "Actualisation des soldes",
"tx_progress_finalizing": "Finalisation de la transaction",
+2
View File
@@ -1561,6 +1561,7 @@
"reduce_motion": "モーションを減らす",
"refresh": "更新",
"refresh_now": "今すぐ更新",
"refresh_waits_for_sync": "ノードはまだ同期中です。同期が完了するとウォレットのデータが更新されます。",
"remove_favorite": "お気に入りを削除",
"repair_wallet": "ウォレットを修復",
"report_bug": "バグを報告",
@@ -2127,6 +2128,7 @@
"tx_loading_queued": "取引の更新を待機中",
"tx_loading_refreshing_cached": "ウォレット履歴を更新中(%d 件キャッシュ済み)",
"tx_loading_scanning_shielded": "シールド履歴をスキャン中 (%d アドレス)",
"tx_loading_waits_for_sync": "ノードの同期が完了すると履歴が更新されます",
"tx_mined": "採掘済み",
"tx_progress_balances": "残高を更新中",
"tx_progress_finalizing": "トランザクションを確定中",
+2
View File
@@ -1561,6 +1561,7 @@
"reduce_motion": "모션 줄이기",
"refresh": "새로고침",
"refresh_now": "지금 새로고침",
"refresh_waits_for_sync": "노드가 아직 동기화 중입니다. 동기화가 끝나면 지갑 데이터가 새로고침됩니다.",
"remove_favorite": "즐겨찾기 제거",
"repair_wallet": "지갑 복구",
"report_bug": "버그 신고",
@@ -2127,6 +2128,7 @@
"tx_loading_queued": "거래 새로고침 대기 중",
"tx_loading_refreshing_cached": "지갑 내역을 새로고침하는 중 (%d개 캐시됨)",
"tx_loading_scanning_shielded": "차폐 내역 스캔 중 (%d개 주소)",
"tx_loading_waits_for_sync": "노드 동기화가 끝나면 내역이 업데이트됩니다",
"tx_mined": "채굴됨",
"tx_progress_balances": "잔액을 새로 고치는 중",
"tx_progress_finalizing": "트랜잭션 마무리 중",
+2
View File
@@ -1561,6 +1561,7 @@
"reduce_motion": "Reduzir Movimento",
"refresh": "Atualizar",
"refresh_now": "Atualizar Agora",
"refresh_waits_for_sync": "O nó ainda está sincronizando. Os dados da carteira serão atualizados quando terminar.",
"remove_favorite": "Remover favorito",
"repair_wallet": "Reparar Carteira",
"report_bug": "Reportar Bug",
@@ -2127,6 +2128,7 @@
"tx_loading_queued": "Atualização de transação na fila",
"tx_loading_refreshing_cached": "Atualizando histórico da carteira (%d em cache)",
"tx_loading_scanning_shielded": "Verificando histórico blindado (%d endereços)",
"tx_loading_waits_for_sync": "O histórico será atualizado quando o nó terminar de sincronizar",
"tx_mined": "MINERADO",
"tx_progress_balances": "Atualizando saldos",
"tx_progress_finalizing": "Finalizando transação",
+2
View File
@@ -1561,6 +1561,7 @@
"reduce_motion": "Уменьшить анимацию",
"refresh": "Обновить",
"refresh_now": "Обновить сейчас",
"refresh_waits_for_sync": "Узел ещё синхронизируется. Данные кошелька обновятся после её завершения.",
"remove_favorite": "Удалить из избранного",
"repair_wallet": "Восстановить кошелёк",
"report_bug": "Сообщить об ошибке",
@@ -2127,6 +2128,7 @@
"tx_loading_queued": "Обновление транзакций поставлено в очередь",
"tx_loading_refreshing_cached": "Обновление истории кошелька (в кеше: %d)",
"tx_loading_scanning_shielded": "Сканирование экранированной истории (%d адресов)",
"tx_loading_waits_for_sync": "История обновится, когда узел завершит синхронизацию",
"tx_mined": "ДОБЫТА",
"tx_progress_balances": "Обновление балансов",
"tx_progress_finalizing": "Завершение транзакции",
+2
View File
@@ -1561,6 +1561,7 @@
"reduce_motion": "减少动画",
"refresh": "刷新",
"refresh_now": "立即刷新",
"refresh_waits_for_sync": "节点仍在同步。同步完成后将刷新钱包数据。",
"remove_favorite": "移除收藏",
"repair_wallet": "修复钱包",
"report_bug": "报告错误",
@@ -2127,6 +2128,7 @@
"tx_loading_queued": "已排队的交易刷新",
"tx_loading_refreshing_cached": "正在刷新钱包历史(已缓存 %d 条)",
"tx_loading_scanning_shielded": "正在扫描屏蔽历史(%d 个地址)",
"tx_loading_waits_for_sync": "节点同步完成后将更新历史记录",
"tx_mined": "已挖得",
"tx_progress_balances": "正在刷新余额",
"tx_progress_finalizing": "正在完成交易",
+66 -21
View File
@@ -801,11 +801,13 @@ void App::update()
// If a wallet switch's daemon failed to start, revert to the previous wallet (main-thread-safe).
processWalletSwitchRevert();
// Track user interaction for auto-lock
if (io.MouseDelta.x != 0 || io.MouseDelta.y != 0 ||
io.MouseClicked[0] || io.MouseClicked[1] ||
io.InputQueueCharacters.Size > 0 ||
ImGui::IsAnyItemActive()) {
// Track user interaction for auto-lock. Not in the background: a minimized window runs update() without
// new frames, so the IO state is the last frame's, and a stale mouse delta kept auto-lock from ever firing.
if (!window_background_ &&
(io.MouseDelta.x != 0 || io.MouseDelta.y != 0 ||
io.MouseClicked[0] || io.MouseClicked[1] ||
io.InputQueueCharacters.Size > 0 ||
ImGui::IsAnyItemActive())) {
last_interaction_ = std::chrono::steady_clock::now();
}
@@ -998,8 +1000,9 @@ void App::update()
// Re-apply the refresh cadence when sync starts/finishes: while syncing we throttle polling to
// a low-impact profile so RPC contention doesn't slow block download (see applyRefreshPolicy).
// effectivelySyncing() includes the post-sync settle window, so this also reverts to the normal
// per-tab cadence once that window elapses.
if (effectivelySyncing() != refresh_policy_syncing_) {
// per-tab cadence once that window elapses. Likewise when the window goes to or comes back from
// the background.
if (effectivelySyncing() != refresh_policy_syncing_ || window_background_ != refresh_policy_background_) {
applyRefreshPolicy(current_page_);
}
@@ -1043,7 +1046,7 @@ void App::update()
// Skip the mining poll while the daemon is in warmup (e.g. during -rescan). Otherwise
// getmininginfo is rejected with -28 ("Rescanning...") every second and floods the log.
// The rescan progress poll below still runs — that's how we track the warmup/rescan.
if (rpcConnected && !state_.warming_up && !runtime_rescan_active_ && !rpcBusy) {
if (rpcConnected && !state_.warming_up && !runtime_rescan_active_ && !rpcBusy && !window_background_) {
refreshMiningInfo();
}
@@ -1401,6 +1404,9 @@ void App::update()
};
});
}
// While the address scan that normally ends the send progress can't run (the background, a catch-up, the
// connect gate, a retry hold), end it once the operations are done: it keeps the window rendering.
if (send_progress_active_ && !addressScanCanRunNow()) maybeFinishTransactionSendProgress();
// After a bootstrap the preserved wallet.dat has stale spent-state and must be reconciled
// against the freshly-imported chain, or the first send tries to spend already-spent notes and
@@ -1428,6 +1434,16 @@ void App::update()
// nothing: the canary brings polling back (markImmediateRefresh) once the node answers
} else if (rpcConnected && !state_.isLocked()) {
const bool walletDataPage = currentPageNeedsWalletDataRefresh();
const services::ScanContext scan = scanContext();
// The connect gate just opened: what it held (the balance, the scans) is owed now. Under a held
// profile the marks are lost, but leaving it marks everything due again (applyRefreshPolicy).
if (scan_gate_.consumeOpenEdge(std::chrono::steady_clock::now(), state_.encryption_state_known,
state_.wallet_tx_count)) {
DEBUG_LOGF("[scan-gate] open\n");
network_refresh_.markDue(RefreshTimer::Core);
network_refresh_.markDue(RefreshTimer::Addresses);
network_refresh_.markDue(RefreshTimer::Transactions);
}
if (!runtime_rescan_active_ && network_refresh_.consumeDue(RefreshTimer::Core)) {
refreshCoreData();
}
@@ -1437,8 +1453,10 @@ void App::update()
// History: refreshTransactionData() decides what this cycle may scan (services::decideTxCycle):
// wallet-wide scans only as the scan budget allows, except explicit work (the first load, an
// invalidation, a new address); sends and other mutations get their rows through Light cycles.
// Held back by the budget, it asks again soon (a waiting stream must keep asking).
if (network_refresh_.consumeDue(RefreshTimer::Transactions) && shouldRunWalletTransactionRefresh()) {
// Held back by the budget, it asks again soon (a waiting stream must keep asking). The hard
// gates come first, so a held timer stays due instead of being eaten by a refused dispatch.
if (services::wholeWalletScanAllowed(scan) && network_refresh_.consumeDue(RefreshTimer::Transactions) &&
shouldRunWalletTransactionRefresh()) {
if (refreshTransactionData()) network_refresh_.retrySoon(RefreshTimer::Transactions, kScanRetrySec);
}
// 0-conf chat: a DEDICATED ~2.5s mempool scan of the chat address, independent of the page's
@@ -1451,15 +1469,32 @@ void App::update()
chat_fast_scan_accum_ = 0.0f;
fastScanChatMemos();
}
if (network_refresh_.isDue(RefreshTimer::Addresses)) {
const services::ScanTier addressTier =
services::planAddressScan(scan, addressListStale(), !address_balances_loaded_);
// What a held profile's timer can't bring (kSyncProfile's and the background's Addresses interval
// is 0): a stale list (list-only) and the session's first balances (while the node catches up).
// refreshAddressData() refuses while a pass runs or the retry hold does, and a pass that lands
// makes the list current and the balances loaded, so this stops by itself.
// The first balances while the node catches up wait for the first balance read to land: sent
// together they queue for the same cs_main and the second one times out.
const bool addressPassOwed = addressTier == services::ScanTier::ListOnly ||
(addressTier == services::ScanTier::Full && !services::wholeWalletScanAllowed(scan) &&
!scan_governor_.inFlight(services::ScanStream::Balance));
if (addressPassOwed) {
const auto steadyNow = std::chrono::steady_clock::now();
if (steadyNow >= address_owed_next_try_) {
address_owed_next_try_ = steadyNow + std::chrono::seconds(2);
refreshAddressData();
}
} else if (addressTier != services::ScanTier::None && network_refresh_.isDue(RefreshTimer::Addresses)) {
// Explicit need (a changed address set, or an in-flight send tracking its change output)
// refreshes now. The routine poll on a wallet page runs only when something it depends on
// changed (tip, mempool, a wallet mutation) or its safety sweep is due, and only as the scan
// budget allows; held back, it asks again soon.
// refreshes now (list-only while the scans are held). The routine poll on a wallet page runs
// only when something it depends on changed (tip, mempool, a wallet mutation) or its safety
// sweep is due, and only as the scan budget allows; held back, it asks again soon.
bool retry = false;
if (addresses_dirty_ || hasTransactionSendProgress()) {
refreshAddressData();
} else if (walletDataPage && !effectivelySyncing()) {
} else if (walletDataPage && addressTier == services::ScanTier::Full) {
using services::ScanStream;
const double scanNow = scanClockNow();
const auto scanSig = currentScanSignals();
@@ -1509,8 +1544,8 @@ void App::update()
}
}
// Price refresh every 60 seconds
if (network_refresh_.consumeDue(RefreshTimer::Price)) {
// Price refresh every 60 seconds (not in the background: it is due again as soon as the window is back)
if (!window_background_ && network_refresh_.consumeDue(RefreshTimer::Price)) {
if (settings_->getFetchPrices()) {
refreshPrice();
refreshMarketChart(); // self-throttled to ~30 min; keeps sparkline history fresh
@@ -5295,12 +5330,18 @@ void App::refreshNow()
// 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);
address_retry_not_before_ = {}; // the user asked: an earlier failed enumeration's hold doesn't apply
// While the node catches up the wallet scans wait (they would slow it down): say so. (Not in the short
// settle window after it caught up: the status bar already says synced, and they run in a moment.)
if (rpc_ && rpc_->isConnected() && state_.sync.syncing)
ui::Notifications::instance().info(TR("refresh_waits_for_sync"));
}
void App::skipDaemonOutputBacklog()
{
// While minimized, App::update() is paused, so daemon_output_offset_ is never advanced and a large
// backlog of daemon output piles up. Parsing it all at once on restore would replay a background
// update() keeps running while minimized, but its output parser (the Fast block) skips while the wallet
// is locked, so daemon_output_offset_ stops advancing and a backlog of daemon output can pile up.
// Parsing it all at once on restore would replay a background
// witness rebuild's progress + completion in a single batch and fire a spurious "Blockchain rescan
// complete" toast. Advance the offset to the current end so only NEW (post-restore) output is parsed.
// A genuine user-initiated rescan still surfaces completion via the getrescaninfo monitor.
@@ -7382,6 +7423,8 @@ std::string App::transactionRefreshProgressText() const
// 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 {};
// The history scans wait while the node catches up.
if (!running && state_.sync.syncing) return TR("tx_loading_waits_for_sync");
char buf[128];
@@ -7461,8 +7504,10 @@ void App::maybeFinishTransactionSendProgress()
// 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;
// left the send-progress indicator stuck on indefinitely. While the address scan can't run (the
// background, a catch-up, the connect gate, a retry hold), finish once the operations are done:
// addresses_dirty_ stays set, so the scan still runs when it can.
if (addressScanCanRunNow() && (addresses_dirty_ || network_refresh_.jobInProgress(Job::Addresses))) return;
send_progress_active_ = false;
}
std::string App::buildDiagnosticsReport()
+32 -5
View File
@@ -27,6 +27,8 @@
#include "daemon/daemon_busy_detect.h"
#include "rpc/rpc_busy_gate.h"
#include "services/history_scan_policy.h"
#include "services/connect_scan_gate.h"
#include "services/scan_policy.h"
#include "services/wallet_scan_governor.h"
#include "services/network_refresh_service.h"
#include "services/wallet_security_controller.h"
@@ -443,8 +445,11 @@ public:
// Something changed the wallet that the scans' change signals (tip, mempool) don't show: a send or other
// operation finished, an import, a Console command. The change-gated scans run again.
void noteWalletMutation() { ++wallet_mutation_epoch_; }
// Minimized, hidden or occluded: no page is visible, so no scan stream gets the visible head start.
void setWindowBackground(bool background) { window_background_ = background; }
// The window is in the background: minimized, hidden, or covered while the app has no focus (debounced
// by the main loop; any input ends it). The background refresh profile then holds the wallet scans and
// the peripheral polls (mining, price), and no page counts as visible to the scan budget.
void setWindowBackground(bool background);
bool isWindowBackground() const { return window_background_; }
// A balance scan is in flight (the staleness badge waits for it).
bool isBalanceRefreshInFlight() const { return scan_governor_.inFlight(services::ScanStream::Balance); }
// The balance is waiting for the scan budget and still within what it promises (its floor plus one scan):
@@ -486,9 +491,9 @@ public:
// Force refresh
void refreshNow();
// Called on window restore: drop the daemon-output backlog that accumulated while minimized (the
// per-frame update loop was paused), so a background witness rebuild that started+finished during
// the minimize isn't parsed in one batch and mistaken for a completed rescan (spurious toast).
// Called on window restore: drop any daemon-output backlog (update() runs while minimized, but its
// output parser skips while the wallet is locked), so a background witness rebuild that started and
// finished meanwhile isn't parsed in one batch and mistaken for a completed rescan (spurious toast).
void skipDaemonOutputBacklog();
void refreshMiningInfo();
void refreshPeerInfo();
@@ -1354,6 +1359,7 @@ private:
bool daemon_start_error_shown_ = false;
int daemon_last_seen_crashes_ = 0; // surface each new embedded-daemon crash reason once
bool refresh_policy_syncing_ = false; // whether the sync-throttle refresh profile is active
bool refresh_policy_background_ = false; // whether the background refresh profile is active
// Sync-settle hysteresis + adaptive balance-poll throttle. Balance polling (z_gettotalbalance) is
// O(mapWallet) and holds the daemon's cs_main, which starves block connection on a large shielded
// wallet — so we keep the low-impact profile briefly after catching up, and back the balance poll
@@ -1386,6 +1392,27 @@ private:
services::ScanSignals currentScanSignals() const;
services::ScanVisibility currentScanVisibility() const;
static double scanClockNow(); // the governor's clock: steady-clock seconds
// C44: holds the wallet-wide scans for a moment after the node starts answering (connect, warmup end),
// until the lock state is known and the node knows its tip or the wallet is small (20 s at most).
services::ConnectScanGate scan_gate_;
// After an address enumeration failed: no address refresh before this (the previous lists stay).
std::chrono::steady_clock::time_point address_retry_not_before_{};
// A full address pass (the per-address balances) has run this connection. Until then the first one runs
// even while the node catches up.
bool address_balances_loaded_ = false;
// The session's first balance read was sent while the node was catching up (once per connection).
bool first_balance_read_tried_ = false;
// Paces the address passes the held profiles' timers can't bring (a stale list, the first balances).
std::chrono::steady_clock::time_point address_owed_next_try_{};
bool scanGateOpen() const;
// The address list was never loaded this session, or the address set may have changed (import, sweep,
// decrypt-import): it loads even while the wallet scans are held (list-only).
bool addressListStale() const;
// What the scan entry points check before sending a wallet-wide scan (services::scan_policy.h).
services::ScanContext scanContext() const;
// The full address pass could be sent now (the hard gates, and no retry hold).
bool addressScanCanRunNow() const;
void armScanGate(const services::NetworkRefreshService::ConnectionInfoResult& info);
// Auto-clear for secrets copied to the clipboard. Only a hash of the copied secret is kept.
std::uint64_t clipboard_secret_hash_ = 0;
double clipboard_clear_deadline_ = 0.0;
+249 -105
View File
@@ -19,7 +19,7 @@
// │
// ├─ success ──► onConnected() ──► [Connected]
// │ │
// │ ▼ refreshData() every 5s
// │ ▼ per-category timers (refreshData() at transitions)
// │ [Running]
// │ │
// │ ├─ RPC error ──► onDisconnected()
@@ -525,6 +525,9 @@ void App::tryConnect()
VERBOSE_LOGF("[connect #%d] Connected successfully%s\n", attempt,
warmingUp ? " (daemon warming up)" : "");
saved_config_ = config; // save for fast-lane connection
// Before onConnected(): its refreshData() then only polls for the warmup's end instead of
// sending wallet RPCs that can only fail with -28.
if (warmingUp) state_.warming_up = true;
onConnected();
if (warmingUp) {
// Daemon is reachable and auth works, but RPC calls will
@@ -732,8 +735,9 @@ void App::onConnected()
// Fetching getwalletinfo here (before refreshData) ensures the lock
// screen appears immediately instead of after 6+ queued RPC calls.
bool initialPrefetchQueued = false;
NetworkRefreshService::ConnectionInfoResult prefetchedInfo;
if (worker_ && rpc_) {
auto prefetchedInfo = NetworkRefreshService::parseConnectionInfoResult(rpc_->getLastConnectInfo());
prefetchedInfo = NetworkRefreshService::parseConnectionInfoResult(rpc_->getLastConnectInfo());
auto enqueued = network_refresh_.enqueue(services::NetworkRefreshService::Job::ConnectionInit, *worker_, [this, prefetchedInfo]() -> rpc::RPCWorker::MainCb {
AppRefreshRpcGateway refreshRpc(*rpc_, "Startup / Connection init");
auto result = NetworkRefreshService::collectConnectionInitResult(refreshRpc, prefetchedInfo);
@@ -757,6 +761,9 @@ void App::onConnected()
// Addresses are unknown on fresh connect — force a fetch
addresses_dirty_ = true;
// Hold the wallet scans until the node knows where the chain is (or the wallet is small), and latch the
// sync profile now if the connect's getinfo already shows it behind.
armScanGate(prefetchedInfo);
// Start the fast-lane RPC connection (dedicated to 1-second mining polls).
// Uses its own curl handle + worker thread so getlocalsolps never blocks
@@ -844,6 +851,11 @@ void App::onDisconnected(const std::string& reason)
history_transparent_tip_ = -1; // reset with the cache session below); cost estimates are kept
scan_governor_.reset(); // tickets of this connection's scans complete as no-ops
recent_probe_rotation_.reset();
scan_gate_.disarm();
address_retry_not_before_ = {};
address_balances_loaded_ = false;
first_balance_read_tried_ = false;
address_owed_next_try_ = {};
mempool_known_ = false;
ui::ShieldDialog::invalidateScope();
address_validation_cache_dirty_ = true;
@@ -956,18 +968,20 @@ void App::handleLostConnection(const std::string& reason)
//
// Data is split into independent categories, each with its own refresh
// function, timer, and in-progress guard. The orchestrator (refreshData)
// dispatches all categories, but each can also be called independently
// (e.g. on tab switch for immediate refresh).
// dispatches all categories at transitions (connect, warmup end, unlock); the
// periodic timers drive them otherwise. Entering a tab marks its timers due.
//
// Categories:
// Core — z_gettotalbalance + getblockchaininfo (balance, sync)
// Addresses — z_listaddresses + listunspent (address list, per-addr balances)
// Transactions — listtransactions + z_listreceivedbyaddress + z_viewtransaction
// Core — getblockchaininfo + getmempoolinfo, and z_gettotalbalance when due (balance, sync)
// Addresses — z_listaddresses + z_listunspent + listunspent (address list, per-addr balances)
// Transactions — listtransactions + z_listreceivedbyaddress + z_listsentbyaddress + z_viewtransaction
// Peers — getpeerinfo + listbanned (already standalone)
// Encryption — getwalletinfo (one-shot on connect)
//
// Intervals are adjusted by applyRefreshPolicy() based on the active tab,
// so the user sees faster updates for the data they're interacting with.
// Intervals are adjusted by applyRefreshPolicy() based on the active tab (or the sync / background
// profiles). The wallet-wide scans (balance, address scan, history) also pass the hard gates
// (services::scan_policy.h) and the scan budget (services::WalletScanGovernor): explicit work bypasses
// only the budget, never the gates.
// ============================================================================
App::RefreshIntervals App::getIntervalsForPage(ui::NavPage page)
@@ -983,10 +997,25 @@ void App::applyRefreshPolicy(ui::NavPage page)
// as the Console tab does today. effectivelySyncing() keeps this profile on briefly after catching
// up (hysteresis) so a large-wallet scan can't immediately re-starve connection and bounce the
// node back into "syncing". Reverts to the per-tab profile once the settle window passes.
// In the background (C134) only the chain state is polled, whatever the sync state.
const bool wasHeld = refresh_policy_syncing_ || refresh_policy_background_;
refresh_policy_syncing_ = effectivelySyncing();
network_refresh_.setIntervals(refresh_policy_syncing_
? services::RefreshScheduler::kSyncProfile
: getIntervalsForPage(page));
refresh_policy_background_ = window_background_;
network_refresh_.setIntervals(services::RefreshScheduler::selectIntervals(
page, refresh_policy_syncing_, refresh_policy_background_));
// Leaving a profile that held the wallet timers: markDue() zeroes a 0-interval timer, so anything marked
// meanwhile (a tab entry, a send's completion, F5) was lost. Mark everything due; the gates in update()
// and the scan budget still decide what actually runs. Here rather than in update(): setCurrentPage()
// also lands here and would swallow update()'s edge.
if (wasHeld && !refresh_policy_syncing_ && !refresh_policy_background_)
network_refresh_.markImmediateRefresh();
}
void App::setWindowBackground(bool background)
{
if (background == window_background_) return;
window_background_ = background;
DEBUG_LOGF("[App] window background=%d\n", background ? 1 : 0);
}
// True while the node is behind, and for a short settle window after it first catches up. The settle
@@ -1028,6 +1057,52 @@ services::ScanSignals App::currentScanSignals() const
return sig;
}
bool App::scanGateOpen() const
{
return scan_gate_.open(std::chrono::steady_clock::now(), state_.encryption_state_known, state_.wallet_tx_count);
}
bool App::addressScanCanRunNow() const
{
return services::wholeWalletScanAllowed(scanContext()) &&
std::chrono::steady_clock::now() >= address_retry_not_before_;
}
bool App::addressListStale() const
{
return state_.last_address_update == 0 || address_validation_cache_dirty_;
}
services::ScanContext App::scanContext() const
{
services::ScanContext c;
c.connected = rpc_ && rpc_->isConnected() && state_.connected;
c.locked = state_.isLocked();
c.warmingUp = state_.warming_up;
c.runtimeRescan = runtime_rescan_active_;
c.rpcBusy = rpc_busy_gate_ && rpc_busy_gate_->busy();
c.syncing = effectivelySyncing();
c.gateOpen = scanGateOpen();
c.background = window_background_;
return c;
}
void App::armScanGate(const NetworkRefreshService::ConnectionInfoResult& info)
{
const bool tipKnown = NetworkRefreshService::connectionInfoTipKnown(info);
const bool behind = NetworkRefreshService::connectionInfoShowsBehind(info);
scan_gate_.arm(std::chrono::steady_clock::now(), tipKnown);
address_retry_not_before_ = {}; // the node (re)started answering: an earlier failure is moot
if (behind) {
// Latch the sync profile before the first dispatch. The next Core result recomputes
// state_.sync.syncing and keeps it latched while the node is behind.
was_core_syncing_ = true;
sync_settle_until_ = std::time(nullptr) +
static_cast<std::time_t>(services::RefreshScheduler::kSyncSettleSeconds);
}
DEBUG_LOGF("[scan-gate] armed: tip %s%s\n", tipKnown ? "known" : "unknown", behind ? ", behind" : "");
}
bool App::isBalanceHeldByScanBudget() const
{
return scan_governor_.heldWithinFloor(services::ScanStream::Balance, scanClockNow());
@@ -1069,36 +1144,35 @@ void App::setCurrentPage(ui::NavPage page)
applyRefreshPolicy(page);
using RefreshTimer = services::NetworkRefreshService::Timer;
// Immediate refresh for the incoming tab's priority data. Gate on ACTUAL RPC connectivity
// (not state_.connected, which is the lite "online" proxy) — lite has no RPC daemon and the
// lite controller refreshes wallet data itself, so these full-node RPC polls must not fire.
// Refresh for the incoming tab. Gate on ACTUAL RPC connectivity (not state_.connected, which is the
// lite "online" proxy) — lite has no RPC daemon and the lite controller refreshes wallet data itself,
// so these full-node RPC polls must not fire. Wallet data isn't forced on entry any more: the timers
// are marked due, and the change gate and the scan budget decide (data that changed since its last
// scan comes first, and a page's own data gets the visible head start). Only what the page can't
// show without is loaded now: a stale address list, a never-loaded history.
if (rpc_ && rpc_->isConnected() && !state_.isLocked()) {
using NP = ui::NavPage;
switch (page) {
case NP::Overview:
refreshCoreData();
network_refresh_.reset(RefreshTimer::Core);
break;
case NP::History:
markTransactionsDirty();
refreshTransactionData();
network_refresh_.reset(RefreshTimer::Transactions);
break;
case NP::Send:
case NP::Receive:
addresses_dirty_ = true;
refreshAddressData();
network_refresh_.reset(RefreshTimer::Addresses);
break;
case NP::Peers:
refreshPeerInfo();
network_refresh_.reset(RefreshTimer::Peers);
break;
case NP::Mining:
refreshMiningInfo();
break;
default:
break;
using Job = services::NetworkRefreshService::Job;
const auto plan = services::planPageEntry(page, scanContext(), addressListStale(), state_.last_tx_update == 0);
if (plan.core) {
refreshCoreData();
network_refresh_.reset(RefreshTimer::Core);
}
if (plan.peers) {
refreshPeerInfo();
network_refresh_.reset(RefreshTimer::Peers);
}
if (plan.mining) refreshMiningInfo();
if (plan.addressesNow != services::ScanTier::None) {
refreshAddressData();
if (network_refresh_.jobInProgress(Job::Addresses)) network_refresh_.reset(RefreshTimer::Addresses);
} else if (plan.markAddresses) {
network_refresh_.markDue(RefreshTimer::Addresses);
}
if (plan.transactionsNow) {
refreshTransactionData();
if (network_refresh_.jobInProgress(Job::Transactions)) network_refresh_.reset(RefreshTimer::Transactions);
} else if (plan.markTransactions) {
network_refresh_.markDue(RefreshTimer::Transactions);
}
}
}
@@ -1348,7 +1422,7 @@ void App::updateWalletIndexForActiveWallet(bool markOpened)
const std::string idHash = state_.connected ? activeWalletIdentityHash() : std::string();
if (!idHash.empty()) {
e.walletIdentityHash = idHash;
e.cachedBalance = state_.totalBalance;
if (state_.last_balance_update != 0) e.cachedBalance = state_.totalBalance; // not before one was read
e.cachedAddressCount = static_cast<long long>(state_.z_addresses.size() + state_.t_addresses.size());
}
@@ -1838,9 +1912,11 @@ void App::refreshData()
// Dispatch each category independently — results trickle into the UI
// as each completes, rather than waiting for the slowest phase.
refreshCoreData();
refreshCoreData(); // the chain state; the balance is decided inside
// The address list first: the history needs it (the address callback marks the history due).
// The address list first: the history needs it (the address callback marks the history due). The scan
// entry points own the hard gates (syncing, the connect gate, locked, busy, background): a held address
// refresh still loads a stale list (list-only), a held history waits.
// refreshTransactionData() picks the cycle itself (Full / Light / the between-blocks poll / none).
if (addresses_dirty_)
refreshAddressData();
@@ -1915,6 +1991,8 @@ void App::refreshCoreData(bool allowBalance)
}
NetworkRefreshService::applyConnectionInfoResult(state_, result.info);
// The node answers now, but may not know the tip yet: hold the scans for a moment.
armScanGate(result.info);
// Trigger full data refresh now that daemon is ready
refreshData();
} else {
@@ -1960,6 +2038,9 @@ void App::refreshCoreData(bool allowBalance)
services::BalancePollInputs balanceIn;
balanceIn.allowBalance = allowBalance;
balanceIn.syncing = effectivelySyncing();
balanceIn.gateOpen = scanGateOpen();
balanceIn.background = window_background_;
balanceIn.firstReadTried = first_balance_read_tried_;
balanceIn.forced = force_balance_refresh_;
balanceIn.neverFetched = state_.last_balance_update == 0;
balanceIn.changed = scan_governor_.needed(services::ScanStream::Balance, scanSig, scanNow);
@@ -1990,6 +2071,7 @@ void App::refreshCoreData(bool allowBalance)
NetworkRefreshService::CoreRefreshPlan plan;
plan.includeBalance = includeBalance;
plan.fetchMempool = !balanceIn.syncing; // a change signal for the scans; takes no cs_main
plan.evenIfBehind = includeBalance && balanceIn.neverFetched; // the session's first read: while behind too
plan.needSpendable = NetworkRefreshService::spendableBalanceNeeded(
state_, !pending_send_info_.empty() || !pending_opids_.empty() || !send_txids_.empty(),
force_balance_refresh_);
@@ -2025,6 +2107,7 @@ void App::refreshCoreData(bool allowBalance)
}
if (result.balanceAttempted) state_.balance_last_attempt_failed = !result.balanceOk;
NetworkRefreshService::applyCoreRefreshResult(state_, result, std::time(nullptr));
if (result.blockchainOk && result.longestChain && *result.longestChain > 0) scan_gate_.noteTipKnown();
if (result.balanceOk) {
// Fresh aggregates: re-apply the pending sends' debits to them. The per-address
// balances are the address scan's to refresh and re-debit.
@@ -2055,9 +2138,9 @@ void App::refreshCoreData(bool allowBalance)
// "behind" readings and stay latched until genuinely caught up. Window must exceed the
// worst-case Core-refresh interval so it can't lapse between two behind readings mid-catchup.
const bool nowSyncing = state_.sync.syncing;
constexpr double kSyncSettleSeconds = 30.0;
if (nowSyncing || was_core_syncing_) {
sync_settle_until_ = std::time(nullptr) + static_cast<std::time_t>(kSyncSettleSeconds);
sync_settle_until_ = std::time(nullptr) +
static_cast<std::time_t>(services::RefreshScheduler::kSyncSettleSeconds);
}
was_core_syncing_ = nowSyncing;
@@ -2170,6 +2253,9 @@ void App::refreshCoreData(bool allowBalance)
};
}, 3);
if (!enqueued.enqueued && balanceTicket) scan_governor_.cancel(balanceTicket);
// The first read while the node catches up is tried once per connection (a failed one would otherwise be
// re-sent after every busy period, a wallet-wide scan into the catch-up each time).
if (enqueued.enqueued && plan.evenIfBehind && balanceIn.syncing) first_balance_read_tried_ = true;
}
// ============================================================================
@@ -2183,9 +2269,26 @@ void App::refreshAddressData(services::ScanTicket ticket)
if (ticket) scan_governor_.cancel(ticket); // not sent after all
return;
}
// No ticket: an explicit refresh (a dirty set, a send in progress, tab entry). Never held back, but
// charged to the scan budget like the routine ones.
if (!ticket) ticket = scan_governor_.beginExplicit(services::ScanStream::Addresses, currentScanSignals(), scanClockNow());
// The hard gates live here, so no caller (tab entry, refreshData, an import, the history's re-route) can
// send the wallet-wide scan into a node that is syncing, warming up, rescanning or busy, into a locked
// wallet, while the window is in the background, or before the connect gate opens. A stale list still
// loads, without the balances (list-only, in the background too), and the session's first balances are
// read once even while the node catches up. Refused work keeps its flags: the periodic refresh, or
// leaving the held profile, retries it.
const services::ScanTier tier = services::planAddressScan(scanContext(), addressListStale(), !address_balances_loaded_);
if (tier == services::ScanTier::None || std::chrono::steady_clock::now() < address_retry_not_before_) {
if (ticket) scan_governor_.cancel(ticket);
return;
}
const bool listOnly = tier == services::ScanTier::ListOnly;
if (listOnly) {
if (ticket) scan_governor_.cancel(ticket); // no wallet scan: nothing to charge
ticket = {};
} else if (!ticket) {
// An explicit refresh (a dirty set, a send in progress, a stale list). Never held back, but charged
// to the scan budget like the routine ones.
ticket = scan_governor_.beginExplicit(services::ScanStream::Addresses, currentScanSignals(), scanClockNow());
}
const std::size_t previousAddressCount = state_.z_addresses.size() + state_.t_addresses.size();
const std::string previousWalletIdentity = transactionHistoryCacheWalletIdentity();
const auto zList = shieldedAddressList();
@@ -2194,58 +2297,80 @@ void App::refreshAddressData(services::ScanTicket ticket)
? NetworkRefreshService::AddressRefreshSnapshot{}
: NetworkRefreshService::buildAddressRefreshSnapshot(state_);
ui::NavPage tracePage = current_page_;
auto enqueued = network_refresh_.enqueue(services::NetworkRefreshService::Job::Addresses, *worker_, [this, previousAddressCount, previousWalletIdentity, previousZ = std::move(previousZ), addressSnapshot = std::move(addressSnapshot), tracePage, ticket]() -> rpc::RPCWorker::MainCb {
AppRefreshRpcGateway refreshRpc(*rpc_, traceSource(tracePage, "Address refresh"));
auto result = NetworkRefreshService::collectAddressRefreshResult(refreshRpc, addressSnapshot);
auto enqueued = network_refresh_.enqueue(services::NetworkRefreshService::Job::Addresses, *worker_, [this, previousAddressCount, previousWalletIdentity, previousZ = std::move(previousZ), addressSnapshot = std::move(addressSnapshot), tracePage, ticket, listOnly]() -> rpc::RPCWorker::MainCb {
AppRefreshRpcGateway refreshRpc(*rpc_, traceSource(tracePage, listOnly ? "Address list" : "Address refresh"));
auto result = NetworkRefreshService::collectAddressRefreshResult(refreshRpc, addressSnapshot, listOnly);
return [this, previousAddressCount, previousWalletIdentity, previousZ, ticket, result = std::move(result)]() mutable {
return [this, previousAddressCount, previousWalletIdentity, previousZ, ticket, listOnly, result = std::move(result)]() mutable {
const bool addrListOk = result.addressListOk; // capture before the move
scan_governor_.complete(ticket, result.scanMs,
(addrListOk && result.balancesOk) ? services::ScanOutcome::Ok
: result.replyTimedOut ? services::ScanOutcome::TimedOut : services::ScanOutcome::Failed,
scanClockNow());
// What was applied: a full pass whose balance scan failed degrades to list-only (it keeps the
// previous balances rather than writing the zeros of the calls that didn't answer).
const bool appliedListOnly = result.listOnly;
const double scanMsAtApply = result.scanMs;
if (ticket) {
scan_governor_.complete(ticket, result.scanMs,
(addrListOk && result.balancesOk) ? services::ScanOutcome::Ok
: result.replyTimedOut ? services::ScanOutcome::TimedOut : services::ScanOutcome::Failed,
scanClockNow());
}
if (!addrListOk) {
// The enumeration failed (-28, a timeout, a missing RPC): its lists may be falsely short. Keep
// the previous lists and the flags, and hold address refreshes for a while (the history keeps
// refreshing with the kept list meanwhile).
address_retry_not_before_ = std::chrono::steady_clock::now() +
std::chrono::duration_cast<std::chrono::steady_clock::duration>(std::chrono::duration<double>(
services::addressRetryDelaySeconds(result.scanMs, result.nodeBusy || result.replyTimedOut)));
DEBUG_LOGF("[addr] address enumeration failed; keeping the previous list\n");
maybeFinishTransactionSendProgress();
return;
}
// #3: this scan already listed the wallet's unspent notes — feed the chat send-budget from it
// (dedups the dedicated chat z_listunspent). Grab it before `result` is moved below.
auto unspentNotes = std::move(result.unspentNotes);
NetworkRefreshService::applyAddressRefreshResult(state_, std::move(result));
updateChatNoteBudgetFromUnspent(unspentNotes);
// Mark the address list as loaded ONLY if enumeration actually succeeded — a swallowed
// z_listaddresses/getaddressesbyaccount failure returns a falsely-short list, and stamping it
// would let the empty-wallet warning trust a spurious 0 count (see maybeWarnEmptyWallet…).
if (addrListOk) state_.last_address_update = std::time(nullptr);
applyPendingSendBalanceDeltas(false);
NetworkRefreshService::applyAddressRefreshResult(state_, std::move(result)); // list-only keeps the balances
if (!appliedListOnly) {
updateChatNoteBudgetFromUnspent(unspentNotes);
applyPendingSendBalanceDeltas(false);
addresses_dirty_ = false; // a list-only pass still owes the full one
address_balances_loaded_ = true;
} else if (!listOnly) {
// The balance scan failed: retry after a hold scaled by what it cost (the abandoned call keeps
// running in the daemon, so this is a lower bound), not the busy floor: explicit passes bypass
// the scan budget, and this one would otherwise go again after every busy period.
address_retry_not_before_ = std::chrono::steady_clock::now() +
std::chrono::duration_cast<std::chrono::steady_clock::duration>(
std::chrono::duration<double>(services::addressRetryDelaySeconds(scanMsAtApply)));
}
state_.last_address_update = std::time(nullptr);
address_validation_cache_dirty_ = false;
address_list_dirty_ = true;
addresses_dirty_ = false;
const std::size_t currentAddressCount = state_.z_addresses.size() + state_.t_addresses.size();
const bool addressSetChanged = currentAddressCount != previousAddressCount ||
transactionHistoryCacheWalletIdentity() != previousWalletIdentity;
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;
}
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);
// 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;
}
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 (addressSetChanged) scan_governor_.invalidate(services::ScanStream::Balance); // the totals moved
if (state_.transactions.empty() || addressSetChanged) {
markTransactionsDirty();
@@ -2253,12 +2378,15 @@ void App::refreshAddressData(services::ScanTicket ticket)
network_refresh_.markDue(services::NetworkRefreshService::Timer::Transactions);
}
if (state_.transactions.empty()) loadTransactionHistoryCacheIfAvailable();
else if (addrListOk) storeTransactionHistoryCacheIfAvailable();
else 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_))
if (!state_.transactions.empty() || initial_history_scan_complete_)
noteHistoryHintsFromUnspent(unspentNotes);
// A list-only pass owes the full one: due as soon as the scans may run.
if (listOnly && services::wholeWalletScanAllowed(scanContext()))
network_refresh_.markDue(services::NetworkRefreshService::Timer::Addresses);
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.
@@ -2280,7 +2408,9 @@ bool App::refreshTransactionData()
// 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 false;
if (state_.isLocked() || state_.warming_up || runtime_rescan_active_ || rpc_busy_gate_->busy()) return false;
// The hard gates (syncing, the connect gate, locked, warming up, rescanning, busy, background): nothing
// runs, the dirty flags stay set, and the periodic refresh or leaving the held profile retries.
if (!services::wholeWalletScanAllowed(scanContext())) return false;
// A cycle in flight: the enqueue would refuse this one.
if (network_refresh_.jobInProgress(services::NetworkRefreshService::Job::Transactions)) return false;
@@ -2333,7 +2463,8 @@ bool App::refreshTransactionData()
return false;
case TxCycle::Full:
case TxCycle::Light:
if (addresses_dirty_) {
// While an address retry hold runs, scan the history with the kept list instead of spinning.
if (addresses_dirty_ && std::chrono::steady_clock::now() >= address_retry_not_before_) {
if (ticket) scan_governor_.cancel(ticket);
// The history needs the current address list; the address callback marks it due again.
refreshAddressData();
@@ -2722,12 +2853,6 @@ void App::refreshMiningInfo()
auto* rpc = (fast_rpc_ && fast_rpc_->isConnected()) ? fast_rpc_.get() : rpc_.get();
if (!w || !rpc) return;
// Capture daemon memory outside (may be accessed on main thread)
double daemonMemMb = 0.0;
if (daemon_controller_) {
daemonMemMb = daemon_controller_->memoryUsageMB();
}
// Slow-tick counter: run full getmininginfo every ~5 seconds
// to reduce RPC overhead. getlocalsolps is only needed while solo mining
// or while the Mining tab is actively showing live local hashrate.
@@ -2744,6 +2869,13 @@ void App::refreshMiningInfo()
// sync-profile intervals don't otherwise cover it).
if (state_.sync.syncing && !includeLocalHashrate) return;
ui::NavPage tracePage = current_page_;
// Capture daemon memory outside (may be accessed on main thread). Only for a poll that runs: the probe
// reads /proc (or the Windows process table).
double daemonMemMb = 0.0;
if (daemon_controller_) {
daemonMemMb = daemon_controller_->memoryUsageMB();
}
auto enqueued = network_refresh_.enqueue(services::NetworkRefreshService::Job::Mining, *w,
[this, rpc, daemonMemMb, doSlowRefresh, includeLocalHashrate, tracePage]() -> rpc::RPCWorker::MainCb {
@@ -3426,9 +3558,8 @@ void App::createNewAddress(bool shielded, std::function<void(const std::string&)
(shielded ? state_.z_addresses : state_.t_addresses).push_back(info);
state_.addresses.push_back(info); // keep combined view in sync immediately
address_list_dirty_ = true;
// Also trigger full refresh to get proper balances
addresses_dirty_ = true;
refreshAddresses();
// No forced scan: a new address has no balance and its spending key (both as injected);
// the periodic refresh lists it next time.
}
if (callback) callback(addr);
};
@@ -3577,6 +3708,7 @@ void App::invalidateAddressValidationCache()
{
address_validation_cache_dirty_ = true;
addresses_dirty_ = true;
address_retry_not_before_ = {}; // the address set changed: a refresh is owed now, whatever failed before
noteWalletMutation(); // an import or sweep: the balance changes without the tip or mempool showing it
invalidateShieldedHistoryScanProgress(true);
}
@@ -3752,6 +3884,9 @@ void App::maybeProvisionChatIdentity()
// identity that works on any daemon. Both secrets are wiped after deriving.
if (!state_.connected || !rpc_ || !worker_) return;
if (!state_.encryption_state_known) return; // don't fetch before we know the lock state
// Nor before the address list: the fallback identity (no mnemonic) derives from a z-address, and an
// empty one would latch chat as unavailable for the session.
if (state_.last_address_update == 0) return;
const std::string fallbackZaddr = chatReplyZaddr(); // main thread: stable identity source
const int fetchGen = chat_session_generation_; // wallet epoch this secret belongs to
chat_identity_fetch_in_flight_ = true;
@@ -4202,7 +4337,13 @@ void App::refreshChatNoteBudgetNode() {
// and holds cs_main for its whole duration — seconds on a large wallet — so an ungated per-few-second
// chat scan here starves block connection and keeps the node from ever pinning to the tip. Chat
// send-readiness can wait until we're synced; this budget is only advisory (the send path re-checks).
if (effectivelySyncing()) return;
// The same hard gates as every wallet scan, except that a queued chat send still drains while the window
// is in the background (the address scan that also feeds the budget waits for the window).
{
services::ScanContext sc = scanContext();
if (!chat_send_queue_.empty() || chat_split_outstanding_) sc.background = false;
if (!services::wholeWalletScanAllowed(sc)) return;
}
// Only pre-warm the send-budget while the user is actually in chat. Off the Chat tab this heavy
// z_listunspent is unnecessary — and on a wallet-data tab, refreshAddressData's own z_listunspent
// already feeds the budget for free (updateChatNoteBudgetFromUnspent), so this dedicated scan is a
@@ -4542,7 +4683,8 @@ void App::fastScanChatMemos()
// address) — seconds on a large wallet — so this 0-conf poll must NOT run while the node is catching
// up, and even when synced it backs off to ~10% wall-clock keyed on its own cost. The block-tip
// harvest still ingests every message on confirmation; this only trades away mempool-speed delivery.
if (effectivelySyncing()) return;
// The same hard gates as every wallet scan (catch-up, the connect gate, the background, busy, ...).
if (!services::wholeWalletScanAllowed(scanContext())) return;
// Mempool-speed 0-conf delivery is only worth its heavy cost while the user is watching chat. Off the
// Chat tab, incoming messages still arrive via the confirmed block-tip harvest (+ toast) within ~1 block.
if (current_page_ != ui::NavPage::Chat) return;
@@ -5043,6 +5185,7 @@ void App::exportSeedPhrase(std::function<void(bool, bool, const std::string&, co
void App::maybeRemindSeedBackup()
{
if (capture_mode_) return; // no live ops during a UI sweep
if (window_background_) return; // when someone can see it
if (lite_wallet_) return; // lite has its own seed UX
if (!settings_ || settings_->getSeedBackupReminded()) return;
if (!state_.connected || !state_.encryption_state_known) return;
@@ -5075,6 +5218,7 @@ void App::maybeRemindSeedBackup()
void App::maybeWarnLargeWallet()
{
if (capture_mode_ || lite_wallet_) return; // no live nags during a UI sweep; lite has no wallet.dat
if (window_background_) return; // when someone can see it
if (!supportsFullNodeLifecycleActions() || !settings_) return;
if (!state_.connected || !state_.encryption_state_known) return;
if (state_.warming_up || state_.daemon_initializing || !state_.sync.isSynced()) return;
+2
View File
@@ -324,6 +324,7 @@ struct WalletState {
// The last balance call that was sent failed (or timed out): the staleness badge then shows at its usual
// thresholds even while the scan budget is holding the next one.
bool balance_last_attempt_failed = false;
int wallet_tx_count = -1; // getwalletinfo's txcount; -1 = unknown
int64_t last_address_update = 0; // set when an address-list refresh applies; 0 = never loaded yet
int64_t last_tx_update = 0;
int64_t last_peer_update = 0;
@@ -370,6 +371,7 @@ struct WalletState {
last_balance_update = last_address_update = last_tx_update = last_peer_update = last_mining_update = 0;
balance_verified_at = 0;
balance_last_attempt_failed = false;
wallet_tx_count = -1;
}
// Rebuild combined addresses list from z/t lists
+17
View File
@@ -1622,6 +1622,7 @@ int main(int argc, char* argv[])
int dpiSettleFrames = 0;
SDL_DisplayID lastLoggedDisplay = 0; // [WINLOG] throttle: log MOVED only when the display changes
Uint64 minimizedLastTickMs = 0; // real-clock tick for the minimized "keep syncing" update
Uint64 backgroundSinceMs = 0; // when the window started to look backgrounded (0 = it doesn't)
{
float s = dragonx::ui::material::Typography::instance().getDpiScale();
int w = 0, h = 0;
@@ -1877,6 +1878,22 @@ int main(int argc, char* argv[])
}
}
// C134: the window is in the background when it is minimized, hidden, or covered while none of our
// windows has focus, for a few seconds with no input meanwhile. Focus loss alone never counts. The
// app then polls only the chain state; coming back is immediate.
{
const Uint64 nowMs = SDL_GetTicks();
const SDL_WindowFlags wf = SDL_GetWindowFlags(window);
const bool flagged = dragonx::services::windowLooksBackground(
(wf & SDL_WINDOW_MINIMIZED) != 0, (wf & SDL_WINDOW_HIDDEN) != 0,
(wf & SDL_WINDOW_OCCLUDED) != 0, SDL_GetKeyboardFocus() != nullptr);
if (!flagged) backgroundSinceMs = 0;
else if (backgroundSinceMs == 0) backgroundSinceMs = nowMs;
app.setWindowBackground(dragonx::services::isBackgroundWindow(
flagged, backgroundSinceMs != 0 ? (nowMs - backgroundSinceMs) / 1000.0 : 0.0,
lastInputMs != 0 ? (nowMs - lastInputMs) / 1000.0 : 1.0e9));
}
// Check if window is minimized
if (SDL_GetWindowFlags(window) & SDL_WINDOW_MINIMIZED) {
// Keep the wallet syncing while minimized: run the logic update (drains RPC results, ticks the
+63
View File
@@ -0,0 +1,63 @@
// DragonX Wallet - ImGui Edition
// Copyright 2024-2026 The Hush Developers
// Released under the GPLv3
#pragma once
// Holds the wallet-wide scans (the balance, the address scan, the history) for a moment after the node
// starts answering: until the lock state is known and either the node knows the chain tip or the wallet is
// small. Right after warmup a node that is behind doesn't know it yet (longestchain stays 0 until its first
// UpdateTip), so the first Core poll would read "synced" and a large wallet's scans would start, holding
// cs_main ahead of the catch-up. Opens after kGraceSeconds whatever happens.
#include <chrono>
namespace dragonx {
namespace services {
class ConnectScanGate {
public:
using Clock = std::chrono::steady_clock;
static constexpr int kGraceSeconds = 20;
static constexpr int kMinWalletTxs = 500; // below this the scans are cheap: don't hold them for the tip
void arm(Clock::time_point now, bool tipKnown)
{
armed_ = true;
edgePending_ = true;
tipKnown_ = tipKnown;
armedAt_ = now;
}
void noteTipKnown() { tipKnown_ = true; }
void disarm()
{
armed_ = false;
edgePending_ = false;
}
bool armed() const { return armed_; }
// walletTxCount: getwalletinfo's txcount, -1 when unknown.
bool open(Clock::time_point now, bool lockStateKnown, int walletTxCount) const
{
if (!armed_) return true;
const bool smallWallet = walletTxCount >= 0 && walletTxCount < kMinWalletTxs;
if (lockStateKnown && (tipKnown_ || smallWallet)) return true;
return now - armedAt_ >= std::chrono::seconds(kGraceSeconds);
}
// True once per arm(), the first time open() is: what the gate held is owed now.
bool consumeOpenEdge(Clock::time_point now, bool lockStateKnown, int walletTxCount)
{
if (!armed_ || !edgePending_ || !open(now, lockStateKnown, walletTxCount)) return false;
edgePending_ = false;
return true;
}
private:
bool armed_ = false;
bool edgePending_ = false;
bool tipKnown_ = false;
Clock::time_point armedAt_{};
};
} // namespace services
} // namespace dragonx
+68 -14
View File
@@ -247,9 +247,21 @@ NetworkRefreshService::WalletEncryptionResult NetworkRefreshService::parseWallet
result.ok = false;
}
}
if (walletInfo.contains("txcount") && walletInfo["txcount"].is_number_integer())
result.txCount = walletInfo["txcount"].get<int>();
return result;
}
bool NetworkRefreshService::connectionInfoTipKnown(const ConnectionInfoResult& info)
{
return info.ok && info.longestChain && *info.longestChain > 0;
}
bool NetworkRefreshService::connectionInfoShowsBehind(const ConnectionInfoResult& info)
{
return connectionInfoTipKnown(info) && info.blocks && *info.blocks < *info.longestChain - 2;
}
NetworkRefreshService::WarmupPollResult NetworkRefreshService::collectWarmupPollResult(RefreshRpcGateway& rpc)
{
WarmupPollResult result;
@@ -378,7 +390,7 @@ NetworkRefreshService::CoreRefreshResult NetworkRefreshService::collectCoreRefre
// balance scans: they need the same cs_main, and each would only take another of its few RPC slots.
bool balanceAttempted = false;
bool spendableQueried = false;
if (plan.includeBalance && !behind && !rpc::rpcKindMeansBusy(blockErrKind)) {
if (plan.includeBalance && (!behind || plan.evenIfBehind) && !rpc::rpcKindMeansBusy(blockErrKind)) {
// z_gettotalbalance is O(mapWallet) and holds the daemon's cs_main for its whole duration —
// seconds on a large shielded wallet. Time it so the caller can throttle how often it polls
// (balanceRefreshDue()), keeping balance scans from starving block connection.
@@ -402,7 +414,9 @@ NetworkRefreshService::CoreRefreshResult NetworkRefreshService::collectCoreRefre
const auto total = totalBalance.is_object() ? totalBalance.find("total") : totalBalance.end();
if (total != totalBalance.end() && total->is_string()) totalZat = parseMoneyZat(total->get<std::string>());
const bool totalMoved = !totalZat || !plan.prevTotalZat || *totalZat != *plan.prevTotalZat;
if (plan.needSpendable || totalMoved) {
// Not on the session's first read while the node is behind: nothing can be sent until it caught
// up anyway, and it is a second whole-wallet scan into the catch-up.
if ((plan.needSpendable || totalMoved) && !(behind && plan.evenIfBehind)) {
spendableQueried = true;
try { // If it fails, spendable degrades to display in apply.
spendableBalance = rpc.call("z_gettotalbalance", json::array({1}));
@@ -753,9 +767,12 @@ NetworkRefreshService::AddressRefreshSnapshot NetworkRefreshService::buildAddres
NetworkRefreshService::AddressRefreshResult NetworkRefreshService::collectAddressRefreshResult(
RefreshRpcGateway& rpc,
const AddressRefreshSnapshot& snapshot)
const AddressRefreshSnapshot& snapshot,
bool listOnly)
{
AddressRefreshResult result;
result.listOnly = listOnly;
// Time the whole scan — z_listunspent (and per-address z_getbalance fallback) hold the daemon's
// cs_main for the duration, seconds on a large shielded wallet. The measured cost feeds the
// caller's adaptive throttle so the address poll can't starve block connection.
@@ -797,9 +814,23 @@ NetworkRefreshService::AddressRefreshResult NetworkRefreshService::collectAddres
DEBUG_LOGF("z_listaddresses error: %s\n", e.what());
result.addressListOk = false; // enumeration failed → the shielded list may be falsely short
result.replyTimedOut |= rpc::rpcFailureKindOf(e) == rpc::RpcFailureKind::ReplyTimeout;
result.nodeBusy |= rpc::rpcKindMeansBusy(rpc::rpcFailureKindOf(e));
}
try {
json tList = rpc.call("getaddressesbyaccount", json::array({""}));
result.transparentAddresses = parseTransparentAddressList(tList);
} catch (const std::exception& e) {
DEBUG_LOGF("getaddressesbyaccount error: %s\n", e.what());
result.addressListOk = false; // enumeration failed → the transparent list may be falsely short
result.replyTimedOut |= rpc::rpcFailureKindOf(e) == rpc::RpcFailureKind::ReplyTimeout;
result.nodeBusy |= rpc::rpcKindMeansBusy(rpc::rpcFailureKindOf(e));
}
// The balances need the lists they belong to: none in a list-only pass, and none when an enumeration
// failed (the caller discards the whole pass and keeps the previous lists).
const bool scanBalances = !listOnly && result.addressListOk;
if (scanBalances) try {
json unspent = rpc.call("z_listunspent", json::array({0, 9999999, false})); // minconf=0 → include 0-conf change
applyShieldedBalancesFromUnspent(result.shieldedAddresses, unspent);
// Retain a minimal view so a downstream consumer (chat note-budget) can reuse this scan instead
@@ -822,8 +853,15 @@ NetworkRefreshService::AddressRefreshResult NetworkRefreshService::collectAddres
}
}
} catch (const std::exception& e) {
const auto kind = rpc::rpcFailureKindOf(e);
result.replyTimedOut |= kind == rpc::RpcFailureKind::ReplyTimeout;
if (rpc::rpcKindMeansBusy(kind)) {
// The node is busy: every z_getbalance would be held back and read 0. Nothing more this pass.
DEBUG_LOGF("z_listunspent: node busy (%s)\n", e.what());
result.balancesOk = false;
result.nodeBusy = true;
} else {
DEBUG_LOGF("z_listunspent unavailable (%s), falling back to z_getbalance\n", e.what());
result.replyTimedOut |= rpc::rpcFailureKindOf(e) == rpc::RpcFailureKind::ReplyTimeout;
for (auto& info : result.shieldedAddresses) {
try { // display total (minconf=0, includes pending change)
json total = rpc.call("z_getbalance", json::array({info.address, 0}));
@@ -834,24 +872,23 @@ NetworkRefreshService::AddressRefreshResult NetworkRefreshService::collectAddres
info.spendableBalance = (!conf.is_null()) ? conf.get<double>() : info.balance;
} catch (...) { info.spendableBalance = info.balance; }
}
}
}
try {
json tList = rpc.call("getaddressesbyaccount", json::array({""}));
result.transparentAddresses = parseTransparentAddressList(tList);
} catch (const std::exception& e) {
DEBUG_LOGF("getaddressesbyaccount error: %s\n", e.what());
result.addressListOk = false; // enumeration failed → the transparent list may be falsely short
result.replyTimedOut |= rpc::rpcFailureKindOf(e) == rpc::RpcFailureKind::ReplyTimeout;
}
try {
if (scanBalances && !result.nodeBusy) try {
json unspent = rpc.call("listunspent", json::array({0})); // minconf=0 → include 0-conf change
applyTransparentBalancesFromUnspent(result.transparentAddresses, unspent);
} catch (const std::exception& e) {
DEBUG_LOGF("listunspent error: %s\n", e.what());
result.balancesOk = false;
result.replyTimedOut |= rpc::rpcFailureKindOf(e) == rpc::RpcFailureKind::ReplyTimeout;
result.nodeBusy |= rpc::rpcKindMeansBusy(rpc::rpcFailureKindOf(e));
}
// A balance that wasn't read reads 0: never overwrite known balances with that. The pass degrades to
// list-only (apply carries the previous balances), and its notes aren't a full view either.
if (scanBalances && !result.balancesOk) {
result.listOnly = true;
result.unspentNotes.clear();
}
result.scanMs = std::chrono::duration<double, std::milli>(
@@ -1694,6 +1731,7 @@ void NetworkRefreshService::applyWalletEncryptionResult(WalletState& state, cons
state.unlocked_until = 0;
}
state.encryption_state_known = true;
if (result.txCount) state.wallet_tx_count = *result.txCount;
}
void NetworkRefreshService::applyConnectionInitResult(WalletState& state, const ConnectionInitResult& result)
@@ -1810,6 +1848,22 @@ void NetworkRefreshService::applyPriceRefreshFailure(WalletState& state,
void NetworkRefreshService::applyAddressRefreshResult(WalletState& state,
AddressRefreshResult&& result)
{
if (result.listOnly) {
// No balance scan ran: keep each address's last known balances (a new address shows 0 until the
// next full pass).
auto carry = [](std::vector<AddressInfo>& fresh, const std::vector<AddressInfo>& previous) {
std::unordered_map<std::string, const AddressInfo*> known;
for (const auto& info : previous) known[info.address] = &info;
for (auto& info : fresh) {
const auto it = known.find(info.address);
if (it == known.end()) continue;
info.balance = it->second->balance;
info.spendableBalance = it->second->spendableBalance;
}
};
carry(result.shieldedAddresses, state.z_addresses);
carry(result.transparentAddresses, state.t_addresses);
}
state.z_addresses = std::move(result.shieldedAddresses);
state.t_addresses = std::move(result.transparentAddresses);
// Keep the combined `addresses` view in sync with the authoritative z/t lists.
+15 -1
View File
@@ -91,6 +91,7 @@ public:
bool ok = false;
bool encrypted = false;
std::int64_t unlockedUntil = 0;
std::optional<int> txCount; // getwalletinfo's txcount (the wallet's transactions)
};
struct WarmupPollResult {
@@ -147,6 +148,7 @@ public:
bool needSpendable = true;
std::optional<std::int64_t> prevTotalZat;
bool fetchMempool = false; // add getmempoolinfo after a successful getblockchaininfo
bool evenIfBehind = false; // the session's first balance read: also while the node is behind
};
struct MiningRefreshResult {
@@ -208,6 +210,10 @@ public:
std::vector<UnspentNoteLite> unspentNotes;
bool balancesOk = true; // false: a balance call failed, the per-address balances may be stale
bool replyTimedOut = false; // a call hit the reply timeout (the daemon kept running it)
// The lists only: no z_listunspent / listunspent, or a balance call failed (the pass then degrades to
// list-only). Applying it carries the previous balances over.
bool listOnly = false;
bool nodeBusy = false; // a call failed because the node was busy (timeout, full queue, held back)
};
struct AddressRefreshSnapshot {
@@ -317,6 +323,10 @@ public:
static Intervals intervalsForPage(ui::NavPage page) { return RefreshScheduler::intervalsForPage(page); }
static ConnectionInfoResult parseConnectionInfoResult(const nlohmann::json& info);
// The node knows the chain tip: longestchain stays 0 after a start until its first UpdateTip.
static bool connectionInfoTipKnown(const ConnectionInfoResult& info);
// The tip is known and the node is more than 2 blocks behind it (the sync tolerance).
static bool connectionInfoShowsBehind(const ConnectionInfoResult& info);
static WalletEncryptionResult parseWalletEncryptionResult(const nlohmann::json& walletInfo);
static WarmupPollResult collectWarmupPollResult(RefreshRpcGateway& rpc);
static ConnectionInitResult collectConnectionInitResult(
@@ -378,9 +388,13 @@ public:
static void applyTransparentBalancesFromUnspent(std::vector<AddressInfo>& addresses,
const nlohmann::json& unspent);
static AddressRefreshSnapshot buildAddressRefreshSnapshot(const WalletState& state);
// listOnly: enumerate the addresses only (cheap), without the wallet-wide balance scans; for while the
// scans are held (the node catching up, the connect gate). Both lists are enumerated first, and the
// balances are scanned only when both enumerations succeeded (a failed pass is discarded whole).
static AddressRefreshResult collectAddressRefreshResult(
RefreshRpcGateway& rpc,
const AddressRefreshSnapshot& snapshot = {});
const AddressRefreshSnapshot& snapshot = {},
bool listOnly = false);
static TransactionRefreshSnapshot buildTransactionRefreshSnapshot(const WalletState& state,
const TransactionViewCache& viewTxCache,
const std::unordered_set<std::string>& sendTxids);
+12
View File
@@ -26,6 +26,18 @@ RefreshScheduler::Intervals RefreshScheduler::intervalsForPage(ui::NavPage page)
}
}
RefreshScheduler::Intervals RefreshScheduler::selectIntervals(ui::NavPage page, bool syncing, bool background)
{
if (background) return kBackgroundProfile;
if (syncing) return kSyncProfile;
return intervalsForPage(page);
}
static_assert(RefreshScheduler::kSyncProfile.core * 2.0f <= RefreshScheduler::kSyncSettleSeconds,
"the sync settle window must outlast two Core polls");
static_assert(RefreshScheduler::kBackgroundProfile.core * 2.0f <= RefreshScheduler::kSyncSettleSeconds,
"the sync settle window must outlast two Core polls");
void RefreshScheduler::applyPage(ui::NavPage page)
{
setIntervals(intervalsForPage(page));
+9
View File
@@ -43,8 +43,17 @@ public:
// the lightweight Console tab syncs faster than the Peers tab. Reverts to the per-tab profile
// once sync completes. (Tx/address/balance data is incomplete mid-sync anyway.)
static constexpr Intervals kSyncProfile{10.0f, 0.0f, 0.0f, 0.0f};
// While the window is in the background (minimized, hidden, or covered without focus): only the chain
// state, slower. It keeps sync detection, the lost-connection check and the balance's own rare poll
// (auto-shield) alive; the wallet scans and peers wait for the window to come back.
static constexpr Intervals kBackgroundProfile{15.0f, 0.0f, 0.0f, 0.0f};
// How long the sync profile holds after the node last read "behind" (see the Core callback). Must
// outlast two Core polls in every held profile, or it could lapse between two "behind" readings.
static constexpr float kSyncSettleSeconds = 30.0f;
static Intervals intervalsForPage(ui::NavPage page);
// The profile in force: background, else syncing, else the page's.
static Intervals selectIntervals(ui::NavPage page, bool syncing, bool background);
void applyPage(ui::NavPage page);
void setIntervals(Intervals intervals);
+146
View File
@@ -0,0 +1,146 @@
// DragonX Wallet - ImGui Edition
// Copyright 2024-2026 The Hush Developers
// Released under the GPLv3
#pragma once
// The hard gates in front of the wallet-wide scans: whether the node can take one at all right now. The
// WalletScanGovernor decides on top of them how often. The scan entry points (refreshAddressData,
// refreshTransactionData) check these themselves, so no caller (tab entry, refreshData, an import, the
// history's re-route through the address scan) can send a scan into a node that is syncing, warming up,
// rescanning, busy or locked, or while the window is in the background. The cheap address listing (no
// wallet scan) still runs for a stale list, and the session's first balances are read once even while the
// node catches up (otherwise every balance would read 0 for the whole catch-up).
//
// Pure, header-only.
#include "ui/sidebar.h"
#include <algorithm>
namespace dragonx {
namespace services {
enum class ScanTier {
None, // nothing
ListOnly, // the address lists only (z_listaddresses, getaddressesbyaccount): cheap, no wallet scan
Full, // the lists and the per-address balances (z_listunspent, listunspent)
};
struct ScanContext {
bool connected = false;
bool locked = false;
bool warmingUp = false;
bool runtimeRescan = false;
bool rpcBusy = false;
bool syncing = false; // effectivelySyncing(): behind, or settling after catching up
bool gateOpen = true; // the ConnectScanGate
bool background = false; // the window is minimized, hidden, or covered without focus
};
// The node answers RPCs that take cs_main without queueing behind something long.
inline bool nodeCanAnswerScans(const ScanContext& c)
{
return c.connected && !c.warmingUp && !c.runtimeRescan && !c.rpcBusy;
}
// A wallet-wide scan (O(wallet), holds cs_main) may be sent.
inline bool wholeWalletScanAllowed(const ScanContext& c)
{
return nodeCanAnswerScans(c) && !c.background && !c.locked && !c.syncing && c.gateOpen;
}
// The session's first read of a wallet value (the balance, the per-address balances): like
// wholeWalletScanAllowed, but also while the node catches up. Once, so the catch-up shows the wallet's last
// known values instead of 0.
inline bool firstReadAllowed(const ScanContext& c)
{
return nodeCanAnswerScans(c) && !c.background && !c.locked && c.gateOpen;
}
// listStale: never loaded this connection, or the address set may have changed (import, sweep,
// decrypt-import). A stale list still loads while the scans are held, without the balances.
// balancesOwed: no full pass has read the per-address balances yet this session.
inline ScanTier planAddressScan(const ScanContext& c, bool listStale, bool balancesOwed = false)
{
if (wholeWalletScanAllowed(c)) return ScanTier::Full;
if (balancesOwed && firstReadAllowed(c)) return ScanTier::Full;
return (nodeCanAnswerScans(c) && listStale) ? ScanTier::ListOnly : ScanTier::None;
}
// What entering a page does. Wallet data isn't fetched on entry any more (the periodic refresh and the
// scan governor decide), except what the page can't show without: a stale address list, a never-loaded
// history.
struct PageEntryPlan {
bool core = false;
bool peers = false;
bool mining = false;
ScanTier addressesNow = ScanTier::None;
bool transactionsNow = false;
bool markAddresses = false;
bool markTransactions = false;
};
inline PageEntryPlan planPageEntry(ui::NavPage page, const ScanContext& c, bool addressListStale,
bool transactionsNeverLoaded)
{
using NP = ui::NavPage;
PageEntryPlan p;
if (!c.connected) return p;
switch (page) {
case NP::Overview:
p.core = !c.runtimeRescan && !c.rpcBusy;
break;
case NP::Peers:
p.peers = !c.rpcBusy;
break;
case NP::Mining:
p.mining = !c.warmingUp && !c.runtimeRescan && !c.rpcBusy;
break;
case NP::Send:
case NP::Receive:
case NP::History: {
const ScanTier tier = planAddressScan(c, addressListStale);
const bool loadNow = addressListStale && tier != ScanTier::None;
if (loadNow) p.addressesNow = tier;
else if (page != NP::History) p.markAddresses = true; // History needs the addresses only when stale
if (page == NP::History) {
if (!loadNow && transactionsNeverLoaded && wholeWalletScanAllowed(c)) p.transactionsNow = true;
else p.markTransactions = true;
}
break;
}
default:
break;
}
return p;
}
// The window counts as in the background when it is minimized or hidden, or covered while none of the
// app's windows has focus (SDL reports occlusion on X11, Wayland and macOS, never on Windows).
inline bool windowLooksBackground(bool minimized, bool hidden, bool occluded, bool appHasFocus)
{
return minimized || hidden || (occluded && !appHasFocus);
}
constexpr double kBackgroundDebounceSeconds = 3.0;
constexpr double kBackgroundInputGraceSeconds = 2.0;
// Background once it has looked so for a few seconds with no input meanwhile (a restore is immediate).
inline bool isBackgroundWindow(bool flagged, double flaggedForSeconds, double secondsSinceInput)
{
return flagged && flaggedForSeconds >= kBackgroundDebounceSeconds &&
secondsSinceInput >= kBackgroundInputGraceSeconds;
}
// After a failed address enumeration, hold address refreshes for max(10 s, 10x what that pass cost). A pass
// that failed because the node was busy (a reply timeout, a full queue, held back by the busy gate) gets the
// floor: its wall time was the wait for cs_main, not its cost, and the busy gate already paces a busy node.
inline double addressRetryDelaySeconds(double failedScanMs, bool nodeBusy = false)
{
if (nodeBusy) return 10.0;
return std::max(10.0, (std::max(0.0, failedScanMs) / 1000.0) / 0.10);
}
} // namespace services
} // namespace dragonx
+7 -2
View File
@@ -46,8 +46,13 @@ double hiddenBalanceIntervalSec(double emaCostSec, bool autoShieldArmed)
BalancePollDecision decideBalancePoll(const BalancePollInputs& in)
{
if (!in.allowBalance || in.syncing) return BalancePollDecision::Skip;
if (in.forced || in.neverFetched) return BalancePollDecision::Explicit;
if (!in.allowBalance || !in.gateOpen) return BalancePollDecision::Skip;
// The session's first read runs even while the node catches up, once per connection and only when
// someone can see it: otherwise the total reads 0 for the whole catch-up. After that (or if that one
// attempt failed) the balance waits for the sync to finish, a forced one too.
if (in.neverFetched && !(in.syncing && (in.background || in.firstReadTried))) return BalancePollDecision::Explicit;
if (in.syncing) return BalancePollDecision::Skip;
if (in.forced) return BalancePollDecision::Explicit;
if (!in.changed) return BalancePollDecision::Skip;
if (in.visible) {
// Just came into view with an old value: fetch it now rather than queue behind the other streams.
+4 -1
View File
@@ -78,7 +78,10 @@ ScanVisibility scanVisibilityForPage(ui::NavPage page, bool windowBackground);
enum class BalancePollDecision { Skip, AskGovernor, Explicit };
struct BalancePollInputs {
bool allowBalance = true; // false for the locked wallet's chain-state poll
bool syncing = false; // the node is catching up (or settling after it)
bool syncing = false; // the node is catching up (or settling after it): only a first read runs
bool background = false; // the window is in the background: no first read while syncing
bool firstReadTried = false; // the first read while syncing was already sent this connection
bool gateOpen = true; // the connect gate (C44): closed right after a connect, at most ~20 s
bool forced = false; // the user's own send
bool neverFetched = false; // no balance yet this session
bool changed = true; // the governor's change gate
+2
View File
@@ -1119,6 +1119,8 @@ void I18n::loadBuiltinEnglish()
strings_["no_transactions"] = "No transactions found";
strings_["loading_transactions"] = "Loading transactions";
strings_["tx_loading_queued"] = "Queued transaction refresh";
strings_["tx_loading_waits_for_sync"] = "History will update when the node finishes syncing";
strings_["refresh_waits_for_sync"] = "The node is still syncing. Wallet data will refresh when it finishes.";
strings_["tx_loading_enriching_sends"] = "Checking sent transaction details (%d)";
strings_["tx_loading_scanning_shielded"] = "Scanning shielded history (%d addresses)";
strings_["tx_loading_refreshing_cached"] = "Refreshing wallet history (%d cached)";
+349 -6
View File
@@ -29,6 +29,8 @@
#include "resources/embedded_resources.h"
#include "services/history_scan_policy.h"
#include "services/wallet_scan_governor.h"
#include "services/scan_policy.h"
#include "services/connect_scan_gate.h"
#include "services/network_refresh_service.h"
#include "services/refresh_scheduler.h"
#include "services/note_consolidation.h"
@@ -1791,11 +1793,11 @@ void testNetworkRefreshRpcCollectors()
auto addresses = Refresh::collectAddressRefreshResult(addressRpc);
EXPECT_TRUE(addressRpc.methodNames() == std::vector<std::string>({
"z_listaddresses", "z_validateaddress", "z_validateaddress",
"z_listunspent", "getaddressesbyaccount", "listunspent"
"getaddressesbyaccount", "z_listunspent", "listunspent" // both lists first, then the balances
}));
EXPECT_EQ(addressRpc.calls[1].params, json::array({"zs-one"}));
EXPECT_EQ(addressRpc.calls[2].params, json::array({"zs-two"}));
EXPECT_EQ(addressRpc.calls[4].params, json::array({""}));
EXPECT_EQ(addressRpc.calls[3].params, json::array({""}));
EXPECT_EQ(addresses.shieldedAddresses.size(), static_cast<size_t>(2));
EXPECT_TRUE(addresses.shieldedAddresses[0].has_spending_key);
EXPECT_FALSE(addresses.shieldedAddresses[1].has_spending_key);
@@ -1812,8 +1814,8 @@ void testNetworkRefreshRpcCollectors()
fallbackRpc.addResponse("listunspent", json::array());
auto fallbackAddresses = Refresh::collectAddressRefreshResult(fallbackRpc);
EXPECT_TRUE(fallbackRpc.methodNames() == std::vector<std::string>({
"z_listaddresses", "z_validateaddress", "z_listunspent",
"z_getbalance", "z_getbalance", "getaddressesbyaccount", "listunspent" // display (minconf=0) + spendable (minconf=1)
"z_listaddresses", "z_validateaddress", "getaddressesbyaccount", "z_listunspent",
"z_getbalance", "z_getbalance", "listunspent" // display (minconf=0) + spendable (minconf=1)
}));
EXPECT_TRUE(fallbackAddresses.shieldedAddresses[0].has_spending_key);
EXPECT_NEAR(fallbackAddresses.shieldedAddresses[0].balance, 4.75, 0.00000001);
@@ -1829,8 +1831,8 @@ void testNetworkRefreshRpcCollectors()
cachedAddressRpc.addResponse("listunspent", json::array());
auto cachedAddresses = Refresh::collectAddressRefreshResult(cachedAddressRpc, addressSnapshot);
EXPECT_TRUE(cachedAddressRpc.methodNames() == std::vector<std::string>({
"z_listaddresses", "z_validateaddress", "z_listunspent",
"getaddressesbyaccount", "listunspent"
"z_listaddresses", "z_validateaddress", "getaddressesbyaccount",
"z_listunspent", "listunspent"
}));
EXPECT_FALSE(cachedAddresses.shieldedAddresses[0].has_spending_key);
EXPECT_TRUE(cachedAddresses.shieldedAddresses[1].has_spending_key);
@@ -10876,6 +10878,14 @@ void testBalancePollDecisionAndVisibility()
EXPECT_TRUE(decideBalancePoll(in) == D::AskGovernor);
{ auto x = in; x.allowBalance = false; x.forced = true; EXPECT_TRUE(decideBalancePoll(x) == D::Skip); }
{ auto x = in; x.syncing = true; x.forced = true; EXPECT_TRUE(decideBalancePoll(x) == D::Skip); }
{ auto x = in; x.syncing = true; x.neverFetched = true; x.changed = false; // the first read: while behind too
EXPECT_TRUE(decideBalancePoll(x) == D::Explicit); }
{ auto x = in; x.syncing = true; x.neverFetched = true; x.firstReadTried = true; // once per connection
EXPECT_TRUE(decideBalancePoll(x) == D::Skip); }
{ auto x = in; x.syncing = true; x.neverFetched = true; x.background = true; // only when it can be seen
EXPECT_TRUE(decideBalancePoll(x) == D::Skip); }
{ auto x = in; x.neverFetched = true; x.firstReadTried = true; x.background = true; // at the tip: as before
EXPECT_TRUE(decideBalancePoll(x) == D::Explicit); }
{ auto x = in; x.forced = true; x.changed = false; EXPECT_TRUE(decideBalancePoll(x) == D::Explicit); }
{ auto x = in; x.neverFetched = true; x.changed = false; EXPECT_TRUE(decideBalancePoll(x) == D::Explicit); }
{ auto x = in; x.changed = false; EXPECT_TRUE(decideBalancePoll(x) == D::Skip); }
@@ -10955,6 +10965,29 @@ void testCoreRefreshMempool()
Refresh::collectCoreRefreshResult(rpc, plan);
EXPECT_TRUE(rpc.methodNames() == std::vector<std::string>({"getblockchaininfo", "getmempoolinfo"}));
}
{ // The first read while behind skips the spendable (minconf=1) call: nothing can be sent mid-catch-up.
MockRefreshRpc rpc;
rpc.addResponse("getblockchaininfo", json{{"blocks", 100}, {"headers", 150}, {"longestchain", 150}});
rpc.addResponse("getmempoolinfo", json{{"size", 0}, {"bytes", 0}});
rpc.addResponse("z_gettotalbalance", total);
auto first = plan;
first.evenIfBehind = true;
first.needSpendable = true;
first.prevTotalZat.reset();
Refresh::collectCoreRefreshResult(rpc, first);
EXPECT_TRUE(rpc.methodNames() == std::vector<std::string>({"getblockchaininfo", "getmempoolinfo", "z_gettotalbalance"}));
}
{ // ...unless it is the session's first read.
MockRefreshRpc rpc;
rpc.addResponse("getblockchaininfo", json{{"blocks", 100}, {"headers", 150}, {"longestchain", 150}});
rpc.addResponse("getmempoolinfo", json{{"size", 0}, {"bytes", 0}});
rpc.addResponse("z_gettotalbalance", total);
auto first = plan;
first.evenIfBehind = true;
auto result = Refresh::collectCoreRefreshResult(rpc, first);
EXPECT_TRUE(rpc.methodNames() == std::vector<std::string>({"getblockchaininfo", "getmempoolinfo", "z_gettotalbalance"}));
EXPECT_TRUE(result.balanceOk);
}
{ // The chain read found the node busy: nothing else.
MockRefreshRpc rpc;
rpc.addException("getblockchaininfo", std::make_exception_ptr(RpcTransportError(
@@ -10997,6 +11030,311 @@ void testRefreshSchedulerRetrySoon()
EXPECT_TRUE(gate.busy());
}
// C134: the refresh profile in force (background > syncing > page), and why leaving a held profile re-marks.
void testRefreshProfileSelection()
{
using dragonx::services::RefreshScheduler;
using Timer = RefreshScheduler::Timer;
using NP = dragonx::ui::NavPage;
auto same = [](const RefreshScheduler::Intervals& a, const RefreshScheduler::Intervals& b) {
return a.core == b.core && a.transactions == b.transactions && a.addresses == b.addresses && a.peers == b.peers;
};
EXPECT_TRUE(same(RefreshScheduler::kBackgroundProfile, RefreshScheduler::Intervals{15.0f, 0.0f, 0.0f, 0.0f}));
for (auto page : {NP::Overview, NP::Send, NP::History, NP::Mining, NP::Console}) {
EXPECT_TRUE(same(RefreshScheduler::selectIntervals(page, false, false), RefreshScheduler::intervalsForPage(page)));
EXPECT_TRUE(same(RefreshScheduler::selectIntervals(page, true, false), RefreshScheduler::kSyncProfile));
EXPECT_TRUE(same(RefreshScheduler::selectIntervals(page, true, true), RefreshScheduler::kBackgroundProfile));
EXPECT_TRUE(same(RefreshScheduler::selectIntervals(page, false, true), RefreshScheduler::kBackgroundProfile));
// The sync settle window outlasts two Core polls in every profile.
EXPECT_TRUE(RefreshScheduler::selectIntervals(page, false, false).core * 2.0f <= RefreshScheduler::kSyncSettleSeconds);
}
{ // In the background only Core comes due; back on a page, everything that accumulated is due.
RefreshScheduler scheduler;
scheduler.setIntervals(RefreshScheduler::kBackgroundProfile);
scheduler.tick(600.0f);
EXPECT_TRUE(scheduler.isDue(Timer::Core));
EXPECT_FALSE(scheduler.isDue(Timer::Transactions));
EXPECT_FALSE(scheduler.isDue(Timer::Addresses));
EXPECT_FALSE(scheduler.isDue(Timer::Peers));
scheduler.setIntervals(RefreshScheduler::intervalsForPage(NP::Overview));
EXPECT_TRUE(scheduler.isDue(Timer::Transactions));
EXPECT_TRUE(scheduler.isDue(Timer::Addresses));
EXPECT_TRUE(scheduler.isDue(Timer::Peers));
}
{ // A mark during a held profile zeroes the timer (lost): leaving it must mark again.
RefreshScheduler scheduler;
scheduler.setIntervals(RefreshScheduler::kBackgroundProfile);
scheduler.tick(600.0f);
scheduler.markDue(Timer::Addresses);
scheduler.setIntervals(RefreshScheduler::intervalsForPage(NP::Overview));
EXPECT_FALSE(scheduler.isDue(Timer::Addresses));
scheduler.markImmediateRefresh();
EXPECT_TRUE(scheduler.isDue(Timer::Addresses));
}
}
// C44: what the connect's getinfo says about the tip, and the txcount from getwalletinfo.
void testConnectionInfoSeeding()
{
using Refresh = dragonx::services::NetworkRefreshService;
using json = nlohmann::json;
auto info = [](int blocks, int longest) {
return Refresh::parseConnectionInfoResult(json{{"version", 120000}, {"blocks", blocks}, {"longestchain", longest}});
};
EXPECT_FALSE(Refresh::connectionInfoTipKnown(info(100, 0))); // no UpdateTip yet
EXPECT_FALSE(Refresh::connectionInfoShowsBehind(info(100, 0)));
EXPECT_TRUE(Refresh::connectionInfoTipKnown(info(100, 110)));
EXPECT_TRUE(Refresh::connectionInfoShowsBehind(info(100, 110)));
EXPECT_FALSE(Refresh::connectionInfoShowsBehind(info(108, 110))); // within the 2-block tolerance
EXPECT_FALSE(Refresh::connectionInfoTipKnown(Refresh::ConnectionInfoResult{})); // not ok
// Applying it leaves the sync state to the Core poll.
dragonx::WalletState state;
Refresh::applyConnectionInfoResult(state, info(100, 110));
EXPECT_FALSE(state.sync.syncing);
EXPECT_FALSE(state.sync.tip_known);
auto encrypted = Refresh::parseWalletEncryptionResult(json{{"unlocked_until", 0}, {"txcount", 5123}});
EXPECT_TRUE(encrypted.txCount && *encrypted.txCount == 5123);
auto plain = Refresh::parseWalletEncryptionResult(json{{"txcount", 42}});
EXPECT_TRUE(plain.txCount && *plain.txCount == 42);
EXPECT_FALSE(static_cast<bool>(Refresh::parseWalletEncryptionResult(json::object()).txCount));
dragonx::WalletState wallet;
EXPECT_EQ(wallet.wallet_tx_count, -1);
Refresh::applyWalletEncryptionResult(wallet, plain);
EXPECT_EQ(wallet.wallet_tx_count, 42);
Refresh::applyWalletEncryptionResult(wallet, Refresh::parseWalletEncryptionResult(json::object()));
EXPECT_EQ(wallet.wallet_tx_count, 42); // unknown doesn't erase it
wallet.clear();
EXPECT_EQ(wallet.wallet_tx_count, -1);
}
void testConnectScanGate()
{
using dragonx::services::ConnectScanGate;
using Clock = ConnectScanGate::Clock;
const auto t0 = Clock::now();
auto at = [&](double s) { return t0 + std::chrono::duration_cast<Clock::duration>(std::chrono::duration<double>(s)); };
ConnectScanGate gate;
EXPECT_TRUE(gate.open(at(0), false, -1)); // unarmed
EXPECT_FALSE(gate.consumeOpenEdge(at(0), true, 10));
gate.arm(at(0), false);
EXPECT_FALSE(gate.open(at(19.9), true, -1)); // tip unknown, size unknown: the grace
EXPECT_TRUE(gate.open(at(20.0), false, -1));
gate.arm(at(0), true);
EXPECT_FALSE(gate.open(at(1), false, -1)); // tip known, lock state not yet
EXPECT_TRUE(gate.open(at(1), true, -1));
gate.arm(at(0), false);
EXPECT_TRUE(gate.open(at(1), true, 100)); // small wallet: no wait for the tip
EXPECT_FALSE(gate.open(at(1), true, 600));
EXPECT_FALSE(gate.open(at(1), false, 100)); // ...but the lock state first
gate.noteTipKnown();
EXPECT_TRUE(gate.open(at(1), true, 600));
// The edge fires once per arm.
gate.arm(at(0), false);
EXPECT_FALSE(gate.consumeOpenEdge(at(5), true, 600));
EXPECT_TRUE(gate.consumeOpenEdge(at(21), true, 600));
EXPECT_FALSE(gate.consumeOpenEdge(at(22), true, 600));
gate.arm(at(30), false); // re-arm restarts the grace
EXPECT_FALSE(gate.open(at(45), true, 600));
EXPECT_TRUE(gate.consumeOpenEdge(at(50), true, 600));
gate.arm(at(60), false);
gate.disarm();
EXPECT_TRUE(gate.open(at(61), false, -1));
EXPECT_FALSE(gate.consumeOpenEdge(at(61), true, 100));
}
void testScanPolicy()
{
using namespace dragonx::services;
using NP = dragonx::ui::NavPage;
ScanContext ok;
ok.connected = true;
EXPECT_TRUE(wholeWalletScanAllowed(ok));
{
auto c = ok; c.connected = false; EXPECT_FALSE(wholeWalletScanAllowed(c));
c = ok; c.locked = true; EXPECT_FALSE(wholeWalletScanAllowed(c)); EXPECT_TRUE(nodeCanAnswerScans(c));
c = ok; c.warmingUp = true; EXPECT_FALSE(wholeWalletScanAllowed(c));
c = ok; c.runtimeRescan = true; EXPECT_FALSE(wholeWalletScanAllowed(c));
c = ok; c.rpcBusy = true; EXPECT_FALSE(wholeWalletScanAllowed(c));
c = ok; c.syncing = true; EXPECT_FALSE(wholeWalletScanAllowed(c)); EXPECT_TRUE(nodeCanAnswerScans(c));
c = ok; c.gateOpen = false; EXPECT_FALSE(wholeWalletScanAllowed(c)); EXPECT_TRUE(nodeCanAnswerScans(c));
c = ok; c.background = true; EXPECT_FALSE(wholeWalletScanAllowed(c)); EXPECT_TRUE(nodeCanAnswerScans(c));
}
EXPECT_TRUE(planAddressScan(ok, false) == ScanTier::Full);
for (auto field : {0, 1, 2, 3}) { // held scans: a stale list still loads, list-only
auto c = ok;
if (field == 0) c.syncing = true;
if (field == 1) c.gateOpen = false;
if (field == 2) c.locked = true;
if (field == 3) c.background = true;
EXPECT_TRUE(planAddressScan(c, true) == ScanTier::ListOnly);
EXPECT_TRUE(planAddressScan(c, false) == ScanTier::None);
}
for (auto field : {0, 1, 2, 3}) { // the node can't answer: nothing at all
auto c = ok;
if (field == 0) c.connected = false;
if (field == 1) c.warmingUp = true;
if (field == 2) c.runtimeRescan = true;
if (field == 3) c.rpcBusy = true;
EXPECT_TRUE(planAddressScan(c, true, true) == ScanTier::None);
}
{ // The session's first balances: read once even while the node catches up, not before the gate opens,
// nor in the background or locked (a stale list still loads list-only there).
auto behind = ok; behind.syncing = true;
EXPECT_TRUE(planAddressScan(behind, false, true) == ScanTier::Full);
EXPECT_TRUE(planAddressScan(behind, true, true) == ScanTier::Full);
EXPECT_TRUE(planAddressScan(behind, false, false) == ScanTier::None);
auto gated = behind; gated.gateOpen = false;
EXPECT_TRUE(planAddressScan(gated, true, true) == ScanTier::ListOnly);
auto hidden = behind; hidden.background = true;
EXPECT_TRUE(planAddressScan(hidden, true, true) == ScanTier::ListOnly);
auto locked = behind; locked.locked = true;
EXPECT_TRUE(planAddressScan(locked, false, true) == ScanTier::None);
EXPECT_TRUE(firstReadAllowed(behind) && !wholeWalletScanAllowed(behind));
}
auto syncing = ok;
syncing.syncing = true;
{ // Receive, synced, list loaded: just mark the timer.
auto p = planPageEntry(NP::Receive, ok, false, false);
EXPECT_TRUE(p.markAddresses && p.addressesNow == ScanTier::None && !p.transactionsNow && !p.markTransactions);
}
{ // Receive, stale list, syncing: the list now, list-only.
auto p = planPageEntry(NP::Receive, syncing, true, false);
EXPECT_TRUE(p.addressesNow == ScanTier::ListOnly && !p.markAddresses);
}
{ // History, list loaded, history never loaded: the history now.
auto p = planPageEntry(NP::History, ok, false, true);
EXPECT_TRUE(p.transactionsNow && !p.markAddresses && p.addressesNow == ScanTier::None);
}
{ // History, stale list: the addresses first (their callback brings the history).
auto p = planPageEntry(NP::History, ok, true, true);
EXPECT_TRUE(p.addressesNow == ScanTier::Full && p.markTransactions && !p.transactionsNow);
}
{ // History while syncing, never loaded: wait (marked).
auto p = planPageEntry(NP::History, syncing, false, true);
EXPECT_TRUE(!p.transactionsNow && p.markTransactions);
}
{
auto busy = ok; busy.rpcBusy = true;
auto rescan = ok; rescan.runtimeRescan = true;
EXPECT_FALSE(planPageEntry(NP::Overview, busy, false, false).core);
EXPECT_FALSE(planPageEntry(NP::Overview, rescan, false, false).core);
EXPECT_TRUE(planPageEntry(NP::Overview, ok, false, false).core);
EXPECT_FALSE(planPageEntry(NP::Mining, rescan, false, false).mining);
EXPECT_TRUE(planPageEntry(NP::Mining, ok, false, false).mining);
EXPECT_FALSE(planPageEntry(NP::Peers, busy, false, false).peers);
ScanContext off;
auto p = planPageEntry(NP::Receive, off, true, true);
EXPECT_TRUE(!p.core && !p.markAddresses && p.addressesNow == ScanTier::None && !p.markTransactions);
}
EXPECT_TRUE(windowLooksBackground(true, false, false, true));
EXPECT_TRUE(windowLooksBackground(false, true, false, true));
EXPECT_TRUE(windowLooksBackground(false, false, true, false));
EXPECT_FALSE(windowLooksBackground(false, false, true, true)); // covered, but the app has focus
EXPECT_FALSE(windowLooksBackground(false, false, false, false)); // focus loss alone
EXPECT_FALSE(isBackgroundWindow(true, 2.9, 10.0));
EXPECT_FALSE(isBackgroundWindow(true, 3.0, 1.0)); // input a second ago
EXPECT_TRUE(isBackgroundWindow(true, 3.0, 2.0));
EXPECT_FALSE(isBackgroundWindow(false, 100.0, 100.0));
EXPECT_NEAR(addressRetryDelaySeconds(0.0), 10.0, 1e-9);
EXPECT_NEAR(addressRetryDelaySeconds(20000.0), 200.0, 1e-9);
EXPECT_NEAR(addressRetryDelaySeconds(30000.0, true), 10.0, 1e-9); // busy: the wait isn't its cost
// The balance is held while the connect gate is closed, even when forced or never read.
BalancePollInputs in;
in.gateOpen = false;
in.forced = true;
EXPECT_TRUE(decideBalancePoll(in) == BalancePollDecision::Skip);
in.forced = false;
in.neverFetched = true;
EXPECT_TRUE(decideBalancePoll(in) == BalancePollDecision::Skip);
}
// C44: the list-only address pass, and a failed enumeration not scanning balances for a short list.
void testAddressListOnlyCollector()
{
using Refresh = dragonx::services::NetworkRefreshService;
using json = nlohmann::json;
{
MockRefreshRpc rpc;
rpc.addResponse("z_listaddresses", json::array({"zs-known", "zs-new"}));
rpc.addResponse("z_validateaddress", json{{"ismine", true}});
rpc.addResponse("getaddressesbyaccount", json::array({"R-known"}));
dragonx::WalletState state;
state.z_addresses.push_back({"zs-known", 1.5, "shielded", true});
state.z_addresses.back().spendableBalance = 1.25;
state.t_addresses.push_back({"R-known", 2.0, "transparent", true});
auto snapshot = Refresh::buildAddressRefreshSnapshot(state);
auto result = Refresh::collectAddressRefreshResult(rpc, snapshot, true);
EXPECT_TRUE(rpc.methodNames() == std::vector<std::string>({"z_listaddresses", "z_validateaddress", "getaddressesbyaccount"}));
EXPECT_TRUE(result.listOnly && result.addressListOk);
Refresh::applyAddressRefreshResult(state, std::move(result));
EXPECT_EQ(state.z_addresses.size(), static_cast<size_t>(2));
EXPECT_NEAR(state.z_addresses[0].balance, 1.5, 1e-9); // carried over
EXPECT_NEAR(state.z_addresses[0].spendableBalance, 1.25, 1e-9);
EXPECT_NEAR(state.z_addresses[1].balance, 0.0, 1e-9); // new: 0 until the full pass
EXPECT_NEAR(state.t_addresses[0].balance, 2.0, 1e-9);
}
{ // A failed enumeration scans no balances: the caller discards the whole pass.
MockRefreshRpc rpc;
rpc.addFailure("z_listaddresses", "Loading block index...");
rpc.addResponse("getaddressesbyaccount", json::array({"R-one"}));
auto result = Refresh::collectAddressRefreshResult(rpc);
EXPECT_TRUE(rpc.methodNames() == std::vector<std::string>({"z_listaddresses", "getaddressesbyaccount"}));
EXPECT_FALSE(result.addressListOk);
EXPECT_FALSE(result.nodeBusy);
}
{
MockRefreshRpc rpc;
rpc.addResponse("z_listaddresses", json::array());
rpc.addFailure("getaddressesbyaccount", "boom");
auto result = Refresh::collectAddressRefreshResult(rpc);
EXPECT_TRUE(rpc.methodNames() == std::vector<std::string>({"z_listaddresses", "getaddressesbyaccount"}));
EXPECT_FALSE(result.addressListOk);
}
{ // A busy balance scan: no z_getbalance fallback (each would be held back and read 0), no listunspent,
// and the pass degrades to list-only so applying it keeps the known balances.
using dragonx::rpc::RpcFailureKind;
using dragonx::rpc::RpcTransportError;
MockRefreshRpc rpc;
rpc.addResponse("z_listaddresses", json::array({"zs-a"}));
rpc.addResponse("z_validateaddress", json{{"ismine", true}});
rpc.addResponse("getaddressesbyaccount", json::array({"R-a"}));
rpc.addException("z_listunspent", std::make_exception_ptr(RpcTransportError(
RpcFailureKind::ReplyTimeout, "RPC error: reply timeout", CURLE_OPERATION_TIMEDOUT, 0, true)));
auto result = Refresh::collectAddressRefreshResult(rpc);
EXPECT_TRUE(rpc.methodNames() == std::vector<std::string>({"z_listaddresses", "z_validateaddress",
"getaddressesbyaccount", "z_listunspent"}));
EXPECT_TRUE(result.listOnly && !result.balancesOk && result.nodeBusy && result.unspentNotes.empty());
EXPECT_TRUE(result.addressListOk);
dragonx::WalletState state;
state.z_addresses.push_back({"zs-a", 1.5, "shielded", true});
state.t_addresses.push_back({"R-a", 2.0, "transparent", true});
Refresh::applyAddressRefreshResult(state, std::move(result));
EXPECT_NEAR(state.z_addresses[0].balance, 1.5, 1e-9);
EXPECT_NEAR(state.t_addresses[0].balance, 2.0, 1e-9);
}
{ // A failed transparent balance call degrades the pass the same way.
MockRefreshRpc rpc;
rpc.addResponse("z_listaddresses", json::array());
rpc.addResponse("getaddressesbyaccount", json::array({"R-a"}));
rpc.addResponse("z_listunspent", json::array());
rpc.addFailure("listunspent", "boom");
auto result = Refresh::collectAddressRefreshResult(rpc);
EXPECT_TRUE(result.listOnly && !result.balancesOk && !result.nodeBusy);
}
{ // A busy node's failure is marked so (its retry hold is the floor, not 10x the wait).
using dragonx::rpc::RpcFailureKind;
using dragonx::rpc::RpcTransportError;
MockRefreshRpc rpc;
rpc.addException("z_listaddresses", std::make_exception_ptr(RpcTransportError(
RpcFailureKind::ReplyTimeout, "RPC error: reply timeout", CURLE_OPERATION_TIMEDOUT, 0, true)));
rpc.addResponse("getaddressesbyaccount", json::array());
auto result = Refresh::collectAddressRefreshResult(rpc);
EXPECT_TRUE(!result.addressListOk && result.nodeBusy && result.replyTimedOut);
}
}
int main()
{
testConnectionConfig();
@@ -11031,6 +11369,11 @@ int main()
testBalancePollDecisionAndVisibility();
testCoreRefreshMempool();
testRefreshSchedulerRetrySoon();
testRefreshProfileSelection();
testConnectionInfoSeeding();
testConnectScanGate();
testScanPolicy();
testAddressListOnlyCollector();
testNetworkRefreshResultModels();
testTrackedSendRetirement();
testBalanceDisplayLerp();
+3
View File
@@ -122,6 +122,9 @@ inherits the GUI's environment.
| `FAKE_STUCK_SHUTDOWN` | 0 | the Sep 29 orphan: hold the datadir lock, no listener, ignore SIGTERM, never exit |
| `FAKE_WALLET_NOTES` | (off) | a note wallet: `addr:count:amount,...` shielded notes; enables `z_mergetoaddress` + its operations (dragonxd rules: confirmed unlocked notes, a batch locked on return, merged note next block) |
| `FAKE_MERGE_SECS` | 6 | how long a `z_mergetoaddress` operation runs before it succeeds |
| `FAKE_TIP_UNKNOWN_SECS` | 0 | `longestchain` reads 0 this long after `Done Loading` (a node that hasn't seen an UpdateTip yet) |
| `FAKE_LONGEST_AHEAD` / `FAKE_CATCHUP_SECS` | 0 / 0 | the network tip (`longestchain`, `headers`) is N blocks ahead, for that long after `Done Loading` (0 = for good) |
| `FAKE_TXCOUNT` | 42 | `getwalletinfo` `txcount` (the GUI's connect gate waits for the tip on wallets of 500+) |
| `FAKE_METHODS_ALL` | 0 | `1` = one `call m=<method> p=<params>` event per request (the GUI's RPC cadence; the `methods` lines keep only the top 10 counts) |
| `FAKE_HEIGHT`, `FAKE_REBUILD_TOTAL`, `FAKE_NOTES`, `FAKE_VERSION` | 3327145, 62583, 13632, 1030050 | cosmetic numbers |
+21 -4
View File
@@ -123,6 +123,10 @@ struct Config {
bool stuckShutdown = false; // the Sep 29 orphan: datadir lock held, no listener, never exits
bool mining = false; // internal miner on: `stop` must wait for cs_main first
bool rebuildSilent = false; // hold cs_main like a rebuild but log no rebuild markers at all
double tipUnknownSecs = 0; // longestchain reads 0 this long after "Done Loading" (no UpdateTip seen yet)
long longestAhead = 0; // the network tip is this many blocks ahead: the node is behind...
double catchupSecs = 0; // ...for this long after "Done Loading" (0 = for good)
long txcount = 42; // getwalletinfo txcount
};
Config g_cfg;
@@ -272,6 +276,7 @@ void event(const std::string& name, const std::string& extra = "")
std::mutex g_csMain; // the daemon's cs_main
std::atomic<long> g_height{0};
std::atomic<bool> g_warmup{true};
std::atomic<long long> g_loadedAtMs{0}; // when warmup ended ("Done Loading")
std::mutex g_warmupMx;
std::string g_warmupStatus = "Loading block index...";
std::atomic<bool> g_minerOn{false};
@@ -602,19 +607,26 @@ Canned cannedReply(const std::string& m, const json& params)
return params[0].is_string() ? params[0].get<std::string>() : params[0].dump();
};
// FAKE_TIP_UNKNOWN_SECS / FAKE_LONGEST_AHEAD / FAKE_CATCHUP_SECS: what the node knows of the network tip.
const double sinceLoaded = g_loadedAtMs.load() > 0 ? (unixMs() - g_loadedAtMs.load()) / 1000.0 : 0.0;
const bool tipUnknown = g_loadedAtMs.load() == 0 || sinceLoaded < g_cfg.tipUnknownSecs;
const long ahead = (g_cfg.catchupSecs > 0 && sinceLoaded >= g_cfg.catchupSecs) ? 0 : g_cfg.longestAhead;
const long longest = tipUnknown ? 0 : h + ahead;
const long headers = h + ahead;
if (m == "getinfo") {
c.result = {{"version", g_cfg.version}, {"protocolversion", 170012}, {"walletversion", 60000},
{"balance", 0.0}, {"blocks", h}, {"longestchain", h}, {"notarized", 0},
{"balance", 0.0}, {"blocks", h}, {"longestchain", longest}, {"notarized", 0},
{"tiptime", now}, {"difficulty", difficulty}, {"keypoololdest", now - 86400},
{"keypoolsize", 101}, {"paytxfee", 0.0}, {"timeoffset", 0}, {"connections", 2},
{"tls_connections", 2}, {"proxy", ""}, {"testnet", false}, {"relayfee", 0.000001},
{"errors", ""}, {"name", "DRAGONX"}, {"p2pport", 21768}, {"rpcport", g_cfg.rpcPort}};
} else if (m == "getblockchaininfo") {
c.result = {{"chain", "main"}, {"blocks", h}, {"synced", true}, {"headers", h},
c.result = {{"chain", "main"}, {"blocks", h}, {"synced", ahead == 0}, {"headers", headers},
{"bestblockhash", fakeHash(h)}, {"difficulty", difficulty}, {"verificationprogress", 1.0},
{"chainwork", std::string(48, '0') + "1f3a5c7e9b2d4f60"}, {"pruned", false},
{"valuePools", json::array()}, {"softforks", json::array()}, {"upgrades", json::object()},
{"longestchain", h}, {"notarized", 0}};
{"longestchain", longest}, {"notarized", 0}};
} else if (m == "getblockcount") {
c.result = h;
} else if (m == "getbestblockhash") {
@@ -631,7 +643,7 @@ Canned cannedReply(const std::string& m, const json& params)
{"size", 1765}};
} else if (m == "getwalletinfo") {
c.result = {{"walletversion", 60000}, {"balance", 0.0}, {"unconfirmed_balance", 0.0},
{"immature_balance", 0.0}, {"txcount", 42}, {"keypoololdest", now - 86400},
{"immature_balance", 0.0}, {"txcount", g_cfg.txcount}, {"keypoololdest", now - 86400},
{"keypoolsize", 101}, {"paytxfee", 0.0}};
} else if (m == "z_gettotalbalance") {
c.result = {{"transparent", "0.00"}, {"private", "1234.5678"}, {"total", "1234.5678"}};
@@ -1348,6 +1360,10 @@ void loadConfig(int argc, char** argv)
g_cfg.noiseSecs = std::max(1.0, envDouble("FAKE_NOISE_SECS", g_cfg.noiseSecs));
g_cfg.serverTimeoutSecs = std::max(1.0, envDouble("FAKE_SERVER_TIMEOUT_SECS", g_cfg.serverTimeoutSecs));
g_cfg.prefill = envLong("FAKE_PREFILL", g_cfg.prefill);
g_cfg.tipUnknownSecs = envDouble("FAKE_TIP_UNKNOWN_SECS", g_cfg.tipUnknownSecs);
g_cfg.longestAhead = std::max(0L, envLong("FAKE_LONGEST_AHEAD", g_cfg.longestAhead));
g_cfg.catchupSecs = envDouble("FAKE_CATCHUP_SECS", g_cfg.catchupSecs);
g_cfg.txcount = envLong("FAKE_TXCOUNT", g_cfg.txcount);
g_cfg.height = envLong("FAKE_HEIGHT", g_cfg.height);
g_cfg.rebuildTotal = std::max(1L, envLong("FAKE_REBUILD_TOTAL", g_cfg.rebuildTotal));
g_cfg.notes = envLong("FAKE_NOTES", g_cfg.notes);
@@ -1469,6 +1485,7 @@ int main(int argc, char** argv)
}
if (initOk) {
setInitMessage("Full Node Done Loading! :)");
g_loadedAtMs = unixMs();
g_warmup = false;
event("warmup-end");
tipThread = std::thread(updateTipLoop);