feat(wallet): optional background auto-consolidation (Settings toggle)

A mining/pool wallet can accumulate tens of thousands of shielded notes, and the
manual "Consolidate" run — capped at 20 batches per address per click — needs
many re-runs to flatten a 10k+ note address. This adds a hands-off alternative:
a Settings toggle ("Auto-consolidate notes", full-node, off by default) that
trickle-merges notes in the background while the wallet is idle, so it sheds
notes over hours/days without the user ever opening the dialog.

Deliberately gentle, per the request: a small batch (15 inputs/tx vs 50 manual)
and only 3 batches per address per cycle, with cycles ~10 min apart — each cycle
is a handful of tiny merges that barely touch the node. It only acts when the
wallet is genuinely idle (walletScanReady: connected, synced, unlocked, node not
busy, not rescanning) and no send / shield / manual run is in progress, and it
goes dormant (re-scanning hourly) once every address is tidy.

Implementation reuses the existing paced services::ConsolidationRun engine in
"keep in each address" mode, so there is NO fund-path change and the run's
pacing / failure breakers / note-locking all apply:
- App::pumpAutoConsolidation (pumped from update() after pumpConsolidation):
  finalizes a finished cycle (schedules the cooldown + dismisses the run so the
  manual dialog never shows a stale auto result), else starts a new cycle when
  idle and the cooldown has elapsed.
- postAutoConsolidationScan: a quiet z_listunspent scan (the dialog's loadScope
  minus the UI state), session-guarded; on result picks candidates and starts.
- startConsolidationQuiet: the same guards as startConsolidation but silent, with
  a gentle RunConfig; finishConsolidation stays silent for auto runs (logs only).
- services::autoConsolidateCandidates (pure, unit-tested): addresses above the
  note floor that are worth merging; plus the gentle kAutoConsolidate* constants.
- The one-time large-wallet nudge is suppressed while auto-consolidation is on.

