Files
ObsidianDragon/src/services/note_consolidation.cpp
T
DanSandClaude Opus 5.5 8b9a498eee 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>
2026-10-09 11:06:34 -05:00

183 lines
5.8 KiB
C++

// DragonX Wallet - ImGui Edition
// Copyright 2024-2026 The Hush Developers
// Released under the GPLv3
#include "note_consolidation.h"
#include <algorithm>
#include <cctype>
#include <map>
namespace dragonx {
namespace services {
std::vector<NoteGroup> groupSpendableNotes(const nlohmann::json& zListUnspent)
{
std::map<std::string, NoteGroup> byAddress;
if (zListUnspent.is_array()) {
for (const auto& n : zListUnspent) {
if (!n.is_object()) continue;
const std::string address = n.value("address", std::string());
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;
g.amount += n.value("amount", 0.0);
}
}
std::vector<NoteGroup> out;
out.reserve(byAddress.size());
for (auto& kv : byAddress) out.push_back(std::move(kv.second));
std::sort(out.begin(), out.end(), [](const NoteGroup& a, const NoteGroup& b) {
if (a.notes != b.notes) return a.notes > b.notes;
return a.address < b.address;
});
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).
return group.notes >= 2 && group.amount > 2.0 * fee;
}
int estimateBatches(int notes, int limit)
{
if (notes < 2) return 0;
if (limit < 2) limit = 2;
return notes / limit + ((notes % limit) >= 2 ? 1 : 0);
}
AddressRun consolidateInPlace(const std::string& address, const MergeCall& call, int maxBatches)
{
AddressRun run;
run.address = address;
for (int b = 0; b < maxBatches; ++b) {
MergeCallResult r = call(address);
if (!r.error.empty()) {
run.error = r.error; // earlier batches (if any) are already submitted and stand
break;
}
run.opids.push_back(r.opid);
run.notesMerged += r.merging;
run.remaining = r.remaining;
if (r.remaining < 2) break; // one leftover note can't be merged into its own address
}
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