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:
+26
@@ -1427,6 +1427,8 @@ void App::update()
|
||||
// While the address scan that normally ends the send progress can't run (the background, a catch-up, the
|
||||
// connect gate, a retry hold), end it once the operations are done: it keeps the window rendering.
|
||||
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();
|
||||
|
||||
// After a bootstrap the preserved wallet.dat has stale spent-state and must be reconciled
|
||||
// against the freshly-imported chain, or the first send tries to spend already-spent notes and
|
||||
@@ -2979,6 +2981,30 @@ void App::renderStatusBar()
|
||||
ImGui::TextColored(ImVec4(0.6f, 0.8f, 1.0f, 1.0f), "%s%s", TR("sb_importing_keys"), dotStr);
|
||||
}
|
||||
|
||||
// C210: a consolidation run keeps going after its dialog closes. A static chip (no animation, no frames of
|
||||
// its own); clicking it reopens the run view.
|
||||
if (consolidation_.active()) {
|
||||
const services::RunProgress p = consolidation_.progress();
|
||||
const ImVec4 col(0.6f, 0.8f, 1.0f, 1.0f);
|
||||
ImGui::SameLine(0, sbSectionGap);
|
||||
ImGui::TextDisabled("|");
|
||||
ImGui::SameLine(0, sbSeparatorGap);
|
||||
ImGui::BeginGroup();
|
||||
ImGui::PushFont(ui::material::Type().iconSmall());
|
||||
ImGui::TextColored(col, ICON_MD_CALL_MERGE);
|
||||
ImGui::PopFont();
|
||||
ImGui::SameLine(0, sbIconTextGap);
|
||||
ImGui::TextColored(col, TR("merge_run_chip_fmt"), p.batchesSubmitted, std::max(1, p.batchesEstimated));
|
||||
ImGui::EndGroup();
|
||||
if (ImGui::IsItemHovered()) {
|
||||
ImGui::SetMouseCursor(ImGuiMouseCursor_Hand);
|
||||
if (!p.stopping) {
|
||||
if (const char* k = services::runWaitKey(p.wait)) ui::material::Tooltip("%s", TR(k));
|
||||
}
|
||||
}
|
||||
if (ImGui::IsItemClicked()) ui::ShieldDialog::showConsolidate(); // reopens the run view
|
||||
}
|
||||
|
||||
// Right side: version always at far right, connection status to its left.
|
||||
// Compute positions dynamically from actual text widths so they
|
||||
// never overlap and always stay within the window at any font scale.
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -690,6 +690,8 @@ void App::tryConnect()
|
||||
void App::onConnected()
|
||||
{
|
||||
maint_abort_.store(false); // maintenance calls queued before this connect have already failed (FIFO)
|
||||
++rpc_session_;
|
||||
if (consolidation_.abort()) ui::Notifications::instance().warning(TR("merge_run_aborted_toast"));
|
||||
state_.connected = true;
|
||||
ui::ShieldDialog::invalidateScope(); // inputs read before this connect may be another wallet's
|
||||
const bool completedSwitch = wallet_switch_pending_confirm_.exchange(false); // this connect confirms a switch
|
||||
@@ -873,6 +875,8 @@ void App::onDisconnected(const std::string& reason)
|
||||
first_balance_read_tried_ = false;
|
||||
address_owed_next_try_ = {};
|
||||
mempool_known_ = false;
|
||||
++rpc_session_;
|
||||
if (consolidation_.abort()) ui::Notifications::instance().warning(TR("merge_run_aborted_toast"));
|
||||
ui::ShieldDialog::invalidateScope();
|
||||
address_validation_cache_dirty_ = true;
|
||||
resetTransactionHistoryCacheSession();
|
||||
@@ -1352,6 +1356,17 @@ void App::markWalletMutated(bool forceBalance)
|
||||
network_refresh_.markWalletMutationRefresh(); // Core / Transactions / Addresses due now
|
||||
}
|
||||
|
||||
services::ScanTicket App::beginAdhocWalletScan()
|
||||
{
|
||||
return scan_governor_.beginExplicit(services::ScanStream::Adhoc, currentScanSignals(), scanClockNow(),
|
||||
/*servesRoutine=*/false);
|
||||
}
|
||||
|
||||
void App::endAdhocWalletScan(const services::ScanTicket& ticket, double costMs, services::ScanOutcome outcome)
|
||||
{
|
||||
scan_governor_.complete(ticket, costMs, outcome, scanClockNow());
|
||||
}
|
||||
|
||||
bool App::invokeSendResultCallback(const std::string& opid, bool ok,
|
||||
const std::string& result)
|
||||
{
|
||||
@@ -1511,6 +1526,10 @@ void App::switchToWallet(const std::string& walletFile, bool stopDaemonConfirmed
|
||||
ui::Notifications::instance().warning("Finish or cancel the seed migration before switching wallets.");
|
||||
return;
|
||||
}
|
||||
if (consolidation_.active()) {
|
||||
ui::Notifications::instance().warning(TR("merge_run_busy"));
|
||||
return;
|
||||
}
|
||||
if (!isUsingEmbeddedDaemon()) {
|
||||
ui::Notifications::instance().warning("Switching wallets needs the embedded daemon — stop any external dragonxd first.");
|
||||
return;
|
||||
@@ -5468,6 +5487,167 @@ void App::maybeRemindSeedBackup()
|
||||
// shrinks in place and shielded-note witness data accumulates, so a mining/shielded wallet can grow
|
||||
// unbounded. Fires ONCE (persisted flag) a warning toast + a clickable "Consolidate notes…" entry in the
|
||||
// bell/alert panel that opens Merge to Address; re-arms if the file later drops back under the threshold.
|
||||
bool App::startConsolidation(const std::vector<services::NoteGroup>& groups, double fee, int batchLimit)
|
||||
{
|
||||
auto& n = ui::Notifications::instance();
|
||||
if (isLiteBuild() || capture_mode_ || shutting_down_.load()) return false;
|
||||
if (consolidation_.active()) {
|
||||
n.warning(TR("merge_run_busy"));
|
||||
return false;
|
||||
}
|
||||
if (show_seed_migration_ || (settings_ && settings_->getSeedMigrationPending())) {
|
||||
n.warning(TR("merge_run_migration_pending"));
|
||||
return false;
|
||||
}
|
||||
if (!rpc_ || !rpc_->isConnected() || !worker_ || !state_.connected || isWalletSwitchInProgress()) {
|
||||
n.error(TR("merge_run_start_failed"));
|
||||
return false;
|
||||
}
|
||||
services::RunConfig cfg;
|
||||
cfg.fee = fee;
|
||||
cfg.limit = batchLimit;
|
||||
if (!consolidation_.start(groups, cfg, services::steadySeconds())) {
|
||||
n.warning(TR("merge_keep_each_select"));
|
||||
return false;
|
||||
}
|
||||
n.success(TR("merge_started"));
|
||||
return true; // syncing, the lock, a busy node and pending sends are the run's own pauses
|
||||
}
|
||||
|
||||
void App::pumpConsolidation()
|
||||
{
|
||||
if (!consolidation_.active()) return;
|
||||
const double now = services::steadySeconds();
|
||||
const services::ScanContext ctx = scanContext();
|
||||
const bool unlockExpired = state_.encrypted && !state_.locked && state_.unlocked_until > 0 &&
|
||||
std::time(nullptr) >= static_cast<std::time_t>(state_.unlocked_until);
|
||||
services::RunEnv env;
|
||||
// Not walletScanReady(): the run is the user's job (no background hold, no connect gate), and it tells its
|
||||
// pauses apart. Not rpc_->isWarmingUp(): that client flag sticks for the session.
|
||||
env.nodeUsable = !isLiteBuild() && !shutting_down_.load() && !daemon_restarting_.load() && worker_ &&
|
||||
ctx.connected && !ctx.warmingUp && !ctx.runtimeRescan && !state_.sync.rescanning;
|
||||
env.busy = ctx.rpcBusy;
|
||||
env.callsAllowed = !ctx.syncing;
|
||||
env.locked = ctx.locked || unlockExpired;
|
||||
env.userSendBusy = hasTransactionSendProgress(); // a send / shield / auto-shield / chat op of ours goes first
|
||||
env.scanBusy = scan_governor_.anyInFlight(); // never stack a merge scan on another GUI scan (both lanes)
|
||||
if ((consolidation_.consumeLockResyncRequest() || unlockExpired) && now >= run_lock_resync_at_) {
|
||||
run_lock_resync_at_ = now + 30.0;
|
||||
refreshEncryptionState();
|
||||
}
|
||||
const services::RunStep step = consolidation_.next(env, now);
|
||||
if (step.action == services::RunAction::Call) postConsolidationCall(step.addrIdx);
|
||||
else if (step.action == services::RunAction::Poll) postConsolidationPoll();
|
||||
else if (step.action == services::RunAction::Finish) finishConsolidation();
|
||||
}
|
||||
|
||||
void App::postConsolidationCall(int addrIdx)
|
||||
{
|
||||
if (!worker_ || !rpc_) return; // before markCallPosted: asked again next update
|
||||
const auto& addrs = consolidation_.addresses();
|
||||
if (addrIdx < 0 || addrIdx >= static_cast<int>(addrs.size())) return;
|
||||
const std::string address = addrs[addrIdx].group.address;
|
||||
const std::uint64_t gen = consolidation_.gen();
|
||||
const std::uint64_t session = rpc_session_;
|
||||
const double fee = consolidation_.config().fee;
|
||||
const int limit = consolidation_.config().limit;
|
||||
const services::ScanTicket ticket = beginAdhocWalletScan(); // GetFilteredNotes: a whole-wallet scan
|
||||
consolidation_.markCallPosted(addrIdx, services::steadySeconds());
|
||||
worker_->post([this, rpc = rpc_.get(), address, addrIdx, gen, fee, limit, session, ticket]() -> rpc::RPCWorker::MainCb {
|
||||
services::MergeCallResult r;
|
||||
rpc::RpcFailureKind kind = rpc::RpcFailureKind::None;
|
||||
const auto t0 = std::chrono::steady_clock::now();
|
||||
try {
|
||||
MaintenanceCallScope maint(*this); // a disconnect aborts it, or refuses it unsent
|
||||
rpc::RPCClient::TraceScope trace("Consolidate / Merge batch");
|
||||
// fromaddresses = [addr], toaddress = addr: the address's notes merge back into it.
|
||||
const nlohmann::json res = rpc->call("z_mergetoaddress",
|
||||
{nlohmann::json::array({address}), address, fee, limit, limit}, services::kRunCallTimeoutSec);
|
||||
services::readMergeCallResult(res, r);
|
||||
} catch (const std::exception& e) {
|
||||
r = services::MergeCallResult{};
|
||||
r.error = e.what();
|
||||
if (r.error.empty()) r.error = "error";
|
||||
kind = services::mergeCallFailureKind(e); // sent but cut off: uncertain, never resent
|
||||
} catch (...) {
|
||||
r = services::MergeCallResult{};
|
||||
r.error = "unknown error";
|
||||
kind = rpc::RpcFailureKind::Other;
|
||||
}
|
||||
const double secs = std::chrono::duration<double>(std::chrono::steady_clock::now() - t0).count();
|
||||
return [this, gen, addrIdx, r, kind, secs, session, ticket]() {
|
||||
endAdhocWalletScan(ticket, secs * 1000.0, services::chatScanOutcome(r.error.empty(), kind));
|
||||
const std::string orphan = consolidation_.onCallResult(gen, addrIdx, r, kind, secs, services::steadySeconds());
|
||||
if (!orphan.empty() && session == rpc_session_) trackOperation(orphan); // never lose an accepted merge
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
void App::postConsolidationPoll()
|
||||
{
|
||||
if (!worker_ || !rpc_) return;
|
||||
const std::vector<std::string> ids = consolidation_.outstandingOpids();
|
||||
const auto filter = NetworkRefreshService::buildOperationFilterParams(ids);
|
||||
if (!filter) return;
|
||||
const std::set<std::string> wanted(ids.begin(), ids.end());
|
||||
const std::uint64_t gen = consolidation_.gen();
|
||||
consolidation_.markPollPosted(services::steadySeconds());
|
||||
// Not charged: z_getoperationstatus reads only the async ops map. It needs the filter fallback itself: the
|
||||
// shared opid poller only learns that the filter is unsupported when it has ops of its own.
|
||||
worker_->post([this, rpc = rpc_.get(), params = *filter, wanted, gen]() -> rpc::RPCWorker::MainCb {
|
||||
bool ok = false;
|
||||
std::vector<services::OpReport> reports;
|
||||
try {
|
||||
rpc::RPCClient::TraceScope trace("Consolidate / Batch status");
|
||||
nlohmann::json result;
|
||||
if (!opid_filter_unsupported_.load(std::memory_order_relaxed)) {
|
||||
try {
|
||||
result = rpc->call("z_getoperationstatus", params);
|
||||
} catch (const rpc::RpcError& e) {
|
||||
if (e.kind() == rpc::RpcFailureKind::Warmup) throw;
|
||||
result = rpc->call("z_getoperationstatus", nlohmann::json::array());
|
||||
opid_filter_unsupported_.store(true, std::memory_order_relaxed);
|
||||
}
|
||||
} else {
|
||||
result = rpc->call("z_getoperationstatus", nlohmann::json::array());
|
||||
}
|
||||
ok = result.is_array();
|
||||
reports = services::parseOpReports(result, wanted);
|
||||
} catch (...) {
|
||||
ok = false;
|
||||
reports.clear();
|
||||
}
|
||||
return [this, gen, ok, reports = std::move(reports)]() {
|
||||
consolidation_.onPollResult(gen, ok, reports, services::steadySeconds());
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
void App::finishConsolidation()
|
||||
{
|
||||
const services::RunSummary s = consolidation_.summary();
|
||||
// 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();
|
||||
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();
|
||||
switch (services::runVerdict(s)) {
|
||||
case services::RunVerdict::Clean: n.success(TR("shield_merge_done")); break;
|
||||
case services::RunVerdict::NothingSubmitted: n.error(TR("merge_keep_each_nothing_submitted")); break;
|
||||
case services::RunVerdict::SomeFailed: n.error(std::string(TR("shield_op_failed")) + err); break;
|
||||
case services::RunVerdict::Unconfirmed: n.warning(TR("send_status_unconfirmed")); break;
|
||||
case services::RunVerdict::Stopped: n.info(TR("merge_run_stopped_toast")); break;
|
||||
case services::RunVerdict::Halted: {
|
||||
char b[512];
|
||||
std::snprintf(b, sizeof(b), TR("merge_run_halted_fmt"), err.c_str());
|
||||
n.error(b);
|
||||
break;
|
||||
}
|
||||
default: break; // Aborted is announced where it happens; it never reaches Finish
|
||||
}
|
||||
}
|
||||
|
||||
void App::maybeWarnLargeWallet()
|
||||
{
|
||||
if (capture_mode_ || lite_wallet_) return; // no live nags during a UI sweep; lite has no wallet.dat
|
||||
@@ -5660,6 +5840,10 @@ void App::beginCreateSeedWallet()
|
||||
|
||||
void App::showSeedMigrationDialog()
|
||||
{
|
||||
if (consolidation_.active()) {
|
||||
ui::Notifications::instance().warning(TR("merge_run_busy"));
|
||||
return;
|
||||
}
|
||||
show_seed_migration_ = true;
|
||||
// Resume a pending migration. If a sweep was already submitted (txid persisted), resume at the
|
||||
// confirm/adopt stage — re-derived from the chain — rather than sweeping again; otherwise start
|
||||
@@ -5765,6 +5949,10 @@ void App::refreshSeedMigrationBalance()
|
||||
// huge-input wallet may leave a small remainder in the (preserved) legacy backup.
|
||||
void App::beginSweepToSeedWallet()
|
||||
{
|
||||
if (consolidation_.active()) {
|
||||
ui::Notifications::instance().warning(TR("merge_run_busy"));
|
||||
return;
|
||||
}
|
||||
if (!rpc_ || !worker_ || !state_.connected) {
|
||||
seed_migration_status_ = "Not connected to the daemon.";
|
||||
seed_migration_step_ = SeedMigrationStep::Error; return;
|
||||
|
||||
@@ -6,7 +6,9 @@
|
||||
|
||||
#include <algorithm>
|
||||
#include <cctype>
|
||||
#include <climits>
|
||||
#include <map>
|
||||
#include <set>
|
||||
|
||||
namespace dragonx {
|
||||
namespace services {
|
||||
@@ -58,24 +60,489 @@ int estimateBatches(int notes, int limit)
|
||||
return notes / limit + ((notes % limit) >= 2 ? 1 : 0);
|
||||
}
|
||||
|
||||
bool recordMergeResult(AddressRun& run, const MergeCallResult& r, int maxBatches)
|
||||
{
|
||||
if (!r.error.empty()) {
|
||||
run.error = r.error; // earlier batches (if any) are already submitted and stand
|
||||
return false;
|
||||
}
|
||||
run.opids.push_back(r.opid);
|
||||
run.notesMerged += r.merging;
|
||||
run.remaining = r.remaining;
|
||||
// One leftover note can't be merged into its own address.
|
||||
return r.remaining >= 2 && static_cast<int>(run.opids.size()) < maxBatches;
|
||||
}
|
||||
|
||||
AddressRun consolidateInPlace(const std::string& address, const MergeCall& call, int maxBatches)
|
||||
{
|
||||
AddressRun run;
|
||||
run.address = address;
|
||||
for (int b = 0; b < maxBatches; ++b) {
|
||||
MergeCallResult r = call(address);
|
||||
if (!r.error.empty()) {
|
||||
run.error = r.error; // earlier batches (if any) are already submitted and stand
|
||||
break;
|
||||
}
|
||||
run.opids.push_back(r.opid);
|
||||
run.notesMerged += r.merging;
|
||||
run.remaining = r.remaining;
|
||||
if (r.remaining < 2) break; // one leftover note can't be merged into its own address
|
||||
}
|
||||
if (maxBatches <= 0) return run;
|
||||
while (recordMergeResult(run, call(address), maxBatches)) {}
|
||||
return run;
|
||||
}
|
||||
|
||||
namespace {
|
||||
std::string lower(std::string s)
|
||||
{
|
||||
for (auto& c : s) c = static_cast<char>(std::tolower(static_cast<unsigned char>(c)));
|
||||
return s;
|
||||
}
|
||||
bool has(const std::string& haystack, const char* needle) { return haystack.find(needle) != std::string::npos; }
|
||||
|
||||
int clampedCount(const nlohmann::json& obj, const char* key)
|
||||
{
|
||||
const auto it = obj.find(key);
|
||||
if (it == obj.end()) return 0;
|
||||
if (it->is_number_unsigned()) {
|
||||
const auto v = it->get<std::uint64_t>();
|
||||
return v > static_cast<std::uint64_t>(INT_MAX) ? INT_MAX : static_cast<int>(v);
|
||||
}
|
||||
if (it->is_number_integer()) {
|
||||
const auto v = it->get<std::int64_t>();
|
||||
return v < 0 ? 0 : (v > INT_MAX ? INT_MAX : static_cast<int>(v));
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
std::string stringField(const nlohmann::json& obj, const char* key)
|
||||
{
|
||||
const auto it = obj.find(key);
|
||||
return (it != obj.end() && it->is_string()) ? it->get<std::string>() : std::string();
|
||||
}
|
||||
} // namespace
|
||||
|
||||
void readMergeCallResult(const nlohmann::json& res, MergeCallResult& out)
|
||||
{
|
||||
out = MergeCallResult{};
|
||||
if (res.is_object()) {
|
||||
out.opid = stringField(res, "opid");
|
||||
out.merging = clampedCount(res, "mergingNotes");
|
||||
out.remaining = clampedCount(res, "remainingNotes");
|
||||
}
|
||||
if (out.opid.empty()) {
|
||||
out = MergeCallResult{};
|
||||
out.error = "no operation id";
|
||||
}
|
||||
}
|
||||
|
||||
OpState opStateFromStatus(const std::string& status)
|
||||
{
|
||||
if (status == "queued") return OpState::Queued;
|
||||
if (status == "executing") return OpState::Executing;
|
||||
if (status == "success") return OpState::Success;
|
||||
if (status == "failed" || status == "cancelled" || status == "canceled") return OpState::Failed;
|
||||
return OpState::Unknown;
|
||||
}
|
||||
|
||||
std::vector<OpReport> parseOpReports(const nlohmann::json& statusArray, const std::set<std::string>& wanted)
|
||||
{
|
||||
std::vector<OpReport> out;
|
||||
if (!statusArray.is_array()) return out;
|
||||
for (const auto& op : statusArray) {
|
||||
if (!op.is_object()) continue;
|
||||
OpReport rep;
|
||||
rep.id = stringField(op, "id");
|
||||
if (rep.id.empty()) continue;
|
||||
if (!wanted.empty() && !wanted.count(rep.id)) continue;
|
||||
rep.status = stringField(op, "status");
|
||||
const auto err = op.find("error");
|
||||
if (err != op.end() && err->is_object()) rep.error = stringField(*err, "message");
|
||||
const auto res = op.find("result");
|
||||
if (res != op.end() && res->is_object()) rep.txid = stringField(*res, "txid");
|
||||
out.push_back(std::move(rep));
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
MergeFailure classifyMergeFailure(rpc::RpcFailureKind kind, const std::string& message)
|
||||
{
|
||||
using K = rpc::RpcFailureKind;
|
||||
switch (kind) {
|
||||
case K::None: return MergeFailure::Uncertain; // answered without an opid: it may have queued the merge
|
||||
case K::Deferred:
|
||||
case K::NotListening:
|
||||
case K::ConnectTimeout:
|
||||
case K::Warmup:
|
||||
case K::QueueFull: return MergeFailure::NotSent; // turned away before z_mergetoaddress ran
|
||||
case K::ReplyTimeout: // the daemon keeps running it and may queue the merge
|
||||
case K::Aborted: // our abort: if sent it may have queued; if not, the session is ending (result stale)
|
||||
return MergeFailure::Uncertain;
|
||||
case K::ShuttingDown:
|
||||
case K::Unhandled: return MergeFailure::Transient; // the HTTP layer dropped it
|
||||
case K::Auth: return MergeFailure::Fatal;
|
||||
default: break; // AppError, Other: by message
|
||||
}
|
||||
const std::string m = lower(message);
|
||||
if (has(m, "not connected")) return MergeFailure::NotSent; // RPCClient throws before sending
|
||||
if (has(m, "still syncing") || has(m, "fully synced")) return MergeFailure::Syncing; // THROW_IF_SYNCING (cheap)
|
||||
if (has(m, "spending key") || has(m, "wallet passphrase") || has(m, "walletpassphrase")) return MergeFailure::NeedsUnlock;
|
||||
if (has(m, "queue is shutting down")) return MergeFailure::Transient; // "...; the operation was not queued"
|
||||
if (has(m, "could not find any funds") || has(m, "already merged") || has(m, "insufficient funds") ||
|
||||
has(m, "greater than the net amount"))
|
||||
return MergeFailure::NothingLeft;
|
||||
return MergeFailure::Fatal;
|
||||
}
|
||||
|
||||
rpc::RpcFailureKind mergeCallFailureKind(const std::exception& e)
|
||||
{
|
||||
const rpc::RpcFailureKind kind = rpc::rpcFailureKindOf(e);
|
||||
if (kind == rpc::RpcFailureKind::Other) {
|
||||
if (const auto* t = dynamic_cast<const rpc::RpcTransportError*>(&e); t && t->requestSent && t->httpStatus == 0)
|
||||
return rpc::RpcFailureKind::ReplyTimeout;
|
||||
}
|
||||
return kind;
|
||||
}
|
||||
|
||||
RunVerdict runVerdict(const RunSummary& s)
|
||||
{
|
||||
switch (s.end) {
|
||||
case RunEnd::None: return RunVerdict::None;
|
||||
case RunEnd::Stopped: return RunVerdict::Stopped;
|
||||
case RunEnd::Halted: return RunVerdict::Halted;
|
||||
case RunEnd::Aborted: return RunVerdict::Aborted;
|
||||
case RunEnd::Completed: break;
|
||||
}
|
||||
if (s.batchesSubmitted == 0 && s.addressesUnconfirmed == 0) return RunVerdict::NothingSubmitted;
|
||||
if (s.addressesFailed > 0) return RunVerdict::SomeFailed;
|
||||
if (s.addressesUnconfirmed > 0) return RunVerdict::Unconfirmed;
|
||||
return RunVerdict::Clean;
|
||||
}
|
||||
|
||||
const char* runWaitKey(RunWait wait)
|
||||
{
|
||||
switch (wait) {
|
||||
case RunWait::Node:
|
||||
case RunWait::Busy: return "merge_waiting_node";
|
||||
case RunWait::Syncing: return "merge_run_wait_sync";
|
||||
case RunWait::Locked: return "merge_wait_locked";
|
||||
case RunWait::UserSend: return "merge_run_wait_send";
|
||||
case RunWait::Scan:
|
||||
case RunWait::Batch: return "merge_run_wait_batch";
|
||||
case RunWait::None:
|
||||
case RunWait::InFlight: break;
|
||||
}
|
||||
return nullptr;
|
||||
}
|
||||
|
||||
bool ConsolidationRun::start(const std::vector<NoteGroup>& groups, RunConfig cfg, double now)
|
||||
{
|
||||
if (phase_ == Phase::Running) return false;
|
||||
cfg.limit = std::max(2, cfg.limit);
|
||||
cfg.maxBatches = std::max(1, cfg.maxBatches);
|
||||
std::vector<RunAddress> addrs;
|
||||
for (const auto& g : groups) {
|
||||
if (g.address.empty() || !worthConsolidating(g, cfg.fee)) continue;
|
||||
RunAddress a;
|
||||
a.group = g;
|
||||
a.run.address = g.address;
|
||||
addrs.push_back(std::move(a));
|
||||
}
|
||||
if (addrs.empty()) return false;
|
||||
const std::uint64_t gen = gen_ + 1;
|
||||
*this = ConsolidationRun();
|
||||
gen_ = gen;
|
||||
cfg_ = cfg;
|
||||
addrs_ = std::move(addrs);
|
||||
phase_ = Phase::Running;
|
||||
nextCallAt_ = nextPollAt_ = now;
|
||||
lastPumpAt_ = now;
|
||||
return true;
|
||||
}
|
||||
|
||||
void ConsolidationRun::requestStop()
|
||||
{
|
||||
if (phase_ == Phase::Running) stopping_ = true;
|
||||
}
|
||||
|
||||
void ConsolidationRun::dismiss()
|
||||
{
|
||||
if (phase_ == Phase::Running) return;
|
||||
const std::uint64_t gen = gen_; // a stale result can never match a later run
|
||||
*this = ConsolidationRun();
|
||||
gen_ = gen;
|
||||
}
|
||||
|
||||
bool ConsolidationRun::abort()
|
||||
{
|
||||
if (phase_ != Phase::Running) return false;
|
||||
++gen_;
|
||||
if (callInFlight_ && callAddr_ >= 0 && callAddr_ < static_cast<int>(addrs_.size())) {
|
||||
auto& a = addrs_[callAddr_];
|
||||
a.done = true;
|
||||
worsen(a, AddrOutcome::Uncertain);
|
||||
}
|
||||
callInFlight_ = pollInFlight_ = false;
|
||||
for (auto& b : batches_) {
|
||||
if (!opStateTerminal(b.state)) applyOpState(b, OpState::Lost, nullptr); // "could not be confirmed"
|
||||
}
|
||||
phase_ = Phase::Finished;
|
||||
end_ = RunEnd::Aborted;
|
||||
lastWait_ = RunWait::None;
|
||||
return true;
|
||||
}
|
||||
|
||||
void ConsolidationRun::endAddress(RunAddress& a, AddrOutcome o, const std::string& msg)
|
||||
{
|
||||
a.done = true;
|
||||
worsen(a, o);
|
||||
if (a.run.error.empty()) a.run.error = msg;
|
||||
if (a.run.opids.empty() && ++deadStreak_ >= kRunMaxFailStreak) halt(msg);
|
||||
}
|
||||
|
||||
void ConsolidationRun::applyOpState(RunBatch& b, OpState st, const OpReport* rep)
|
||||
{
|
||||
b.state = st;
|
||||
if (b.addrIdx < 0 || b.addrIdx >= static_cast<int>(addrs_.size())) return;
|
||||
RunAddress& a = addrs_[b.addrIdx];
|
||||
switch (st) {
|
||||
case OpState::Success:
|
||||
if (rep) b.txid = rep->txid;
|
||||
++a.opsDone;
|
||||
opFailStreak_ = 0;
|
||||
break;
|
||||
case OpState::Failed:
|
||||
if (rep) b.error = rep->error;
|
||||
if (a.failure.empty()) a.failure = b.error;
|
||||
a.done = true;
|
||||
worsen(a, AddrOutcome::Failed);
|
||||
if (++opFailStreak_ >= kRunMaxFailStreak) halt(b.error);
|
||||
break;
|
||||
case OpState::Lost:
|
||||
a.done = true;
|
||||
worsen(a, AddrOutcome::Uncertain);
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
RunStep ConsolidationRun::next(const RunEnv& env, double now)
|
||||
{
|
||||
if (phase_ != Phase::Running) return {};
|
||||
if (callInFlight_ && now - callPostedAt_ > kRunWatchdogSec) { // its result never came: a late reply is an orphan
|
||||
callInFlight_ = false;
|
||||
++gen_;
|
||||
if (callAddr_ >= 0 && callAddr_ < static_cast<int>(addrs_.size()))
|
||||
endAddress(addrs_[callAddr_], AddrOutcome::Uncertain, "");
|
||||
nextCallAt_ = std::max(nextCallAt_, now + kRunMaxGapSec);
|
||||
}
|
||||
if (pollInFlight_ && now - pollPostedAt_ > kRunWatchdogSec) {
|
||||
pollInFlight_ = false;
|
||||
++gen_;
|
||||
}
|
||||
// An op the daemon never reports done is presumed lost after an hour in which it could have been polled
|
||||
// (not while the node was gone or busy: it may simply not have been asked), or after 4 h whatever happened.
|
||||
const double dt = std::max(0.0, now - lastPumpAt_);
|
||||
lastPumpAt_ = now;
|
||||
const bool pollable = env.nodeUsable && !env.busy;
|
||||
for (auto& b : batches_) {
|
||||
if (opStateTerminal(b.state)) continue;
|
||||
if (pollable) b.pollableSec += std::min(dt, std::max(0.0, now - b.acceptedAt));
|
||||
if (b.pollableSec > kRunOpMaxSec || now - b.acceptedAt > kRunOpHardMaxSec) applyOpState(b, OpState::Lost, nullptr);
|
||||
}
|
||||
while (cur_ < addrs_.size() && addrs_[cur_].done) ++cur_;
|
||||
const bool moreCalls = !stopping_ && !halted_ && cur_ < addrs_.size();
|
||||
const int outstanding = static_cast<int>(std::count_if(batches_.begin(), batches_.end(),
|
||||
[](const RunBatch& b) { return !opStateTerminal(b.state); }));
|
||||
auto wait = [&](RunWait w) {
|
||||
lastWait_ = w;
|
||||
return RunStep{};
|
||||
};
|
||||
if (!callInFlight_ && !pollInFlight_ && !moreCalls && outstanding == 0) {
|
||||
phase_ = Phase::Finished;
|
||||
end_ = halted_ ? RunEnd::Halted : (stopping_ && cur_ < addrs_.size()) ? RunEnd::Stopped : RunEnd::Completed;
|
||||
lastWait_ = RunWait::None;
|
||||
return {RunAction::Finish, -1}; // exactly once
|
||||
}
|
||||
if (callInFlight_ || pollInFlight_) return wait(RunWait::InFlight); // one run RPC at a time
|
||||
if (!env.nodeUsable) return wait(RunWait::Node); // blocks polls too
|
||||
if (env.busy) return wait(RunWait::Busy); // z_getoperationstatus needs cs_main
|
||||
if (outstanding > 0 && now >= nextPollAt_) {
|
||||
lastWait_ = RunWait::None;
|
||||
return {RunAction::Poll, -1};
|
||||
}
|
||||
if (!moreCalls) return wait(RunWait::None); // waiting for submitted batches
|
||||
if (!env.callsAllowed || now < syncHoldUntil_) return wait(RunWait::Syncing);
|
||||
if (env.locked || now < unlockHoldUntil_) return wait(RunWait::Locked);
|
||||
if (env.userSendBusy) return wait(RunWait::UserSend);
|
||||
if (now < nextCallAt_) return wait(RunWait::Batch);
|
||||
if (!batches_.empty() && (batches_.back().state == OpState::Unknown || batches_.back().state == OpState::Queued))
|
||||
return wait(RunWait::Batch); // at most 2 unfinished ops, at most 2 x limit notes locked
|
||||
if (env.scanBusy) return wait(RunWait::Scan); // another GUI wallet scan is in the daemon
|
||||
lastWait_ = RunWait::None;
|
||||
return {RunAction::Call, static_cast<int>(cur_)};
|
||||
}
|
||||
|
||||
void ConsolidationRun::markCallPosted(int addrIdx, double now)
|
||||
{
|
||||
if (phase_ != Phase::Running || addrIdx < 0 || addrIdx >= static_cast<int>(addrs_.size())) return;
|
||||
callInFlight_ = true;
|
||||
callAddr_ = addrIdx;
|
||||
callPostedAt_ = now;
|
||||
worsen(addrs_[addrIdx], AddrOutcome::Running);
|
||||
}
|
||||
|
||||
void ConsolidationRun::markPollPosted(double now)
|
||||
{
|
||||
if (phase_ != Phase::Running) return;
|
||||
pollInFlight_ = true;
|
||||
pollPostedAt_ = now;
|
||||
}
|
||||
|
||||
std::string ConsolidationRun::onCallResult(std::uint64_t gen, int addrIdx, const MergeCallResult& r,
|
||||
rpc::RpcFailureKind kind, double callSec, double now)
|
||||
{
|
||||
const bool accepted = r.error.empty() && !r.opid.empty();
|
||||
if (gen != gen_ || phase_ != Phase::Running || !callInFlight_ || addrIdx != callAddr_)
|
||||
return accepted ? r.opid : std::string();
|
||||
callInFlight_ = false;
|
||||
const double gapAt = now + std::clamp(callSec, kRunMinGapSec, kRunMaxGapSec);
|
||||
nextCallAt_ = gapAt;
|
||||
RunAddress& a = addrs_[addrIdx];
|
||||
if (accepted) {
|
||||
a.transient = 0;
|
||||
deadStreak_ = 0;
|
||||
const bool more = recordMergeResult(a.run, r, cfg_.maxBatches);
|
||||
RunBatch b;
|
||||
b.addrIdx = addrIdx;
|
||||
b.opid = r.opid;
|
||||
b.merging = r.merging;
|
||||
b.acceptedAt = now;
|
||||
batches_.push_back(std::move(b));
|
||||
nextPollAt_ = now; // learn its state before the next call
|
||||
if (!more) {
|
||||
a.done = true;
|
||||
worsen(a, AddrOutcome::Done);
|
||||
}
|
||||
return {};
|
||||
}
|
||||
const std::string msg = r.error.empty() ? std::string("no operation id") : r.error;
|
||||
switch (classifyMergeFailure(kind, msg)) {
|
||||
case MergeFailure::NotSent: // the same batch again, no retry budget
|
||||
nextCallAt_ = std::max(gapAt, now + kRunNotSentRetrySec);
|
||||
break;
|
||||
case MergeFailure::Syncing: // no retry budget
|
||||
syncHoldUntil_ = now + kRunSyncHoldSec;
|
||||
break;
|
||||
case MergeFailure::NeedsUnlock:
|
||||
if (++a.unlockRefusals >= kRunMaxUnlockRefusals) {
|
||||
endAddress(a, AddrOutcome::Failed, msg);
|
||||
} else {
|
||||
unlockHoldUntil_ = now + kRunUnlockHoldSec;
|
||||
lockResync_ = true;
|
||||
}
|
||||
break;
|
||||
case MergeFailure::NothingLeft:
|
||||
if (a.run.opids.empty()) {
|
||||
endAddress(a, AddrOutcome::Failed, msg);
|
||||
} else {
|
||||
a.done = true;
|
||||
worsen(a, AddrOutcome::Done);
|
||||
}
|
||||
break;
|
||||
case MergeFailure::Uncertain: // never resent
|
||||
endAddress(a, AddrOutcome::Uncertain, msg);
|
||||
break;
|
||||
case MergeFailure::Transient:
|
||||
if (++a.transient > kRunMaxTransientRetries) {
|
||||
endAddress(a, AddrOutcome::Failed, msg);
|
||||
} else {
|
||||
nextCallAt_ = std::max(gapAt, now + std::min(60.0, 5.0 * static_cast<double>(1 << (a.transient - 1))));
|
||||
}
|
||||
break;
|
||||
case MergeFailure::Fatal:
|
||||
endAddress(a, AddrOutcome::Failed, msg);
|
||||
break;
|
||||
}
|
||||
return {};
|
||||
}
|
||||
|
||||
void ConsolidationRun::onPollResult(std::uint64_t gen, bool ok, const std::vector<OpReport>& reports, double now)
|
||||
{
|
||||
if (gen != gen_ || phase_ != Phase::Running || !pollInFlight_) return;
|
||||
pollInFlight_ = false;
|
||||
nextPollAt_ = now + kRunPollIntervalSec;
|
||||
if (!ok) return;
|
||||
// No call can be in flight during a poll, so the non-terminal batches now are exactly the ids it asked about.
|
||||
std::map<std::string, const OpReport*> byId;
|
||||
for (const auto& rep : reports) byId[rep.id] = &rep;
|
||||
for (auto& b : batches_) {
|
||||
if (opStateTerminal(b.state)) continue;
|
||||
const auto it = byId.find(b.opid);
|
||||
if (it == byId.end()) {
|
||||
if (++b.misses >= kRunMaxPollMisses) applyOpState(b, OpState::Lost, nullptr);
|
||||
continue;
|
||||
}
|
||||
b.misses = 0;
|
||||
const OpState st = opStateFromStatus(it->second->status);
|
||||
if (st != OpState::Unknown) applyOpState(b, st, it->second);
|
||||
}
|
||||
}
|
||||
|
||||
bool ConsolidationRun::consumeLockResyncRequest()
|
||||
{
|
||||
const bool r = lockResync_;
|
||||
lockResync_ = false;
|
||||
return r;
|
||||
}
|
||||
|
||||
std::vector<std::string> ConsolidationRun::outstandingOpids() const
|
||||
{
|
||||
std::vector<std::string> ids;
|
||||
for (const auto& b : batches_) {
|
||||
if (!opStateTerminal(b.state)) ids.push_back(b.opid);
|
||||
}
|
||||
return ids;
|
||||
}
|
||||
|
||||
RunProgress ConsolidationRun::progress() const
|
||||
{
|
||||
RunProgress p;
|
||||
p.addresses = static_cast<int>(addrs_.size());
|
||||
for (const auto& a : addrs_) {
|
||||
if (a.done) ++p.addressesDone;
|
||||
const int submitted = static_cast<int>(a.run.opids.size());
|
||||
p.batchesEstimated += a.done ? submitted
|
||||
: std::max(std::min(estimateBatches(a.group.notes, cfg_.limit), cfg_.maxBatches), submitted);
|
||||
}
|
||||
p.batchesSubmitted = static_cast<int>(batches_.size());
|
||||
for (const auto& b : batches_) {
|
||||
if (opStateTerminal(b.state)) ++p.batchesDone;
|
||||
if (b.state == OpState::Success) p.notesMerged += b.merging;
|
||||
}
|
||||
p.stopping = stopping_;
|
||||
p.wait = lastWait_;
|
||||
return p;
|
||||
}
|
||||
|
||||
RunSummary ConsolidationRun::summary() const
|
||||
{
|
||||
RunSummary s;
|
||||
s.end = end_;
|
||||
s.addresses = static_cast<int>(addrs_.size());
|
||||
for (const auto& a : addrs_) {
|
||||
if (a.outcome == AddrOutcome::Failed) ++s.addressesFailed;
|
||||
if (a.outcome == AddrOutcome::Uncertain) ++s.addressesUnconfirmed;
|
||||
}
|
||||
s.batchesSubmitted = static_cast<int>(batches_.size());
|
||||
for (const auto& b : batches_) {
|
||||
if (b.state == OpState::Success) {
|
||||
++s.batchesSucceeded;
|
||||
s.notesMerged += b.merging;
|
||||
}
|
||||
}
|
||||
s.firstError = haltError_;
|
||||
if (s.firstError.empty()) {
|
||||
for (const auto& a : addrs_) {
|
||||
if (a.outcome != AddrOutcome::Failed) continue;
|
||||
s.firstError = !a.failure.empty() ? a.failure : a.run.error;
|
||||
if (!s.firstError.empty()) break;
|
||||
}
|
||||
}
|
||||
return s;
|
||||
}
|
||||
|
||||
ScopeWant scopeScanWant(const ScopeScan& scan, const ScopeInputs& in)
|
||||
{
|
||||
if (!in.mergeMode || scan.phase == ScopePhase::Loaded) return ScopeWant::Nothing;
|
||||
|
||||
@@ -17,6 +17,7 @@
|
||||
#include <chrono>
|
||||
#include <cstdint>
|
||||
#include <functional>
|
||||
#include <set>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
@@ -67,6 +68,168 @@ struct AddressRun {
|
||||
// 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
|
||||
|
||||
+173
-188
@@ -17,6 +17,7 @@
|
||||
|
||||
#include <algorithm>
|
||||
#include <cstdint>
|
||||
#include <chrono>
|
||||
#include <set>
|
||||
#include <stdexcept>
|
||||
#include <vector>
|
||||
@@ -54,13 +55,9 @@ static bool s_per_address = false;
|
||||
static std::vector<services::NoteGroup> s_groups; // spendable shielded notes by address (scope load)
|
||||
static std::set<std::string> s_selected; // addresses ticked for per-address consolidation
|
||||
static std::set<std::string> s_unticked; // unticked by the user this opening: kept across a re-scan
|
||||
struct PerAddressRun {
|
||||
services::AddressRun run;
|
||||
std::vector<int> state; // per opid: 0 = running, 1 = done, 2 = failed, 3 = gone from the daemon (unknown)
|
||||
std::string failure; // first failed op's message
|
||||
};
|
||||
static std::vector<PerAddressRun> s_runs; // after submit
|
||||
static bool s_runs_submitting = false;
|
||||
// The per-address run itself is App's (App::consolidation()): it keeps going when the dialog closes. A finished
|
||||
// one is dismissed when the dialog next opens, so the form starts fresh.
|
||||
static bool s_dismiss_finished = false;
|
||||
// The op-status polls: one in flight at a time, cleared only by its reply, even across a close and
|
||||
// reopen; a reply for an earlier opening (another session) changes nothing else.
|
||||
static bool s_poll_in_flight = false;
|
||||
@@ -75,8 +72,6 @@ static void resetTransient()
|
||||
s_status_message.clear();
|
||||
s_operation_id.clear();
|
||||
s_creating_addr = false;
|
||||
s_runs.clear();
|
||||
s_runs_submitting = false;
|
||||
}
|
||||
|
||||
// Count + sum spendable transparent UTXOs and shielded notes so the user can see the scope of a
|
||||
@@ -86,10 +81,13 @@ static void loadScope(App* app)
|
||||
{
|
||||
if (!app || !app->worker()) return;
|
||||
const std::uint32_t gen = services::scopeScanBegin(s_scan, services::steadySeconds());
|
||||
app->worker()->post([app, rpc = app->rpc(), gen]() -> rpc::RPCWorker::MainCb {
|
||||
// Two whole-wallet scans the user asked for: never held back, but charged to the scan budget.
|
||||
const services::ScanTicket ticket = app->beginAdhocWalletScan();
|
||||
app->worker()->post([app, rpc = app->rpc(), gen, ticket]() -> rpc::RPCWorker::MainCb {
|
||||
int tC = 0, zC = 0; double tA = 0.0, zA = 0.0; std::string error;
|
||||
rpc::RpcFailureKind kind = rpc::RpcFailureKind::None;
|
||||
std::vector<services::NoteGroup> groups;
|
||||
const auto t0 = std::chrono::steady_clock::now();
|
||||
try {
|
||||
if (!rpc) throw std::runtime_error("Not connected");
|
||||
// On a large wallet each scan can outlast the default 30 s; give them 120 s, counted as
|
||||
@@ -113,7 +111,9 @@ static void loadScope(App* app)
|
||||
error = "unknown error";
|
||||
kind = rpc::RpcFailureKind::Other;
|
||||
}
|
||||
return [tC, zC, tA, zA, error, kind, gen, groups]() {
|
||||
const double costMs = std::chrono::duration<double, std::milli>(std::chrono::steady_clock::now() - t0).count();
|
||||
return [app, ticket, costMs, tC, zC, tA, zA, error, kind, gen, groups]() {
|
||||
app->endAdhocWalletScan(ticket, costMs, services::chatScanOutcome(error.empty(), kind));
|
||||
if (!services::scopeScanFinish(s_scan, gen, error, kind, services::steadySeconds())) return;
|
||||
s_t_count = tC; s_z_count = zC; s_t_amount = tA; s_z_amount = zA;
|
||||
s_groups = groups;
|
||||
@@ -141,6 +141,7 @@ void ShieldDialog::show(Mode mode)
|
||||
s_consolidate = false; // reset preset flags so stale statics don't leak across opens
|
||||
s_src = 2;
|
||||
resetTransient();
|
||||
s_dismiss_finished = true;
|
||||
s_from_address[0] = '\0';
|
||||
if (mode == Mode::ShieldCoinbase) strncpy(s_from_address, "*", sizeof(s_from_address));
|
||||
s_to_address[0] = '\0';
|
||||
@@ -228,6 +229,7 @@ static void autoSelectDestination(const WalletState& state)
|
||||
// AND kicks the modal's own live-progress poll.
|
||||
static void submitOperation(App* app)
|
||||
{
|
||||
if (!app->worker()) return; // before the pending state, so a missing worker can't wedge it
|
||||
s_operation_pending = true;
|
||||
s_op_terminal = false;
|
||||
s_status_message = TR("shield_submitting");
|
||||
@@ -267,12 +269,15 @@ static void submitOperation(App* app)
|
||||
else fromAddrs = { "*" };
|
||||
std::string to(s_to_address);
|
||||
double fee = s_fee; int limit = s_utxo_limit;
|
||||
if (!app->worker()) return;
|
||||
app->worker()->post([app, rpc = app->rpc(), fromAddrs, to, fee, limit]() -> rpc::RPCWorker::MainCb {
|
||||
// The merge's GetFilteredNotes is a whole-wallet scan: charged to the scan budget, never held back.
|
||||
const services::ScanTicket ticket = app->beginAdhocWalletScan();
|
||||
app->worker()->post([app, rpc = app->rpc(), fromAddrs, to, fee, limit, ticket]() -> rpc::RPCWorker::MainCb {
|
||||
nlohmann::json addrs = nlohmann::json::array();
|
||||
for (const auto& a : fromAddrs) addrs.push_back(a);
|
||||
nlohmann::json result; std::string error;
|
||||
bool uncertain = false;
|
||||
rpc::RpcFailureKind kind = rpc::RpcFailureKind::None;
|
||||
const auto t0 = std::chrono::steady_clock::now();
|
||||
try {
|
||||
if (!rpc) throw std::runtime_error("Not connected");
|
||||
// The daemon scans the whole wallet before it returns the opid; on a large wallet that can
|
||||
@@ -281,15 +286,22 @@ static void submitOperation(App* app)
|
||||
App::MaintenanceCallScope maint(*app);
|
||||
rpc::RPCClient::TraceScope trace("Shield dialog / Consolidate");
|
||||
// fromaddrs, toaddr, fee, transparent_limit, shielded_limit — cap both to the batch size.
|
||||
result = rpc->call("z_mergetoaddress", {addrs, to, fee, limit, limit}, services::kScopeCallTimeoutSec);
|
||||
result = rpc->call("z_mergetoaddress", {addrs, to, fee, limit, limit}, services::kRunCallTimeoutSec);
|
||||
} catch (const std::exception& e) {
|
||||
error = e.what();
|
||||
if (error.empty()) error = "error";
|
||||
kind = rpc::rpcFailureKindOf(e);
|
||||
// Sent, then cut off with no reply: our own abort on a lifecycle disconnect, the reply timeout
|
||||
// or a dropped connection. The daemon keeps running the handler and may still queue the merge.
|
||||
if (const auto* t = dynamic_cast<const rpc::RpcTransportError*>(&e))
|
||||
uncertain = t->requestSent && t->httpStatus == 0;
|
||||
} catch (...) {
|
||||
error = "unknown error";
|
||||
kind = rpc::RpcFailureKind::Other;
|
||||
}
|
||||
return [app, result, error, uncertain]() {
|
||||
const double ms = std::chrono::duration<double, std::milli>(std::chrono::steady_clock::now() - t0).count();
|
||||
return [app, result, error, uncertain, ticket, kind, ms]() {
|
||||
app->endAdhocWalletScan(ticket, ms, services::chatScanOutcome(error.empty(), kind));
|
||||
if (error.empty()) {
|
||||
s_operation_id = result.value("opid", "");
|
||||
s_status_message = TR("merge_progress");
|
||||
@@ -318,134 +330,17 @@ static std::vector<services::NoteGroup> selectedGroups()
|
||||
return out;
|
||||
}
|
||||
|
||||
// Per-address consolidation. One background job submits, address by address, merges of each address's
|
||||
// notes back into that address — several batches when it holds more notes than the batch size.
|
||||
// z_mergetoaddress locks a batch's notes before it returns, so back-to-back calls take disjoint notes.
|
||||
// Per-address consolidation: App runs it (App::startConsolidation): paced, one merge at a time, and it keeps
|
||||
// going when this dialog closes. App toasts any refusal.
|
||||
static void submitPerAddress(App* app)
|
||||
{
|
||||
const auto groups = selectedGroups();
|
||||
if (groups.empty() || !app->worker()) return;
|
||||
s_runs_submitting = true;
|
||||
s_operation_pending = true;
|
||||
s_op_terminal = false;
|
||||
s_status_message = TR("shield_submitting");
|
||||
s_last_poll = ImGui::GetTime();
|
||||
const double fee = s_fee;
|
||||
const int limit = s_utxo_limit;
|
||||
app->worker()->post([app, rpc = app->rpc(), groups, fee, limit]() -> rpc::RPCWorker::MainCb {
|
||||
std::vector<services::AddressRun> runs;
|
||||
for (const auto& g : groups) {
|
||||
runs.push_back(services::consolidateInPlace(g.address, [&](const std::string& addr) {
|
||||
services::MergeCallResult r;
|
||||
try {
|
||||
rpc::RPCClient::TraceScope trace("Shield dialog / Consolidate in place");
|
||||
// fromaddrs = [addr], toaddr = addr: the address's notes merge back into it.
|
||||
const nlohmann::json res = rpc->call("z_mergetoaddress",
|
||||
{nlohmann::json::array({addr}), addr, fee, limit, limit});
|
||||
r.opid = res.value("opid", std::string());
|
||||
r.merging = res.value("mergingNotes", 0);
|
||||
r.remaining = res.value("remainingNotes", 0);
|
||||
if (r.opid.empty()) r.error = "no operation id";
|
||||
} catch (const std::exception& e) { r.error = e.what(); }
|
||||
return r;
|
||||
}));
|
||||
}
|
||||
return [app, runs]() {
|
||||
s_runs_submitting = false;
|
||||
s_runs.clear();
|
||||
int submitted = 0;
|
||||
for (const auto& r : runs) {
|
||||
PerAddressRun pr;
|
||||
pr.run = r;
|
||||
pr.state.assign(r.opids.size(), 0);
|
||||
for (const auto& id : r.opids) { app->trackOperation(id); ++submitted; }
|
||||
s_runs.push_back(std::move(pr));
|
||||
}
|
||||
if (submitted > 0) {
|
||||
s_status_message = TR("merge_progress");
|
||||
Notifications::instance().success(TR("merge_started"));
|
||||
} else {
|
||||
s_operation_pending = false; s_op_terminal = true;
|
||||
s_status_message = TR("merge_keep_each_nothing_submitted");
|
||||
Notifications::instance().error(TR("merge_keep_each_nothing_submitted"));
|
||||
}
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
// Progress of a per-address run: poll every ~2s until each batch's operation is done or failed. Filtered
|
||||
// to the batches still running, as the shared poller does (unfiltered if the daemon rejected the filter).
|
||||
static void pollPerAddress(App* app)
|
||||
{
|
||||
if (s_runs.empty() || s_op_terminal || s_poll_in_flight || !app->worker()) return;
|
||||
const double now = ImGui::GetTime();
|
||||
if (now - s_last_poll < 2.0) return;
|
||||
std::vector<std::string> running;
|
||||
for (const auto& r : s_runs)
|
||||
for (size_t i = 0; i < r.run.opids.size(); ++i)
|
||||
if (r.state[i] == 0) running.push_back(r.run.opids[i]);
|
||||
const auto filter = services::NetworkRefreshService::buildOperationFilterParams(running);
|
||||
if (!filter) return;
|
||||
s_last_poll = now;
|
||||
s_poll_in_flight = true;
|
||||
const unsigned session = s_poll_session;
|
||||
nlohmann::json params = app->opidFilterUnsupported() ? nlohmann::json::array() : *filter;
|
||||
app->worker()->post([rpc = app->rpc(), params = std::move(params), session]() -> rpc::RPCWorker::MainCb {
|
||||
nlohmann::json result; std::string error;
|
||||
try {
|
||||
if (!rpc) throw std::runtime_error("Not connected");
|
||||
rpc::RPCClient::TraceScope trace("Shield dialog / Consolidate in place status");
|
||||
result = rpc->call("z_getoperationstatus", params);
|
||||
} catch (const std::exception& e) { error = e.what(); }
|
||||
return [result, error, session]() {
|
||||
s_poll_in_flight = false;
|
||||
if (session != s_poll_session) return; // a reply for an earlier opening
|
||||
if (!error.empty() || !result.is_array()) return; // transient — retry next tick
|
||||
std::set<std::string> reported;
|
||||
for (const auto& op : result) {
|
||||
if (!op.is_object() || !op.contains("id") || !op["id"].is_string()) continue;
|
||||
const std::string id = op["id"].get<std::string>();
|
||||
reported.insert(id);
|
||||
const std::string status = (op.contains("status") && op["status"].is_string())
|
||||
? op["status"].get<std::string>() : std::string();
|
||||
for (auto& r : s_runs) {
|
||||
for (size_t i = 0; i < r.run.opids.size(); ++i) {
|
||||
if (r.run.opids[i] != id || r.state[i] != 0) continue;
|
||||
if (status == "success") r.state[i] = 1;
|
||||
else if (status == "failed" || status == "cancelled" || status == "canceled") {
|
||||
r.state[i] = 2;
|
||||
if (r.failure.empty() && op.contains("error") && op["error"].is_object() &&
|
||||
op["error"].contains("message") && op["error"]["message"].is_string())
|
||||
r.failure = op["error"]["message"].get<std::string>();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// A valid reply without one of our running ops: the daemon no longer holds it (restart, or
|
||||
// popped by z_getoperationresult); its outcome can't be confirmed. One poll at a time, and
|
||||
// states change only here, so the running set now is the set that was asked for.
|
||||
for (auto& r : s_runs)
|
||||
for (size_t i = 0; i < r.run.opids.size(); ++i)
|
||||
if (r.state[i] == 0 && !reported.count(r.run.opids[i])) r.state[i] = 3;
|
||||
bool allDone = true;
|
||||
int failed = 0, unknown = 0;
|
||||
for (const auto& r : s_runs)
|
||||
for (int st : r.state) { if (st == 0) allDone = false; if (st == 2) ++failed; if (st == 3) ++unknown; }
|
||||
if (!allDone) return;
|
||||
s_operation_pending = false;
|
||||
s_op_terminal = true;
|
||||
if (failed > 0) {
|
||||
s_status_message = TR("merge_keep_each_some_failed");
|
||||
Notifications::instance().error(TR("merge_keep_each_some_failed"));
|
||||
} else if (unknown > 0) {
|
||||
s_status_message = TR("send_status_unconfirmed");
|
||||
Notifications::instance().warning(TR("send_status_unconfirmed"));
|
||||
} else {
|
||||
s_status_message = TR("shield_completed");
|
||||
Notifications::instance().success(TR("shield_merge_done"));
|
||||
}
|
||||
};
|
||||
});
|
||||
if (groups.empty()) return;
|
||||
if (app->startConsolidation(groups, s_fee, s_utxo_limit)) {
|
||||
s_confirm = false;
|
||||
s_status_message.clear(); // an earlier "Into one" error / uncertain text belongs to that op
|
||||
s_op_terminal = false;
|
||||
}
|
||||
}
|
||||
|
||||
// Live-progress self-poll: while an op is in flight, poll z_getoperationstatus every ~2s so the modal
|
||||
@@ -547,12 +442,16 @@ void ShieldDialog::render(App* app)
|
||||
const auto& state = app->getWalletState();
|
||||
autoSelectDestination(state);
|
||||
pollOperation(app);
|
||||
pollPerAddress(app);
|
||||
const services::ConsolidationRun& run = app->consolidation();
|
||||
if (s_dismiss_finished) { // a finished run from an earlier opening: start with a fresh form
|
||||
s_dismiss_finished = false;
|
||||
app->dismissConsolidation();
|
||||
}
|
||||
if (isMerge) {
|
||||
services::ScopeInputs in;
|
||||
in.mergeMode = true;
|
||||
in.busyWithOp = !s_operation_id.empty() || s_operation_pending || !s_runs.empty();
|
||||
in.nodeReady = app->walletScanReady();
|
||||
in.busyWithOp = !s_operation_id.empty() || s_operation_pending || run.hasRun();
|
||||
in.nodeReady = app->walletScanReady() && !app->walletScanInFlight(); // don't stack on another wallet scan
|
||||
in.now = services::steadySeconds();
|
||||
if (services::scopeScanWant(s_scan, in) == services::ScopeWant::Post) loadScope(app);
|
||||
}
|
||||
@@ -563,9 +462,13 @@ void ShieldDialog::render(App* app)
|
||||
: TR("shield_description"));
|
||||
ImGui::Spacing();
|
||||
|
||||
// Submitted — inputs frozen, showing progress.
|
||||
const bool opInFlight = !s_operation_id.empty() || s_runs_submitting || !s_runs.empty();
|
||||
// Submitted — inputs frozen, showing progress. s_operation_pending covers the wait for the opid (up to
|
||||
// 120 s), when Confirm used to stay armed.
|
||||
const bool opInFlight = s_operation_pending || !s_operation_id.empty() || (isMerge && run.hasRun());
|
||||
const bool perAddress = isMerge && s_per_address;
|
||||
const float rowH = ImGui::GetFrameHeightWithSpacing();
|
||||
int maxRows = static_cast<int>(S.drawElement("dialogs.shield", "list-max-rows").size);
|
||||
if (maxRows < 1) maxRows = 7;
|
||||
|
||||
// ── Merge: scope + source selector ──────────────────────────────────────────────────────────
|
||||
if (isMerge && !opInFlight) {
|
||||
@@ -620,8 +523,8 @@ void ShieldDialog::render(App* app)
|
||||
if (s_scan.phase == services::ScopePhase::Loaded && listed == 0) {
|
||||
wrappedCaption(material::Warning(), TR("merge_keep_each_none"));
|
||||
} else if (listed > 0) {
|
||||
const float rowH = ImGui::GetFrameHeightWithSpacing();
|
||||
const float listH = std::min(static_cast<float>(listed), 7.0f) * rowH + ImGui::GetStyle().WindowPadding.y * 2.0f;
|
||||
const float listH = std::min(static_cast<float>(listed), static_cast<float>(maxRows)) * rowH +
|
||||
ImGui::GetStyle().WindowPadding.y * 2.0f;
|
||||
if (ImGui::BeginChild("##ConsolidateEach", ImVec2(-1, listH), ImGuiChildFlags_Borders)) {
|
||||
for (const auto& g : s_groups) {
|
||||
if (g.notes < 2) continue;
|
||||
@@ -751,51 +654,98 @@ void ShieldDialog::render(App* app)
|
||||
ImGui::Spacing();
|
||||
}
|
||||
|
||||
// ── Per-address progress ────────────────────────────────────────────────────────────────────
|
||||
if (!s_runs.empty()) {
|
||||
bool leftovers = false;
|
||||
for (const auto& r : s_runs) {
|
||||
const std::string label = app->getAddressLabel(r.run.address);
|
||||
const char* name = label.empty() ? nullptr : label.c_str();
|
||||
const std::string shortA = shortAddr(r.run.address);
|
||||
int done = 0, failed = 0, unknown = 0;
|
||||
for (int st : r.state) { if (st == 1) ++done; if (st == 2) ++failed; if (st == 3) ++unknown; }
|
||||
const int batches = static_cast<int>(r.run.opids.size());
|
||||
char line[320];
|
||||
ImU32 col = material::OnSurfaceMedium();
|
||||
if (batches == 0) {
|
||||
std::snprintf(line, sizeof(line), TR("merge_keep_each_status_not_started"), name ? name : shortA.c_str(),
|
||||
r.run.error.c_str());
|
||||
col = material::Error();
|
||||
} else if (failed > 0) {
|
||||
std::snprintf(line, sizeof(line), TR("merge_keep_each_status_failed"), name ? name : shortA.c_str(),
|
||||
r.failure.c_str());
|
||||
col = material::Error();
|
||||
} else if (unknown > 0 && done + unknown == batches) {
|
||||
std::snprintf(line, sizeof(line), TR("merge_keep_each_status_unconfirmed"), name ? name : shortA.c_str());
|
||||
col = material::Warning();
|
||||
} else if (done == batches) {
|
||||
std::snprintf(line, sizeof(line), TR("merge_keep_each_status_done"), name ? name : shortA.c_str(),
|
||||
r.run.notesMerged, batches);
|
||||
col = material::Success();
|
||||
} else {
|
||||
std::snprintf(line, sizeof(line), TR("merge_keep_each_status_running"), name ? name : shortA.c_str(),
|
||||
r.run.notesMerged, done, batches);
|
||||
// ── Per-address run (App-owned) ────────────────────────────────────────────────────────────
|
||||
if (isMerge && run.hasRun()) {
|
||||
const services::RunProgress p = run.progress();
|
||||
const services::RunSummary sum = run.summary();
|
||||
const bool live = run.active();
|
||||
if (live) {
|
||||
char cap[400];
|
||||
if (p.stopping) std::snprintf(cap, sizeof(cap), "%s", TR("merge_run_stopping"));
|
||||
else std::snprintf(cap, sizeof(cap), TR("merge_run_progress_fmt"), p.batchesSubmitted,
|
||||
std::max(1, p.batchesEstimated), p.notesMerged);
|
||||
wrappedCaption(material::OnSurfaceMedium(), cap);
|
||||
if (!p.stopping) {
|
||||
if (const char* key = services::runWaitKey(run.lastWait())) wrappedCaption(material::OnSurfaceMedium(), TR(key));
|
||||
}
|
||||
wrappedCaption(col, line);
|
||||
if (batches > 1 || r.run.remaining >= 2) leftovers = true;
|
||||
}
|
||||
ImGui::ProgressBar(static_cast<float>(p.batchesDone) / static_cast<float>(std::max(1, p.batchesEstimated)),
|
||||
ImVec2(-1, 0));
|
||||
const auto& addrs = run.addresses();
|
||||
const float runListH = std::min(static_cast<float>(addrs.size()), static_cast<float>(maxRows)) * rowH +
|
||||
ImGui::GetStyle().WindowPadding.y * 2.0f;
|
||||
bool leftovers = false;
|
||||
if (ImGui::BeginChild("##ConsolidateRuns", ImVec2(-1, runListH), ImGuiChildFlags_Borders)) {
|
||||
for (const auto& a : addrs) {
|
||||
const std::string label = app->getAddressLabel(a.group.address);
|
||||
const std::string name = label.empty() ? shortAddr(a.group.address) : label;
|
||||
const int nOps = static_cast<int>(a.run.opids.size());
|
||||
char line[400];
|
||||
ImU32 col = material::OnSurfaceMedium();
|
||||
if (nOps == 0 && !a.done) {
|
||||
// Stopping: an address never called will not be sent; one with a call in flight still may be.
|
||||
const bool willRun = live && !(p.stopping && a.outcome == services::AddrOutcome::Pending);
|
||||
std::snprintf(line, sizeof(line), willRun ? TR("merge_run_status_waiting") : TR("merge_run_status_not_run"),
|
||||
name.c_str(), a.group.notes);
|
||||
} else if (a.outcome == services::AddrOutcome::Uncertain) { // a call or batch whose result is unknown
|
||||
std::snprintf(line, sizeof(line), TR("merge_keep_each_status_unconfirmed"), name.c_str());
|
||||
col = material::Warning();
|
||||
} else if (nOps == 0) {
|
||||
std::snprintf(line, sizeof(line), TR("merge_keep_each_status_not_started"), name.c_str(),
|
||||
a.run.error.empty() ? TR("shield_unknown_error") : a.run.error.c_str());
|
||||
col = material::Error();
|
||||
} else if (a.outcome == services::AddrOutcome::Failed) {
|
||||
const std::string why = !a.failure.empty() ? a.failure
|
||||
: !a.run.error.empty() ? a.run.error : std::string(TR("shield_unknown_error"));
|
||||
std::snprintf(line, sizeof(line), TR("merge_keep_each_status_failed"), name.c_str(), why.c_str());
|
||||
col = material::Error();
|
||||
} else if ((a.done || !live) && a.opsDone == nOps) { // an ended run: every batch is terminal
|
||||
std::snprintf(line, sizeof(line), TR("merge_keep_each_status_done"), name.c_str(), a.run.notesMerged, nOps);
|
||||
col = a.done ? material::Success() : material::OnSurfaceMedium(); // stopped part-way: not complete
|
||||
} else { // only while live: batches outstanding or more to submit
|
||||
std::snprintf(line, sizeof(line), TR("merge_keep_each_status_running"), name.c_str(), a.run.notesMerged,
|
||||
a.opsDone, nOps);
|
||||
}
|
||||
wrappedCaption(col, line);
|
||||
if (nOps > 1 || a.run.remaining >= 2) leftovers = true;
|
||||
}
|
||||
}
|
||||
ImGui::EndChild();
|
||||
if (leftovers) {
|
||||
ImGui::Spacing();
|
||||
ImGui::PushStyleColor(ImGuiCol_Text, ImGui::ColorConvertU32ToFloat4(material::OnSurfaceMedium()));
|
||||
ImGui::TextWrapped("%s", TR("merge_keep_each_run_again"));
|
||||
ImGui::PopStyleColor();
|
||||
wrappedCaption(material::OnSurfaceMedium(), TR("merge_keep_each_run_again"));
|
||||
}
|
||||
if (!live) { // below the rows: merge_keep_each_some_failed says "see the list above"
|
||||
std::string endLine;
|
||||
ImU32 endCol = material::OnSurfaceMedium();
|
||||
switch (services::runVerdict(sum)) {
|
||||
case services::RunVerdict::Clean: endLine = TR("shield_completed"); endCol = material::Success(); break;
|
||||
case services::RunVerdict::NothingSubmitted: endLine = TR("merge_keep_each_nothing_submitted"); endCol = material::Error(); break;
|
||||
case services::RunVerdict::SomeFailed: endLine = TR("merge_keep_each_some_failed"); endCol = material::Error(); break;
|
||||
case services::RunVerdict::Unconfirmed: endLine = TR("send_status_unconfirmed"); endCol = material::Warning(); break;
|
||||
case services::RunVerdict::Stopped: endLine = TR("merge_run_stopped_toast"); break;
|
||||
case services::RunVerdict::Halted: {
|
||||
std::string err = sum.firstError.empty() ? std::string(TR("shield_unknown_error")) : sum.firstError;
|
||||
if (err.size() > 160) err = err.substr(0, 160) + "...";
|
||||
char hb[512];
|
||||
std::snprintf(hb, sizeof(hb), TR("merge_run_halted_fmt"), err.c_str());
|
||||
endLine = hb;
|
||||
endCol = material::Error();
|
||||
break;
|
||||
}
|
||||
case services::RunVerdict::Aborted: endLine = TR("merge_run_aborted_toast"); endCol = material::Warning(); break;
|
||||
default: break;
|
||||
}
|
||||
if (!endLine.empty()) {
|
||||
ImGui::Spacing();
|
||||
wrappedCaption(endCol, endLine.c_str());
|
||||
}
|
||||
}
|
||||
ImGui::Spacing();
|
||||
}
|
||||
|
||||
// ── Live progress / status ──────────────────────────────────────────────────────────────────
|
||||
if (!s_status_message.empty()) {
|
||||
if (!s_status_message.empty() && !(isMerge && run.hasRun())) { // the single-op path's message, not the run's
|
||||
if (s_operation_pending && !s_op_terminal)
|
||||
ImGui::TextColored(ImVec4(1.0f, 0.8f, 0.0f, 1.0f), "%s", s_status_message.c_str());
|
||||
else
|
||||
@@ -808,11 +758,34 @@ void ShieldDialog::render(App* app)
|
||||
const bool syncing = state.sync.syncing;
|
||||
const bool haveDest = s_to_address[0] != '\0';
|
||||
|
||||
if (isMerge && run.hasRun()) {
|
||||
// The run's footer: Stop + Close while it runs (it keeps going in the background), Done once it ended.
|
||||
ImFont* bf = S.resolveFont(cancelBtn.font);
|
||||
if (run.active()) {
|
||||
material::BeginOverlayDialogFooter((shieldBtn.width + cancelBtn.width) * dp + ImGui::GetStyle().ItemSpacing.x,
|
||||
/*drawSeparator=*/false);
|
||||
ImGui::BeginDisabled(run.stopping());
|
||||
if (material::TactileButton(TR("merge_run_stop"), ImVec2(shieldBtn.width * dp, 0), S.resolveFont(shieldBtn.font)))
|
||||
app->stopConsolidation();
|
||||
ImGui::EndDisabled();
|
||||
ImGui::SameLine();
|
||||
if (material::TactileButton(TR("close"), ImVec2(cancelBtn.width * dp, 0), bf)) s_open = false;
|
||||
} else {
|
||||
material::BeginOverlayDialogFooter(cancelBtn.width * dp, /*drawSeparator=*/false);
|
||||
if (material::TactileButton(TR("done"), ImVec2(cancelBtn.width * dp, 0), bf)) {
|
||||
app->dismissConsolidation();
|
||||
s_open = false;
|
||||
}
|
||||
}
|
||||
material::EndOverlayDialog();
|
||||
return;
|
||||
}
|
||||
|
||||
if (opInFlight) {
|
||||
// After submit: just a Close button (progress shows above; op continues in the background).
|
||||
material::BeginOverlayDialogFooter(cancelBtn.width, /*drawSeparator=*/false);
|
||||
material::BeginOverlayDialogFooter(cancelBtn.width * dp, /*drawSeparator=*/false);
|
||||
if (material::TactileButton(s_op_terminal ? TR("done") : TR("close"),
|
||||
ImVec2(cancelBtn.width, 0), S.resolveFont(cancelBtn.font)))
|
||||
ImVec2(cancelBtn.width * dp, 0), S.resolveFont(cancelBtn.font)))
|
||||
s_open = false;
|
||||
material::EndOverlayDialog();
|
||||
return;
|
||||
@@ -848,11 +821,11 @@ void ShieldDialog::render(App* app)
|
||||
if (isMerge && scopeLoaded && srcCount() == 0) can_submit = false;
|
||||
if (perAddress) can_submit = connected && !syncing && scopeLoaded && !selectedGroups().empty();
|
||||
|
||||
float footerBtnW = shieldBtn.width + cancelBtn.width + ImGui::GetStyle().ItemSpacing.x;
|
||||
float footerBtnW = (shieldBtn.width + cancelBtn.width) * dp + ImGui::GetStyle().ItemSpacing.x;
|
||||
material::BeginOverlayDialogFooter(footerBtnW, /*drawSeparator=*/false);
|
||||
|
||||
if (!can_submit) ImGui::BeginDisabled();
|
||||
if (material::TactileButton(primaryLabel, ImVec2(shieldBtn.width, 0), S.resolveFont(shieldBtn.font))) {
|
||||
if (material::TactileButton(primaryLabel, ImVec2(shieldBtn.width * dp, 0), S.resolveFont(shieldBtn.font))) {
|
||||
if (s_confirm) { if (perAddress) submitPerAddress(app); else submitOperation(app); }
|
||||
else { s_confirm = true; } // first click → show the confirm summary
|
||||
}
|
||||
@@ -860,13 +833,25 @@ void ShieldDialog::render(App* app)
|
||||
if (!can_submit && ImGui::IsItemHovered(ImGuiHoveredFlags_AllowWhenDisabled)) {
|
||||
if (!connected) material::Tooltip("%s", TR("send_tooltip_not_connected"));
|
||||
else if (syncing) material::Tooltip("%s", TR("send_tooltip_syncing"));
|
||||
else if (perAddress && s_scan.phase != services::ScopePhase::Loaded) {
|
||||
// Why the list isn't there yet, not "select addresses".
|
||||
char why[320];
|
||||
if (s_scan.phase == services::ScopePhase::Failed) {
|
||||
const std::string reason = s_scan.error.size() > 160 ? s_scan.error.substr(0, 160) + "..." : s_scan.error;
|
||||
std::snprintf(why, sizeof(why), TR("merge_scope_failed_fmt"), reason.c_str());
|
||||
} else {
|
||||
std::snprintf(why, sizeof(why), "%s", s_scan.phase == services::ScopePhase::Loading ? TR("merge_scope_loading")
|
||||
: state.isLocked() ? TR("merge_wait_locked") : TR("merge_waiting_node"));
|
||||
}
|
||||
material::Tooltip("%s", why);
|
||||
}
|
||||
else if (perAddress) material::Tooltip("%s", TR("merge_keep_each_select"));
|
||||
else if (!haveDest) material::Tooltip("%s", TR("shield_select_z"));
|
||||
else if (isMerge && srcCount() == 0) material::Tooltip("%s", TR("merge_no_spendable"));
|
||||
}
|
||||
|
||||
ImGui::SameLine();
|
||||
if (material::TactileButton(secondaryLabel, ImVec2(cancelBtn.width, 0), S.resolveFont(cancelBtn.font))) {
|
||||
if (material::TactileButton(secondaryLabel, ImVec2(cancelBtn.width * dp, 0), S.resolveFont(cancelBtn.font))) {
|
||||
if (s_confirm) s_confirm = false; // Back → return to the form
|
||||
else s_open = false; // Cancel → close
|
||||
}
|
||||
|
||||
@@ -2543,6 +2543,22 @@ void I18n::loadBuiltinEnglish()
|
||||
strings_["merge_keep_each_status_running"] = "%s — merging %d notes (%d of %d batches done)…";
|
||||
strings_["merge_keep_each_status_unconfirmed"] = "%s \xE2\x80\x94 status could not be confirmed";
|
||||
strings_["merge_keep_each_run_again"] = "Addresses merged in several batches now hold one note per batch. Run this again after those confirm to merge them into one.";
|
||||
strings_["merge_run_progress_fmt"] = "Consolidating\xE2\x80\xA6 batch %d of about %d, %d notes merged so far. You can close this window; it keeps running in the background.";
|
||||
strings_["merge_run_stop"] = "Stop";
|
||||
strings_["merge_run_stopping"] = "Stopping after the batches already sent\xE2\x80\xA6";
|
||||
strings_["merge_run_status_waiting"] = "%s \xE2\x80\x94 waiting (%d notes)";
|
||||
strings_["merge_run_status_not_run"] = "%s \xE2\x80\x94 not consolidated (%d notes)";
|
||||
strings_["merge_run_wait_sync"] = "Paused: the node is still syncing.";
|
||||
strings_["merge_run_wait_send"] = "Paused until your pending transaction goes through.";
|
||||
strings_["merge_run_wait_batch"] = "Spacing the batches so the node stays responsive\xE2\x80\xA6";
|
||||
strings_["merge_run_err_timeout"] = "The node did not answer in time; the batch may still be queued.";
|
||||
strings_["merge_run_stopped_toast"] = "Consolidation stopped. Run it again to merge the rest.";
|
||||
strings_["merge_run_aborted_toast"] = "The node connection was reset, so consolidation stopped. Run it again to continue.";
|
||||
strings_["merge_run_start_failed"] = "Couldn't start the consolidation \xE2\x80\x94 the node isn't connected.";
|
||||
strings_["merge_run_chip_fmt"] = "Consolidating %d/%d";
|
||||
strings_["merge_run_halted_fmt"] = "Consolidation stopped after repeated failures: %s";
|
||||
strings_["merge_run_busy"] = "A consolidation is running. Wait for it to finish, or stop it, first.";
|
||||
strings_["merge_run_migration_pending"] = "Finish or cancel the seed migration before consolidating.";
|
||||
strings_["done"] = "Done";
|
||||
|
||||
// --- Transaction Details Dialog ---
|
||||
|
||||
Reference in New Issue
Block a user