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
+64 -2
View File
@@ -10,8 +10,12 @@
// Shielded only: a transparent address can't receive a non-coinbase output on DragonX (private chain).
// Pure apart from the merge call it is handed, so the test target can drive it.
#include "rpc/rpc_failure.h"
#include <nlohmann/json.hpp>
#include <chrono>
#include <cstdint>
#include <functional>
#include <string>
#include <vector>
@@ -26,10 +30,14 @@ struct NoteGroup {
double amount = 0.0;
};
// Spendable shielded notes grouped by address, most notes first. Transparent UTXOs and unconfirmed or
// watch-only notes are left out (z_mergetoaddress only spends confirmed notes it has keys for).
// Spendable shielded notes grouped by address, most notes first. Transparent UTXOs and unconfirmed,
// watch-only or locked notes are left out (z_mergetoaddress only spends confirmed, unlocked notes it has
// keys for).
std::vector<NoteGroup> groupSpendableNotes(const nlohmann::json& zListUnspent);
// A z_listunspent entry the wallet has locked, e.g. for an operation in flight ("locked": true).
bool noteIsLocked(const nlohmann::json& zListUnspentEntry);
// Worth consolidating in place: at least two notes, and more than one batch's fee to work with.
bool worthConsolidating(const NoteGroup& group, double fee);
@@ -59,5 +67,59 @@ struct AddressRun {
// locks a batch's notes before the call returns, so each call takes the next batch. At most maxBatches.
AddressRun consolidateInPlace(const std::string& address, const MergeCall& call, int maxBatches = 20);
// ── Scope scan (the Merge/Consolidate dialog's listunspent + z_listunspent) ─────────────────────────
// Both calls are whole-wallet scans under the daemon's cs_main. The dialog used to post the pair again on
// the frame after any failure, every frame for as long as it stayed open (and forever on Lite, which has
// no node). This decides when a scan may be posted; the dialog owns one ScopeScan.
inline double steadySeconds() // ImGui::GetTime() stands still while the window is minimized
{
return std::chrono::duration<double>(std::chrono::steady_clock::now().time_since_epoch()).count();
}
inline constexpr long kScopeCallTimeoutSec = 120; // each call; a large wallet can take minutes
inline constexpr double kScopeInFlightMaxSec = 1800; // a scan outstanding this long is presumed lost
inline constexpr int kScopeMaxAutoRetries = 3;
enum class ScopePhase { Idle, Loading, Loaded, Failed };
struct ScopeScan {
ScopePhase phase = ScopePhase::Idle;
std::uint32_t gen = 0; // bumped per dialog session; a result from another session is dropped
bool inFlight = false; // a scan is posted and its result not yet in (survives close/reopen)
std::uint32_t inFlightGen = 0; // the session that posted it: only its result clears inFlight
double startedAt = 0.0;
int failures = 0;
bool autoRetry = false; // the last failure was "node not ready yet": retry by itself
double retryAt = 0.0;
std::string error;
};
struct ScopeInputs {
bool mergeMode = false;
bool busyWithOp = false; // a merge was submitted or is in flight
bool nodeReady = false; // App::walletScanReady()
double now = 0.0;
};
enum class ScopeWant { Nothing, Post, WaitNode, WaitBackoff };
ScopeWant scopeScanWant(const ScopeScan& scan, const ScopeInputs& in);
// Mark a scan posted: Loading, in flight for this session. Returns the generation to finish it with.
std::uint32_t scopeScanBegin(ScopeScan& scan, double now);
// Record a scan's result. True when it belongs to the current session and succeeded: apply its data.
bool scopeScanFinish(ScopeScan& scan, std::uint32_t gen, const std::string& error, rpc::RpcFailureKind kind,
double now);
// The dialog opened: start a new session, unless a scan of the last one is still loading (adopt it).
void scopeScanOpen(ScopeScan& scan, double now);
// The data went stale (reconnect): the next want is a fresh scan. An in-flight scan stays guarded.
void scopeScanInvalidate(ScopeScan& scan);
// The user pressed Retry after a failure.
void scopeScanRetry(ScopeScan& scan);
double scopeRetryDelaySec(int failures); // 10, 30, then 60 s
// Retry by itself only when the node wasn't ready (refused, warming up, held back as busy, aborted):
// not after a reply timeout or a real error, each of which would cost another pair of full scans.
bool scopeFailureRetries(rpc::RpcFailureKind kind, const std::string& message);
} // namespace services
} // namespace dragonx