// DragonX Wallet - ImGui Edition // Copyright 2024-2026 The Hush Developers // Released under the GPLv3 #include "note_consolidation.h" #include #include #include #include #include namespace dragonx { namespace services { std::vector groupSpendableNotes(const nlohmann::json& zListUnspent) { std::map 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 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 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); } bool recordMergeResult(AddressRun& run, const MergeCallResult& r, int maxBatches) { if (!r.error.empty()) { run.error = r.error; // earlier batches (if any) are already submitted and stand return false; } run.opids.push_back(r.opid); run.notesMerged += r.merging; run.remaining = r.remaining; // One leftover note can't be merged into its own address. return r.remaining >= 2 && static_cast(run.opids.size()) < maxBatches; } AddressRun consolidateInPlace(const std::string& address, const MergeCall& call, int maxBatches) { AddressRun run; run.address = address; if (maxBatches <= 0) return run; while (recordMergeResult(run, call(address), maxBatches)) {} return run; } namespace { std::string lower(std::string s) { for (auto& c : s) c = static_cast(std::tolower(static_cast(c))); return s; } bool has(const std::string& haystack, const char* needle) { return haystack.find(needle) != std::string::npos; } int clampedCount(const nlohmann::json& obj, const char* key) { const auto it = obj.find(key); if (it == obj.end()) return 0; if (it->is_number_unsigned()) { const auto v = it->get(); return v > static_cast(INT_MAX) ? INT_MAX : static_cast(v); } if (it->is_number_integer()) { const auto v = it->get(); return v < 0 ? 0 : (v > INT_MAX ? INT_MAX : static_cast(v)); } return 0; } std::string stringField(const nlohmann::json& obj, const char* key) { const auto it = obj.find(key); return (it != obj.end() && it->is_string()) ? it->get() : std::string(); } } // namespace void readMergeCallResult(const nlohmann::json& res, MergeCallResult& out) { out = MergeCallResult{}; if (res.is_object()) { out.opid = stringField(res, "opid"); out.merging = clampedCount(res, "mergingNotes"); out.remaining = clampedCount(res, "remainingNotes"); } if (out.opid.empty()) { out = MergeCallResult{}; out.error = "no operation id"; } } OpState opStateFromStatus(const std::string& status) { if (status == "queued") return OpState::Queued; if (status == "executing") return OpState::Executing; if (status == "success") return OpState::Success; if (status == "failed" || status == "cancelled" || status == "canceled") return OpState::Failed; return OpState::Unknown; } std::vector parseOpReports(const nlohmann::json& statusArray, const std::set& wanted) { std::vector out; if (!statusArray.is_array()) return out; for (const auto& op : statusArray) { if (!op.is_object()) continue; OpReport rep; rep.id = stringField(op, "id"); if (rep.id.empty()) continue; if (!wanted.empty() && !wanted.count(rep.id)) continue; rep.status = stringField(op, "status"); const auto err = op.find("error"); if (err != op.end() && err->is_object()) rep.error = stringField(*err, "message"); const auto res = op.find("result"); if (res != op.end() && res->is_object()) rep.txid = stringField(*res, "txid"); out.push_back(std::move(rep)); } return out; } MergeFailure classifyMergeFailure(rpc::RpcFailureKind kind, const std::string& message) { using K = rpc::RpcFailureKind; switch (kind) { case K::None: return MergeFailure::Uncertain; // answered without an opid: it may have queued the merge case K::Deferred: case K::NotListening: case K::ConnectTimeout: case K::Warmup: case K::QueueFull: return MergeFailure::NotSent; // turned away before z_mergetoaddress ran case K::ReplyTimeout: // the daemon keeps running it and may queue the merge case K::Aborted: // our abort: if sent it may have queued; if not, the session is ending (result stale) return MergeFailure::Uncertain; case K::ShuttingDown: case K::Unhandled: return MergeFailure::Transient; // the HTTP layer dropped it case K::Auth: return MergeFailure::Fatal; default: break; // AppError, Other: by message } const std::string m = lower(message); if (has(m, "not connected")) return MergeFailure::NotSent; // RPCClient throws before sending if (has(m, "still syncing") || has(m, "fully synced")) return MergeFailure::Syncing; // THROW_IF_SYNCING (cheap) if (has(m, "spending key") || has(m, "wallet passphrase") || has(m, "walletpassphrase")) return MergeFailure::NeedsUnlock; if (has(m, "queue is shutting down")) return MergeFailure::Transient; // "...; the operation was not queued" if (has(m, "could not find any funds") || has(m, "already merged") || has(m, "insufficient funds") || has(m, "greater than the net amount")) return MergeFailure::NothingLeft; return MergeFailure::Fatal; } rpc::RpcFailureKind mergeCallFailureKind(const std::exception& e) { const rpc::RpcFailureKind kind = rpc::rpcFailureKindOf(e); if (kind == rpc::RpcFailureKind::Other) { if (const auto* t = dynamic_cast(&e); t && t->requestSent && t->httpStatus == 0) return rpc::RpcFailureKind::ReplyTimeout; } return kind; } RunVerdict runVerdict(const RunSummary& s) { switch (s.end) { case RunEnd::None: return RunVerdict::None; case RunEnd::Stopped: return RunVerdict::Stopped; case RunEnd::Halted: return RunVerdict::Halted; case RunEnd::Aborted: return RunVerdict::Aborted; case RunEnd::Completed: break; } if (s.batchesSubmitted == 0 && s.addressesUnconfirmed == 0) return RunVerdict::NothingSubmitted; if (s.addressesFailed > 0) return RunVerdict::SomeFailed; if (s.addressesUnconfirmed > 0) return RunVerdict::Unconfirmed; return RunVerdict::Clean; } const char* runWaitKey(RunWait wait) { switch (wait) { case RunWait::Node: case RunWait::Busy: return "merge_waiting_node"; case RunWait::Syncing: return "merge_run_wait_sync"; case RunWait::Locked: return "merge_wait_locked"; case RunWait::UserSend: return "merge_run_wait_send"; case RunWait::Scan: case RunWait::Batch: return "merge_run_wait_batch"; case RunWait::None: case RunWait::InFlight: break; } return nullptr; } bool ConsolidationRun::start(const std::vector& groups, RunConfig cfg, double now) { if (phase_ == Phase::Running) return false; cfg.limit = std::max(2, cfg.limit); cfg.maxBatches = std::max(1, cfg.maxBatches); std::vector addrs; for (const auto& g : groups) { if (g.address.empty() || !worthConsolidating(g, cfg.fee)) continue; RunAddress a; a.group = g; a.run.address = g.address; addrs.push_back(std::move(a)); } if (addrs.empty()) return false; const std::uint64_t gen = gen_ + 1; *this = ConsolidationRun(); gen_ = gen; cfg_ = cfg; addrs_ = std::move(addrs); phase_ = Phase::Running; nextCallAt_ = nextPollAt_ = now; lastPumpAt_ = now; return true; } void ConsolidationRun::requestStop() { if (phase_ == Phase::Running) stopping_ = true; } void ConsolidationRun::dismiss() { if (phase_ == Phase::Running) return; const std::uint64_t gen = gen_; // a stale result can never match a later run *this = ConsolidationRun(); gen_ = gen; } bool ConsolidationRun::abort() { if (phase_ != Phase::Running) return false; ++gen_; if (callInFlight_ && callAddr_ >= 0 && callAddr_ < static_cast(addrs_.size())) { auto& a = addrs_[callAddr_]; a.done = true; worsen(a, AddrOutcome::Uncertain); } callInFlight_ = pollInFlight_ = false; for (auto& b : batches_) { if (!opStateTerminal(b.state)) applyOpState(b, OpState::Lost, nullptr); // "could not be confirmed" } phase_ = Phase::Finished; end_ = RunEnd::Aborted; lastWait_ = RunWait::None; return true; } void ConsolidationRun::endAddress(RunAddress& a, AddrOutcome o, const std::string& msg) { a.done = true; worsen(a, o); if (a.run.error.empty()) a.run.error = msg; if (a.run.opids.empty() && ++deadStreak_ >= kRunMaxFailStreak) halt(msg); } void ConsolidationRun::applyOpState(RunBatch& b, OpState st, const OpReport* rep) { b.state = st; if (b.addrIdx < 0 || b.addrIdx >= static_cast(addrs_.size())) return; RunAddress& a = addrs_[b.addrIdx]; switch (st) { case OpState::Success: if (rep) b.txid = rep->txid; ++a.opsDone; opFailStreak_ = 0; break; case OpState::Failed: if (rep) b.error = rep->error; if (a.failure.empty()) a.failure = b.error; a.done = true; worsen(a, AddrOutcome::Failed); if (++opFailStreak_ >= kRunMaxFailStreak) halt(b.error); break; case OpState::Lost: a.done = true; worsen(a, AddrOutcome::Uncertain); break; default: break; } } RunStep ConsolidationRun::next(const RunEnv& env, double now) { if (phase_ != Phase::Running) return {}; if (callInFlight_ && now - callPostedAt_ > kRunWatchdogSec) { // its result never came: a late reply is an orphan callInFlight_ = false; ++gen_; if (callAddr_ >= 0 && callAddr_ < static_cast(addrs_.size())) endAddress(addrs_[callAddr_], AddrOutcome::Uncertain, ""); nextCallAt_ = std::max(nextCallAt_, now + kRunMaxGapSec); } if (pollInFlight_ && now - pollPostedAt_ > kRunWatchdogSec) { pollInFlight_ = false; ++gen_; } // An op the daemon never reports done is presumed lost after an hour in which it could have been polled // (not while the node was gone or busy: it may simply not have been asked), or after 4 h whatever happened. const double dt = std::max(0.0, now - lastPumpAt_); lastPumpAt_ = now; const bool pollable = env.nodeUsable && !env.busy; for (auto& b : batches_) { if (opStateTerminal(b.state)) continue; if (pollable) b.pollableSec += std::min(dt, std::max(0.0, now - b.acceptedAt)); if (b.pollableSec > kRunOpMaxSec || now - b.acceptedAt > kRunOpHardMaxSec) applyOpState(b, OpState::Lost, nullptr); } while (cur_ < addrs_.size() && addrs_[cur_].done) ++cur_; const bool moreCalls = !stopping_ && !halted_ && cur_ < addrs_.size(); const int outstanding = static_cast(std::count_if(batches_.begin(), batches_.end(), [](const RunBatch& b) { return !opStateTerminal(b.state); })); auto wait = [&](RunWait w) { lastWait_ = w; return RunStep{}; }; if (!callInFlight_ && !pollInFlight_ && !moreCalls && outstanding == 0) { phase_ = Phase::Finished; end_ = halted_ ? RunEnd::Halted : (stopping_ && cur_ < addrs_.size()) ? RunEnd::Stopped : RunEnd::Completed; lastWait_ = RunWait::None; return {RunAction::Finish, -1}; // exactly once } if (callInFlight_ || pollInFlight_) return wait(RunWait::InFlight); // one run RPC at a time if (!env.nodeUsable) return wait(RunWait::Node); // blocks polls too if (env.busy) return wait(RunWait::Busy); // z_getoperationstatus needs cs_main if (outstanding > 0 && now >= nextPollAt_) { lastWait_ = RunWait::None; return {RunAction::Poll, -1}; } if (!moreCalls) return wait(RunWait::None); // waiting for submitted batches if (!env.callsAllowed || now < syncHoldUntil_) return wait(RunWait::Syncing); if (env.locked || now < unlockHoldUntil_) return wait(RunWait::Locked); if (env.userSendBusy) return wait(RunWait::UserSend); if (now < nextCallAt_) return wait(RunWait::Batch); if (!batches_.empty() && (batches_.back().state == OpState::Unknown || batches_.back().state == OpState::Queued)) return wait(RunWait::Batch); // at most 2 unfinished ops, at most 2 x limit notes locked if (env.scanBusy) return wait(RunWait::Scan); // another GUI wallet scan is in the daemon lastWait_ = RunWait::None; return {RunAction::Call, static_cast(cur_)}; } void ConsolidationRun::markCallPosted(int addrIdx, double now) { if (phase_ != Phase::Running || addrIdx < 0 || addrIdx >= static_cast(addrs_.size())) return; callInFlight_ = true; callAddr_ = addrIdx; callPostedAt_ = now; worsen(addrs_[addrIdx], AddrOutcome::Running); } void ConsolidationRun::markPollPosted(double now) { if (phase_ != Phase::Running) return; pollInFlight_ = true; pollPostedAt_ = now; } std::string ConsolidationRun::onCallResult(std::uint64_t gen, int addrIdx, const MergeCallResult& r, rpc::RpcFailureKind kind, double callSec, double now) { const bool accepted = r.error.empty() && !r.opid.empty(); if (gen != gen_ || phase_ != Phase::Running || !callInFlight_ || addrIdx != callAddr_) return accepted ? r.opid : std::string(); callInFlight_ = false; const double gapAt = now + std::clamp(callSec, kRunMinGapSec, kRunMaxGapSec); nextCallAt_ = gapAt; RunAddress& a = addrs_[addrIdx]; if (accepted) { a.transient = 0; deadStreak_ = 0; const bool more = recordMergeResult(a.run, r, cfg_.maxBatches); RunBatch b; b.addrIdx = addrIdx; b.opid = r.opid; b.merging = r.merging; b.acceptedAt = now; batches_.push_back(std::move(b)); nextPollAt_ = now; // learn its state before the next call if (!more) { a.done = true; worsen(a, AddrOutcome::Done); } return {}; } const std::string msg = r.error.empty() ? std::string("no operation id") : r.error; switch (classifyMergeFailure(kind, msg)) { case MergeFailure::NotSent: // the same batch again, no retry budget nextCallAt_ = std::max(gapAt, now + kRunNotSentRetrySec); break; case MergeFailure::Syncing: // no retry budget syncHoldUntil_ = now + kRunSyncHoldSec; break; case MergeFailure::NeedsUnlock: if (++a.unlockRefusals >= kRunMaxUnlockRefusals) { endAddress(a, AddrOutcome::Failed, msg); } else { unlockHoldUntil_ = now + kRunUnlockHoldSec; lockResync_ = true; } break; case MergeFailure::NothingLeft: if (a.run.opids.empty()) { endAddress(a, AddrOutcome::Failed, msg); } else { a.done = true; worsen(a, AddrOutcome::Done); } break; case MergeFailure::Uncertain: // never resent endAddress(a, AddrOutcome::Uncertain, msg); break; case MergeFailure::Transient: if (++a.transient > kRunMaxTransientRetries) { endAddress(a, AddrOutcome::Failed, msg); } else { nextCallAt_ = std::max(gapAt, now + std::min(60.0, 5.0 * static_cast(1 << (a.transient - 1)))); } break; case MergeFailure::Fatal: endAddress(a, AddrOutcome::Failed, msg); break; } return {}; } void ConsolidationRun::onPollResult(std::uint64_t gen, bool ok, const std::vector& reports, double now) { if (gen != gen_ || phase_ != Phase::Running || !pollInFlight_) return; pollInFlight_ = false; nextPollAt_ = now + kRunPollIntervalSec; if (!ok) return; // No call can be in flight during a poll, so the non-terminal batches now are exactly the ids it asked about. std::map byId; for (const auto& rep : reports) byId[rep.id] = &rep; for (auto& b : batches_) { if (opStateTerminal(b.state)) continue; const auto it = byId.find(b.opid); if (it == byId.end()) { if (++b.misses >= kRunMaxPollMisses) applyOpState(b, OpState::Lost, nullptr); continue; } b.misses = 0; const OpState st = opStateFromStatus(it->second->status); if (st != OpState::Unknown) applyOpState(b, st, it->second); } } bool ConsolidationRun::consumeLockResyncRequest() { const bool r = lockResync_; lockResync_ = false; return r; } std::vector ConsolidationRun::outstandingOpids() const { std::vector ids; for (const auto& b : batches_) { if (!opStateTerminal(b.state)) ids.push_back(b.opid); } return ids; } RunProgress ConsolidationRun::progress() const { RunProgress p; p.addresses = static_cast(addrs_.size()); for (const auto& a : addrs_) { if (a.done) ++p.addressesDone; const int submitted = static_cast(a.run.opids.size()); p.batchesEstimated += a.done ? submitted : std::max(std::min(estimateBatches(a.group.notes, cfg_.limit), cfg_.maxBatches), submitted); } p.batchesSubmitted = static_cast(batches_.size()); for (const auto& b : batches_) { if (opStateTerminal(b.state)) ++p.batchesDone; if (b.state == OpState::Success) p.notesMerged += b.merging; } p.stopping = stopping_; p.wait = lastWait_; return p; } RunSummary ConsolidationRun::summary() const { RunSummary s; s.end = end_; s.addresses = static_cast(addrs_.size()); for (const auto& a : addrs_) { if (a.outcome == AddrOutcome::Failed) ++s.addressesFailed; if (a.outcome == AddrOutcome::Uncertain) ++s.addressesUnconfirmed; } s.batchesSubmitted = static_cast(batches_.size()); for (const auto& b : batches_) { if (b.state == OpState::Success) { ++s.batchesSucceeded; s.notesMerged += b.merging; } } s.firstError = haltError_; if (s.firstError.empty()) { for (const auto& a : addrs_) { if (a.outcome != AddrOutcome::Failed) continue; s.firstError = !a.failure.empty() ? a.failure : a.run.error; if (!s.firstError.empty()) break; } } return s; } 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(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