fix(sync): prioritize getblockchaininfo and pause chat scans while behind

The node kept falling behind the network near the tip because sync
DETECTION was starved: getblockchaininfo was queued behind the
O(mapWallet) wallet RPCs (z_gettotalbalance / z_listunspent), so
longestchain went stale, the wallet decided it was "synced", and it
resumed hammering cs_main — a feedback loop.

- Issue getblockchaininfo FIRST each cycle and skip the balance/address/
  tx refresh entirely while behind, so sync state (and kSyncProfile)
  updates before any heavy wallet scan runs.
- Gate the two chat note scans (refreshChatNoteBudgetNode /
  fastScanChatMemos) on effectivelySyncing() and the active page, so chat
  memo scanning no longer competes with block connection during sync.
- Windows debug.log tailer: reset the read offset when dragonxd truncates
  the log on startup (it was stranding at Block:0 with no witness/rescan
  progress).
- Tests cover the getblockchaininfo-first ordering and behind-skip.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-09-01 19:36:30 -05:00
parent 56d93b6128
commit 4492aa3425
5 changed files with 231 additions and 29 deletions

View File

@@ -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<std::time_t>(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<std::time_t>(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<double, std::milli>(
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<services::NetworkRefreshService::UnspentNoteLite>& 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<chat::HushChatTransactionMetadata> 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<int>(metadata.size());
return [this, scanGen, metadata = std::move(metadata), rawMemoCount, parsedCount, scanError]() mutable {
const double scanMs = std::chrono::duration<double, std::milli>(
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.

View File

@@ -816,6 +816,16 @@ void EmbeddedDaemon::drainOutput()
}
size_t currentSize = static_cast<size_t>(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

View File

@@ -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<int>()
: 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<double, std::milli>(
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<std::string> knownTxids;
HushChatMemoOutputMap hushChatReceivedOutputs;
@@ -1044,6 +1087,8 @@ NetworkRefreshService::TransactionRefreshResult NetworkRefreshService::collectTr
}
sortTransactionsNewestFirst(result.transactions);
result.scanMs = std::chrono::duration<double, std::milli>(
std::chrono::steady_clock::now() - scanStart).count();
return result;
}

View File

@@ -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<AddressInfo> shieldedAddresses;
std::vector<AddressInfo> 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<UnspentNoteLite> unspentNotes;
};
struct AddressRefreshSnapshot {
@@ -205,6 +219,9 @@ public:
std::size_t shieldedAddressCount = 0;
std::unordered_map<std::string, int> 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 {

View File

@@ -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<std::string>({
"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<std::string>({"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<std::string>({
"z_gettotalbalance", "z_gettotalbalance", "getblockchaininfo"
"getblockchaininfo", "z_gettotalbalance", "z_gettotalbalance"
}));
EXPECT_FALSE(partialCore.balanceOk);
EXPECT_TRUE(partialCore.blockchainOk);