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:
@@ -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.
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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_;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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)) {
|
||||
|
||||
@@ -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.";
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user