diff --git a/src/app_network.cpp b/src/app_network.cpp index 57a1c23..cc2f979 100644 --- a/src/app_network.cpp +++ b/src/app_network.cpp @@ -882,13 +882,32 @@ bool App::effectivelySyncing() const // cheap wallet (sub-cadence cost) is unaffected — the tab's Core timer stays the real cadence; a ~20s // scan on a large wallet backs off to roughly every ~200s instead of every 2s, freeing cs_main for // block connection. A wallet mutation bypasses this via force_balance_refresh_. +bool App::scanRefreshDue(std::int64_t lastUpdate, double lastScanMs) const +{ + constexpr double kScanDutyCycle = 0.10; + if (lastUpdate == 0) return true; // never fetched + if (lastScanMs <= 0.0) return true; // no cost measured yet + const double minInterval = (lastScanMs / 1000.0) / kScanDutyCycle; + return std::difftime(std::time(nullptr), static_cast(lastUpdate)) >= minInterval; +} + bool App::balanceRefreshDue() const { - constexpr double kBalanceDutyCycle = 0.10; - if (state_.last_balance_update == 0) return true; // never fetched - if (last_balance_scan_ms_ <= 0.0) return true; // no cost measured yet - const double minInterval = (last_balance_scan_ms_ / 1000.0) / kBalanceDutyCycle; - return std::difftime(std::time(nullptr), state_.last_balance_update) >= minInterval; + return scanRefreshDue(state_.last_balance_update, last_balance_scan_ms_); +} + +// The address scan (z_listunspent) and history scan (z_listreceivedbyaddress) are just as O(mapWallet) +// and cs_main-bound as the balance scan, so they get the same adaptive back-off. This is what keeps a +// large synced wallet from re-scanning them every tab cadence — the near-tip starvation the balance-only +// throttle missed (only the periodic poll is gated; explicit refreshes call the refresh directly). +bool App::addressRefreshDue() const +{ + return scanRefreshDue(state_.last_address_update, last_address_scan_ms_); +} + +bool App::txRefreshDue() const +{ + return scanRefreshDue(state_.last_tx_update, last_tx_scan_ms_); } bool App::currentPageNeedsWalletDataRefresh() const @@ -1739,11 +1758,19 @@ void App::refreshCoreData() // cost (0 when balance was skipped), and arm the settle window only on the // syncing→caught-up edge so a wallet synced from the start is never throttled at connect. if (result.balanceScanMs > 0.0) last_balance_scan_ms_ = result.balanceScanMs; + // Sticky-behind hysteresis: hold the low-impact profile for kSyncSettleSeconds after the + // LAST time we read "behind" — not only on the caught-up edge. While actually syncing we + // re-arm every Core tick, so the profile stays engaged continuously; once we truly reach + // the tip (within the 2-block tolerance in applyCoreRefreshResult) we stop re-arming and it + // lapses after the window. This is what breaks the near-tip feedback loop: a single stale or + // optimistic "caught up" reading can no longer immediately unleash the heavy O(mapWallet) + // scans and re-starve the tail (which dropped us behind again → oscillation). With the + // scans suppressed, kSyncProfile keeps sync detection itself cheap, so we keep getting fresh + // "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; - if (nowSyncing) { - sync_settle_until_ = 0; - } else if (was_core_syncing_) { - constexpr double kSyncSettleSeconds = 8.0; + constexpr double kSyncSettleSeconds = 30.0; + if (nowSyncing || was_core_syncing_) { sync_settle_until_ = std::time(nullptr) + static_cast(kSyncSettleSeconds); } was_core_syncing_ = nowSyncing; @@ -1872,7 +1899,13 @@ void App::refreshAddressData() return [this, previousAddressCount, previousWalletIdentity, result = std::move(result)]() mutable { const bool addrListOk = result.addressListOk; // capture before the move + const double scanMs = result.scanMs; // feed the adaptive throttle (addressRefreshDue) + if (scanMs > 0.0) last_address_scan_ms_ = scanMs; + // #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…). @@ -1931,6 +1964,13 @@ void App::refreshTransactionData() transactionSnapshot.maxShieldedReceiveScans = shieldedReceiveScanBudget(current_page_); transactionSnapshot.shieldedScanTipTolerance = shieldedScanTipTolerance(transactionSnapshot.shieldedAddresses.size()); + // When fully synced, widen the re-scan tolerance so the O(mapWallet) per-address history scan runs + // every several blocks instead of ~every 2 — the heaviest residual synced-state cs_main cost. The + // balance poll still surfaces incoming funds on its own cadence; only the detailed history list lags a + // few minutes. (During catch-up the tx refresh is suppressed by kSyncProfile, so this is synced-only.) + if (!effectivelySyncing()) + transactionSnapshot.shieldedScanTipTolerance = + std::max(transactionSnapshot.shieldedScanTipTolerance, 8); ui::NavPage tracePage = current_page_; auto enqueued = network_refresh_.enqueue(services::NetworkRefreshService::Job::Transactions, *worker_, [this, currentBlocks, @@ -1941,6 +1981,7 @@ void App::refreshTransactionData() return [this, result = std::move(result)]() mutable { bool shieldedScanComplete = result.shieldedScanComplete; + if (result.scanMs > 0.0) last_tx_scan_ms_ = result.scanMs; // feed the adaptive throttle (txRefreshDue) std::size_t nextShieldedScanStartIndex = result.nextShieldedScanStartIndex; auto shieldedScanHeights = std::move(result.shieldedScanHeights); NetworkRefreshService::TransactionCacheUpdate cacheUpdate{ @@ -2006,6 +2047,9 @@ void App::refreshRecentTransactionData() transactionSnapshot.maxShieldedReceiveScans = 1; transactionSnapshot.shieldedScanTipTolerance = shieldedScanTipTolerance(transactionSnapshot.shieldedAddresses.size()); + if (!effectivelySyncing()) // synced: widen the re-scan tolerance (see refreshTransactionData) + transactionSnapshot.shieldedScanTipTolerance = + std::max(transactionSnapshot.shieldedScanTipTolerance, 8); ui::NavPage tracePage = current_page_; auto enqueued = network_refresh_.enqueue(services::NetworkRefreshService::Job::Transactions, *worker_, [this, currentBlocks, @@ -3535,9 +3579,25 @@ void App::refreshChatNoteBudget(const wallet::LiteWalletAppRefreshModel& model) void App::refreshChatNoteBudgetNode() { if (lite_wallet_ || !chat::hushChatFeatureEnabledAtBuild() || !chat_service_.hasIdentity()) return; if (!state_.connected || !rpc_ || !worker_ || state_.isLocked()) return; + // Never contend the daemon's cs_main while the node is catching up. z_listunspent is O(mapWallet) + // and holds cs_main for its whole duration — seconds on a large wallet — so an ungated per-few-second + // chat scan here starves block connection and keeps the node from ever pinning to the tip. Chat + // send-readiness can wait until we're synced; this budget is only advisory (the send path re-checks). + if (effectivelySyncing()) return; + // Only pre-warm the send-budget while the user is actually in chat. Off the Chat tab this heavy + // z_listunspent is unnecessary — and on a wallet-data tab, refreshAddressData's own z_listunspent + // already feeds the budget for free (updateChatNoteBudgetFromUnspent), so this dedicated scan is a + // fallback for the Chat tab (where the address refresh doesn't run). + if (current_page_ != ui::NavPage::Chat) return; if (chat_note_scan_in_flight_) return; // one scan at a time const double now = ImGui::GetTime(); - if (now - chat_note_scan_last_ < 4.0) return; // rate limit; self-throttles via the in-flight flag too + // Adaptive back-off keyed on the last scan's own cost: on a large wallet where the scan takes many + // seconds, this stretches the interval so the scan can't occupy more than ~10% of wall-clock (same + // duty-cycle discipline as the balance/address/tx polls). A cheap wallet stays at the 4s floor. + double minInterval = 4.0; + if (chat_note_scan_ms_ > 0.0) + minInterval = std::max(4.0, (chat_note_scan_ms_ / 1000.0) / 0.10); + if (now - chat_note_scan_last_ < minInterval) return; chat_note_scan_last_ = now; chat_note_scan_in_flight_ = true; @@ -3548,6 +3608,7 @@ void App::refreshChatNoteBudgetNode() { int verified = 0, pipeline = 0; std::uint64_t verifiedZat = 0; bool ok = false; + const auto scanStart = std::chrono::steady_clock::now(); try { rpc::RPCClient::TraceScope trace("HushChat / note-buffer scan"); nlohmann::json notes = rpc_->call("z_listunspent", nlohmann::json::array({0})); // 0 = include maturing @@ -3567,9 +3628,12 @@ void App::refreshChatNoteBudgetNode() { } } } catch (const std::exception&) {} - return [this, scanGen, ok, verified, pipeline, verifiedZat]() { + const double scanMs = std::chrono::duration( + std::chrono::steady_clock::now() - scanStart).count(); + return [this, scanGen, ok, verified, pipeline, verifiedZat, scanMs]() { if (scanGen != chat_session_generation_) return; // wallet switched — drop (reset cleared the flag) chat_note_scan_in_flight_ = false; + if (scanMs > 0.0) chat_note_scan_ms_ = scanMs; // feed the adaptive back-off if (!ok) return; // z_listunspent unavailable — leave caches as-is chat_note_model_seen_ = true; chat_verified_note_budget_ = verified; @@ -3584,6 +3648,36 @@ void App::refreshChatNoteBudgetNode() { }); } +// #3: derive the chat send-budget from an already-collected z_listunspent (refreshAddressData's scan), +// so we don't issue a duplicate z_listunspent. Runs on the main thread; the notes are pre-parsed to the +// fields we need (amount / locked / TRUE confirmations), so there's no RPC and no worker hop. Mirrors the +// apply in refreshChatNoteBudgetNode and stamps chat_note_scan_last_ so the dedicated scan backs off. +void App::updateChatNoteBudgetFromUnspent( + const std::vector& unspentNotes) { + if (lite_wallet_ || !chat::hushChatFeatureEnabledAtBuild() || !chat_service_.hasIdentity()) return; + const int confsNeeded = chatConfsRequired(); + const std::uint64_t minVal = chatDrgxToZat(kChatMinFeeDrgx); + int verified = 0, pipeline = 0; + std::uint64_t verifiedZat = 0; + for (const auto& nz : unspentNotes) { + if (nz.locked) continue; // tied up by an in-flight send + const std::uint64_t amtZat = chatDrgxToZat(nz.amount); + if (amtZat < minVal) continue; // skip dust below a fee + ++pipeline; // unspent self-note (verified or maturing) + if (nz.confirmations >= confsNeeded) { ++verified; verifiedZat += amtZat; } + } + chat_note_model_seen_ = true; + chat_verified_note_budget_ = verified; + chat_pipeline_note_count_ = pipeline; + chat_verified_shielded_zat_ = verifiedZat; + chat_note_scan_last_ = ImGui::GetTime(); // counts as a fresh scan → dedicated scan backs off + if (chat_split_outstanding_ && + (chat_pipeline_note_count_ >= kChatRefillTrigger || + ImGui::GetTime() - chat_split_submitted_at_ > kChatSplitWatchdogSecs)) { + chat_split_outstanding_ = false; + } +} + void App::enqueueChatSend(LiteOpKind kind, const chat::OutgoingChatMemos& memos, const std::string& echoLocalId) { QueuedChatOp op; op.kind = kind; @@ -3825,15 +3919,28 @@ void App::fastScanChatMemos() if (!chat::hushChatFeatureEnabledAtBuild() || !chat_service_.hasIdentity()) return; if (!state_.connected || !rpc_ || !worker_) return; if (chat_fast_scan_in_flight_) return; // don't stack RPCs if a previous scan is still running + // z_listreceivedbyaddress is O(mapWallet) (the daemon iterates every wtx even when filtered to one + // address) — seconds on a large wallet — so this 0-conf poll must NOT run while the node is catching + // up, and even when synced it backs off to ~10% wall-clock keyed on its own cost. The block-tip + // harvest still ingests every message on confirmation; this only trades away mempool-speed delivery. + if (effectivelySyncing()) return; + // Mempool-speed 0-conf delivery is only worth its heavy cost while the user is watching chat. Off the + // Chat tab, incoming messages still arrive via the confirmed block-tip harvest (+ toast) within ~1 block. + if (current_page_ != ui::NavPage::Chat) return; + const double nowT = ImGui::GetTime(); + if (chat_fast_scan_ms_ > 0.0 && + nowT - chat_fast_scan_last_ < (chat_fast_scan_ms_ / 1000.0) / 0.10) return; const std::string addr = chatReplyZaddr(); if (addr.empty()) return; + chat_fast_scan_last_ = nowT; chat_fast_scan_in_flight_ = true; const int scanGen = chat_session_generation_; // guard: drop the result if the wallet switches/locks worker_->post([this, addr, scanGen]() -> rpc::RPCWorker::MainCb { std::vector metadata; int rawMemoCount = 0; // received notes carrying a memo at the reply addr (0-conf visibility signal) std::string scanError; + const auto scanStart = std::chrono::steady_clock::now(); try { rpc::RPCClient::TraceScope trace("HushChat / 0-conf fast scan"); nlohmann::json received = rpc_->call("z_listreceivedbyaddress", {addr, 0}); // 0 = include mempool @@ -3864,12 +3971,15 @@ void App::fastScanChatMemos() } } catch (const std::exception& e) { scanError = e.what(); } const int parsedCount = static_cast(metadata.size()); - return [this, scanGen, metadata = std::move(metadata), rawMemoCount, parsedCount, scanError]() mutable { + const double scanMs = std::chrono::duration( + std::chrono::steady_clock::now() - scanStart).count(); + return [this, scanGen, metadata = std::move(metadata), rawMemoCount, parsedCount, scanError, scanMs]() mutable { // The wallet was switched/locked between post and now — this metadata belongs to the previous // session. resetChatSession already reset the in-flight flag, so just drop; clearing it here // would clobber a new session's own in-flight scan (mirrors the broadcast/identity guards). if (scanGen != chat_session_generation_) return; chat_fast_scan_in_flight_ = false; + if (scanMs > 0.0) chat_fast_scan_ms_ = scanMs; // feed the adaptive back-off // Diagnostic (console App/chat channel): log when the memo-note count at the reply address // CHANGES, so a stable mempool doesn't spam every 2.5s. Reveals whether inbound 0-conf messages // are reaching us at all, and how many parse as chat vs are unrelated memos. diff --git a/src/daemon/embedded_daemon.cpp b/src/daemon/embedded_daemon.cpp index 0ca0e1a..c05c4f6 100644 --- a/src/daemon/embedded_daemon.cpp +++ b/src/daemon/embedded_daemon.cpp @@ -816,11 +816,21 @@ void EmbeddedDaemon::drainOutput() } size_t currentSize = static_cast(fileSize.QuadPart); + // Truncation / rotation detection. dragonxd shrinks debug.log on startup (ShrinkDebugFile keeps + // only the tail once it has grown large), so the file can become SMALLER than where we left off. + // Our offset was set to the PRE-shrink size at spawn, so without this reset it stays stranded ahead + // of the freshly-truncated file and we read NOTHING for the whole session — no block height (status + // bar shows "Block: 0") and, worse, no witness-rebuild progress, since the "Setting Initial Sapling + // Witness …" lines the warmup progress bar parses only exist in debug.log on Windows. On a shrink, + // restart from the new beginning; the parser is monotonic and converges as it reaches current output. + if (currentSize < debug_log_offset_) { + debug_log_offset_ = 0; + } if (currentSize <= debug_log_offset_) { CloseHandle(hFile); return; // No new data } - + // Seek to where we left off LARGE_INTEGER seekPos; seekPos.QuadPart = static_cast(debug_log_offset_); diff --git a/src/services/network_refresh_service.cpp b/src/services/network_refresh_service.cpp index 33d331f..0763966 100644 --- a/src/services/network_refresh_service.cpp +++ b/src/services/network_refresh_service.cpp @@ -297,7 +297,31 @@ NetworkRefreshService::CoreRefreshResult NetworkRefreshService::collectCoreRefre bool blockOk = false; double balanceScanMs = 0.0; - if (includeBalance) { + // getblockchaininfo FIRST — it's cheap, and the sync state it returns gates everything else + // (the balance/address/tx throttles and kSyncProfile). Running it BEFORE the O(mapWallet) balance + // scan keeps sync detection from being delayed behind (or, under contention, starved by) that + // scan — the failure mode where the wallet kept reading "synced" while actually falling behind, + // so kSyncProfile never engaged. See effectivelySyncing()'s sticky-behind latch. + try { + blockInfo = rpc.call("getblockchaininfo", json::array()); + blockOk = true; + } catch (const std::exception& e) { + DEBUG_LOGF("BlockchainInfo error: %s\n", e.what()); + } + + // If getblockchaininfo shows we're behind (same 2-block tolerance as applyCoreRefreshResult), skip + // the balance scan this cycle regardless of includeBalance: the balance is incomplete mid-sync + // anyway, and skipping it lets the sync-state update — and hence kSyncProfile — take effect at the + // end of THIS (now-cheap) task instead of being delayed ~20s behind the scan. This is what lets the + // sticky-behind latch engage promptly the first time the node falls behind. + bool behind = false; + if (blockOk && blockInfo.is_object()) { + const long long b = blockInfo.value("blocks", 0LL); + const long long lc = blockInfo.value("longestchain", 0LL); + if (lc > 0 && b < lc - 2) behind = true; + } + + if (includeBalance && !behind) { // 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. @@ -315,13 +339,6 @@ NetworkRefreshService::CoreRefreshResult NetworkRefreshService::collectCoreRefre std::chrono::steady_clock::now() - balanceStart).count(); } - try { - blockInfo = rpc.call("getblockchaininfo", json::array()); - blockOk = true; - } catch (const std::exception& e) { - DEBUG_LOGF("BlockchainInfo error: %s\n", e.what()); - } - auto result = parseCoreRefreshResult(totalBalance, spendableBalance, balanceOk, blockInfo, blockOk); result.balanceScanMs = balanceScanMs; return result; @@ -603,6 +620,10 @@ NetworkRefreshService::AddressRefreshResult NetworkRefreshService::collectAddres const AddressRefreshSnapshot& snapshot) { AddressRefreshResult result; + // 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. + const auto scanStart = std::chrono::steady_clock::now(); try { json zList = rpc.call("z_listaddresses", json::array()); @@ -644,6 +665,22 @@ NetworkRefreshService::AddressRefreshResult NetworkRefreshService::collectAddres 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 + // of issuing its own z_listunspent. rawconfirmations is the TRUE depth; `confirmations` is + // dPoW-clamped to 1 and understates it. + if (unspent.is_array()) { + result.unspentNotes.reserve(unspent.size()); + for (const auto& nz : unspent) { + if (!nz.is_object()) continue; + UnspentNoteLite lite; + lite.amount = nz.value("amount", 0.0); + lite.locked = nz.value("locked", false); + lite.confirmations = (nz.contains("rawconfirmations") && nz["rawconfirmations"].is_number_integer()) + ? nz["rawconfirmations"].get() + : nz.value("confirmations", 0); + result.unspentNotes.push_back(lite); + } + } } catch (const std::exception& e) { DEBUG_LOGF("z_listunspent unavailable (%s), falling back to z_getbalance\n", e.what()); for (auto& info : result.shieldedAddresses) { @@ -673,6 +710,8 @@ NetworkRefreshService::AddressRefreshResult NetworkRefreshService::collectAddres DEBUG_LOGF("listunspent error: %s\n", e.what()); } + result.scanMs = std::chrono::duration( + std::chrono::steady_clock::now() - scanStart).count(); return result; } @@ -864,6 +903,10 @@ NetworkRefreshService::TransactionRefreshResult NetworkRefreshService::collectTr result.blockHeight = currentBlockHeight; result.shieldedAddressCount = snapshot.shieldedAddresses.size(); result.shieldedScanHeights = snapshot.shieldedScanHeights; + // Time the whole scan — the per-address z_listreceivedbyaddress pass is O(mapWallet) and holds the + // daemon's cs_main. The measured cost feeds the caller's adaptive throttle (txRefreshDue()) so the + // routine full history rescan can't starve block connection on a large wallet. + const auto scanStart = std::chrono::steady_clock::now(); std::set knownTxids; HushChatMemoOutputMap hushChatReceivedOutputs; @@ -1044,6 +1087,8 @@ NetworkRefreshService::TransactionRefreshResult NetworkRefreshService::collectTr } sortTransactionsNewestFirst(result.transactions); + result.scanMs = std::chrono::duration( + std::chrono::steady_clock::now() - scanStart).count(); return result; } diff --git a/src/services/network_refresh_service.h b/src/services/network_refresh_service.h index 514a345..426463e 100644 --- a/src/services/network_refresh_service.h +++ b/src/services/network_refresh_service.h @@ -147,6 +147,14 @@ public: std::string errorMessage; }; + // Minimal per-note view of a z_listunspent entry — just what a downstream consumer needs to derive + // spendable-note budgets without re-scanning. Kept UI/feature-agnostic (no chat specifics here). + struct UnspentNoteLite { + double amount = 0.0; // note value, DRGX + bool locked = false; // tied up by an in-flight send + int confirmations = 0; // TRUE depth (rawconfirmations when present, else confirmations) + }; + struct AddressRefreshResult { std::vector shieldedAddresses; std::vector transparentAddresses; @@ -154,6 +162,12 @@ public: // lists may be falsely short. Consumers that treat an empty list as authoritative (e.g. the // empty-wallet warning) must not trust a 0 count unless this is true. bool addressListOk = true; + // Wall-clock spent in the address scan (dominated by z_listunspent — O(mapWallet), holds the + // daemon's cs_main). Lets the caller throttle how often it polls (addressRefreshDue()). + double scanMs = 0.0; + // The wallet's unspent notes from this same z_listunspent scan, so a consumer (e.g. the chat + // note-budget) can be fed for free instead of running its own duplicate z_listunspent. + std::vector unspentNotes; }; struct AddressRefreshSnapshot { @@ -205,6 +219,9 @@ public: std::size_t shieldedAddressCount = 0; std::unordered_map shieldedScanHeights; bool shieldedScanComplete = true; + // Wall-clock spent in the history scan (z_listreceivedbyaddress — O(mapWallet), holds cs_main). + // Lets the caller throttle the routine full rescan by its measured cost (txRefreshDue()). + double scanMs = 0.0; }; struct OperationStatusPollResult { diff --git a/tests/test_phase4.cpp b/tests/test_phase4.cpp index 1d33034..9c5f775 100644 --- a/tests/test_phase4.cpp +++ b/tests/test_phase4.cpp @@ -1417,31 +1417,51 @@ void testNetworkRefreshRpcCollectors() }); coreRpc.addResponse("getblockchaininfo", json{ {"blocks", 150}, - {"headers", 155}, + {"headers", 150}, {"bestblockhash", "core-best-150"}, - {"verificationprogress", 0.80}, - {"longestchain", 160}, + {"verificationprogress", 1.0}, + {"longestchain", 150}, // caught up → balance scan runs {"notarized", 145} }); auto core = Refresh::collectCoreRefreshResult(coreRpc); + // getblockchaininfo is issued FIRST — sync detection gates (and must not be delayed behind) the + // O(mapWallet) balance scan. When caught up, the balance scan follows: display (minconf=0) + + // spendable (minconf=1). EXPECT_TRUE(coreRpc.methodNames() == std::vector({ - "z_gettotalbalance", "z_gettotalbalance", "getblockchaininfo" // display (minconf=0) + spendable (minconf=1) + "getblockchaininfo", "z_gettotalbalance", "z_gettotalbalance" })); - EXPECT_EQ(coreRpc.calls[0].params, json::array({0})); - EXPECT_EQ(coreRpc.calls[1].params, json::array({1})); + EXPECT_EQ(coreRpc.calls[1].params, json::array({0})); + EXPECT_EQ(coreRpc.calls[2].params, json::array({1})); EXPECT_TRUE(core.balanceOk); EXPECT_TRUE(core.blockchainOk); EXPECT_NEAR(*core.totalBalance, 4.25, 0.00000001); EXPECT_EQ(*core.blocks, 150); EXPECT_EQ(*core.bestBlockHash, std::string("core-best-150")); - EXPECT_EQ(*core.longestChain, 160); + EXPECT_EQ(*core.longestChain, 150); + + // When getblockchaininfo shows the node is behind (blocks < longestchain - 2), the balance scan is + // skipped this cycle regardless of includeBalance — so sync-state (and hence kSyncProfile) updates + // promptly instead of after the multi-second scan. Only getblockchaininfo is issued. + MockRefreshRpc coreBehindRpc; + coreBehindRpc.addResponse("z_gettotalbalance", json{{"total", "4.25000000"}}); + coreBehindRpc.addResponse("getblockchaininfo", json{ + {"blocks", 150}, {"headers", 160}, {"longestchain", 160} + }); + auto coreBehind = Refresh::collectCoreRefreshResult(coreBehindRpc); + EXPECT_TRUE(coreBehindRpc.methodNames() == std::vector({"getblockchaininfo"})); + EXPECT_FALSE(coreBehind.balanceOk); + EXPECT_TRUE(coreBehind.blockchainOk); + EXPECT_EQ(*coreBehind.blocks, 150); + EXPECT_EQ(*coreBehind.longestChain, 160); MockRefreshRpc coreFallbackRpc; coreFallbackRpc.addFailure("z_gettotalbalance", "wallet warming up"); coreFallbackRpc.addResponse("getblockchaininfo", json{{"blocks", 8}, {"headers", 9}}); auto partialCore = Refresh::collectCoreRefreshResult(coreFallbackRpc); + // No longestchain in the response → not classified as "behind" → balance is still attempted (here it + // fails). getblockchaininfo is still issued first. EXPECT_TRUE(coreFallbackRpc.methodNames() == std::vector({ - "z_gettotalbalance", "z_gettotalbalance", "getblockchaininfo" + "getblockchaininfo", "z_gettotalbalance", "z_gettotalbalance" })); EXPECT_FALSE(partialCore.balanceOk); EXPECT_TRUE(partialCore.blockchainOk);