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
+21
View File
@@ -31,6 +31,7 @@
#include "services/scan_policy.h"
#include "services/wallet_scan_governor.h"
#include "services/chat_scan_policy.h"
#include "services/note_consolidation.h"
#include "services/network_refresh_service.h"
#include "services/wallet_security_controller.h"
#include "services/wallet_security_workflow.h"
@@ -492,6 +493,19 @@ public:
// full node that is past warmup, not rescanning and not busy.
bool walletScanReady() const;
// C210: the "Keep in each address" consolidation run. Owned here so it keeps going when the Merge dialog
// closes; pumped from update(). Full node only (refused in Lite). Main thread.
const services::ConsolidationRun& consolidation() const { return consolidation_; }
bool startConsolidation(const std::vector<services::NoteGroup>& groups, double fee, int batchLimit);
void stopConsolidation() { consolidation_.requestStop(); }
void dismissConsolidation() { consolidation_.dismiss(); } // no-op while running
// A one-off wallet-wide scan the user asked for (the Merge dialog's scope read, a merge): never held back,
// but charged to the scan budget (ScanStream::Adhoc) so the routine scans make room. Main thread.
services::ScanTicket beginAdhocWalletScan();
void endAdhocWalletScan(const services::ScanTicket& ticket, double costMs, services::ScanOutcome outcome);
// A GUI wallet scan is in the daemon or queued for it: a one-off scan waits rather than stack on cs_main.
bool walletScanInFlight() const { return scan_governor_.anyInFlight(); }
// Force refresh
void refreshNow();
// Called on window restore: drop any daemon-output backlog (update() runs while minimized, but its
@@ -1661,6 +1675,13 @@ private:
std::atomic<bool> opid_filter_unsupported_{false}; // see opidFilterUnsupported(); set on the worker
std::atomic<int> maint_calls_{0}; // maintenance calls in flight (MaintenanceCallScope)
std::atomic<bool> maint_abort_{false}; // set by abortMaintenanceCalls(), cleared on connect
services::ConsolidationRun consolidation_; // C210 (see consolidation())
std::uint64_t rpc_session_ = 0; // ++ in onConnected/onDisconnected: an orphan merge is tracked only within one
double run_lock_resync_at_ = 0.0; // steadySeconds() before which the run asks for no lock-state re-read
void pumpConsolidation();
void postConsolidationCall(int addrIdx);
void postConsolidationPoll();
void finishConsolidation();
// Consecutive Core-refresh cycles where BOTH core RPCs failed → likely a dead
// connection. After kCoreFailuresBeforeDisconnect, tear down and reconnect.
int consecutive_core_failures_ = 0;