perf(net): one balance scan per poll, filtered opstatus, a guarded merge scope
The Core poll ran z_gettotalbalance twice (minconf 0 and 1), each a whole- wallet scan under dragonxd's cs_main. The spendable (minconf=1) call now runs only after the display call succeeded, and only when something is unconfirmed or pending or the exact total moved: about 95% of polls make one scan instead of two. A forced post-send balance can no longer be lost to a refused enqueue, and pending-send debits aren't re-applied each tick. z_getoperationstatus is filtered to our opids with its real argument form ([[id,...]]; the old ["id"] was rejected), falling back to the unfiltered call if a daemon refuses the filter. The Shield/Merge dialog's own polls are filtered and session-guarded too, and finally reach Done; the dead RPCClient wrappers are deleted. The Merge/Consolidate scope scan becomes a tested state machine: one scan at a time, failures latch behind a Retry button, only "node not ready" retries by itself (10/30/60 s, at most 3), Lite never scans. Its 120 s calls are maintenance calls: every main-thread disconnect aborts one in flight first, so a lifecycle action no longer freezes the UI behind it. An aborted merge that was already sent says it may still go through. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -16,7 +16,9 @@
|
||||
#include "imgui.h"
|
||||
|
||||
#include <algorithm>
|
||||
#include <cstdint>
|
||||
#include <set>
|
||||
#include <stdexcept>
|
||||
#include <vector>
|
||||
#include <string>
|
||||
#include <cstdio>
|
||||
@@ -41,9 +43,8 @@ static bool s_op_terminal = false; // async op reached success/fa
|
||||
static std::string s_operation_id;
|
||||
static std::string s_status_message;
|
||||
static double s_last_poll = 0.0; // live-progress self-poll timer (ImGui::GetTime seconds)
|
||||
// Scope of what can be consolidated (fetched once on open, merge mode only).
|
||||
static bool s_scope_loading = false;
|
||||
static bool s_scope_loaded = false;
|
||||
// Scope of what can be consolidated (merge mode only). services::ScopeScan decides when to scan.
|
||||
static services::ScopeScan s_scan;
|
||||
static int s_t_count = 0, s_z_count = 0;
|
||||
static double s_t_amount = 0.0, s_z_amount = 0.0;
|
||||
static bool s_creating_addr = false; // z_getnewaddress in flight (empty state)
|
||||
@@ -52,16 +53,22 @@ static bool s_creating_addr = false; // z_getnewaddress in flight (
|
||||
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
|
||||
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 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;
|
||||
static unsigned s_poll_session = 0;
|
||||
|
||||
static void resetTransient()
|
||||
{
|
||||
++s_poll_session;
|
||||
s_operation_pending = false;
|
||||
s_op_terminal = false;
|
||||
s_confirm = false;
|
||||
@@ -73,33 +80,47 @@ static void resetTransient()
|
||||
}
|
||||
|
||||
// Count + sum spendable transparent UTXOs and shielded notes so the user can see the scope of a
|
||||
// consolidation (and how many batches it may take). Read-only; runs off the UI thread.
|
||||
// consolidation (and how many batches it may take). Read-only; runs off the UI thread. Only when
|
||||
// services::scopeScanWant() says so: both calls are whole-wallet scans under the daemon's cs_main.
|
||||
static void loadScope(App* app)
|
||||
{
|
||||
if (!app || !app->worker()) return;
|
||||
s_scope_loading = true; s_scope_loaded = false;
|
||||
app->worker()->post([app, rpc = app->rpc()]() -> rpc::RPCWorker::MainCb {
|
||||
const std::uint32_t gen = services::scopeScanBegin(s_scan, services::steadySeconds());
|
||||
app->worker()->post([app, rpc = app->rpc(), gen]() -> 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;
|
||||
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
|
||||
// maintenance calls so a disconnect aborts them rather than waiting.
|
||||
App::MaintenanceCallScope maint(*app);
|
||||
rpc::RPCClient::TraceScope trace("Shield dialog / Scope count");
|
||||
nlohmann::json us = rpc->call("listunspent", nlohmann::json::array({0}));
|
||||
nlohmann::json us = rpc->call("listunspent", nlohmann::json::array({0}), services::kScopeCallTimeoutSec);
|
||||
if (us.is_array()) for (const auto& u : us) {
|
||||
if (u.value("confirmations", 0) >= 1 && u.value("spendable", true)) { ++tC; tA += u.value("amount", 0.0); }
|
||||
}
|
||||
nlohmann::json zs = rpc->call("z_listunspent", nlohmann::json::array({0}));
|
||||
nlohmann::json zs = rpc->call("z_listunspent", nlohmann::json::array({0}), services::kScopeCallTimeoutSec);
|
||||
if (zs.is_array()) for (const auto& z : zs) {
|
||||
if (z.value("confirmations", 0) >= 1) { ++zC; zA += z.value("amount", 0.0); }
|
||||
if (z.value("confirmations", 0) >= 1 && !services::noteIsLocked(z)) { ++zC; zA += z.value("amount", 0.0); }
|
||||
}
|
||||
groups = services::groupSpendableNotes(zs);
|
||||
} catch (const std::exception& e) { error = e.what(); }
|
||||
return [tC, zC, tA, zA, error, groups]() {
|
||||
s_scope_loading = false; s_scope_loaded = error.empty();
|
||||
} catch (const std::exception& e) {
|
||||
error = e.what();
|
||||
kind = rpc::rpcFailureKindOf(e);
|
||||
if (error.empty()) error = "error";
|
||||
} catch (...) {
|
||||
error = "unknown error";
|
||||
kind = rpc::RpcFailureKind::Other;
|
||||
}
|
||||
return [tC, zC, tA, zA, error, kind, gen, groups]() {
|
||||
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;
|
||||
s_selected.clear(); // every address worth it, ticked by default
|
||||
s_selected.clear(); // every address worth it, ticked by default, except ones the user unticked
|
||||
for (const auto& g : s_groups)
|
||||
if (services::worthConsolidating(g, s_fee)) s_selected.insert(g.address);
|
||||
if (services::worthConsolidating(g, s_fee) && !s_unticked.count(g.address))
|
||||
s_selected.insert(g.address);
|
||||
// Clamp the source to what actually has inputs (unless the user is mid-op).
|
||||
const bool tOk = tC > 0, zOk = zC > 0;
|
||||
if (!s_operation_pending) {
|
||||
@@ -128,12 +149,13 @@ void ShieldDialog::show(Mode mode)
|
||||
s_utxo_limit = (int)schema::UI().drawElement("business", "utxo-limit").size;
|
||||
if (s_utxo_limit < 1) s_utxo_limit = 50;
|
||||
s_advanced = false;
|
||||
s_scope_loaded = false; s_scope_loading = false;
|
||||
services::scopeScanOpen(s_scan, services::steadySeconds());
|
||||
s_t_count = s_z_count = 0; s_t_amount = s_z_amount = 0.0;
|
||||
s_last_poll = 0.0;
|
||||
s_per_address = false;
|
||||
s_groups.clear();
|
||||
s_selected.clear();
|
||||
s_unticked.clear();
|
||||
}
|
||||
|
||||
void ShieldDialog::showShieldCoinbase(const std::string& fromAddress)
|
||||
@@ -163,6 +185,15 @@ void ShieldDialog::hide()
|
||||
resetTransient();
|
||||
}
|
||||
|
||||
void ShieldDialog::invalidateScope()
|
||||
{
|
||||
s_confirm = false; // what the user was confirming was read on the old connection
|
||||
services::scopeScanInvalidate(s_scan);
|
||||
s_t_count = s_z_count = 0; s_t_amount = s_z_amount = 0.0;
|
||||
s_groups.clear();
|
||||
s_selected.clear();
|
||||
}
|
||||
|
||||
// Relevant count/amount for the currently-selected merge source.
|
||||
static int srcCount() { return s_src == 0 ? s_t_count : s_src == 1 ? s_z_count : (s_t_count + s_z_count); }
|
||||
static double srcAmount() { return s_src == 0 ? s_t_amount : s_src == 1 ? s_z_amount : (s_t_amount + s_z_amount); }
|
||||
@@ -241,17 +272,34 @@ static void submitOperation(App* app)
|
||||
nlohmann::json addrs = nlohmann::json::array();
|
||||
for (const auto& a : fromAddrs) addrs.push_back(a);
|
||||
nlohmann::json result; std::string error;
|
||||
bool uncertain = false;
|
||||
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
|
||||
// outlast 30 s, which reported a failure for a merge that had started. 120 s, as a maintenance
|
||||
// call so a disconnect aborts it instead of waiting.
|
||||
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});
|
||||
} catch (const std::exception& e) { error = e.what(); }
|
||||
return [app, result, error]() {
|
||||
result = rpc->call("z_mergetoaddress", {addrs, to, fee, limit, limit}, services::kScopeCallTimeoutSec);
|
||||
} catch (const std::exception& e) {
|
||||
error = e.what();
|
||||
// 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;
|
||||
}
|
||||
return [app, result, error, uncertain]() {
|
||||
if (error.empty()) {
|
||||
s_operation_id = result.value("opid", "");
|
||||
s_status_message = TR("merge_progress");
|
||||
Notifications::instance().success(TR("merge_started"));
|
||||
app->trackOperation(s_operation_id);
|
||||
} else if (uncertain) {
|
||||
s_operation_pending = false; s_op_terminal = true;
|
||||
s_confirm = false; // don't leave Confirm armed for a merge that may still be running
|
||||
s_status_message = TR("merge_submit_uncertain");
|
||||
Notifications::instance().warning(TR("merge_submit_uncertain"));
|
||||
} else {
|
||||
s_operation_pending = false; s_op_terminal = true;
|
||||
s_status_message = std::string(TR("shield_error_prefix")) + error;
|
||||
@@ -325,86 +373,142 @@ static void submitPerAddress(App* app)
|
||||
});
|
||||
}
|
||||
|
||||
// Progress of a per-address run: poll every ~2s until each batch's operation is done or failed. Unfiltered,
|
||||
// like the shared poller (some daemons reject the per-opid form); only our operations are read.
|
||||
// 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 || !app->worker()) return;
|
||||
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;
|
||||
app->worker()->post([rpc = app->rpc()]() -> rpc::RPCWorker::MainCb {
|
||||
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", nlohmann::json::array());
|
||||
result = rpc->call("z_getoperationstatus", params);
|
||||
} catch (const std::exception& e) { error = e.what(); }
|
||||
return [result, error]() {
|
||||
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") {
|
||||
else if (status == "failed" || status == "cancelled" || status == "canceled") {
|
||||
r.state[i] = 2;
|
||||
if (r.failure.empty() && op.contains("error") && op["error"].is_object())
|
||||
r.failure = op["error"].value("message", std::string());
|
||||
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;
|
||||
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; }
|
||||
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("shield_completed");
|
||||
Notifications::instance().success(TR("shield_merge_done"));
|
||||
} else {
|
||||
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"));
|
||||
}
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
// Live-progress self-poll: while an op is in flight, poll z_getoperationstatus every ~2s so the modal
|
||||
// shows "Consolidating… → Done/Failed" without a manual button. (The shared poller also tracks it for
|
||||
// balance refresh; this drives only the inline display.)
|
||||
// shows "Consolidating… → Done/Failed" without a manual button. (The shared poller also tracks it: it
|
||||
// refreshes balances and raises the failure toast; this drives the inline display and the success toast.)
|
||||
static void pollOperation(App* app)
|
||||
{
|
||||
if (s_operation_id.empty() || s_op_terminal || !app->worker()) return;
|
||||
if (s_operation_id.empty() || s_op_terminal || s_poll_in_flight || !app->worker()) return;
|
||||
const double now = ImGui::GetTime();
|
||||
if (now - s_last_poll < 2.0) return;
|
||||
const std::string opid = s_operation_id;
|
||||
// [[opid]]: the argument is an ARRAY of ids. The old {json::array({opid})} collapsed to ["opid"]
|
||||
// (C++17 brace init of a json from one json copies it), which the daemon rejects, so this poll failed
|
||||
// every time and the dialog never saw its op finish.
|
||||
const auto filter = services::NetworkRefreshService::buildOperationFilterParams({opid});
|
||||
if (!filter) return;
|
||||
s_last_poll = now;
|
||||
std::string opid = s_operation_id;
|
||||
app->worker()->post([rpc = app->rpc(), opid]() -> rpc::RPCWorker::MainCb {
|
||||
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), opid, 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 / Op status");
|
||||
result = rpc->call("z_getoperationstatus", {nlohmann::json::array({opid})});
|
||||
result = rpc->call("z_getoperationstatus", params);
|
||||
} catch (const std::exception& e) { error = e.what(); }
|
||||
return [result, error]() {
|
||||
if (!error.empty() || !result.is_array() || result.empty()) return; // transient — retry next tick
|
||||
const auto& op = result[0];
|
||||
const std::string status = op.value("status", "");
|
||||
return [result, error, opid, session]() {
|
||||
s_poll_in_flight = false;
|
||||
if (session != s_poll_session || opid != s_operation_id) return; // the dialog moved on
|
||||
if (!error.empty() || !result.is_array()) return; // transient — retry next tick
|
||||
const nlohmann::json* found = nullptr;
|
||||
for (const auto& o : result) {
|
||||
if (o.is_object() && o.contains("id") && o["id"].is_string() && o["id"].get<std::string>() == opid) {
|
||||
found = &o;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (!found) {
|
||||
// A valid reply without our op: the daemon no longer holds it (it restarted, or another
|
||||
// client popped it with z_getoperationresult). It registers an op before returning its id,
|
||||
// so this is no race. The outcome can't be confirmed; stop polling.
|
||||
s_operation_pending = false; s_op_terminal = true;
|
||||
s_status_message = TR("send_status_unconfirmed");
|
||||
Notifications::instance().warning(TR("send_status_unconfirmed"));
|
||||
return;
|
||||
}
|
||||
const nlohmann::json& op = *found;
|
||||
const std::string status = (op.contains("status") && op["status"].is_string())
|
||||
? op["status"].get<std::string>() : std::string();
|
||||
if (status == "success") {
|
||||
s_operation_pending = false; s_op_terminal = true;
|
||||
s_status_message = TR("shield_completed");
|
||||
Notifications::instance().success(TR("shield_merge_done"));
|
||||
} else if (status == "failed") {
|
||||
std::string msg = op.value("error", nlohmann::json{}).value("message", std::string(TR("shield_unknown_error")));
|
||||
} else if (status == "failed" || status == "cancelled" || status == "canceled") {
|
||||
std::string msg = TR("shield_unknown_error");
|
||||
if (op.contains("error") && op["error"].is_object() && op["error"].contains("message") &&
|
||||
op["error"]["message"].is_string())
|
||||
msg = op["error"]["message"].get<std::string>();
|
||||
s_operation_pending = false; s_op_terminal = true;
|
||||
s_status_message = std::string(TR("shield_op_failed")) + msg;
|
||||
Notifications::instance().error(std::string(TR("shield_op_failed")) + msg);
|
||||
// No toast: the op is tracked (App::trackOperation) and the shared opid poller raises the
|
||||
// one failure toast, whether this dialog is open or not.
|
||||
}
|
||||
// queued / executing → leave the "Consolidating…" message and keep polling.
|
||||
};
|
||||
@@ -426,6 +530,9 @@ void ShieldDialog::render(App* app)
|
||||
auto cancelBtn = S.button("dialogs.shield", "cancel-button");
|
||||
const float dp = Layout::dpiScale();
|
||||
const bool isMerge = (s_mode == Mode::MergeToAddress);
|
||||
// Merging needs the full node's z_mergetoaddress. Lite has no node: its scope scan used to fail and be
|
||||
// re-posted every frame for as long as the dialog stayed open. (Shield coinbase keeps its own gating.)
|
||||
if (isMerge && !app->supportsFullNodeLifecycleActions()) { s_open = false; return; }
|
||||
|
||||
const char* title = s_consolidate ? TR("consolidate_title")
|
||||
: isMerge ? TR("merge_title")
|
||||
@@ -441,7 +548,14 @@ void ShieldDialog::render(App* app)
|
||||
autoSelectDestination(state);
|
||||
pollOperation(app);
|
||||
pollPerAddress(app);
|
||||
if (isMerge && !s_scope_loaded && !s_scope_loading && s_operation_id.empty()) loadScope(app);
|
||||
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.now = services::steadySeconds();
|
||||
if (services::scopeScanWant(s_scan, in) == services::ScopeWant::Post) loadScope(app);
|
||||
}
|
||||
|
||||
// ── Description ──────────────────────────────────────────────────────────────────────────────
|
||||
ImGui::TextWrapped("%s", s_consolidate ? TR("consolidate_desc")
|
||||
@@ -455,14 +569,24 @@ void ShieldDialog::render(App* app)
|
||||
|
||||
// ── Merge: scope + source selector ──────────────────────────────────────────────────────────
|
||||
if (isMerge && !opInFlight) {
|
||||
if (s_scope_loading) {
|
||||
if (s_scan.phase == services::ScopePhase::Loading) {
|
||||
material::Type().textColored(material::TypeStyle::Caption, material::OnSurfaceMedium(),
|
||||
TR("merge_scope_loading"));
|
||||
} else if (s_scope_loaded) {
|
||||
} else if (s_scan.phase == services::ScopePhase::Loaded) {
|
||||
char buf[160];
|
||||
std::snprintf(buf, sizeof(buf), TR("merge_scope_fmt"),
|
||||
s_t_count, s_z_count, fmtAmt(s_t_amount + s_z_amount).c_str());
|
||||
material::Type().textColored(material::TypeStyle::Caption, material::OnSurfaceMedium(), buf);
|
||||
} else 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;
|
||||
char buf[320];
|
||||
std::snprintf(buf, sizeof(buf), TR("merge_scope_failed_fmt"), reason.c_str());
|
||||
wrappedCaption(material::Warning(), buf);
|
||||
if (material::TactileButton(TR("retry"), ImVec2(0, 0), S.resolveFont(cancelBtn.font)))
|
||||
services::scopeScanRetry(s_scan);
|
||||
} else {
|
||||
material::Type().textColored(material::TypeStyle::Caption, material::OnSurfaceMedium(),
|
||||
state.isLocked() ? TR("merge_wait_locked") : TR("merge_waiting_node"));
|
||||
}
|
||||
ImGui::Spacing();
|
||||
|
||||
@@ -493,7 +617,7 @@ void ShieldDialog::render(App* app)
|
||||
ImGui::Spacing();
|
||||
int listed = 0;
|
||||
for (const auto& g : s_groups) if (g.notes >= 2) ++listed;
|
||||
if (s_scope_loaded && listed == 0) {
|
||||
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();
|
||||
@@ -511,8 +635,8 @@ void ShieldDialog::render(App* app)
|
||||
ImGui::PushID(g.address.c_str());
|
||||
ImGui::BeginDisabled(!worth);
|
||||
if (ImGui::Checkbox(row, &ticked)) {
|
||||
if (ticked) s_selected.insert(g.address);
|
||||
else s_selected.erase(g.address);
|
||||
if (ticked) { s_selected.insert(g.address); s_unticked.erase(g.address); }
|
||||
else { s_selected.erase(g.address); s_unticked.insert(g.address); }
|
||||
}
|
||||
ImGui::EndDisabled();
|
||||
if (ImGui::IsItemHovered(ImGuiHoveredFlags_AllowWhenDisabled))
|
||||
@@ -618,7 +742,7 @@ void ShieldDialog::render(App* app)
|
||||
ImGui::Spacing();
|
||||
wrappedCaption(material::Warning(), hb);
|
||||
}
|
||||
} else if (isMerge && s_scope_loaded && srcCount() > s_utxo_limit) {
|
||||
} else if (isMerge && s_scan.phase == services::ScopePhase::Loaded && srcCount() > s_utxo_limit) {
|
||||
char hb[160];
|
||||
std::snprintf(hb, sizeof(hb), TR("merge_batch_fmt"), s_utxo_limit);
|
||||
ImGui::Spacing();
|
||||
@@ -634,8 +758,8 @@ void ShieldDialog::render(App* app)
|
||||
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;
|
||||
for (int st : r.state) { if (st == 1) ++done; if (st == 2) ++failed; }
|
||||
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();
|
||||
@@ -647,6 +771,9 @@ void ShieldDialog::render(App* app)
|
||||
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);
|
||||
@@ -717,8 +844,9 @@ void ShieldDialog::render(App* app)
|
||||
}
|
||||
|
||||
bool can_submit = haveDest && connected && !syncing;
|
||||
if (isMerge && s_scope_loaded && srcCount() == 0) can_submit = false;
|
||||
if (perAddress) can_submit = connected && !syncing && s_scope_loaded && !selectedGroups().empty();
|
||||
const bool scopeLoaded = s_scan.phase == services::ScopePhase::Loaded;
|
||||
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;
|
||||
material::BeginOverlayDialogFooter(footerBtnW, /*drawSeparator=*/false);
|
||||
|
||||
@@ -52,6 +52,12 @@ public:
|
||||
* @brief Close the dialog (used by the UI sweep teardown)
|
||||
*/
|
||||
static void hide();
|
||||
|
||||
/**
|
||||
* @brief Forget the scope (inputs) read for the Merge/Consolidate view: the node connection changed,
|
||||
* so it may be a different wallet. The next render reads it again once the node is ready.
|
||||
*/
|
||||
static void invalidateScope();
|
||||
};
|
||||
|
||||
} // namespace ui
|
||||
|
||||
Reference in New Issue
Block a user