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>
183 lines
5.8 KiB
C++
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
|