perf(net): one balance scan per poll, filtered opstatus, a guarded merge scope

The Core poll ran z_gettotalbalance twice (minconf 0 and 1), each a whole-
wallet scan under dragonxd's cs_main. The spendable (minconf=1) call now
runs only after the display call succeeded, and only when something is
unconfirmed or pending or the exact total moved: about 95% of polls make
one scan instead of two. A forced post-send balance can no longer be lost
to a refused enqueue, and pending-send debits aren't re-applied each tick.

z_getoperationstatus is filtered to our opids with its real argument form
([[id,...]]; the old ["id"] was rejected), falling back to the unfiltered
call if a daemon refuses the filter. The Shield/Merge dialog's own polls
are filtered and session-guarded too, and finally reach Done; the dead
RPCClient wrappers are deleted.

The Merge/Consolidate scope scan becomes a tested state machine: one scan
at a time, failures latch behind a Retry button, only "node not ready"
retries by itself (10/30/60 s, at most 3), Lite never scans. Its 120 s
calls are maintenance calls: every main-thread disconnect aborts one in
flight first, so a lifecycle action no longer freezes the UI behind it.
An aborted merge that was already sent says it may still go through.

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 4eed4283d6
commit 8b9a498eee
23 changed files with 1044 additions and 128 deletions
+100 -8
View File
@@ -7,6 +7,7 @@
#include <chrono>
#include <cmath>
#include <cstdlib>
#include <limits>
#include <map>
#include <set>
#include <unordered_map>
@@ -271,6 +272,9 @@ NetworkRefreshService::CoreRefreshResult NetworkRefreshService::parseCoreRefresh
result.shieldedBalance = readBalanceString(totalBalance, "private");
result.transparentBalance = readBalanceString(totalBalance, "transparent");
result.totalBalance = readBalanceString(totalBalance, "total");
const auto total = totalBalance.find("total");
if (total != totalBalance.end() && total->is_string())
result.totalBalanceZat = parseMoneyZat(total->get<std::string>());
}
if (spendableBalance.is_object()) { // confirmed totals (minconf=1); left unset on old daemons
result.spendableShieldedBalance = readBalanceString(spendableBalance, "private");
@@ -290,7 +294,8 @@ NetworkRefreshService::CoreRefreshResult NetworkRefreshService::parseCoreRefresh
return result;
}
NetworkRefreshService::CoreRefreshResult NetworkRefreshService::collectCoreRefreshResult(RefreshRpcGateway& rpc, bool includeBalance)
NetworkRefreshService::CoreRefreshResult NetworkRefreshService::collectCoreRefreshResult(RefreshRpcGateway& rpc,
const CoreRefreshPlan& plan)
{
json totalBalance;
json spendableBalance;
@@ -328,7 +333,9 @@ NetworkRefreshService::CoreRefreshResult NetworkRefreshService::collectCoreRefre
// Don't follow a chain-info call that found the node busy (a reply timeout or a full queue) with two
// balance scans: they need the same cs_main, and each would only take another of its few RPC slots.
if (includeBalance && !behind && !rpc::rpcKindMeansBusy(blockErrKind)) {
bool balanceAttempted = false;
bool spendableQueried = false;
if (plan.includeBalance && !behind && !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.
@@ -340,9 +347,25 @@ NetworkRefreshService::CoreRefreshResult NetworkRefreshService::collectCoreRefre
balanceErrKind = rpc::rpcFailureKindOf(e);
DEBUG_LOGF("Balance error: %s\n", e.what());
}
try { // SPENDABLE total: minconf=1 (confirmed). If absent, spendable degrades to display in apply.
spendableBalance = rpc.call("z_gettotalbalance", json::array({1}));
} catch (...) {}
// A Deferred call was held back by the busy gate and never reached the daemon.
balanceAttempted = balanceErrKind != rpc::RpcFailureKind::Deferred;
// SPENDABLE total: minconf=1 (confirmed), a second whole-wallet scan. Only after the display call
// succeeded (its result is dropped otherwise), and only when it can differ from the display total:
// something is unconfirmed or pending (needSpendable), or the exact total moved since the last
// poll, which is how a new incoming 0-conf payment first shows. Skipped, spendable equals display
// in applyCoreRefreshResult and nothing reads as unconfirmed.
if (balanceOk) {
std::optional<std::int64_t> totalZat;
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) {
spendableQueried = true;
try { // If it fails, spendable degrades to display in apply.
spendableBalance = rpc.call("z_gettotalbalance", json::array({1}));
} catch (...) {}
}
}
balanceScanMs = std::chrono::duration<double, std::milli>(
std::chrono::steady_clock::now() - balanceStart).count();
}
@@ -351,9 +374,65 @@ NetworkRefreshService::CoreRefreshResult NetworkRefreshService::collectCoreRefre
result.balanceScanMs = balanceScanMs;
result.blockchainErrKind = blockErrKind;
result.balanceErrKind = balanceErrKind;
result.balanceAttempted = balanceAttempted;
result.spendableQueried = spendableQueried;
return result;
}
std::optional<std::int64_t> NetworkRefreshService::parseMoneyZat(const std::string& text)
{
constexpr std::int64_t kCoin = 100000000;
// Leaves room for up to 99999999 zatoshis of fraction on top of whole * 1e8.
constexpr std::int64_t kMaxWhole = std::numeric_limits<std::int64_t>::max() / kCoin - 1;
std::size_t i = 0;
const bool negative = !text.empty() && text[0] == '-';
if (negative) ++i;
std::int64_t whole = 0;
std::size_t wholeDigits = 0;
for (; i < text.size() && text[i] >= '0' && text[i] <= '9'; ++i, ++wholeDigits) {
const int digit = text[i] - '0';
if (whole > (kMaxWhole - digit) / 10) return std::nullopt; // the amount would overflow
whole = whole * 10 + digit;
}
if (wholeDigits == 0) return std::nullopt;
std::int64_t fraction = 0;
if (i < text.size() && text[i] == '.') {
++i;
std::size_t fractionDigits = 0;
for (; i < text.size() && text[i] >= '0' && text[i] <= '9'; ++i, ++fractionDigits) {
if (fractionDigits == 8) return std::nullopt; // finer than a zatoshi: not a daemon amount
fraction = fraction * 10 + (text[i] - '0');
}
if (fractionDigits == 0) return std::nullopt;
for (; fractionDigits < 8; ++fractionDigits) fraction *= 10;
}
if (i != text.size()) return std::nullopt;
const std::int64_t zat = whole * kCoin + fraction;
return negative ? -zat : zat;
}
std::optional<std::int64_t> NetworkRefreshService::nextTotalBaseline(const CoreRefreshResult& result,
std::optional<std::int64_t> previous)
{
if (!result.balanceOk) return previous; // no fresh total
if (result.spendableQueried && !result.spendableTotalBalance) return std::nullopt; // re-measure next poll
return result.totalBalanceZat;
}
bool NetworkRefreshService::spendableBalanceNeeded(const WalletState& state, bool pendingSendWork, bool forced)
{
if (forced || pendingSendWork) return true;
if (state.last_balance_update == 0) return true;
if (state.unconfirmedBalance > 1e-9) return true;
// The address scan saw unconfirmed funds (z_listunspent/listunspent at minconf=0 with confirmations).
auto anyUnconfirmed = [](const std::vector<AddressInfo>& addresses) {
return std::any_of(addresses.begin(), addresses.end(), [](const AddressInfo& address) {
return address.balance - address.spendableBalance > 1e-9;
});
};
return anyUnconfirmed(state.z_addresses) || anyUnconfirmed(state.t_addresses);
}
NetworkRefreshService::MiningRefreshResult NetworkRefreshService::parseMiningRefreshResult(
const json& miningInfo, bool miningOk, const json& localHashrate, bool hashrateOk, double daemonMemoryMb)
{
@@ -1327,9 +1406,10 @@ NetworkRefreshService::OperationStatusPollResult NetworkRefreshService::parseOpe
OperationStatusPollResult parsed;
if (!result.is_array()) return parsed;
// We poll z_getoperationstatus with no filter (this daemon rejects the per-opid filtered form),
// so the result lists ALL operations. Only act on the ones we're tracking — otherwise an
// unrelated failed/old operation would fire a spurious error toast or pollute send state.
// The poll is filtered to our opids (buildOperationFilterParams), but falls back to the unfiltered
// form, which lists ALL operations, if a daemon rejects the filter. Only act on the ones we're
// tracking — otherwise an unrelated failed/old operation would fire a spurious error toast or
// pollute send state.
const std::set<std::string> requested(requestedOpids.begin(), requestedOpids.end());
std::set<std::string> reported;
@@ -1379,6 +1459,18 @@ NetworkRefreshService::OperationStatusPollResult NetworkRefreshService::parseOpe
return parsed;
}
std::optional<json> NetworkRefreshService::buildOperationFilterParams(const std::vector<std::string>& opids)
{
json ids = json::array();
for (const auto& opid : opids) {
if (!opid.empty()) ids.push_back(opid);
}
if (ids.empty()) return std::nullopt;
json params = json::array();
params.push_back(std::move(ids));
return params;
}
void NetworkRefreshService::applyConnectionInfoResult(WalletState& state, const ConnectionInfoResult& result)
{
if (!result.ok) return;