diff --git a/CMakeLists.txt b/CMakeLists.txt index 4a41225..33b32ce 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -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 diff --git a/res/lang/de.json b/res/lang/de.json index f6bb536..0dc8224 100644 --- a/res/lang/de.json +++ b/res/lang/de.json @@ -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", diff --git a/res/lang/es.json b/res/lang/es.json index e07c2e6..4984d69 100644 --- a/res/lang/es.json +++ b/res/lang/es.json @@ -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", diff --git a/res/lang/fr.json b/res/lang/fr.json index edb322a..d50158e 100644 --- a/res/lang/fr.json +++ b/res/lang/fr.json @@ -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", diff --git a/res/lang/ja.json b/res/lang/ja.json index 19a7130..c9fe295 100644 --- a/res/lang/ja.json +++ b/res/lang/ja.json @@ -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": "トランザクションを確定中", diff --git a/res/lang/ko.json b/res/lang/ko.json index 7d02462..6b6b996 100644 --- a/res/lang/ko.json +++ b/res/lang/ko.json @@ -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": "트랜잭션 마무리 중", diff --git a/res/lang/pt.json b/res/lang/pt.json index 14cdcf2..1e10cad 100644 --- a/res/lang/pt.json +++ b/res/lang/pt.json @@ -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", diff --git a/res/lang/ru.json b/res/lang/ru.json index 148f6f4..d1345c4 100644 --- a/res/lang/ru.json +++ b/res/lang/ru.json @@ -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": "Завершение транзакции", diff --git a/res/lang/zh.json b/res/lang/zh.json index 50da100..8780511 100644 --- a/res/lang/zh.json +++ b/res/lang/zh.json @@ -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": "正在完成交易", diff --git a/src/app.cpp b/src/app.cpp index 428d58b..b5ca298 100644 --- a/src/app.cpp +++ b/src/app.cpp @@ -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() diff --git a/src/app.h b/src/app.h index ac0c6b0..2b06778 100644 --- a/src/app.h +++ b/src/app.h @@ -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; diff --git a/src/app_network.cpp b/src/app_network.cpp index a281c87..8d13cce 100644 --- a/src/app_network.cpp +++ b/src/app_network.cpp @@ -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(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(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(kSyncSettleSeconds); + sync_settle_until_ = std::time(nullptr) + + static_cast(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::duration( + 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::duration(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 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 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::functiongetSeedBackupReminded()) 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; diff --git a/src/data/wallet_state.h b/src/data/wallet_state.h index a838e25..370a389 100644 --- a/src/data/wallet_state.h +++ b/src/data/wallet_state.h @@ -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 diff --git a/src/main.cpp b/src/main.cpp index 4020e82..50285f9 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -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 diff --git a/src/services/connect_scan_gate.h b/src/services/connect_scan_gate.h new file mode 100644 index 0000000..f95b29d --- /dev/null +++ b/src/services/connect_scan_gate.h @@ -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 + +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 diff --git a/src/services/network_refresh_service.cpp b/src/services/network_refresh_service.cpp index 4517439..98df15a 100644 --- a/src/services/network_refresh_service.cpp +++ b/src/services/network_refresh_service.cpp @@ -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(); 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()); 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() : 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( @@ -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& fresh, const std::vector& previous) { + std::unordered_map 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. diff --git a/src/services/network_refresh_service.h b/src/services/network_refresh_service.h index 20925f7..aea4a40 100644 --- a/src/services/network_refresh_service.h +++ b/src/services/network_refresh_service.h @@ -91,6 +91,7 @@ public: bool ok = false; bool encrypted = false; std::int64_t unlockedUntil = 0; + std::optional txCount; // getwalletinfo's txcount (the wallet's transactions) }; struct WarmupPollResult { @@ -147,6 +148,7 @@ public: bool needSpendable = true; std::optional 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 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& 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& sendTxids); diff --git a/src/services/refresh_scheduler.cpp b/src/services/refresh_scheduler.cpp index 367c595..1cafb55 100644 --- a/src/services/refresh_scheduler.cpp +++ b/src/services/refresh_scheduler.cpp @@ -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)); diff --git a/src/services/refresh_scheduler.h b/src/services/refresh_scheduler.h index c7b376e..b159fa4 100644 --- a/src/services/refresh_scheduler.h +++ b/src/services/refresh_scheduler.h @@ -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); diff --git a/src/services/scan_policy.h b/src/services/scan_policy.h new file mode 100644 index 0000000..b7c01ed --- /dev/null +++ b/src/services/scan_policy.h @@ -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 + +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 diff --git a/src/services/wallet_scan_governor.cpp b/src/services/wallet_scan_governor.cpp index b9320a1..80202c2 100644 --- a/src/services/wallet_scan_governor.cpp +++ b/src/services/wallet_scan_governor.cpp @@ -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. diff --git a/src/services/wallet_scan_governor.h b/src/services/wallet_scan_governor.h index e31354a..1159c73 100644 --- a/src/services/wallet_scan_governor.h +++ b/src/services/wallet_scan_governor.h @@ -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 diff --git a/src/util/i18n.cpp b/src/util/i18n.cpp index dcff5e1..5de2ba3 100644 --- a/src/util/i18n.cpp +++ b/src/util/i18n.cpp @@ -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)"; diff --git a/tests/test_phase4.cpp b/tests/test_phase4.cpp index 9d41916..6164821 100644 --- a/tests/test_phase4.cpp +++ b/tests/test_phase4.cpp @@ -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({ "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(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({ - "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({ - "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({"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({"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({"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(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(std::chrono::duration(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({"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(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({"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({"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({"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(); diff --git a/tools/fake_dragonxd/README.md b/tools/fake_dragonxd/README.md index 90593b2..07401cb 100644 --- a/tools/fake_dragonxd/README.md +++ b/tools/fake_dragonxd/README.md @@ -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= p=` 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 | diff --git a/tools/fake_dragonxd/fake_dragonxd.cpp b/tools/fake_dragonxd/fake_dragonxd.cpp index 812da70..5769e3e 100644 --- a/tools/fake_dragonxd/fake_dragonxd.cpp +++ b/tools/fake_dragonxd/fake_dragonxd.cpp @@ -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 g_height{0}; std::atomic g_warmup{true}; +std::atomic g_loadedAtMs{0}; // when warmup ended ("Done Loading") std::mutex g_warmupMx; std::string g_warmupStatus = "Loading block index..."; std::atomic g_minerOn{false}; @@ -602,19 +607,26 @@ Canned cannedReply(const std::string& m, const json& params) return params[0].is_string() ? params[0].get() : 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);