From 64294d15185a59bb3586e93461bdc77b55f91e3e Mon Sep 17 00:00:00 2001 From: DanS Date: Mon, 26 Oct 2026 13:18:00 -0500 Subject: [PATCH] feat(wallet): optional background auto-consolidation (Settings toggle) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- src/app.cpp | 2 + src/app.h | 9 +++ src/app_network.cpp | 104 ++++++++++++++++++++++++++++ src/config/settings.cpp | 2 + src/config/settings.h | 5 ++ src/services/note_consolidation.cpp | 8 +++ src/services/note_consolidation.h | 16 +++++ src/ui/pages/settings_page.cpp | 6 ++ src/util/i18n.cpp | 2 + tests/test_phase4.cpp | 13 ++++ 10 files changed, 167 insertions(+) diff --git a/src/app.cpp b/src/app.cpp index c1eb5a9..05051c3 100644 --- a/src/app.cpp +++ b/src/app.cpp @@ -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. diff --git a/src/app.h b/src/app.h index 88fafe2..6777428 100644 --- a/src/app.h +++ b/src/app.h @@ -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& 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; diff --git a/src/app_network.cpp b/src/app_network.cpp index 3ef2545..cd9e601 100644 --- a/src/app_network.cpp +++ b/src/app_network.cpp @@ -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& 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 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(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 diff --git a/src/config/settings.cpp b/src/config/settings.cpp index 1419d5f..3d1daf6 100644 --- a/src/config/settings.cpp +++ b/src/config/settings.cpp @@ -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_; diff --git a/src/config/settings.h b/src/config/settings.h index b73de58..f2e7387 100644 --- a/src/config/settings.h +++ b/src/config/settings.h @@ -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; diff --git a/src/services/note_consolidation.cpp b/src/services/note_consolidation.cpp index 5fd4b53..da593da 100644 --- a/src/services/note_consolidation.cpp +++ b/src/services/note_consolidation.cpp @@ -53,6 +53,14 @@ bool worthConsolidating(const NoteGroup& group, double fee) return group.notes >= 2 && group.amount > 2.0 * fee; } +std::vector autoConsolidateCandidates(const std::vector& groups, int minNotes, double fee) +{ + std::vector 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; diff --git a/src/services/note_consolidation.h b/src/services/note_consolidation.h index 43775eb..bd55ab8 100644 --- a/src/services/note_consolidation.h +++ b/src/services/note_consolidation.h @@ -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 autoConsolidateCandidates(const std::vector& groups, int minNotes, double fee); + // What one z_mergetoaddress call reported. struct MergeCallResult { std::string opid; diff --git a/src/ui/pages/settings_page.cpp b/src/ui/pages/settings_page.cpp index 7e910dc..4d501b7 100644 --- a/src/ui/pages/settings_page.cpp +++ b/src/ui/pages/settings_page.cpp @@ -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)) { diff --git a/src/util/i18n.cpp b/src/util/i18n.cpp index 56b88d8..07b304b 100644 --- a/src/util/i18n.cpp +++ b/src/util/i18n.cpp @@ -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."; diff --git a/tests/test_phase4.cpp b/tests/test_phase4.cpp index 2b8055d..9d4ea1c 100644 --- a/tests/test_phase4.cpp +++ b/tests/test_phase4.cpp @@ -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 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);