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

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

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-09 11:06:34 -05:00
co-authored by Claude Opus 5.5
parent 323143e111
commit f86cc4d6b7
26 changed files with 1083 additions and 159 deletions
+68 -14
View File
@@ -247,9 +247,21 @@ NetworkRefreshService::WalletEncryptionResult NetworkRefreshService::parseWallet
result.ok = false;
}
}
if (walletInfo.contains("txcount") && walletInfo["txcount"].is_number_integer())
result.txCount = walletInfo["txcount"].get<int>();
return result;
}
bool NetworkRefreshService::connectionInfoTipKnown(const ConnectionInfoResult& info)
{
return info.ok && info.longestChain && *info.longestChain > 0;
}
bool NetworkRefreshService::connectionInfoShowsBehind(const ConnectionInfoResult& info)
{
return connectionInfoTipKnown(info) && info.blocks && *info.blocks < *info.longestChain - 2;
}
NetworkRefreshService::WarmupPollResult NetworkRefreshService::collectWarmupPollResult(RefreshRpcGateway& rpc)
{
WarmupPollResult result;
@@ -378,7 +390,7 @@ NetworkRefreshService::CoreRefreshResult NetworkRefreshService::collectCoreRefre
// balance scans: they need the same cs_main, and each would only take another of its few RPC slots.
bool balanceAttempted = false;
bool spendableQueried = false;
if (plan.includeBalance && !behind && !rpc::rpcKindMeansBusy(blockErrKind)) {
if (plan.includeBalance && (!behind || plan.evenIfBehind) && !rpc::rpcKindMeansBusy(blockErrKind)) {
// z_gettotalbalance is O(mapWallet) and holds the daemon's cs_main for its whole duration —
// seconds on a large shielded wallet. Time it so the caller can throttle how often it polls
// (balanceRefreshDue()), keeping balance scans from starving block connection.
@@ -402,7 +414,9 @@ NetworkRefreshService::CoreRefreshResult NetworkRefreshService::collectCoreRefre
const auto total = totalBalance.is_object() ? totalBalance.find("total") : totalBalance.end();
if (total != totalBalance.end() && total->is_string()) totalZat = parseMoneyZat(total->get<std::string>());
const bool totalMoved = !totalZat || !plan.prevTotalZat || *totalZat != *plan.prevTotalZat;
if (plan.needSpendable || totalMoved) {
// Not on the session's first read while the node is behind: nothing can be sent until it caught
// up anyway, and it is a second whole-wallet scan into the catch-up.
if ((plan.needSpendable || totalMoved) && !(behind && plan.evenIfBehind)) {
spendableQueried = true;
try { // If it fails, spendable degrades to display in apply.
spendableBalance = rpc.call("z_gettotalbalance", json::array({1}));
@@ -753,9 +767,12 @@ NetworkRefreshService::AddressRefreshSnapshot NetworkRefreshService::buildAddres
NetworkRefreshService::AddressRefreshResult NetworkRefreshService::collectAddressRefreshResult(
RefreshRpcGateway& rpc,
const AddressRefreshSnapshot& snapshot)
const AddressRefreshSnapshot& snapshot,
bool listOnly)
{
AddressRefreshResult result;
result.listOnly = listOnly;
// Time the whole scan — z_listunspent (and per-address z_getbalance fallback) hold the daemon's
// cs_main for the duration, seconds on a large shielded wallet. The measured cost feeds the
// caller's adaptive throttle so the address poll can't starve block connection.
@@ -797,9 +814,23 @@ NetworkRefreshService::AddressRefreshResult NetworkRefreshService::collectAddres
DEBUG_LOGF("z_listaddresses error: %s\n", e.what());
result.addressListOk = false; // enumeration failed → the shielded list may be falsely short
result.replyTimedOut |= rpc::rpcFailureKindOf(e) == rpc::RpcFailureKind::ReplyTimeout;
result.nodeBusy |= rpc::rpcKindMeansBusy(rpc::rpcFailureKindOf(e));
}
try {
json tList = rpc.call("getaddressesbyaccount", json::array({""}));
result.transparentAddresses = parseTransparentAddressList(tList);
} catch (const std::exception& e) {
DEBUG_LOGF("getaddressesbyaccount error: %s\n", e.what());
result.addressListOk = false; // enumeration failed → the transparent list may be falsely short
result.replyTimedOut |= rpc::rpcFailureKindOf(e) == rpc::RpcFailureKind::ReplyTimeout;
result.nodeBusy |= rpc::rpcKindMeansBusy(rpc::rpcFailureKindOf(e));
}
// The balances need the lists they belong to: none in a list-only pass, and none when an enumeration
// failed (the caller discards the whole pass and keeps the previous lists).
const bool scanBalances = !listOnly && result.addressListOk;
if (scanBalances) try {
json unspent = rpc.call("z_listunspent", json::array({0, 9999999, false})); // minconf=0 → include 0-conf change
applyShieldedBalancesFromUnspent(result.shieldedAddresses, unspent);
// Retain a minimal view so a downstream consumer (chat note-budget) can reuse this scan instead
@@ -822,8 +853,15 @@ NetworkRefreshService::AddressRefreshResult NetworkRefreshService::collectAddres
}
}
} catch (const std::exception& e) {
const auto kind = rpc::rpcFailureKindOf(e);
result.replyTimedOut |= kind == rpc::RpcFailureKind::ReplyTimeout;
if (rpc::rpcKindMeansBusy(kind)) {
// The node is busy: every z_getbalance would be held back and read 0. Nothing more this pass.
DEBUG_LOGF("z_listunspent: node busy (%s)\n", e.what());
result.balancesOk = false;
result.nodeBusy = true;
} else {
DEBUG_LOGF("z_listunspent unavailable (%s), falling back to z_getbalance\n", e.what());
result.replyTimedOut |= rpc::rpcFailureKindOf(e) == rpc::RpcFailureKind::ReplyTimeout;
for (auto& info : result.shieldedAddresses) {
try { // display total (minconf=0, includes pending change)
json total = rpc.call("z_getbalance", json::array({info.address, 0}));
@@ -834,24 +872,23 @@ NetworkRefreshService::AddressRefreshResult NetworkRefreshService::collectAddres
info.spendableBalance = (!conf.is_null()) ? conf.get<double>() : info.balance;
} catch (...) { info.spendableBalance = info.balance; }
}
}
}
try {
json tList = rpc.call("getaddressesbyaccount", json::array({""}));
result.transparentAddresses = parseTransparentAddressList(tList);
} catch (const std::exception& e) {
DEBUG_LOGF("getaddressesbyaccount error: %s\n", e.what());
result.addressListOk = false; // enumeration failed → the transparent list may be falsely short
result.replyTimedOut |= rpc::rpcFailureKindOf(e) == rpc::RpcFailureKind::ReplyTimeout;
}
try {
if (scanBalances && !result.nodeBusy) try {
json unspent = rpc.call("listunspent", json::array({0})); // minconf=0 → include 0-conf change
applyTransparentBalancesFromUnspent(result.transparentAddresses, unspent);
} catch (const std::exception& e) {
DEBUG_LOGF("listunspent error: %s\n", e.what());
result.balancesOk = false;
result.replyTimedOut |= rpc::rpcFailureKindOf(e) == rpc::RpcFailureKind::ReplyTimeout;
result.nodeBusy |= rpc::rpcKindMeansBusy(rpc::rpcFailureKindOf(e));
}
// A balance that wasn't read reads 0: never overwrite known balances with that. The pass degrades to
// list-only (apply carries the previous balances), and its notes aren't a full view either.
if (scanBalances && !result.balancesOk) {
result.listOnly = true;
result.unspentNotes.clear();
}
result.scanMs = std::chrono::duration<double, std::milli>(
@@ -1694,6 +1731,7 @@ void NetworkRefreshService::applyWalletEncryptionResult(WalletState& state, cons
state.unlocked_until = 0;
}
state.encryption_state_known = true;
if (result.txCount) state.wallet_tx_count = *result.txCount;
}
void NetworkRefreshService::applyConnectionInitResult(WalletState& state, const ConnectionInitResult& result)
@@ -1810,6 +1848,22 @@ void NetworkRefreshService::applyPriceRefreshFailure(WalletState& state,
void NetworkRefreshService::applyAddressRefreshResult(WalletState& state,
AddressRefreshResult&& result)
{
if (result.listOnly) {
// No balance scan ran: keep each address's last known balances (a new address shows 0 until the
// next full pass).
auto carry = [](std::vector<AddressInfo>& fresh, const std::vector<AddressInfo>& previous) {
std::unordered_map<std::string, const AddressInfo*> known;
for (const auto& info : previous) known[info.address] = &info;
for (auto& info : fresh) {
const auto it = known.find(info.address);
if (it == known.end()) continue;
info.balance = it->second->balance;
info.spendableBalance = it->second->spendableBalance;
}
};
carry(result.shieldedAddresses, state.z_addresses);
carry(result.transparentAddresses, state.t_addresses);
}
state.z_addresses = std::move(result.shieldedAddresses);
state.t_addresses = std::move(result.transparentAddresses);
// Keep the combined `addresses` view in sync with the authoritative z/t lists.