// DragonX Wallet - ImGui Edition // Copyright 2024-2026 The Hush Developers // Released under the GPLv3 #include "shield_dialog.h" #include "../../app.h" #include "../../config/version.h" #include "../../data/wallet_state.h" #include "../../rpc/rpc_client.h" #include "../../rpc/rpc_worker.h" #include "../../services/note_consolidation.h" #include "../../util/i18n.h" #include "../notifications.h" #include "../schema/ui_schema.h" #include "../material/draw_helpers.h" #include "imgui.h" #include #include #include #include #include #include #include #include namespace dragonx { namespace ui { // ── Static dialog state ───────────────────────────────────────────────────────────────────────── static bool s_open = false; static ShieldDialog::Mode s_mode = ShieldDialog::Mode::ShieldCoinbase; static bool s_consolidate = false; // opened from the wallet-bloat nudge (shielded preset + framing) static int s_src = 2; // merge source: 0 = transparent, 1 = shielded, 2 = both static char s_from_address[512] = "*"; static char s_to_address[512] = ""; static int s_selected_zaddr_idx = -1; static double s_fee = DRAGONX_DEFAULT_FEE; static int s_utxo_limit = 50; // overridden by schema at runtime static bool s_advanced = false; // Advanced (fee + batch size) disclosure static bool s_confirm = false; // inline "confirm before moving funds" phase static bool s_operation_pending = false; static bool s_op_terminal = false; // async op reached success/failed — freeze inputs enum class OpOutcome { None, Success, Failed, Uncertain }; static OpOutcome s_op_outcome = OpOutcome::None; // tags the terminal status for colour (display only) 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 (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) // "Keep each address's funds in that address": every ticked address's notes merge back into it, so // separately labelled funds stay apart. Shielded only (a transparent address can't take change). static bool s_per_address = false; static std::vector s_groups; // spendable shielded notes by address (scope load) static std::set s_selected; // addresses ticked for per-address consolidation static std::set s_unticked; // unticked by the user this opening: kept across a re-scan // 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; static unsigned s_poll_session = 0; static void resetTransient() { ++s_poll_session; s_operation_pending = false; s_op_terminal = false; s_op_outcome = OpOutcome::None; s_confirm = false; s_status_message.clear(); s_operation_id.clear(); s_creating_addr = false; } // 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. 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; const std::uint32_t gen = services::scopeScanBegin(s_scan, services::steadySeconds()); // 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 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 // 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}), 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}), services::kScopeCallTimeoutSec); if (zs.is_array()) for (const auto& z : zs) { 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(); kind = rpc::rpcFailureKindOf(e); if (error.empty()) error = "error"; } catch (...) { error = "unknown error"; kind = rpc::RpcFailureKind::Other; } const double costMs = std::chrono::duration(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; 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_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) { if (s_consolidate && zOk) s_src = 1; // bloat nudge → shielded else if (s_src == 0 && !tOk) s_src = zOk ? 1 : 2; else if (s_src == 1 && !zOk) s_src = tOk ? 0 : 2; else if (!tOk && zOk) s_src = 1; else if (tOk && !zOk) s_src = 0; } }; }); } void ShieldDialog::show(Mode mode) { s_mode = mode; s_open = true; 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'; s_selected_zaddr_idx = -1; s_fee = DRAGONX_DEFAULT_FEE; s_utxo_limit = (int)schema::UI().drawElement("business", "utxo-limit").size; if (s_utxo_limit < 1) s_utxo_limit = 50; s_advanced = 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) { show(Mode::ShieldCoinbase); strncpy(s_from_address, fromAddress.c_str(), sizeof(s_from_address) - 1); } void ShieldDialog::showMerge() { show(Mode::MergeToAddress); s_consolidate = false; s_src = 2; // generic merge: both sources } void ShieldDialog::showConsolidate() { show(Mode::MergeToAddress); s_consolidate = true; s_src = 1; // wallet-bloat consolidation targets shielded notes (witness bloat) s_per_address = true; // shed notes without moving funds between (labelled) addresses } void ShieldDialog::hide() { s_open = false; 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); } static std::string fmtAmt(double v) { char b[48]; std::snprintf(b, sizeof(b), "%.4f", v); return b; } // Shield-coinbase source helpers. "*" = all spendable mining (transparent) addresses; else one address. // Balances are DISPLAY totals (may include immature/non-coinbase), so the UI wording says "up to". static double miningBalance(const WalletState& st, const char* from) { if (std::strcmp(from, "*") == 0) { double sum = 0.0; for (const auto& a : st.t_addresses) if (a.isSpendable()) sum += a.balance; return sum; } const int i = addressIndexByString(st.t_addresses, from); return i >= 0 ? st.t_addresses[i].balance : 0.0; } static int miningAddrCount(const WalletState& st, const char* from) { if (std::strcmp(from, "*") == 0) { int n = 0; for (const auto& a : st.t_addresses) if (a.isSpendable() && a.balance > 0.0) ++n; return n; } const int i = addressIndexByString(st.t_addresses, from); return (i >= 0 && st.t_addresses[i].balance > 0.0) ? 1 : 0; } // A caption-style line that wraps at the dialog edge (Type().textColored doesn't wrap). static void wrappedCaption(ImU32 color, const char* text) { ImGui::PushStyleColor(ImGuiCol_Text, ImGui::ColorConvertU32ToFloat4(color)); material::Type().textWrapped(material::TypeStyle::Caption, text); ImGui::PopStyleColor(); } static std::string shortAddr(const std::string& a) { if (a.size() <= 20) return a; return a.substr(0, 10) + "…" + a.substr(a.size() - 8); } // Auto-pick the best spendable z-address as the default destination (fewest hops for the user). static void autoSelectDestination(const WalletState& state) { if (s_to_address[0] != '\0' || state.z_addresses.empty()) return; int idx = bestSpendableAddressIndex(state.z_addresses); if (idx < 0) idx = 0; s_selected_zaddr_idx = idx; strncpy(s_to_address, state.z_addresses[idx].address.c_str(), sizeof(s_to_address) - 1); } // Fire the actual shield/merge op. Registers the opid with the shared poller (for balance refresh) // 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_op_outcome = OpOutcome::None; s_status_message = TR("shield_submitting"); s_last_poll = ImGui::GetTime(); if (s_mode == ShieldDialog::Mode::ShieldCoinbase) { std::string from(s_from_address), to(s_to_address); double fee = s_fee; int limit = s_utxo_limit; if (!app->worker()) return; app->worker()->post([app, rpc = app->rpc(), from, to, fee, limit]() -> rpc::RPCWorker::MainCb { nlohmann::json result; std::string error; try { rpc::RPCClient::TraceScope trace("Shield dialog / Shield coinbase"); result = rpc->call("z_shieldcoinbase", {from, to, fee, limit}); } catch (const std::exception& e) { error = e.what(); } return [app, result, error]() { if (error.empty()) { s_operation_id = result.value("opid", ""); s_status_message = TR("merge_progress"); Notifications::instance().success(TR("shield_started")); app->trackOperation(s_operation_id); } else { s_operation_pending = false; s_op_terminal = true; s_op_outcome = OpOutcome::Failed; s_status_message = std::string(TR("shield_error_prefix")) + util::friendlyRpcError(error); Notifications::instance().error(std::string(TR("shield_send_failed")) + util::friendlyRpcError(error)); } }; }); return; } // Merge / consolidate. Source → z_mergetoaddress fromaddress selector (this is the fix: shielded // notes, not just transparent UTXOs — the wallet-bloat the nudge warns about is shielded witnesses). std::vector fromAddrs; if (s_src == 0) fromAddrs = { "ANY_TADDR" }; else if (s_src == 1) fromAddrs = { "ANY_SAPLING" }; else fromAddrs = { "*" }; std::string to(s_to_address); double fee = s_fee; int limit = s_utxo_limit; // 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 // 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}, 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(&e)) uncertain = t->requestSent && t->httpStatus == 0; } catch (...) { error = "unknown error"; kind = rpc::RpcFailureKind::Other; } const double ms = std::chrono::duration(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"); Notifications::instance().success(TR("merge_started")); app->trackOperation(s_operation_id); } else if (uncertain) { s_operation_pending = false; s_op_terminal = true; s_op_outcome = OpOutcome::Uncertain; 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_op_outcome = OpOutcome::Failed; s_status_message = std::string(TR("shield_error_prefix")) + util::friendlyRpcError(error); Notifications::instance().error(std::string(TR("merge_send_failed")) + util::friendlyRpcError(error)); } }; }); } // The ticked addresses that are worth consolidating, in list order. static std::vector selectedGroups() { std::vector out; for (const auto& g : s_groups) if (s_selected.count(g.address) && services::worthConsolidating(g, s_fee)) out.push_back(g); return out; } // 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()) 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; s_op_outcome = OpOutcome::None; } } // 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: 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 || 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; 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", params); } catch (const std::exception& e) { error = e.what(); } 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() == 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_op_outcome = OpOutcome::Uncertain; 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(); if (status == "success") { s_operation_pending = false; s_op_terminal = true; s_op_outcome = OpOutcome::Success; s_status_message = TR("shield_completed"); Notifications::instance().success(TR("shield_merge_done")); } 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(); s_operation_pending = false; s_op_terminal = true; s_op_outcome = OpOutcome::Failed; s_status_message = 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. }; }); } void ShieldDialog::render(App* app) { if (!s_open) return; auto& S = schema::UI(); auto win = S.window("dialogs.shield"); auto addrLbl = S.label("dialogs.shield", "address-label"); auto addrFront = S.label("dialogs.shield", "address-front-label"); auto addrBack = S.label("dialogs.shield", "address-back-label"); auto feeInput = S.input("dialogs.shield", "fee-input"); auto utxoInput = S.input("dialogs.shield", "utxo-limit-input"); auto shieldBtn = S.button("dialogs.shield", "shield-button"); 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") : TR("shield_title"); material::OverlayDialogSpec ov; ov.title = title; ov.p_open = &s_open; ov.style = material::OverlayStyle::BlurFloat; ov.cardWidth = win.width; ov.idSuffix = "shielddialog"; if (!material::BeginOverlayDialog(ov)) return; const auto& state = app->getWalletState(); autoSelectDestination(state); pollOperation(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 || 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); } // ── Description ────────────────────────────────────────────────────────────────────────────── ImGui::TextWrapped("%s", s_consolidate ? TR("consolidate_desc") : isMerge ? TR("merge_description") : TR("shield_description")); ImGui::Spacing(); // 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(S.drawElement("dialogs.shield", "list-max-rows").size); if (maxRows < 1) maxRows = 7; // ── Merge: scope + source selector ────────────────────────────────────────────────────────── if (isMerge && !opInFlight) { if (s_scan.phase == services::ScopePhase::Loading) { material::Type().textColored(material::TypeStyle::Caption, material::OnSurfaceMedium(), TR("merge_scope_loading")); } 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(); // Where the funds go: all into one address, or each address's notes back into that address. ImGui::TextUnformatted(TR("merge_where")); // own line: the choices are long in some languages int where = s_per_address ? 1 : 0; ImGui::RadioButton(TR("merge_into_one"), &where, 0); ImGui::SameLine(); ImGui::RadioButton(TR("merge_keep_each"), &where, 1); s_per_address = (where == 1); ImGui::Spacing(); // Source selector — only offer the types that actually have inputs. const bool tOk = s_t_count > 0, zOk = s_z_count > 0; if (tOk && zOk && !s_per_address) { ImGui::AlignTextToFramePadding(); ImGui::TextUnformatted(TR("merge_source")); ImGui::SameLine(0, Layout::spacingLg()); ImGui::RadioButton(TR("merge_src_shielded"), &s_src, 1); ImGui::SameLine(); ImGui::RadioButton(TR("merge_src_transparent"), &s_src, 0); ImGui::SameLine(); ImGui::RadioButton(TR("merge_src_both"), &s_src, 2); ImGui::Spacing(); } // Keep each address's funds in place: pick the addresses. Shielded only — a transparent address // can't take a merge's output, its funds can only be shielded ("Into one address"). if (s_per_address) { wrappedCaption(material::OnSurfaceMedium(), TR("merge_keep_each_desc")); ImGui::Spacing(); int listed = 0; for (const auto& g : s_groups) if (g.notes >= 2) ++listed; if (s_scan.phase == services::ScopePhase::Loaded && listed == 0) { wrappedCaption(material::Warning(), TR("merge_keep_each_none")); } else if (listed > 0) { const float listH = std::min(static_cast(listed), static_cast(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; const bool worth = services::worthConsolidating(g, s_fee); bool ticked = worth && s_selected.count(g.address) > 0; const std::string label = app->getAddressLabel(g.address); char row[256]; std::snprintf(row, sizeof(row), TR("merge_keep_each_row_fmt"), label.empty() ? shortAddr(g.address).c_str() : label.c_str(), g.notes, fmtAmt(g.amount).c_str()); ImGui::PushID(g.address.c_str()); ImGui::BeginDisabled(!worth); if (ImGui::Checkbox(row, &ticked)) { 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)) material::Tooltip("%s%s%s", g.address.c_str(), worth ? "" : "\n\n", worth ? "" : TR("merge_keep_each_too_small")); ImGui::PopID(); } } ImGui::EndChild(); } ImGui::Spacing(); } } // ── Shield coinbase: from address ─────────────────────────────────────────────────────────── if (!isMerge && !opInFlight) { // Pick the mining (transparent) address to shield from a dropdown with balances, instead of a // free-text box with a "*" wildcard. "*" shields all of them. ImGui::TextUnformatted(TR("shield_from_address")); const bool allFrom = std::strcmp(s_from_address, "*") == 0; const std::string fromLbl = allFrom ? std::string(TR("shield_all_mining_addrs")) : (app->getAddressLabel(s_from_address).empty() ? shortAddr(s_from_address) : app->getAddressLabel(s_from_address)); ImGui::SetNextItemWidth(-1); if (ImGui::BeginCombo("##FromAddr", fromLbl.c_str())) { if (ImGui::Selectable(TR("shield_all_mining_addrs"), allFrom)) strncpy(s_from_address, "*", sizeof(s_from_address)); for (const auto& a : state.t_addresses) { if (!a.isSpendable()) continue; const std::string lbl = app->getAddressLabel(a.address); char item[192]; std::snprintf(item, sizeof(item), "%s (%s DRGX)", lbl.empty() ? shortAddr(a.address).c_str() : lbl.c_str(), fmtAmt(a.balance).c_str()); const bool sel = std::strcmp(s_from_address, a.address.c_str()) == 0; if (ImGui::Selectable(item, sel)) { strncpy(s_from_address, a.address.c_str(), sizeof(s_from_address) - 1); s_from_address[sizeof(s_from_address) - 1] = '\0'; } } ImGui::EndCombo(); } ImGui::Spacing(); } // ── Destination (z-address) ───────────────────────────────────────────────────────────────── if (!opInFlight) { if (!perAddress) { ImGui::TextUnformatted(TR("shield_to_address")); if (state.z_addresses.empty()) { material::Type().textColored(material::TypeStyle::Caption, material::Warning(), TR("shield_no_zaddr_hint")); ImGui::Spacing(); if (s_creating_addr) { ImGui::TextDisabled("%s", TR("merge_creating")); } else if (material::TactileButton(TR("merge_create_zaddr"), ImVec2(0, 0), S.resolveFont(shieldBtn.font))) { s_creating_addr = true; if (app->worker()) app->worker()->post([app, rpc = app->rpc()]() -> rpc::RPCWorker::MainCb { std::string addr, error; try { rpc::RPCClient::TraceScope trace("Shield dialog / New z-address"); addr = rpc->call("z_getnewaddress", nlohmann::json::array()).get(); } catch (const std::exception& e) { error = e.what(); } return [app, addr, error]() { s_creating_addr = false; if (error.empty() && !addr.empty()) { strncpy(s_to_address, addr.c_str(), sizeof(s_to_address) - 1); Notifications::instance().success(TR("merge_addr_created")); } else { Notifications::instance().error(std::string(TR("shield_error_prefix")) + error); } }; }); } } else { std::string to_display = s_to_address[0] ? s_to_address : TR("shield_select_z"); if (to_display.length() > static_cast(addrLbl.truncate)) to_display = to_display.substr(0, addrFront.truncate) + "..." + to_display.substr(to_display.length() - addrBack.truncate); ImGui::SetNextItemWidth(-1); if (ImGui::BeginCombo("##ToAddr", to_display.c_str())) { for (size_t i = 0; i < state.z_addresses.size(); i++) { std::string label = state.z_addresses[i].address; if (label.length() > static_cast(addrLbl.truncate)) label = label.substr(0, addrFront.truncate) + "..." + label.substr(label.length() - addrBack.truncate); bool selected = (s_selected_zaddr_idx == static_cast(i)); if (ImGui::Selectable(label.c_str(), selected)) { s_selected_zaddr_idx = static_cast(i); strncpy(s_to_address, state.z_addresses[i].address.c_str(), sizeof(s_to_address) - 1); } if (selected) ImGui::SetItemDefaultFocus(); } ImGui::EndCombo(); } } ImGui::Spacing(); } // !perAddress // ── Advanced (fee + batch size) ───────────────────────────────────────────────────────── ImDrawList* dl = ImGui::GetWindowDrawList(); material::CollapsibleHeader(dl, "##AdvToggle", TR("merge_advanced"), s_advanced, ImGui::GetContentRegionAvail().x, material::Type().caption(), material::OnSurfaceMedium()); if (s_advanced) { ImGui::Spacing(); ImGui::TextUnformatted(TR("fee_label")); ImGui::SetNextItemWidth(feeInput.width * dp); ImGui::InputDouble("##Fee", &s_fee, 0.0001, 0.001, "%.8f"); if (s_fee < 0.0) s_fee = 0.0; if (s_fee > 1.0) s_fee = 1.0; ImGui::SameLine(); ImGui::TextDisabled("DRGX"); material::Type().textColored(material::TypeStyle::Caption, material::OnSurfaceMedium(), TR("merge_fee_hint")); ImGui::Spacing(); ImGui::TextUnformatted(TR("merge_max_inputs")); ImGui::SetNextItemWidth(utxoInput.width * dp); ImGui::InputInt("##Limit", &s_utxo_limit); if (s_utxo_limit < 1) s_utxo_limit = 1; if (s_utxo_limit > 100) s_utxo_limit = 100; } // Batch hint: one run only merges up to the limit; large sets need repeats. if (perAddress) { bool multi = false; for (const auto& g : selectedGroups()) if (services::estimateBatches(g.notes, s_utxo_limit) > 1) multi = true; if (multi) { char hb[200]; std::snprintf(hb, sizeof(hb), TR("merge_keep_each_batches_fmt"), s_utxo_limit); ImGui::Spacing(); wrappedCaption(material::Warning(), hb); } } 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(); material::Type().textColored(material::TypeStyle::Caption, material::Warning(), hb); } ImGui::Spacing(); } // ── 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)); } } ImGui::ProgressBar(static_cast(p.batchesDone) / static_cast(std::max(1, p.batchesEstimated)), ImVec2(-1, 0)); const auto& addrs = run.addresses(); const float runListH = std::min(static_cast(addrs.size()), static_cast(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(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(); 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() && !(isMerge && run.hasRun())) { // the single-op path's message, not the run's const bool inFlight = s_operation_pending && !s_op_terminal; const ImU32 stCol = inFlight ? material::Warning() : s_op_outcome == OpOutcome::Success ? material::Success() : s_op_outcome == OpOutcome::Failed ? material::Error() : s_op_outcome == OpOutcome::Uncertain ? material::Warning() : material::OnSurfaceMedium(); ImGui::PushStyleColor(ImGuiCol_Text, ImGui::ColorConvertU32ToFloat4(stCol)); if (inFlight) ImGui::Text("%s%s", s_status_message.c_str(), material::LoadingDots()); // spinner while scanning else ImGui::TextWrapped("%s", s_status_message.c_str()); ImGui::PopStyleColor(); ImGui::Spacing(); } // ── Footer ────────────────────────────────────────────────────────────────────────────────── const bool connected = app->isConnected(); 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 * dp, /*drawSeparator=*/false); if (material::TactileButton(s_op_terminal ? TR("done") : TR("close"), ImVec2(cancelBtn.width * dp, 0), S.resolveFont(cancelBtn.font))) s_open = false; material::EndOverlayDialog(); return; } const char* primaryLabel = s_confirm ? TR("merge_confirm_btn") : s_consolidate ? TR("consolidate_funds_btn") : isMerge ? TR("merge_funds") : TR("shield_funds"); const char* secondaryLabel = s_confirm ? TR("merge_back") : TR("cancel"); // Confirm summary (inline, before the fund-moving call). Merge/consolidate shows amount + input // count; shield-coinbase just gets the button relabel (its inputs aren't enumerated here). if (s_confirm && perAddress) { int notes = 0, batches = 0; const auto sel = selectedGroups(); for (const auto& g : sel) { notes += g.notes; batches += services::estimateBatches(g.notes, s_utxo_limit); } char cb[320]; std::snprintf(cb, sizeof(cb), TR("merge_keep_each_confirm_fmt"), notes, static_cast(sel.size()), fmtAmt(s_fee * batches).c_str()); ImGui::TextWrapped("%s", cb); ImGui::Spacing(); } else if (s_confirm && isMerge) { char cb[200]; std::snprintf(cb, sizeof(cb), TR("merge_confirm_fmt"), fmtAmt(srcAmount()).c_str(), srcCount(), shortAddr(s_to_address).c_str()); ImGui::TextWrapped("%s", cb); ImGui::Spacing(); } else if (s_confirm && !isMerge) { // Shield-coinbase preview: amount / address count / fee / destination (no amount/count was shown before). const bool allFrom = std::strcmp(s_from_address, "*") == 0; const std::string toLbl = app->getAddressLabel(s_to_address); char cb[360]; std::snprintf(cb, sizeof(cb), TR("shield_confirm_fmt"), fmtAmt(miningBalance(state, s_from_address)).c_str(), allFrom ? miningAddrCount(state, s_from_address) : 1, s_utxo_limit, toLbl.empty() ? shortAddr(s_to_address).c_str() : toLbl.c_str(), fmtAmt(s_fee).c_str()); ImGui::TextWrapped("%s", cb); ImGui::Spacing(); } bool can_submit = haveDest && connected && !syncing; // Block a shield from an empty source or one with no funds (the free-text box allowed both). if (!isMerge && (s_from_address[0] == '\0' || miningBalance(state, s_from_address) <= 0.0)) can_submit = false; 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) * dp + ImGui::GetStyle().ItemSpacing.x; material::BeginOverlayDialogFooter(footerBtnW, /*drawSeparator=*/false); if (!can_submit) ImGui::BeginDisabled(); 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 } if (!can_submit) ImGui::EndDisabled(); 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 * dp, 0), S.resolveFont(cancelBtn.font))) { if (s_confirm) s_confirm = false; // Back → return to the form else s_open = false; // Cancel → close } material::EndOverlayDialog(); } } // namespace ui } // namespace dragonx