perf(net): hold wallet scans at connect, in the background and on tab entry
- Connect gate: after the node starts answering, wallet scans wait until the lock state is known and the node knows its tip (or the wallet has fewer than 500 transactions), at most 20 s. A node already behind latches the sync profile from the connect's getinfo. Meanwhile a list-only pass keeps the address list and the cached history up, and the session's first balance and per-address balances are read once even while catching up, so the wallet shows its last-synced values instead of 0. - Background: minimized, hidden, or covered without focus (3 s debounce, any input cancels) polls only the chain state every 15 s. Wallet scans, the mining poll, price, nudges and auto-lock interaction tracking pause; auto-lock now fires while minimized. Leaving a held profile marks every timer due again. - Tab entry marks the timers due instead of forcing scans; a stale list or a never-loaded history still loads on entry. - The scan entry points own the hard gates, failed enumerations keep the previous lists behind a retry hold, and failed balance calls never overwrite known balances with 0. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
+249
-105
@@ -19,7 +19,7 @@
|
||||
// │
|
||||
// ├─ success ──► onConnected() ──► [Connected]
|
||||
// │ │
|
||||
// │ ▼ refreshData() every 5s
|
||||
// │ ▼ per-category timers (refreshData() at transitions)
|
||||
// │ [Running]
|
||||
// │ │
|
||||
// │ ├─ RPC error ──► onDisconnected()
|
||||
@@ -525,6 +525,9 @@ void App::tryConnect()
|
||||
VERBOSE_LOGF("[connect #%d] Connected successfully%s\n", attempt,
|
||||
warmingUp ? " (daemon warming up)" : "");
|
||||
saved_config_ = config; // save for fast-lane connection
|
||||
// Before onConnected(): its refreshData() then only polls for the warmup's end instead of
|
||||
// sending wallet RPCs that can only fail with -28.
|
||||
if (warmingUp) state_.warming_up = true;
|
||||
onConnected();
|
||||
if (warmingUp) {
|
||||
// Daemon is reachable and auth works, but RPC calls will
|
||||
@@ -732,8 +735,9 @@ void App::onConnected()
|
||||
// Fetching getwalletinfo here (before refreshData) ensures the lock
|
||||
// screen appears immediately instead of after 6+ queued RPC calls.
|
||||
bool initialPrefetchQueued = false;
|
||||
NetworkRefreshService::ConnectionInfoResult prefetchedInfo;
|
||||
if (worker_ && rpc_) {
|
||||
auto prefetchedInfo = NetworkRefreshService::parseConnectionInfoResult(rpc_->getLastConnectInfo());
|
||||
prefetchedInfo = NetworkRefreshService::parseConnectionInfoResult(rpc_->getLastConnectInfo());
|
||||
auto enqueued = network_refresh_.enqueue(services::NetworkRefreshService::Job::ConnectionInit, *worker_, [this, prefetchedInfo]() -> rpc::RPCWorker::MainCb {
|
||||
AppRefreshRpcGateway refreshRpc(*rpc_, "Startup / Connection init");
|
||||
auto result = NetworkRefreshService::collectConnectionInitResult(refreshRpc, prefetchedInfo);
|
||||
@@ -757,6 +761,9 @@ void App::onConnected()
|
||||
|
||||
// Addresses are unknown on fresh connect — force a fetch
|
||||
addresses_dirty_ = true;
|
||||
// Hold the wallet scans until the node knows where the chain is (or the wallet is small), and latch the
|
||||
// sync profile now if the connect's getinfo already shows it behind.
|
||||
armScanGate(prefetchedInfo);
|
||||
|
||||
// Start the fast-lane RPC connection (dedicated to 1-second mining polls).
|
||||
// Uses its own curl handle + worker thread so getlocalsolps never blocks
|
||||
@@ -844,6 +851,11 @@ void App::onDisconnected(const std::string& reason)
|
||||
history_transparent_tip_ = -1; // reset with the cache session below); cost estimates are kept
|
||||
scan_governor_.reset(); // tickets of this connection's scans complete as no-ops
|
||||
recent_probe_rotation_.reset();
|
||||
scan_gate_.disarm();
|
||||
address_retry_not_before_ = {};
|
||||
address_balances_loaded_ = false;
|
||||
first_balance_read_tried_ = false;
|
||||
address_owed_next_try_ = {};
|
||||
mempool_known_ = false;
|
||||
ui::ShieldDialog::invalidateScope();
|
||||
address_validation_cache_dirty_ = true;
|
||||
@@ -956,18 +968,20 @@ void App::handleLostConnection(const std::string& reason)
|
||||
//
|
||||
// Data is split into independent categories, each with its own refresh
|
||||
// function, timer, and in-progress guard. The orchestrator (refreshData)
|
||||
// dispatches all categories, but each can also be called independently
|
||||
// (e.g. on tab switch for immediate refresh).
|
||||
// dispatches all categories at transitions (connect, warmup end, unlock); the
|
||||
// periodic timers drive them otherwise. Entering a tab marks its timers due.
|
||||
//
|
||||
// Categories:
|
||||
// Core — z_gettotalbalance + getblockchaininfo (balance, sync)
|
||||
// Addresses — z_listaddresses + listunspent (address list, per-addr balances)
|
||||
// Transactions — listtransactions + z_listreceivedbyaddress + z_viewtransaction
|
||||
// Core — getblockchaininfo + getmempoolinfo, and z_gettotalbalance when due (balance, sync)
|
||||
// Addresses — z_listaddresses + z_listunspent + listunspent (address list, per-addr balances)
|
||||
// Transactions — listtransactions + z_listreceivedbyaddress + z_listsentbyaddress + z_viewtransaction
|
||||
// Peers — getpeerinfo + listbanned (already standalone)
|
||||
// Encryption — getwalletinfo (one-shot on connect)
|
||||
//
|
||||
// Intervals are adjusted by applyRefreshPolicy() based on the active tab,
|
||||
// so the user sees faster updates for the data they're interacting with.
|
||||
// Intervals are adjusted by applyRefreshPolicy() based on the active tab (or the sync / background
|
||||
// profiles). The wallet-wide scans (balance, address scan, history) also pass the hard gates
|
||||
// (services::scan_policy.h) and the scan budget (services::WalletScanGovernor): explicit work bypasses
|
||||
// only the budget, never the gates.
|
||||
// ============================================================================
|
||||
|
||||
App::RefreshIntervals App::getIntervalsForPage(ui::NavPage page)
|
||||
@@ -983,10 +997,25 @@ void App::applyRefreshPolicy(ui::NavPage page)
|
||||
// as the Console tab does today. effectivelySyncing() keeps this profile on briefly after catching
|
||||
// up (hysteresis) so a large-wallet scan can't immediately re-starve connection and bounce the
|
||||
// node back into "syncing". Reverts to the per-tab profile once the settle window passes.
|
||||
// In the background (C134) only the chain state is polled, whatever the sync state.
|
||||
const bool wasHeld = refresh_policy_syncing_ || refresh_policy_background_;
|
||||
refresh_policy_syncing_ = effectivelySyncing();
|
||||
network_refresh_.setIntervals(refresh_policy_syncing_
|
||||
? services::RefreshScheduler::kSyncProfile
|
||||
: getIntervalsForPage(page));
|
||||
refresh_policy_background_ = window_background_;
|
||||
network_refresh_.setIntervals(services::RefreshScheduler::selectIntervals(
|
||||
page, refresh_policy_syncing_, refresh_policy_background_));
|
||||
// Leaving a profile that held the wallet timers: markDue() zeroes a 0-interval timer, so anything marked
|
||||
// meanwhile (a tab entry, a send's completion, F5) was lost. Mark everything due; the gates in update()
|
||||
// and the scan budget still decide what actually runs. Here rather than in update(): setCurrentPage()
|
||||
// also lands here and would swallow update()'s edge.
|
||||
if (wasHeld && !refresh_policy_syncing_ && !refresh_policy_background_)
|
||||
network_refresh_.markImmediateRefresh();
|
||||
}
|
||||
|
||||
void App::setWindowBackground(bool background)
|
||||
{
|
||||
if (background == window_background_) return;
|
||||
window_background_ = background;
|
||||
DEBUG_LOGF("[App] window background=%d\n", background ? 1 : 0);
|
||||
}
|
||||
|
||||
// True while the node is behind, and for a short settle window after it first catches up. The settle
|
||||
@@ -1028,6 +1057,52 @@ services::ScanSignals App::currentScanSignals() const
|
||||
return sig;
|
||||
}
|
||||
|
||||
bool App::scanGateOpen() const
|
||||
{
|
||||
return scan_gate_.open(std::chrono::steady_clock::now(), state_.encryption_state_known, state_.wallet_tx_count);
|
||||
}
|
||||
|
||||
bool App::addressScanCanRunNow() const
|
||||
{
|
||||
return services::wholeWalletScanAllowed(scanContext()) &&
|
||||
std::chrono::steady_clock::now() >= address_retry_not_before_;
|
||||
}
|
||||
|
||||
bool App::addressListStale() const
|
||||
{
|
||||
return state_.last_address_update == 0 || address_validation_cache_dirty_;
|
||||
}
|
||||
|
||||
services::ScanContext App::scanContext() const
|
||||
{
|
||||
services::ScanContext c;
|
||||
c.connected = rpc_ && rpc_->isConnected() && state_.connected;
|
||||
c.locked = state_.isLocked();
|
||||
c.warmingUp = state_.warming_up;
|
||||
c.runtimeRescan = runtime_rescan_active_;
|
||||
c.rpcBusy = rpc_busy_gate_ && rpc_busy_gate_->busy();
|
||||
c.syncing = effectivelySyncing();
|
||||
c.gateOpen = scanGateOpen();
|
||||
c.background = window_background_;
|
||||
return c;
|
||||
}
|
||||
|
||||
void App::armScanGate(const NetworkRefreshService::ConnectionInfoResult& info)
|
||||
{
|
||||
const bool tipKnown = NetworkRefreshService::connectionInfoTipKnown(info);
|
||||
const bool behind = NetworkRefreshService::connectionInfoShowsBehind(info);
|
||||
scan_gate_.arm(std::chrono::steady_clock::now(), tipKnown);
|
||||
address_retry_not_before_ = {}; // the node (re)started answering: an earlier failure is moot
|
||||
if (behind) {
|
||||
// Latch the sync profile before the first dispatch. The next Core result recomputes
|
||||
// state_.sync.syncing and keeps it latched while the node is behind.
|
||||
was_core_syncing_ = true;
|
||||
sync_settle_until_ = std::time(nullptr) +
|
||||
static_cast<std::time_t>(services::RefreshScheduler::kSyncSettleSeconds);
|
||||
}
|
||||
DEBUG_LOGF("[scan-gate] armed: tip %s%s\n", tipKnown ? "known" : "unknown", behind ? ", behind" : "");
|
||||
}
|
||||
|
||||
bool App::isBalanceHeldByScanBudget() const
|
||||
{
|
||||
return scan_governor_.heldWithinFloor(services::ScanStream::Balance, scanClockNow());
|
||||
@@ -1069,36 +1144,35 @@ void App::setCurrentPage(ui::NavPage page)
|
||||
applyRefreshPolicy(page);
|
||||
using RefreshTimer = services::NetworkRefreshService::Timer;
|
||||
|
||||
// Immediate refresh for the incoming tab's priority data. Gate on ACTUAL RPC connectivity
|
||||
// (not state_.connected, which is the lite "online" proxy) — lite has no RPC daemon and the
|
||||
// lite controller refreshes wallet data itself, so these full-node RPC polls must not fire.
|
||||
// Refresh for the incoming tab. Gate on ACTUAL RPC connectivity (not state_.connected, which is the
|
||||
// lite "online" proxy) — lite has no RPC daemon and the lite controller refreshes wallet data itself,
|
||||
// so these full-node RPC polls must not fire. Wallet data isn't forced on entry any more: the timers
|
||||
// are marked due, and the change gate and the scan budget decide (data that changed since its last
|
||||
// scan comes first, and a page's own data gets the visible head start). Only what the page can't
|
||||
// show without is loaded now: a stale address list, a never-loaded history.
|
||||
if (rpc_ && rpc_->isConnected() && !state_.isLocked()) {
|
||||
using NP = ui::NavPage;
|
||||
switch (page) {
|
||||
case NP::Overview:
|
||||
refreshCoreData();
|
||||
network_refresh_.reset(RefreshTimer::Core);
|
||||
break;
|
||||
case NP::History:
|
||||
markTransactionsDirty();
|
||||
refreshTransactionData();
|
||||
network_refresh_.reset(RefreshTimer::Transactions);
|
||||
break;
|
||||
case NP::Send:
|
||||
case NP::Receive:
|
||||
addresses_dirty_ = true;
|
||||
refreshAddressData();
|
||||
network_refresh_.reset(RefreshTimer::Addresses);
|
||||
break;
|
||||
case NP::Peers:
|
||||
refreshPeerInfo();
|
||||
network_refresh_.reset(RefreshTimer::Peers);
|
||||
break;
|
||||
case NP::Mining:
|
||||
refreshMiningInfo();
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
using Job = services::NetworkRefreshService::Job;
|
||||
const auto plan = services::planPageEntry(page, scanContext(), addressListStale(), state_.last_tx_update == 0);
|
||||
if (plan.core) {
|
||||
refreshCoreData();
|
||||
network_refresh_.reset(RefreshTimer::Core);
|
||||
}
|
||||
if (plan.peers) {
|
||||
refreshPeerInfo();
|
||||
network_refresh_.reset(RefreshTimer::Peers);
|
||||
}
|
||||
if (plan.mining) refreshMiningInfo();
|
||||
if (plan.addressesNow != services::ScanTier::None) {
|
||||
refreshAddressData();
|
||||
if (network_refresh_.jobInProgress(Job::Addresses)) network_refresh_.reset(RefreshTimer::Addresses);
|
||||
} else if (plan.markAddresses) {
|
||||
network_refresh_.markDue(RefreshTimer::Addresses);
|
||||
}
|
||||
if (plan.transactionsNow) {
|
||||
refreshTransactionData();
|
||||
if (network_refresh_.jobInProgress(Job::Transactions)) network_refresh_.reset(RefreshTimer::Transactions);
|
||||
} else if (plan.markTransactions) {
|
||||
network_refresh_.markDue(RefreshTimer::Transactions);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1348,7 +1422,7 @@ void App::updateWalletIndexForActiveWallet(bool markOpened)
|
||||
const std::string idHash = state_.connected ? activeWalletIdentityHash() : std::string();
|
||||
if (!idHash.empty()) {
|
||||
e.walletIdentityHash = idHash;
|
||||
e.cachedBalance = state_.totalBalance;
|
||||
if (state_.last_balance_update != 0) e.cachedBalance = state_.totalBalance; // not before one was read
|
||||
e.cachedAddressCount = static_cast<long long>(state_.z_addresses.size() + state_.t_addresses.size());
|
||||
}
|
||||
|
||||
@@ -1838,9 +1912,11 @@ void App::refreshData()
|
||||
|
||||
// Dispatch each category independently — results trickle into the UI
|
||||
// as each completes, rather than waiting for the slowest phase.
|
||||
refreshCoreData();
|
||||
refreshCoreData(); // the chain state; the balance is decided inside
|
||||
|
||||
// The address list first: the history needs it (the address callback marks the history due).
|
||||
// The address list first: the history needs it (the address callback marks the history due). The scan
|
||||
// entry points own the hard gates (syncing, the connect gate, locked, busy, background): a held address
|
||||
// refresh still loads a stale list (list-only), a held history waits.
|
||||
// refreshTransactionData() picks the cycle itself (Full / Light / the between-blocks poll / none).
|
||||
if (addresses_dirty_)
|
||||
refreshAddressData();
|
||||
@@ -1915,6 +1991,8 @@ void App::refreshCoreData(bool allowBalance)
|
||||
}
|
||||
|
||||
NetworkRefreshService::applyConnectionInfoResult(state_, result.info);
|
||||
// The node answers now, but may not know the tip yet: hold the scans for a moment.
|
||||
armScanGate(result.info);
|
||||
// Trigger full data refresh now that daemon is ready
|
||||
refreshData();
|
||||
} else {
|
||||
@@ -1960,6 +2038,9 @@ void App::refreshCoreData(bool allowBalance)
|
||||
services::BalancePollInputs balanceIn;
|
||||
balanceIn.allowBalance = allowBalance;
|
||||
balanceIn.syncing = effectivelySyncing();
|
||||
balanceIn.gateOpen = scanGateOpen();
|
||||
balanceIn.background = window_background_;
|
||||
balanceIn.firstReadTried = first_balance_read_tried_;
|
||||
balanceIn.forced = force_balance_refresh_;
|
||||
balanceIn.neverFetched = state_.last_balance_update == 0;
|
||||
balanceIn.changed = scan_governor_.needed(services::ScanStream::Balance, scanSig, scanNow);
|
||||
@@ -1990,6 +2071,7 @@ void App::refreshCoreData(bool allowBalance)
|
||||
NetworkRefreshService::CoreRefreshPlan plan;
|
||||
plan.includeBalance = includeBalance;
|
||||
plan.fetchMempool = !balanceIn.syncing; // a change signal for the scans; takes no cs_main
|
||||
plan.evenIfBehind = includeBalance && balanceIn.neverFetched; // the session's first read: while behind too
|
||||
plan.needSpendable = NetworkRefreshService::spendableBalanceNeeded(
|
||||
state_, !pending_send_info_.empty() || !pending_opids_.empty() || !send_txids_.empty(),
|
||||
force_balance_refresh_);
|
||||
@@ -2025,6 +2107,7 @@ void App::refreshCoreData(bool allowBalance)
|
||||
}
|
||||
if (result.balanceAttempted) state_.balance_last_attempt_failed = !result.balanceOk;
|
||||
NetworkRefreshService::applyCoreRefreshResult(state_, result, std::time(nullptr));
|
||||
if (result.blockchainOk && result.longestChain && *result.longestChain > 0) scan_gate_.noteTipKnown();
|
||||
if (result.balanceOk) {
|
||||
// Fresh aggregates: re-apply the pending sends' debits to them. The per-address
|
||||
// balances are the address scan's to refresh and re-debit.
|
||||
@@ -2055,9 +2138,9 @@ void App::refreshCoreData(bool allowBalance)
|
||||
// "behind" readings and stay latched until genuinely caught up. Window must exceed the
|
||||
// worst-case Core-refresh interval so it can't lapse between two behind readings mid-catchup.
|
||||
const bool nowSyncing = state_.sync.syncing;
|
||||
constexpr double kSyncSettleSeconds = 30.0;
|
||||
if (nowSyncing || was_core_syncing_) {
|
||||
sync_settle_until_ = std::time(nullptr) + static_cast<std::time_t>(kSyncSettleSeconds);
|
||||
sync_settle_until_ = std::time(nullptr) +
|
||||
static_cast<std::time_t>(services::RefreshScheduler::kSyncSettleSeconds);
|
||||
}
|
||||
was_core_syncing_ = nowSyncing;
|
||||
|
||||
@@ -2170,6 +2253,9 @@ void App::refreshCoreData(bool allowBalance)
|
||||
};
|
||||
}, 3);
|
||||
if (!enqueued.enqueued && balanceTicket) scan_governor_.cancel(balanceTicket);
|
||||
// The first read while the node catches up is tried once per connection (a failed one would otherwise be
|
||||
// re-sent after every busy period, a wallet-wide scan into the catch-up each time).
|
||||
if (enqueued.enqueued && plan.evenIfBehind && balanceIn.syncing) first_balance_read_tried_ = true;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
@@ -2183,9 +2269,26 @@ void App::refreshAddressData(services::ScanTicket ticket)
|
||||
if (ticket) scan_governor_.cancel(ticket); // not sent after all
|
||||
return;
|
||||
}
|
||||
// No ticket: an explicit refresh (a dirty set, a send in progress, tab entry). Never held back, but
|
||||
// charged to the scan budget like the routine ones.
|
||||
if (!ticket) ticket = scan_governor_.beginExplicit(services::ScanStream::Addresses, currentScanSignals(), scanClockNow());
|
||||
// The hard gates live here, so no caller (tab entry, refreshData, an import, the history's re-route) can
|
||||
// send the wallet-wide scan into a node that is syncing, warming up, rescanning or busy, into a locked
|
||||
// wallet, while the window is in the background, or before the connect gate opens. A stale list still
|
||||
// loads, without the balances (list-only, in the background too), and the session's first balances are
|
||||
// read once even while the node catches up. Refused work keeps its flags: the periodic refresh, or
|
||||
// leaving the held profile, retries it.
|
||||
const services::ScanTier tier = services::planAddressScan(scanContext(), addressListStale(), !address_balances_loaded_);
|
||||
if (tier == services::ScanTier::None || std::chrono::steady_clock::now() < address_retry_not_before_) {
|
||||
if (ticket) scan_governor_.cancel(ticket);
|
||||
return;
|
||||
}
|
||||
const bool listOnly = tier == services::ScanTier::ListOnly;
|
||||
if (listOnly) {
|
||||
if (ticket) scan_governor_.cancel(ticket); // no wallet scan: nothing to charge
|
||||
ticket = {};
|
||||
} else if (!ticket) {
|
||||
// An explicit refresh (a dirty set, a send in progress, a stale list). Never held back, but charged
|
||||
// to the scan budget like the routine ones.
|
||||
ticket = scan_governor_.beginExplicit(services::ScanStream::Addresses, currentScanSignals(), scanClockNow());
|
||||
}
|
||||
const std::size_t previousAddressCount = state_.z_addresses.size() + state_.t_addresses.size();
|
||||
const std::string previousWalletIdentity = transactionHistoryCacheWalletIdentity();
|
||||
const auto zList = shieldedAddressList();
|
||||
@@ -2194,58 +2297,80 @@ void App::refreshAddressData(services::ScanTicket ticket)
|
||||
? NetworkRefreshService::AddressRefreshSnapshot{}
|
||||
: NetworkRefreshService::buildAddressRefreshSnapshot(state_);
|
||||
ui::NavPage tracePage = current_page_;
|
||||
auto enqueued = network_refresh_.enqueue(services::NetworkRefreshService::Job::Addresses, *worker_, [this, previousAddressCount, previousWalletIdentity, previousZ = std::move(previousZ), addressSnapshot = std::move(addressSnapshot), tracePage, ticket]() -> rpc::RPCWorker::MainCb {
|
||||
AppRefreshRpcGateway refreshRpc(*rpc_, traceSource(tracePage, "Address refresh"));
|
||||
auto result = NetworkRefreshService::collectAddressRefreshResult(refreshRpc, addressSnapshot);
|
||||
auto enqueued = network_refresh_.enqueue(services::NetworkRefreshService::Job::Addresses, *worker_, [this, previousAddressCount, previousWalletIdentity, previousZ = std::move(previousZ), addressSnapshot = std::move(addressSnapshot), tracePage, ticket, listOnly]() -> rpc::RPCWorker::MainCb {
|
||||
AppRefreshRpcGateway refreshRpc(*rpc_, traceSource(tracePage, listOnly ? "Address list" : "Address refresh"));
|
||||
auto result = NetworkRefreshService::collectAddressRefreshResult(refreshRpc, addressSnapshot, listOnly);
|
||||
|
||||
return [this, previousAddressCount, previousWalletIdentity, previousZ, ticket, result = std::move(result)]() mutable {
|
||||
return [this, previousAddressCount, previousWalletIdentity, previousZ, ticket, listOnly, result = std::move(result)]() mutable {
|
||||
const bool addrListOk = result.addressListOk; // capture before the move
|
||||
scan_governor_.complete(ticket, result.scanMs,
|
||||
(addrListOk && result.balancesOk) ? services::ScanOutcome::Ok
|
||||
: result.replyTimedOut ? services::ScanOutcome::TimedOut : services::ScanOutcome::Failed,
|
||||
scanClockNow());
|
||||
// What was applied: a full pass whose balance scan failed degrades to list-only (it keeps the
|
||||
// previous balances rather than writing the zeros of the calls that didn't answer).
|
||||
const bool appliedListOnly = result.listOnly;
|
||||
const double scanMsAtApply = result.scanMs;
|
||||
if (ticket) {
|
||||
scan_governor_.complete(ticket, result.scanMs,
|
||||
(addrListOk && result.balancesOk) ? services::ScanOutcome::Ok
|
||||
: result.replyTimedOut ? services::ScanOutcome::TimedOut : services::ScanOutcome::Failed,
|
||||
scanClockNow());
|
||||
}
|
||||
if (!addrListOk) {
|
||||
// The enumeration failed (-28, a timeout, a missing RPC): its lists may be falsely short. Keep
|
||||
// the previous lists and the flags, and hold address refreshes for a while (the history keeps
|
||||
// refreshing with the kept list meanwhile).
|
||||
address_retry_not_before_ = std::chrono::steady_clock::now() +
|
||||
std::chrono::duration_cast<std::chrono::steady_clock::duration>(std::chrono::duration<double>(
|
||||
services::addressRetryDelaySeconds(result.scanMs, result.nodeBusy || result.replyTimedOut)));
|
||||
DEBUG_LOGF("[addr] address enumeration failed; keeping the previous list\n");
|
||||
maybeFinishTransactionSendProgress();
|
||||
return;
|
||||
}
|
||||
// #3: this scan already listed the wallet's unspent notes — feed the chat send-budget from it
|
||||
// (dedups the dedicated chat z_listunspent). Grab it before `result` is moved below.
|
||||
auto unspentNotes = std::move(result.unspentNotes);
|
||||
NetworkRefreshService::applyAddressRefreshResult(state_, std::move(result));
|
||||
updateChatNoteBudgetFromUnspent(unspentNotes);
|
||||
// Mark the address list as loaded ONLY if enumeration actually succeeded — a swallowed
|
||||
// z_listaddresses/getaddressesbyaccount failure returns a falsely-short list, and stamping it
|
||||
// would let the empty-wallet warning trust a spurious 0 count (see maybeWarnEmptyWallet…).
|
||||
if (addrListOk) state_.last_address_update = std::time(nullptr);
|
||||
applyPendingSendBalanceDeltas(false);
|
||||
NetworkRefreshService::applyAddressRefreshResult(state_, std::move(result)); // list-only keeps the balances
|
||||
if (!appliedListOnly) {
|
||||
updateChatNoteBudgetFromUnspent(unspentNotes);
|
||||
applyPendingSendBalanceDeltas(false);
|
||||
addresses_dirty_ = false; // a list-only pass still owes the full one
|
||||
address_balances_loaded_ = true;
|
||||
} else if (!listOnly) {
|
||||
// The balance scan failed: retry after a hold scaled by what it cost (the abandoned call keeps
|
||||
// running in the daemon, so this is a lower bound), not the busy floor: explicit passes bypass
|
||||
// the scan budget, and this one would otherwise go again after every busy period.
|
||||
address_retry_not_before_ = std::chrono::steady_clock::now() +
|
||||
std::chrono::duration_cast<std::chrono::steady_clock::duration>(
|
||||
std::chrono::duration<double>(services::addressRetryDelaySeconds(scanMsAtApply)));
|
||||
}
|
||||
state_.last_address_update = std::time(nullptr);
|
||||
address_validation_cache_dirty_ = false;
|
||||
address_list_dirty_ = true;
|
||||
addresses_dirty_ = false;
|
||||
const std::size_t currentAddressCount = state_.z_addresses.size() + state_.t_addresses.size();
|
||||
const bool addressSetChanged = currentAddressCount != previousAddressCount ||
|
||||
transactionHistoryCacheWalletIdentity() != previousWalletIdentity;
|
||||
if (addrListOk) {
|
||||
// Drop removed addresses' scan heights; the others stay valid (a changed set used to wipe
|
||||
// every height, so one new address re-scanned the whole wallet). A z-address we never
|
||||
// scanned that wasn't listed before (a key imported from the Console, say) may bring
|
||||
// older sends with it, which a windowed sweep can't reach: sweep the whole wallet once.
|
||||
pruneShieldedHistoryScanProgress();
|
||||
// New: in neither the list at dispatch (GUI-created addresses are added to it before the
|
||||
// refresh, and have no history) nor the last good listing (a blank list in between proves
|
||||
// nothing), and never scanned.
|
||||
bool newUnscannedZ = false;
|
||||
std::unordered_set<std::string> listedZ;
|
||||
for (const auto& address : state_.z_addresses) {
|
||||
if (address.address.empty()) continue;
|
||||
listedZ.insert(address.address);
|
||||
if (!previousZ.count(address.address) && !history_listed_z_.count(address.address) &&
|
||||
!shielded_history_scan_heights_.count(address.address))
|
||||
newUnscannedZ = true;
|
||||
}
|
||||
if (newUnscannedZ && (!previousZ.empty() || !history_listed_z_.empty())) {
|
||||
history_sent_scan_height_ = -1;
|
||||
history_full_sweep_failures_ = 0;
|
||||
history_full_sweep_retry_at_ = 0;
|
||||
++history_sent_epoch_;
|
||||
}
|
||||
history_listed_z_ = std::move(listedZ);
|
||||
// Drop removed addresses' scan heights; the others stay valid (a changed set used to wipe
|
||||
// every height, so one new address re-scanned the whole wallet). A z-address we never
|
||||
// scanned that wasn't listed before (a key imported from the Console, say) may bring
|
||||
// older sends with it, which a windowed sweep can't reach: sweep the whole wallet once.
|
||||
pruneShieldedHistoryScanProgress();
|
||||
// New: in neither the list at dispatch (GUI-created addresses are added to it before the
|
||||
// refresh, and have no history) nor the last good listing (a blank list in between proves
|
||||
// nothing), and never scanned.
|
||||
bool newUnscannedZ = false;
|
||||
std::unordered_set<std::string> listedZ;
|
||||
for (const auto& address : state_.z_addresses) {
|
||||
if (address.address.empty()) continue;
|
||||
listedZ.insert(address.address);
|
||||
if (!previousZ.count(address.address) && !history_listed_z_.count(address.address) &&
|
||||
!shielded_history_scan_heights_.count(address.address))
|
||||
newUnscannedZ = true;
|
||||
}
|
||||
if (newUnscannedZ && (!previousZ.empty() || !history_listed_z_.empty())) {
|
||||
history_sent_scan_height_ = -1;
|
||||
history_full_sweep_failures_ = 0;
|
||||
history_full_sweep_retry_at_ = 0;
|
||||
++history_sent_epoch_;
|
||||
}
|
||||
history_listed_z_ = std::move(listedZ);
|
||||
if (addressSetChanged) scan_governor_.invalidate(services::ScanStream::Balance); // the totals moved
|
||||
if (state_.transactions.empty() || addressSetChanged) {
|
||||
markTransactionsDirty();
|
||||
@@ -2253,12 +2378,15 @@ void App::refreshAddressData(services::ScanTicket ticket)
|
||||
network_refresh_.markDue(services::NetworkRefreshService::Timer::Transactions);
|
||||
}
|
||||
if (state_.transactions.empty()) loadTransactionHistoryCacheIfAvailable();
|
||||
else if (addrListOk) storeTransactionHistoryCacheIfAvailable();
|
||||
else storeTransactionHistoryCacheIfAvailable();
|
||||
// Hints compare the notes with the history on screen: only once it's there (the cache loaded
|
||||
// just above, or a pass is done). Before that every note looks missing, and the first pass scans
|
||||
// every address anyway.
|
||||
if (addrListOk && (!state_.transactions.empty() || initial_history_scan_complete_))
|
||||
if (!state_.transactions.empty() || initial_history_scan_complete_)
|
||||
noteHistoryHintsFromUnspent(unspentNotes);
|
||||
// A list-only pass owes the full one: due as soon as the scans may run.
|
||||
if (listOnly && services::wholeWalletScanAllowed(scanContext()))
|
||||
network_refresh_.markDue(services::NetworkRefreshService::Timer::Addresses);
|
||||
maybeFinishTransactionSendProgress();
|
||||
// Addresses (hence identity + count) are now known — refresh the wallet index's cached
|
||||
// metadata for the active wallet. Throttled: upsert() only writes on a real change.
|
||||
@@ -2280,7 +2408,9 @@ bool App::refreshTransactionData()
|
||||
// callback's re-route), so every caller gets the same rules. Wallet-wide scans hold the daemon's
|
||||
// cs_main: never while it can't take them.
|
||||
if (!worker_ || !rpc_ || !rpc_->isConnected() || !state_.connected) return false;
|
||||
if (state_.isLocked() || state_.warming_up || runtime_rescan_active_ || rpc_busy_gate_->busy()) return false;
|
||||
// The hard gates (syncing, the connect gate, locked, warming up, rescanning, busy, background): nothing
|
||||
// runs, the dirty flags stay set, and the periodic refresh or leaving the held profile retries.
|
||||
if (!services::wholeWalletScanAllowed(scanContext())) return false;
|
||||
// A cycle in flight: the enqueue would refuse this one.
|
||||
if (network_refresh_.jobInProgress(services::NetworkRefreshService::Job::Transactions)) return false;
|
||||
|
||||
@@ -2333,7 +2463,8 @@ bool App::refreshTransactionData()
|
||||
return false;
|
||||
case TxCycle::Full:
|
||||
case TxCycle::Light:
|
||||
if (addresses_dirty_) {
|
||||
// While an address retry hold runs, scan the history with the kept list instead of spinning.
|
||||
if (addresses_dirty_ && std::chrono::steady_clock::now() >= address_retry_not_before_) {
|
||||
if (ticket) scan_governor_.cancel(ticket);
|
||||
// The history needs the current address list; the address callback marks it due again.
|
||||
refreshAddressData();
|
||||
@@ -2722,12 +2853,6 @@ void App::refreshMiningInfo()
|
||||
auto* rpc = (fast_rpc_ && fast_rpc_->isConnected()) ? fast_rpc_.get() : rpc_.get();
|
||||
if (!w || !rpc) return;
|
||||
|
||||
// Capture daemon memory outside (may be accessed on main thread)
|
||||
double daemonMemMb = 0.0;
|
||||
if (daemon_controller_) {
|
||||
daemonMemMb = daemon_controller_->memoryUsageMB();
|
||||
}
|
||||
|
||||
// Slow-tick counter: run full getmininginfo every ~5 seconds
|
||||
// to reduce RPC overhead. getlocalsolps is only needed while solo mining
|
||||
// or while the Mining tab is actively showing live local hashrate.
|
||||
@@ -2744,6 +2869,13 @@ void App::refreshMiningInfo()
|
||||
// sync-profile intervals don't otherwise cover it).
|
||||
if (state_.sync.syncing && !includeLocalHashrate) return;
|
||||
ui::NavPage tracePage = current_page_;
|
||||
|
||||
// Capture daemon memory outside (may be accessed on main thread). Only for a poll that runs: the probe
|
||||
// reads /proc (or the Windows process table).
|
||||
double daemonMemMb = 0.0;
|
||||
if (daemon_controller_) {
|
||||
daemonMemMb = daemon_controller_->memoryUsageMB();
|
||||
}
|
||||
|
||||
auto enqueued = network_refresh_.enqueue(services::NetworkRefreshService::Job::Mining, *w,
|
||||
[this, rpc, daemonMemMb, doSlowRefresh, includeLocalHashrate, tracePage]() -> rpc::RPCWorker::MainCb {
|
||||
@@ -3426,9 +3558,8 @@ void App::createNewAddress(bool shielded, std::function<void(const std::string&)
|
||||
(shielded ? state_.z_addresses : state_.t_addresses).push_back(info);
|
||||
state_.addresses.push_back(info); // keep combined view in sync immediately
|
||||
address_list_dirty_ = true;
|
||||
// Also trigger full refresh to get proper balances
|
||||
addresses_dirty_ = true;
|
||||
refreshAddresses();
|
||||
// No forced scan: a new address has no balance and its spending key (both as injected);
|
||||
// the periodic refresh lists it next time.
|
||||
}
|
||||
if (callback) callback(addr);
|
||||
};
|
||||
@@ -3577,6 +3708,7 @@ void App::invalidateAddressValidationCache()
|
||||
{
|
||||
address_validation_cache_dirty_ = true;
|
||||
addresses_dirty_ = true;
|
||||
address_retry_not_before_ = {}; // the address set changed: a refresh is owed now, whatever failed before
|
||||
noteWalletMutation(); // an import or sweep: the balance changes without the tip or mempool showing it
|
||||
invalidateShieldedHistoryScanProgress(true);
|
||||
}
|
||||
@@ -3752,6 +3884,9 @@ void App::maybeProvisionChatIdentity()
|
||||
// identity that works on any daemon. Both secrets are wiped after deriving.
|
||||
if (!state_.connected || !rpc_ || !worker_) return;
|
||||
if (!state_.encryption_state_known) return; // don't fetch before we know the lock state
|
||||
// Nor before the address list: the fallback identity (no mnemonic) derives from a z-address, and an
|
||||
// empty one would latch chat as unavailable for the session.
|
||||
if (state_.last_address_update == 0) return;
|
||||
const std::string fallbackZaddr = chatReplyZaddr(); // main thread: stable identity source
|
||||
const int fetchGen = chat_session_generation_; // wallet epoch this secret belongs to
|
||||
chat_identity_fetch_in_flight_ = true;
|
||||
@@ -4202,7 +4337,13 @@ void App::refreshChatNoteBudgetNode() {
|
||||
// and holds cs_main for its whole duration — seconds on a large wallet — so an ungated per-few-second
|
||||
// chat scan here starves block connection and keeps the node from ever pinning to the tip. Chat
|
||||
// send-readiness can wait until we're synced; this budget is only advisory (the send path re-checks).
|
||||
if (effectivelySyncing()) return;
|
||||
// The same hard gates as every wallet scan, except that a queued chat send still drains while the window
|
||||
// is in the background (the address scan that also feeds the budget waits for the window).
|
||||
{
|
||||
services::ScanContext sc = scanContext();
|
||||
if (!chat_send_queue_.empty() || chat_split_outstanding_) sc.background = false;
|
||||
if (!services::wholeWalletScanAllowed(sc)) return;
|
||||
}
|
||||
// Only pre-warm the send-budget while the user is actually in chat. Off the Chat tab this heavy
|
||||
// z_listunspent is unnecessary — and on a wallet-data tab, refreshAddressData's own z_listunspent
|
||||
// already feeds the budget for free (updateChatNoteBudgetFromUnspent), so this dedicated scan is a
|
||||
@@ -4542,7 +4683,8 @@ void App::fastScanChatMemos()
|
||||
// address) — seconds on a large wallet — so this 0-conf poll must NOT run while the node is catching
|
||||
// up, and even when synced it backs off to ~10% wall-clock keyed on its own cost. The block-tip
|
||||
// harvest still ingests every message on confirmation; this only trades away mempool-speed delivery.
|
||||
if (effectivelySyncing()) return;
|
||||
// The same hard gates as every wallet scan (catch-up, the connect gate, the background, busy, ...).
|
||||
if (!services::wholeWalletScanAllowed(scanContext())) return;
|
||||
// Mempool-speed 0-conf delivery is only worth its heavy cost while the user is watching chat. Off the
|
||||
// Chat tab, incoming messages still arrive via the confirmed block-tip harvest (+ toast) within ~1 block.
|
||||
if (current_page_ != ui::NavPage::Chat) return;
|
||||
@@ -5043,6 +5185,7 @@ void App::exportSeedPhrase(std::function<void(bool, bool, const std::string&, co
|
||||
void App::maybeRemindSeedBackup()
|
||||
{
|
||||
if (capture_mode_) return; // no live ops during a UI sweep
|
||||
if (window_background_) return; // when someone can see it
|
||||
if (lite_wallet_) return; // lite has its own seed UX
|
||||
if (!settings_ || settings_->getSeedBackupReminded()) return;
|
||||
if (!state_.connected || !state_.encryption_state_known) return;
|
||||
@@ -5075,6 +5218,7 @@ void App::maybeRemindSeedBackup()
|
||||
void App::maybeWarnLargeWallet()
|
||||
{
|
||||
if (capture_mode_ || lite_wallet_) return; // no live nags during a UI sweep; lite has no wallet.dat
|
||||
if (window_background_) return; // when someone can see it
|
||||
if (!supportsFullNodeLifecycleActions() || !settings_) return;
|
||||
if (!state_.connected || !state_.encryption_state_known) return;
|
||||
if (state_.warming_up || state_.daemon_initializing || !state_.sync.isSynced()) return;
|
||||
|
||||
Reference in New Issue
Block a user