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:
2026-10-09 11:06:34 -05:00
co-authored by Claude Opus 5.5
parent 4eed4283d6
commit 8b9a498eee
23 changed files with 1044 additions and 128 deletions
+186 -58
View File
@@ -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);