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:
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user