feat(merge): run per-address consolidation as a paced background job

"Keep in each address" posted one task that called z_mergetoaddress for
every batch of every address back to back; each call is a whole-wallet
scan under cs_main, and the daemon runs the ops one at a time anyway.
It is now an App-owned services::ConsolidationRun:

- one RPC at a time; the next merge only after the previous batch left the
  queue and a gap as long as the last call took (2-120 s);
- pauses while the node syncs, the wallet is locked, a send of ours is in
  progress, or another wallet scan is running;
- failures classified: nothing that may have queued is ever resent; a
  breaker stops after 3 dead addresses or 3 failed ops in a row;
- each merge is charged to the scan budget; one re-read and one forced
  balance at the end;
- it keeps running when the dialog closes: a status-bar chip, a run view
  with progress, the wait reason and a Stop button.

The "Into one" merge can no longer be confirmed twice while its call is
in flight. fake_dragonxd gains FAKE_SCAN_SECS, FAKE_MERGE_SERIAL and
FAKE_MERGE_FAIL.

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 b29a09eaa0
commit cee8b9d7f6
19 changed files with 1875 additions and 202 deletions
+478 -11
View File
@@ -6,7 +6,9 @@
#include <algorithm>
#include <cctype>
#include <climits>
#include <map>
#include <set>
namespace dragonx {
namespace services {
@@ -58,24 +60,489 @@ int estimateBatches(int notes, int limit)
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<int>(run.opids.size()) < maxBatches;
}
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
}
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<char>(std::tolower(static_cast<unsigned char>(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<std::uint64_t>();
return v > static_cast<std::uint64_t>(INT_MAX) ? INT_MAX : static_cast<int>(v);
}
if (it->is_number_integer()) {
const auto v = it->get<std::int64_t>();
return v < 0 ? 0 : (v > INT_MAX ? INT_MAX : static_cast<int>(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>() : 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<OpReport> parseOpReports(const nlohmann::json& statusArray, const std::set<std::string>& wanted)
{
std::vector<OpReport> 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<const rpc::RpcTransportError*>(&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<NoteGroup>& 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<RunAddress> 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<int>(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<int>(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<int>(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<int>(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<int>(cur_)};
}
void ConsolidationRun::markCallPosted(int addrIdx, double now)
{
if (phase_ != Phase::Running || addrIdx < 0 || addrIdx >= static_cast<int>(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<double>(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<OpReport>& 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<std::string, const OpReport*> 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<std::string> ConsolidationRun::outstandingOpids() const
{
std::vector<std::string> 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<int>(addrs_.size());
for (const auto& a : addrs_) {
if (a.done) ++p.addressesDone;
const int submitted = static_cast<int>(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<int>(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<int>(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<int>(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;