Settings plumbing (auto_consolidate, default false) + a daemon-gated toggle in
the Wallet tab's options, with a tooltip. Build + tests + hygiene green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-10-26 13:18:00 -05:00
co-authored by Claude Opus 4.8
parent 54f8359bd4
commit 64294d1518
10 changed files with 167 additions and 0 deletions
+2
View File
@@ -1592,6 +1592,8 @@ void App::update()
if (send_progress_active_ && !addressScanCanRunNow()) maybeFinishTransactionSendProgress();
// C210: the "Keep in each address" consolidation run, if one is going (paced; one RPC at a time).
pumpConsolidation();
// Background auto-consolidation (settings toggle): gently trickle-merge notes when the wallet is idle.
pumpAutoConsolidation();
// A Settings bootstrap that finished while its dialog wasn't drawn (clicked away, minimized): process it
// here so the connect and reconcile gates release and the post-bootstrap rescan is armed.
+9
View File
@@ -1942,6 +1942,15 @@ private:
void postConsolidationCall(int addrIdx);
void postConsolidationPoll();
void finishConsolidation();
// Automatic background consolidation (settings-gated): drives the SAME paced ConsolidationRun in tiny,
// gentle cycles when the wallet is idle, so a mining/pool wallet sheds notes without the user ever
// clicking Consolidate. pumpAutoConsolidation() is pumped from update() right after pumpConsolidation().
void pumpAutoConsolidation();
void postAutoConsolidationScan(); // a quiet z_listunspent scan → startConsolidationQuiet on candidates
bool startConsolidationQuiet(const std::vector<services::NoteGroup>& groups, services::RunConfig cfg);
bool auto_consolidate_run_ = false; // the active run is ours (silent; gentle config)
bool auto_consolidate_scan_in_flight_ = false; // an auto note scan is posted
double auto_consolidate_next_at_ = 0.0; // steadySeconds() before which no new cycle/scan starts
// Consecutive Core-refresh cycles where BOTH core RPCs failed → likely a dead
// connection. After kCoreFailuresBeforeDisconnect, tear down and reconnect.
int consecutive_core_failures_ = 0;
+104
View File
@@ -4256,6 +4256,9 @@ void App::resetChatSession()
lite_send_callback_ = nullptr;
chat_send_queue_.clear();
chat_send_parts_remaining_.clear(); // no queued parts survive a reset — drop their outstanding counts
auto_consolidate_run_ = false; // any auto run is torn down with the connection; start fresh next wallet
auto_consolidate_scan_in_flight_ = false;
auto_consolidate_next_at_ = 0.0;
inflight_op_ = LiteInflightOp{};
chat_verified_note_budget_ = 0;
chat_pipeline_note_count_ = 0;
@@ -6340,6 +6343,14 @@ void App::finishConsolidation()
// 2E: a batch of user ops re-reads the wallet and forces one balance, at its end.
if (s.batchesSucceeded > 0) markWalletMutated(/*forceBalance=*/true);
else if (s.batchesSubmitted > 0) noteWalletMutation();
// A background auto-consolidation cycle surfaces nothing per cycle — it is meant to be invisible.
// pumpAutoConsolidation() schedules the next cycle and dismisses the finished run. Log for diagnostics.
if (auto_consolidate_run_) {
wallet::liteLog("auto-consolidate: cycle done — " + std::to_string(s.batchesSucceeded) + "/" +
std::to_string(s.batchesSubmitted) + " batch(es), " + std::to_string(s.notesMerged) +
" note(s) merged");
return;
}
std::string err = s.firstError.empty() ? std::string(TR("shield_unknown_error")) : s.firstError;
if (err.size() > 160) err = err.substr(0, 160) + "...";
auto& n = ui::Notifications::instance();
@@ -6359,11 +6370,104 @@ void App::finishConsolidation()
}
}
// Start the shared ConsolidationRun with the SAME guards as startConsolidation but SILENTLY and with a
// caller-chosen (gentle) config — the background driver's entry point. No toasts; marks the run as ours.
bool App::startConsolidationQuiet(const std::vector<services::NoteGroup>& groups, services::RunConfig cfg)
{
if (isLiteBuild() || capture_mode_ || shutting_down_.load()) return false;
if (consolidation_.active() || consolidation_.hasRun()) return false; // never fight a manual run
if (show_seed_migration_ || (settings_ && settings_->getSeedMigrationPending())) return false;
if (!rpc_ || !rpc_->isConnected() || !worker_ || !state_.connected || isWalletSwitchInProgress()) return false;
if (!consolidation_.start(groups, cfg, services::steadySeconds())) return false;
auto_consolidate_run_ = true;
wallet::liteLog("auto-consolidate: starting a gentle cycle over " + std::to_string(groups.size()) +
" address(es), limit " + std::to_string(cfg.limit) + ", " + std::to_string(cfg.maxBatches) +
" batch(es) each");
return true;
}
// A quiet whole-wallet note scan for the auto driver (the manual dialog's loadScope, minus the UI state).
// On result: nothing worth merging → go dormant; candidates → start a gentle run; failure → back off.
void App::postAutoConsolidationScan()
{
if (!worker_ || !rpc_) return;
const std::uint64_t session = rpc_session_;
const services::ScanTicket ticket = beginAdhocWalletScan(); // z_listunspent is a whole-wallet scan
auto_consolidate_scan_in_flight_ = true;
worker_->post([this, rpc = rpc_.get(), session, ticket]() -> rpc::RPCWorker::MainCb {
std::vector<services::NoteGroup> groups;
std::string error;
rpc::RpcFailureKind kind = rpc::RpcFailureKind::None;
const auto t0 = std::chrono::steady_clock::now();
try {
MaintenanceCallScope maint(*this);
rpc::RPCClient::TraceScope trace("Auto-consolidate / note scan");
const nlohmann::json zs = rpc->call("z_listunspent", nlohmann::json::array({0}),
services::kScopeCallTimeoutSec);
groups = services::groupSpendableNotes(zs);
} catch (const std::exception& e) {
error = e.what(); if (error.empty()) error = "error";
kind = services::mergeCallFailureKind(e);
} catch (...) { error = "unknown error"; kind = rpc::RpcFailureKind::Other; }
const double secs = std::chrono::duration<double>(std::chrono::steady_clock::now() - t0).count();
return [this, groups = std::move(groups), error, kind, secs, session, ticket]() {
endAdhocWalletScan(ticket, secs * 1000.0, services::chatScanOutcome(error.empty(), kind));
auto_consolidate_scan_in_flight_ = false;
if (session != rpc_session_) return; // wallet switched under us — drop
const double now = services::steadySeconds();
if (!error.empty()) { // scan failed — back off and try later
auto_consolidate_next_at_ = now + services::kAutoConsolidateRetrySec;
return;
}
const double fee = services::RunConfig{}.fee; // 0.0001
const auto cand = services::autoConsolidateCandidates(groups, services::kAutoConsolidateMinNotes, fee);
if (cand.empty()) { // tidy enough — go dormant, re-scan rarely
auto_consolidate_next_at_ = now + services::kAutoConsolidateIdleRescanSec;
return;
}
services::RunConfig cfg;
cfg.fee = fee;
cfg.limit = services::kAutoConsolidateLimit;
cfg.maxBatches = services::kAutoConsolidateMaxBatches;
if (!startConsolidationQuiet(cand, cfg)) // a manual run grabbed it, or a transient guard
auto_consolidate_next_at_ = now + services::kAutoConsolidateCooldownSec;
// On success the cooldown is set when the run finishes (pumpAutoConsolidation).
};
});
}
// Drive the background auto-consolidation: finalize a finished cycle, else start a new one when idle and the
// cooldown has elapsed. Pumped from update() right after pumpConsolidation().
void App::pumpAutoConsolidation()
{
// Finalize a cycle we started once its run is no longer active (whether it ended via Finish or an abort):
// schedule the next cycle and dismiss the finished run so the manual dialog never shows a stale result.
if (auto_consolidate_run_ && !consolidation_.active()) {
const services::RunSummary s = consolidation_.summary();
const bool trouble = consolidation_.end() == services::RunEnd::Halted ||
consolidation_.end() == services::RunEnd::Aborted || s.batchesSucceeded == 0;
auto_consolidate_next_at_ = services::steadySeconds() +
(trouble ? services::kAutoConsolidateRetrySec : services::kAutoConsolidateCooldownSec);
consolidation_.dismiss();
auto_consolidate_run_ = false;
return;
}
if (!settings_ || !settings_->getAutoConsolidate()) return;
if (auto_consolidate_scan_in_flight_) return;
if (consolidation_.active() || consolidation_.hasRun()) return; // a manual run owns the engine
if (show_seed_migration_ || settings_->getSeedMigrationPending()) return;
if (!walletScanReady() || walletScanInFlight()) return; // connected, synced, unlocked, not busy/rescanning
if (hasTransactionSendProgress()) return; // our own sends go first
if (services::steadySeconds() < auto_consolidate_next_at_) return; // cooldown / dormancy
postAutoConsolidationScan();
}
void App::maybeWarnLargeWallet()
{
if (capture_mode_ || lite_wallet_) return; // no live nags during a UI sweep; lite has no wallet.dat
if (window_background_) return; // when someone can see it
if (!supportsFullNodeLifecycleActions() || !settings_) return;
if (settings_->getAutoConsolidate()) return; // the background consolidator handles bloat quietly
if (!state_.connected || !state_.encryption_state_known) return;
if (state_.warming_up || state_.daemon_initializing || !state_.sync.isSynced()) return;
if (large_wallet_checked_) return; // stat wallet.dat at most once per launch
+2
View File
@@ -153,6 +153,7 @@ bool Settings::load(const std::string& path)
loadScalar(j, "theme", theme_);
loadScalar(j, "save_ztxs", save_ztxs_);
loadScalar(j, "auto_shield", auto_shield_);
loadScalar(j, "auto_consolidate", auto_consolidate_);
loadScalar(j, "use_tor", use_tor_);
loadScalar(j, "allow_custom_fees", allow_custom_fees_);
loadScalar(j, "default_fee", default_fee_);
@@ -530,6 +531,7 @@ bool Settings::save(const std::string& path)
j["theme"] = theme_;
j["save_ztxs"] = save_ztxs_;
j["auto_shield"] = auto_shield_;
j["auto_consolidate"] = auto_consolidate_;
j["use_tor"] = use_tor_;
j["allow_custom_fees"] = allow_custom_fees_;
j["default_fee"] = default_fee_;
+5
View File
@@ -229,6 +229,10 @@ public:
bool getAutoShield() const { return auto_shield_; }
void setAutoShield(bool shield) { auto_shield_ = shield; }
// Automatic background note consolidation (full-node): when on, the wallet quietly merges small notes
// in tiny batches while idle (see App::pumpAutoConsolidation). Off by default.
bool getAutoConsolidate() const { return auto_consolidate_; }
void setAutoConsolidate(bool v) { auto_consolidate_ = v; }
bool getUseTor() const { return use_tor_; }
void setUseTor(bool tor) { use_tor_ = tor; }
@@ -727,6 +731,7 @@ private:
int time_format_ = 0; // global clock: 0 = 24h, 1 = 12h
bool save_ztxs_ = true;
bool auto_shield_ = true;
bool auto_consolidate_ = false;
bool use_tor_ = false;
bool allow_custom_fees_ = false;
double default_fee_ = 0.0001;
+8
View File
@@ -53,6 +53,14 @@ bool worthConsolidating(const NoteGroup& group, double fee)
return group.notes >= 2 && group.amount > 2.0 * fee;
}
std::vector<NoteGroup> autoConsolidateCandidates(const std::vector<NoteGroup>& groups, int minNotes, double fee)
{
std::vector<NoteGroup> out;
for (const auto& g : groups)
if (g.notes > minNotes && worthConsolidating(g, fee)) out.push_back(g);
return out;
}
int estimateBatches(int notes, int limit)
{
if (notes < 2) return 0;
+16
View File
@@ -46,6 +46,22 @@ bool worthConsolidating(const NoteGroup& group, double fee);
// note isn't a call (the daemon refuses to merge one note into its own address).
int estimateBatches(int notes, int limit);
// ── Automatic (background) consolidation ──
// Gentle defaults for App::pumpAutoConsolidation: a small batch and only a couple of batches per address
// per cycle, spaced well apart, so each cycle is a few small merges that barely touch the node. The manual
// dialog's 50-input / 20-batch run stays for when the user wants speed.
inline constexpr int kAutoConsolidateLimit = 15; // inputs per merge (vs 50 in a manual run)
inline constexpr int kAutoConsolidateMaxBatches = 3; // batches per address per cycle
inline constexpr int kAutoConsolidateMinNotes = 25; // only act on addresses above this; dormant below
inline constexpr double kAutoConsolidateCooldownSec = 600; // between cycles that did work (~10 min)
inline constexpr double kAutoConsolidateIdleRescanSec = 3600; // between scans while nothing needs merging (~1 h)
inline constexpr double kAutoConsolidateRetrySec = 1800; // back off longer after a failed/halted cycle
// Addresses worth consolidating automatically: worthConsolidating(group, fee) AND more than `minNotes`
// notes, so a near-tidy address is left alone (and the driver can go dormant). Pure; preserves input order
// (groupSpendableNotes already returns most-notes-first).
std::vector<NoteGroup> autoConsolidateCandidates(const std::vector<NoteGroup>& groups, int minNotes, double fee);
// What one z_mergetoaddress call reported.
struct MergeCallResult {
std::string opid;
+6
View File
@@ -92,6 +92,7 @@ struct SettingsPageState {
bool save_ztxs = true;
bool allow_custom_fees = false;
bool auto_shield = false;
bool auto_consolidate = false;
bool fetch_prices = true;
bool use_tor = false;
char rpc_host[128] = DRAGONX_DEFAULT_RPC_HOST;
@@ -423,6 +424,7 @@ static void loadSettingsPageState(App* app) {
s_settingsState.save_ztxs = settings->getSaveZtxs();
s_settingsState.allow_custom_fees = settings->getAllowCustomFees();
s_settingsState.auto_shield = settings->getAutoShield();
s_settingsState.auto_consolidate = settings->getAutoConsolidate();
s_settingsState.fetch_prices = settings->getFetchPrices();
s_settingsState.use_tor = settings->getUseTor();
@@ -512,6 +514,7 @@ static void saveSettingsPageState(config::Settings* settings) {
settings->setSaveZtxs(s_settingsState.save_ztxs);
settings->setAllowCustomFees(s_settingsState.allow_custom_fees);
settings->setAutoShield(s_settingsState.auto_shield);
settings->setAutoConsolidate(s_settingsState.auto_consolidate);
settings->setFetchPrices(s_settingsState.fetch_prices);
settings->setUseTor(s_settingsState.use_tor);
settings->setTxExplorerUrl(s_settingsState.tx_explorer);
@@ -1330,6 +1333,9 @@ void RenderSettingsPage(App* app) {
if (CB(TrId("stop_external", "stop_ext"), &s_settingsState.stop_external_daemon))
saveSettingsPageState(app->settings());
if (ImGui::IsItemHovered()) material::Tooltip("%s", TR("tt_stop_external"));
if (CB(TrId("auto_consolidate", "auto_consol"), &s_settingsState.auto_consolidate))
saveSettingsPageState(app->settings());
if (ImGui::IsItemHovered()) material::Tooltip("%s", TR("tt_auto_consolidate"));
// (Stratum pool hosting lives in the Node & Security tab — it's a node feature.)
}
if (CB(TrId("verbose_logging", "verbose"), &s_settingsState.verbose_logging)) {
+2
View File
@@ -643,6 +643,7 @@ void I18n::loadBuiltinEnglish()
// Wallet settings
strings_["save_z_transactions"] = "Save shielded tx history";
strings_["auto_shield"] = "Auto-shield";
strings_["auto_consolidate"] = "Auto-consolidate notes";
strings_["use_tor"] = "Use Tor";
strings_["keep_daemon"] = "Keep daemon running";
strings_["stop_external"] = "Stop external daemon";
@@ -934,6 +935,7 @@ void I18n::loadBuiltinEnglish()
strings_["tt_clear_ztx"] = "Delete locally cached z-transaction history";
strings_["tt_save_ztx"] = "Store z-address transaction history locally for faster loading";
strings_["tt_auto_shield"] = "Automatically move transparent balance to shielded addresses for privacy";
strings_["tt_auto_consolidate"] = "Quietly merge small notes in the background while the wallet is idle, in tiny batches. Slower than a manual consolidation but barely noticeable, and each address's funds stay separate. Good for mining/pool wallets that accumulate thousands of notes.";
strings_["tt_tor"] = "Route daemon connections through the Tor network for anonymity";
strings_["tt_tor_unavailable"] = "Not available yet: this wallet does not route any traffic through Tor. For the node only, you can add proxy=127.0.0.1:9050 to DRAGONX.conf and restart the node. Tor must be running, or the node finds no peers. Price, update, bootstrap and pool requests still connect directly; use a system-wide VPN/Tor for those.";
strings_["tt_tor_unavailable_lite"] = "Not available yet: this wallet does not route any traffic through Tor. Use a system-wide VPN/Tor.";
+13
View File
@@ -7332,6 +7332,19 @@ void testNoteConsolidation()
EXPECT_FALSE(worthConsolidating(groups[1], fee)); // a single note: nothing to merge
EXPECT_FALSE(worthConsolidating(NoteGroup{"zsD", 4, 0.00015}, fee)); // the fee would eat it
// Auto-consolidation candidates: above the per-address note floor AND worth it. A near-tidy address
// (at/below the floor) is left alone so the background driver can go dormant.
std::vector<NoteGroup> autoGroups{
{"zBig", 120, 50.0}, // above floor, worth it -> candidate
{"zAtFloor", 25, 10.0}, // exactly the floor -> excluded (> is strict)
{"zSmall", 8, 5.0}, // below floor -> excluded
{"zDust", 200, 0.00005} // above floor but fee would eat it -> excluded
};
const auto cand = autoConsolidateCandidates(autoGroups, kAutoConsolidateMinNotes, fee);
EXPECT_EQ(cand.size(), size_t(1));
EXPECT_EQ(cand[0].address, std::string("zBig"));
EXPECT_TRUE(autoConsolidateCandidates({}, kAutoConsolidateMinNotes, fee).empty());
EXPECT_EQ(estimateBatches(0, 90), 0);
EXPECT_EQ(estimateBatches(1, 90), 0);
EXPECT_EQ(estimateBatches(2, 90), 1);