"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>
289 lines
14 KiB
C++
289 lines
14 KiB
C++
// DragonX Wallet - ImGui Edition
|
|
// Copyright 2024-2026 The Hush Developers
|
|
// Released under the GPLv3
|
|
|
|
#pragma once
|
|
|
|
// Consolidating notes WITHOUT moving funds between addresses: each address's notes are merged back into
|
|
// that same address (z_mergetoaddress [addr] addr), so funds kept apart under different labels stay apart
|
|
// while the wallet sheds notes (fewer notes = smaller wallet, faster witness rebuilds and scans).
|
|
// 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 <set>
|
|
#include <string>
|
|
#include <vector>
|
|
|
|
namespace dragonx {
|
|
namespace services {
|
|
|
|
// One shielded address's spendable notes (confirmed, spendable), from z_listunspent.
|
|
struct NoteGroup {
|
|
std::string address;
|
|
int notes = 0;
|
|
double amount = 0.0;
|
|
};
|
|
|
|
// 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);
|
|
|
|
// Merge calls needed to bring `notes` down when each call takes at most `limit` of them. A lone leftover
|
|
// note isn't a call (the daemon refuses to merge one note into its own address).
|
|
int estimateBatches(int notes, int limit);
|
|
|
|
// What one z_mergetoaddress call reported.
|
|
struct MergeCallResult {
|
|
std::string opid;
|
|
int merging = 0; // notes this call is merging
|
|
int remaining = 0; // notes of the address it left for a later call
|
|
std::string error; // non-empty: the call failed (nothing was submitted)
|
|
};
|
|
using MergeCall = std::function<MergeCallResult(const std::string& address)>;
|
|
|
|
// The outcome for one address.
|
|
struct AddressRun {
|
|
std::string address;
|
|
std::vector<std::string> opids; // one per submitted batch
|
|
int notesMerged = 0;
|
|
int remaining = 0; // notes still left after the last batch
|
|
std::string error; // the call that stopped it, if any (earlier batches still went out)
|
|
};
|
|
|
|
// Submit merges of `address` into itself until fewer than two of its notes are left unmerged. The daemon
|
|
// 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);
|
|
|
|
// ── Merge-call stepper ──
|
|
// One call's result into the address's run (r.error set: run.error = r.error, false). True while another call
|
|
// for this address is worth making: at least 2 unlocked notes left and fewer than maxBatches submitted.
|
|
bool recordMergeResult(AddressRun& run, const MergeCallResult& r, int maxBatches = 20);
|
|
// z_mergetoaddress's reply. Never throws. A non-empty string "opid" = accepted; mergingNotes/remainingNotes only
|
|
// when integral (clamped to [0, INT_MAX]), else 0; no opid: error "no operation id".
|
|
void readMergeCallResult(const nlohmann::json& res, MergeCallResult& out);
|
|
|
|
// ── Async op status ──
|
|
enum class OpState : std::uint8_t { Unknown, Queued, Executing, Success, Failed, Lost };
|
|
inline bool opStateTerminal(OpState s) { return s == OpState::Success || s == OpState::Failed || s == OpState::Lost; }
|
|
struct OpReport {
|
|
std::string id, status, error, txid; // error = error.message, txid = result.txid
|
|
};
|
|
OpState opStateFromStatus(const std::string& status); // queued/executing/success; failed|cancelled|canceled: Failed
|
|
// Type-checked, never throws. Skips non-objects and missing/non-string/empty ids; keeps only `wanted` (all if empty).
|
|
std::vector<OpReport> parseOpReports(const nlohmann::json& statusArray, const std::set<std::string>& wanted = {});
|
|
|
|
// ── Merge-call failures ──
|
|
enum class MergeFailure : std::uint8_t {
|
|
NotSent, // turned away before z_mergetoaddress ran: the same batch again, later
|
|
Syncing, // THROW_IF_SYNCING: hold the run while the node catches up
|
|
NeedsUnlock, // a locked keystore ("could not find spending key")
|
|
NothingLeft, // no funds / already merged / too small for the fee
|
|
Uncertain, // it may have queued the merge (a reply timeout, an answer without an opid): never resent
|
|
Transient, // the HTTP layer dropped it, or the async queue is shutting down: retry with backoff
|
|
Fatal, // anything else: this address is done
|
|
};
|
|
MergeFailure classifyMergeFailure(rpc::RpcFailureKind kind, const std::string& message);
|
|
// The failure kind of a z_mergetoaddress call that threw. Sent, then cut off before any HTTP status
|
|
// (connection reset, an empty or partial reply): the daemon may have queued the merge, exactly as after a
|
|
// reply timeout, so it reads as one. Any other failure keeps its kind.
|
|
rpc::RpcFailureKind mergeCallFailureKind(const std::exception& e);
|
|
|
|
// ── Paced per-address consolidation run (owned by App, pumped from App::update(); main thread) ──
|
|
// The "Keep in each address" mode used to post 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. Here: one run RPC at a time, the next call only after the previous batch left the queue and a
|
|
// gap as long as the last call took, failures classified (nothing that may have queued is ever resent), and a
|
|
// breaker after three dead addresses or three failed ops in a row.
|
|
inline constexpr double kRunMinGapSec = 2.0;
|
|
inline constexpr double kRunMaxGapSec = 120.0;
|
|
inline constexpr double kRunPollIntervalSec = 3.0;
|
|
inline constexpr long kRunCallTimeoutSec = 120;
|
|
inline constexpr double kRunWatchdogSec = 1800.0; // a call or poll outstanding this long is presumed lost
|
|
inline constexpr double kRunOpMaxSec = 3600.0; // an op not terminal after this much pollable time is presumed lost
|
|
inline constexpr double kRunOpHardMaxSec = 4 * kRunOpMaxSec; // ...and after this long whatever happened
|
|
inline constexpr double kRunNotSentRetrySec = 5.0;
|
|
inline constexpr double kRunSyncHoldSec = 30.0;
|
|
inline constexpr double kRunUnlockHoldSec = 30.0;
|
|
inline constexpr int kRunMaxTransientRetries = 5;
|
|
inline constexpr int kRunMaxUnlockRefusals = 2;
|
|
inline constexpr int kRunMaxFailStreak = 3;
|
|
inline constexpr int kRunMaxPollMisses = 2;
|
|
|
|
struct RunConfig {
|
|
double fee = 0.0001;
|
|
int limit = 50;
|
|
int maxBatches = 20;
|
|
};
|
|
struct RunEnv {
|
|
bool nodeUsable = false; // connected, not warming up or rescanning
|
|
bool busy = false; // the busy gate holds RPCs that need cs_main
|
|
bool callsAllowed = true; // not syncing / settling
|
|
bool locked = false;
|
|
bool userSendBusy = false; // a send / shield / auto-shield / chat op of ours is in progress
|
|
bool scanBusy = false; // another GUI wallet scan is in the daemon
|
|
};
|
|
enum class RunAction : std::uint8_t { None, Call, Poll, Finish };
|
|
enum class RunWait : std::uint8_t { None, InFlight, Node, Busy, Syncing, Locked, UserSend, Scan, Batch };
|
|
enum class RunEnd : std::uint8_t { None, Completed, Stopped, Halted, Aborted };
|
|
enum class AddrOutcome : std::uint8_t { Pending, Running, Done, Uncertain, Failed }; // ordered: worse wins
|
|
enum class RunVerdict : std::uint8_t { None, Clean, NothingSubmitted, SomeFailed, Unconfirmed, Stopped, Halted, Aborted };
|
|
struct RunStep {
|
|
RunAction action = RunAction::None;
|
|
int addrIdx = -1;
|
|
};
|
|
struct RunAddress {
|
|
NoteGroup group;
|
|
AddressRun run;
|
|
AddrOutcome outcome = AddrOutcome::Pending;
|
|
bool done = false;
|
|
int opsDone = 0;
|
|
int transient = 0;
|
|
int unlockRefusals = 0;
|
|
std::string failure;
|
|
};
|
|
struct RunBatch {
|
|
int addrIdx = 0;
|
|
std::string opid;
|
|
int merging = 0;
|
|
OpState state = OpState::Unknown;
|
|
std::string error, txid;
|
|
int misses = 0;
|
|
double acceptedAt = 0.0;
|
|
double pollableSec = 0.0; // time since accepted in which a status poll could have gone out
|
|
};
|
|
struct RunProgress {
|
|
int addresses = 0, addressesDone = 0, batchesSubmitted = 0, batchesEstimated = 0, batchesDone = 0, notesMerged = 0;
|
|
bool stopping = false;
|
|
RunWait wait = RunWait::None;
|
|
};
|
|
struct RunSummary {
|
|
RunEnd end = RunEnd::None;
|
|
int addresses = 0, addressesFailed = 0, addressesUnconfirmed = 0, batchesSubmitted = 0, batchesSucceeded = 0,
|
|
notesMerged = 0;
|
|
std::string firstError;
|
|
};
|
|
RunVerdict runVerdict(const RunSummary& s);
|
|
const char* runWaitKey(RunWait wait); // i18n key of the wait line, nullptr when there is nothing to say
|
|
|
|
class ConsolidationRun {
|
|
public:
|
|
bool start(const std::vector<NoteGroup>& groups, RunConfig cfg, double now);
|
|
void requestStop();
|
|
bool abort();
|
|
RunStep next(const RunEnv& env, double now);
|
|
void markCallPosted(int addrIdx, double now);
|
|
void markPollPosted(double now);
|
|
// Returns an orphan opid (an accepted merge whose result is stale) for the caller to track, else "".
|
|
std::string onCallResult(std::uint64_t gen, int addrIdx, const MergeCallResult& r, rpc::RpcFailureKind kind,
|
|
double callSec, double now);
|
|
void onPollResult(std::uint64_t gen, bool ok, const std::vector<OpReport>& reports, double now);
|
|
bool consumeLockResyncRequest();
|
|
std::vector<std::string> outstandingOpids() const;
|
|
void dismiss();
|
|
bool active() const { return phase_ == Phase::Running; }
|
|
bool hasRun() const { return phase_ != Phase::Idle; }
|
|
bool stopping() const { return stopping_; }
|
|
std::uint64_t gen() const { return gen_; }
|
|
RunWait lastWait() const { return lastWait_; }
|
|
RunEnd end() const { return end_; }
|
|
RunProgress progress() const;
|
|
RunSummary summary() const;
|
|
const std::vector<RunAddress>& addresses() const { return addrs_; }
|
|
const std::vector<RunBatch>& batches() const { return batches_; }
|
|
const RunConfig& config() const { return cfg_; }
|
|
|
|
private:
|
|
enum class Phase : std::uint8_t { Idle, Running, Finished };
|
|
static void worsen(RunAddress& a, AddrOutcome o) { if (o > a.outcome) a.outcome = o; }
|
|
void endAddress(RunAddress& a, AddrOutcome o, const std::string& msg);
|
|
void applyOpState(RunBatch& b, OpState st, const OpReport* rep);
|
|
void halt(const std::string& msg) { halted_ = true; if (haltError_.empty()) haltError_ = msg; }
|
|
|
|
Phase phase_ = Phase::Idle;
|
|
RunEnd end_ = RunEnd::None;
|
|
RunConfig cfg_;
|
|
std::vector<RunAddress> addrs_;
|
|
std::vector<RunBatch> batches_;
|
|
std::uint64_t gen_ = 0;
|
|
std::size_t cur_ = 0;
|
|
int callAddr_ = -1;
|
|
bool callInFlight_ = false, pollInFlight_ = false, stopping_ = false, halted_ = false, lockResync_ = false;
|
|
double callPostedAt_ = 0, pollPostedAt_ = 0, nextCallAt_ = 0, nextPollAt_ = 0, syncHoldUntil_ = 0,
|
|
unlockHoldUntil_ = 0;
|
|
int deadStreak_ = 0, opFailStreak_ = 0;
|
|
std::string haltError_;
|
|
RunWait lastWait_ = RunWait::None;
|
|
double lastPumpAt_ = 0.0;
|
|
};
|
|
|
|
// ── 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
|