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
+111
View File
@@ -5,6 +5,7 @@
#include "note_consolidation.h"
#include <algorithm>
#include <cctype>
#include <map>
namespace dragonx {
@@ -20,6 +21,7 @@ std::vector<NoteGroup> groupSpendableNotes(const nlohmann::json& zListUnspent)
if (address.empty()) continue;
if (n.value("confirmations", 0) < 1) continue; // z_mergetoaddress spends confirmed notes only
if (!n.value("spendable", true)) continue; // watch-only: no key to spend it
if (noteIsLocked(n)) continue; // held by an operation in flight
NoteGroup& g = byAddress[address];
g.address = address;
++g.notes;
@@ -36,6 +38,13 @@ std::vector<NoteGroup> groupSpendableNotes(const nlohmann::json& zListUnspent)
return out;
}
bool noteIsLocked(const nlohmann::json& zListUnspentEntry)
{
if (!zListUnspentEntry.is_object()) return false;
const auto it = zListUnspentEntry.find("locked");
return it != zListUnspentEntry.end() && it->is_boolean() && it->get<bool>();
}
bool worthConsolidating(const NoteGroup& group, double fee)
{
// The daemon rejects a merge whose fee is more than what it nets (merged value < 2 x fee).
@@ -67,5 +76,107 @@ AddressRun consolidateInPlace(const std::string& address, const MergeCall& call,
return run;
}
ScopeWant scopeScanWant(const ScopeScan& scan, const ScopeInputs& in)
{
if (!in.mergeMode || scan.phase == ScopePhase::Loaded) return ScopeWant::Nothing;
if (scan.inFlight || in.busyWithOp) return ScopeWant::Nothing;
if (scan.phase == ScopePhase::Failed && !scan.autoRetry) return ScopeWant::Nothing; // waits for Retry
if (in.now < scan.retryAt) return ScopeWant::WaitBackoff;
if (!in.nodeReady) return ScopeWant::WaitNode;
return ScopeWant::Post;
}
std::uint32_t scopeScanBegin(ScopeScan& scan, double now)
{
scan.phase = ScopePhase::Loading;
scan.inFlight = true;
scan.inFlightGen = scan.gen;
scan.startedAt = now;
return scan.gen;
}
bool scopeScanFinish(ScopeScan& scan, std::uint32_t gen, const std::string& error, rpc::RpcFailureKind kind,
double now)
{
if (scan.inFlight && gen == scan.inFlightGen) scan.inFlight = false;
if (gen != scan.gen) return false; // a scan from an earlier session: its data is not this one's
if (error.empty()) {
scan.phase = ScopePhase::Loaded;
scan.failures = 0;
scan.autoRetry = false;
scan.retryAt = 0.0;
scan.error.clear();
return true;
}
scan.phase = ScopePhase::Failed;
++scan.failures;
scan.error = error;
scan.autoRetry = scopeFailureRetries(kind, error) && scan.failures <= kScopeMaxAutoRetries;
scan.retryAt = scan.autoRetry ? now + scopeRetryDelaySec(scan.failures) : 0.0;
return false;
}
void scopeScanOpen(ScopeScan& scan, double now)
{
if (scan.inFlight && now - scan.startedAt > kScopeInFlightMaxSec) scan.inFlight = false; // lost
if (scan.inFlight && scan.phase == ScopePhase::Loading) return; // still loading for the last session
++scan.gen;
scan.phase = ScopePhase::Idle;
scan.failures = 0;
scan.autoRetry = false;
scan.retryAt = 0.0;
scan.error.clear();
}
void scopeScanInvalidate(ScopeScan& scan)
{
++scan.gen;
scan.phase = ScopePhase::Idle;
scan.failures = 0;
scan.autoRetry = false;
scan.retryAt = 0.0;
scan.error.clear();
}
void scopeScanRetry(ScopeScan& scan)
{
if (scan.phase != ScopePhase::Failed) return;
scan.phase = ScopePhase::Idle;
scan.failures = 0;
scan.autoRetry = false;
scan.retryAt = 0.0;
scan.error.clear();
}
double scopeRetryDelaySec(int failures)
{
if (failures <= 1) return 10.0;
if (failures == 2) return 30.0;
return 60.0;
}
bool scopeFailureRetries(rpc::RpcFailureKind kind, const std::string& message)
{
using K = rpc::RpcFailureKind;
switch (kind) {
case K::NotListening:
case K::ConnectTimeout:
case K::Warmup:
case K::Deferred:
case K::ShuttingDown:
case K::Unhandled:
case K::Aborted:
return true;
case K::Other: {
std::string lower = message;
std::transform(lower.begin(), lower.end(), lower.begin(),
[](unsigned char c) { return static_cast<char>(std::tolower(c)); });
return lower.find("not connected") != std::string::npos;
}
default:
return false; // ReplyTimeout, QueueFull, AppError, Auth, parse/type errors
}
}
} // namespace services
} // namespace dragonx