feat(wallet): consolidate notes per address, keeping each address's funds in place
Users who keep funds apart under different labelled addresses can now shed notes (smaller wallet, faster scans/witness rebuilds) without the funds being merged into a single address. - Merge/Consolidate dialog: "Where should the funds go?" - Into one address (unchanged) or Keep in each address. The latter lists shielded addresses with 2+ spendable notes (label, note count, amount; too-small ones disabled) and merges each address's notes back into that address with z_mergetoaddress [addr] addr. Addresses with more notes than the batch size get several batches (the daemon locks a batch's notes before returning, so calls take disjoint notes); the dialog suggests a second run after those confirm. Per-address progress; ops keep running if the dialog is closed. The Consolidate (wallet-size) preset opens in this mode; Merge keeps "Into one". Shielded only: a transparent address can't take a merge's output. - services/note_consolidation: groupSpendableNotes, worthConsolidating, estimateBatches, consolidateInPlace (pure; driven by a merge-call function), with unit tests against a stand-in following dragonxd's rules. - fake_dragonxd: optional note wallet (FAKE_WALLET_NOTES, FAKE_MERGE_SECS) with z_mergetoaddress and its operations. - i18n: 17 new keys in all 8 languages; CJK subset rebuilt. - UI sweep: modal-consolidate target. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -401,6 +401,10 @@ void App::buildSweepCatalog()
|
||||
add("modal-merge", ui::NavPage::Send,
|
||||
[](App&) { ui::ShieldDialog::showMerge(); },
|
||||
[](App&) { ui::ShieldDialog::hide(); });
|
||||
// Consolidate (wallet-size preset): keeps each address's funds in that address by default.
|
||||
add("modal-consolidate", ui::NavPage::Send,
|
||||
[](App&) { ui::ShieldDialog::showConsolidate(); },
|
||||
[](App&) { ui::ShieldDialog::hide(); });
|
||||
// z->t transfer so the (converted) deshielding DialogWarningHeader renders.
|
||||
add("modal-transfer", ui::NavPage::Overview,
|
||||
[](App& a) {
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
// DragonX Wallet - ImGui Edition
|
||||
// Copyright 2024-2026 The Hush Developers
|
||||
// Released under the GPLv3
|
||||
|
||||
#include "note_consolidation.h"
|
||||
|
||||
#include <algorithm>
|
||||
#include <map>
|
||||
|
||||
namespace dragonx {
|
||||
namespace services {
|
||||
|
||||
std::vector<NoteGroup> groupSpendableNotes(const nlohmann::json& zListUnspent)
|
||||
{
|
||||
std::map<std::string, NoteGroup> byAddress;
|
||||
if (zListUnspent.is_array()) {
|
||||
for (const auto& n : zListUnspent) {
|
||||
if (!n.is_object()) continue;
|
||||
const std::string address = n.value("address", std::string());
|
||||
if (address.empty()) continue;
|
||||
if (n.value("confirmations", 0) < 1) continue; // z_mergetoaddress spends confirmed notes only
|
||||
if (!n.value("spendable", true)) continue; // watch-only: no key to spend it
|
||||
NoteGroup& g = byAddress[address];
|
||||
g.address = address;
|
||||
++g.notes;
|
||||
g.amount += n.value("amount", 0.0);
|
||||
}
|
||||
}
|
||||
std::vector<NoteGroup> out;
|
||||
out.reserve(byAddress.size());
|
||||
for (auto& kv : byAddress) out.push_back(std::move(kv.second));
|
||||
std::sort(out.begin(), out.end(), [](const NoteGroup& a, const NoteGroup& b) {
|
||||
if (a.notes != b.notes) return a.notes > b.notes;
|
||||
return a.address < b.address;
|
||||
});
|
||||
return out;
|
||||
}
|
||||
|
||||
bool worthConsolidating(const NoteGroup& group, double fee)
|
||||
{
|
||||
// The daemon rejects a merge whose fee is more than what it nets (merged value < 2 x fee).
|
||||
return group.notes >= 2 && group.amount > 2.0 * fee;
|
||||
}
|
||||
|
||||
int estimateBatches(int notes, int limit)
|
||||
{
|
||||
if (notes < 2) return 0;
|
||||
if (limit < 2) limit = 2;
|
||||
return notes / limit + ((notes % limit) >= 2 ? 1 : 0);
|
||||
}
|
||||
|
||||
AddressRun consolidateInPlace(const std::string& address, const MergeCall& call, int maxBatches)
|
||||
{
|
||||
AddressRun run;
|
||||
run.address = address;
|
||||
for (int b = 0; b < maxBatches; ++b) {
|
||||
MergeCallResult r = call(address);
|
||||
if (!r.error.empty()) {
|
||||
run.error = r.error; // earlier batches (if any) are already submitted and stand
|
||||
break;
|
||||
}
|
||||
run.opids.push_back(r.opid);
|
||||
run.notesMerged += r.merging;
|
||||
run.remaining = r.remaining;
|
||||
if (r.remaining < 2) break; // one leftover note can't be merged into its own address
|
||||
}
|
||||
return run;
|
||||
}
|
||||
|
||||
} // namespace services
|
||||
} // namespace dragonx
|
||||
@@ -0,0 +1,63 @@
|
||||
// DragonX Wallet - ImGui Edition
|
||||
// Copyright 2024-2026 The Hush Developers
|
||||
// Released under the GPLv3
|
||||
|
||||
#pragma once
|
||||
|
||||
// Consolidating notes WITHOUT moving funds between addresses: each address's notes are merged back into
|
||||
// that same address (z_mergetoaddress [addr] addr), so funds kept apart under different labels stay apart
|
||||
// while the wallet sheds notes (fewer notes = smaller wallet, faster witness rebuilds and scans).
|
||||
// Shielded only: a transparent address can't receive a non-coinbase output on DragonX (private chain).
|
||||
// Pure apart from the merge call it is handed, so the test target can drive it.
|
||||
|
||||
#include <nlohmann/json.hpp>
|
||||
|
||||
#include <functional>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
namespace dragonx {
|
||||
namespace services {
|
||||
|
||||
// One shielded address's spendable notes (confirmed, spendable), from z_listunspent.
|
||||
struct NoteGroup {
|
||||
std::string address;
|
||||
int notes = 0;
|
||||
double amount = 0.0;
|
||||
};
|
||||
|
||||
// Spendable shielded notes grouped by address, most notes first. Transparent UTXOs and unconfirmed or
|
||||
// watch-only notes are left out (z_mergetoaddress only spends confirmed notes it has keys for).
|
||||
std::vector<NoteGroup> groupSpendableNotes(const nlohmann::json& zListUnspent);
|
||||
|
||||
// Worth consolidating in place: at least two notes, and more than one batch's fee to work with.
|
||||
bool worthConsolidating(const NoteGroup& group, double fee);
|
||||
|
||||
// Merge calls needed to bring `notes` down when each call takes at most `limit` of them. A lone leftover
|
||||
// note isn't a call (the daemon refuses to merge one note into its own address).
|
||||
int estimateBatches(int notes, int limit);
|
||||
|
||||
// What one z_mergetoaddress call reported.
|
||||
struct MergeCallResult {
|
||||
std::string opid;
|
||||
int merging = 0; // notes this call is merging
|
||||
int remaining = 0; // notes of the address it left for a later call
|
||||
std::string error; // non-empty: the call failed (nothing was submitted)
|
||||
};
|
||||
using MergeCall = std::function<MergeCallResult(const std::string& address)>;
|
||||
|
||||
// The outcome for one address.
|
||||
struct AddressRun {
|
||||
std::string address;
|
||||
std::vector<std::string> opids; // one per submitted batch
|
||||
int notesMerged = 0;
|
||||
int remaining = 0; // notes still left after the last batch
|
||||
std::string error; // the call that stopped it, if any (earlier batches still went out)
|
||||
};
|
||||
|
||||
// Submit merges of `address` into itself until fewer than two of its notes are left unmerged. The daemon
|
||||
// locks a batch's notes before the call returns, so each call takes the next batch. At most maxBatches.
|
||||
AddressRun consolidateInPlace(const std::string& address, const MergeCall& call, int maxBatches = 20);
|
||||
|
||||
} // namespace services
|
||||
} // namespace dragonx
|
||||
@@ -8,12 +8,15 @@
|
||||
#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 <algorithm>
|
||||
#include <set>
|
||||
#include <vector>
|
||||
#include <string>
|
||||
#include <cstdio>
|
||||
@@ -44,6 +47,18 @@ static bool s_scope_loaded = false;
|
||||
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<services::NoteGroup> s_groups; // spendable shielded notes by address (scope load)
|
||||
static std::set<std::string> s_selected; // addresses ticked for per-address consolidation
|
||||
struct PerAddressRun {
|
||||
services::AddressRun run;
|
||||
std::vector<int> state; // per opid: 0 = running, 1 = done, 2 = failed
|
||||
std::string failure; // first failed op's message
|
||||
};
|
||||
static std::vector<PerAddressRun> s_runs; // after submit
|
||||
static bool s_runs_submitting = false;
|
||||
|
||||
static void resetTransient()
|
||||
{
|
||||
@@ -53,6 +68,8 @@ static void resetTransient()
|
||||
s_status_message.clear();
|
||||
s_operation_id.clear();
|
||||
s_creating_addr = false;
|
||||
s_runs.clear();
|
||||
s_runs_submitting = false;
|
||||
}
|
||||
|
||||
// Count + sum spendable transparent UTXOs and shielded notes so the user can see the scope of a
|
||||
@@ -63,6 +80,7 @@ static void loadScope(App* app)
|
||||
s_scope_loading = true; s_scope_loaded = false;
|
||||
app->worker()->post([app, rpc = app->rpc()]() -> rpc::RPCWorker::MainCb {
|
||||
int tC = 0, zC = 0; double tA = 0.0, zA = 0.0; std::string error;
|
||||
std::vector<services::NoteGroup> groups;
|
||||
try {
|
||||
rpc::RPCClient::TraceScope trace("Shield dialog / Scope count");
|
||||
nlohmann::json us = rpc->call("listunspent", nlohmann::json::array({0}));
|
||||
@@ -73,10 +91,15 @@ static void loadScope(App* app)
|
||||
if (zs.is_array()) for (const auto& z : zs) {
|
||||
if (z.value("confirmations", 0) >= 1) { ++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]() {
|
||||
return [tC, zC, tA, zA, error, groups]() {
|
||||
s_scope_loading = false; s_scope_loaded = error.empty();
|
||||
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
|
||||
for (const auto& g : s_groups)
|
||||
if (services::worthConsolidating(g, s_fee)) 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) {
|
||||
@@ -108,6 +131,9 @@ void ShieldDialog::show(Mode mode)
|
||||
s_scope_loaded = false; s_scope_loading = false;
|
||||
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();
|
||||
}
|
||||
|
||||
void ShieldDialog::showShieldCoinbase(const std::string& fromAddress)
|
||||
@@ -128,6 +154,7 @@ 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()
|
||||
@@ -142,6 +169,14 @@ static double srcAmount() { return s_src == 0 ? s_t_amount : s_src == 1 ? s_z_am
|
||||
|
||||
static std::string fmtAmt(double v) { char b[48]; std::snprintf(b, sizeof(b), "%.4f", v); return b; }
|
||||
|
||||
// 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;
|
||||
@@ -226,6 +261,121 @@ static void submitOperation(App* app)
|
||||
});
|
||||
}
|
||||
|
||||
// The ticked addresses that are worth consolidating, in list order.
|
||||
static std::vector<services::NoteGroup> selectedGroups()
|
||||
{
|
||||
std::vector<services::NoteGroup> 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. One background job submits, address by address, merges of each address's
|
||||
// notes back into that address — several batches when it holds more notes than the batch size.
|
||||
// z_mergetoaddress locks a batch's notes before it returns, so back-to-back calls take disjoint notes.
|
||||
static void submitPerAddress(App* app)
|
||||
{
|
||||
const auto groups = selectedGroups();
|
||||
if (groups.empty() || !app->worker()) return;
|
||||
s_runs_submitting = true;
|
||||
s_operation_pending = true;
|
||||
s_op_terminal = false;
|
||||
s_status_message = TR("shield_submitting");
|
||||
s_last_poll = ImGui::GetTime();
|
||||
const double fee = s_fee;
|
||||
const int limit = s_utxo_limit;
|
||||
app->worker()->post([app, rpc = app->rpc(), groups, fee, limit]() -> rpc::RPCWorker::MainCb {
|
||||
std::vector<services::AddressRun> runs;
|
||||
for (const auto& g : groups) {
|
||||
runs.push_back(services::consolidateInPlace(g.address, [&](const std::string& addr) {
|
||||
services::MergeCallResult r;
|
||||
try {
|
||||
rpc::RPCClient::TraceScope trace("Shield dialog / Consolidate in place");
|
||||
// fromaddrs = [addr], toaddr = addr: the address's notes merge back into it.
|
||||
const nlohmann::json res = rpc->call("z_mergetoaddress",
|
||||
{nlohmann::json::array({addr}), addr, fee, limit, limit});
|
||||
r.opid = res.value("opid", std::string());
|
||||
r.merging = res.value("mergingNotes", 0);
|
||||
r.remaining = res.value("remainingNotes", 0);
|
||||
if (r.opid.empty()) r.error = "no operation id";
|
||||
} catch (const std::exception& e) { r.error = e.what(); }
|
||||
return r;
|
||||
}));
|
||||
}
|
||||
return [app, runs]() {
|
||||
s_runs_submitting = false;
|
||||
s_runs.clear();
|
||||
int submitted = 0;
|
||||
for (const auto& r : runs) {
|
||||
PerAddressRun pr;
|
||||
pr.run = r;
|
||||
pr.state.assign(r.opids.size(), 0);
|
||||
for (const auto& id : r.opids) { app->trackOperation(id); ++submitted; }
|
||||
s_runs.push_back(std::move(pr));
|
||||
}
|
||||
if (submitted > 0) {
|
||||
s_status_message = TR("merge_progress");
|
||||
Notifications::instance().success(TR("merge_started"));
|
||||
} else {
|
||||
s_operation_pending = false; s_op_terminal = true;
|
||||
s_status_message = TR("merge_keep_each_nothing_submitted");
|
||||
Notifications::instance().error(TR("merge_keep_each_nothing_submitted"));
|
||||
}
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
// Progress of a per-address run: poll every ~2s until each batch's operation is done or failed. Unfiltered,
|
||||
// like the shared poller (some daemons reject the per-opid form); only our operations are read.
|
||||
static void pollPerAddress(App* app)
|
||||
{
|
||||
if (s_runs.empty() || s_op_terminal || !app->worker()) return;
|
||||
const double now = ImGui::GetTime();
|
||||
if (now - s_last_poll < 2.0) return;
|
||||
s_last_poll = now;
|
||||
app->worker()->post([rpc = app->rpc()]() -> rpc::RPCWorker::MainCb {
|
||||
nlohmann::json result; std::string error;
|
||||
try {
|
||||
rpc::RPCClient::TraceScope trace("Shield dialog / Consolidate in place status");
|
||||
result = rpc->call("z_getoperationstatus", nlohmann::json::array());
|
||||
} catch (const std::exception& e) { error = e.what(); }
|
||||
return [result, error]() {
|
||||
if (!error.empty() || !result.is_array()) return; // transient — retry next tick
|
||||
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>();
|
||||
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") {
|
||||
r.state[i] = 2;
|
||||
if (r.failure.empty() && op.contains("error") && op["error"].is_object())
|
||||
r.failure = op["error"].value("message", std::string());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
bool allDone = true;
|
||||
int failed = 0;
|
||||
for (const auto& r : s_runs)
|
||||
for (int st : r.state) { if (st == 0) allDone = false; if (st == 2) ++failed; }
|
||||
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 {
|
||||
s_status_message = TR("merge_keep_each_some_failed");
|
||||
Notifications::instance().error(TR("merge_keep_each_some_failed"));
|
||||
}
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
// 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.)
|
||||
@@ -290,6 +440,7 @@ void ShieldDialog::render(App* app)
|
||||
const auto& state = app->getWalletState();
|
||||
autoSelectDestination(state);
|
||||
pollOperation(app);
|
||||
pollPerAddress(app);
|
||||
if (isMerge && !s_scope_loaded && !s_scope_loading && s_operation_id.empty()) loadScope(app);
|
||||
|
||||
// ── Description ──────────────────────────────────────────────────────────────────────────────
|
||||
@@ -298,7 +449,9 @@ void ShieldDialog::render(App* app)
|
||||
: TR("shield_description"));
|
||||
ImGui::Spacing();
|
||||
|
||||
const bool opInFlight = !s_operation_id.empty(); // submitted — inputs frozen, showing progress
|
||||
// Submitted — inputs frozen, showing progress.
|
||||
const bool opInFlight = !s_operation_id.empty() || s_runs_submitting || !s_runs.empty();
|
||||
const bool perAddress = isMerge && s_per_address;
|
||||
|
||||
// ── Merge: scope + source selector ──────────────────────────────────────────────────────────
|
||||
if (isMerge && !opInFlight) {
|
||||
@@ -313,9 +466,17 @@ void ShieldDialog::render(App* app)
|
||||
}
|
||||
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) {
|
||||
if (tOk && zOk && !s_per_address) {
|
||||
ImGui::AlignTextToFramePadding();
|
||||
ImGui::TextUnformatted(TR("merge_source"));
|
||||
ImGui::SameLine(0, Layout::spacingLg());
|
||||
@@ -324,6 +485,46 @@ void ShieldDialog::render(App* app)
|
||||
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_scope_loaded && listed == 0) {
|
||||
wrappedCaption(material::Warning(), TR("merge_keep_each_none"));
|
||||
} else if (listed > 0) {
|
||||
const float rowH = ImGui::GetFrameHeightWithSpacing();
|
||||
const float listH = std::min(static_cast<float>(listed), 7.0f) * rowH + ImGui::GetStyle().WindowPadding.y * 2.0f;
|
||||
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);
|
||||
else s_selected.erase(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 ───────────────────────────────────────────────────────────
|
||||
@@ -335,6 +536,7 @@ void ShieldDialog::render(App* app)
|
||||
|
||||
// ── 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"));
|
||||
@@ -381,6 +583,7 @@ void ShieldDialog::render(App* app)
|
||||
}
|
||||
}
|
||||
ImGui::Spacing();
|
||||
} // !perAddress
|
||||
|
||||
// ── Advanced (fee + batch size) ─────────────────────────────────────────────────────────
|
||||
ImDrawList* dl = ImGui::GetWindowDrawList();
|
||||
@@ -406,7 +609,16 @@ void ShieldDialog::render(App* app)
|
||||
}
|
||||
|
||||
// Batch hint: one run only merges up to the limit; large sets need repeats.
|
||||
if (isMerge && s_scope_loaded && srcCount() > s_utxo_limit) {
|
||||
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_scope_loaded && srcCount() > s_utxo_limit) {
|
||||
char hb[160];
|
||||
std::snprintf(hb, sizeof(hb), TR("merge_batch_fmt"), s_utxo_limit);
|
||||
ImGui::Spacing();
|
||||
@@ -415,6 +627,46 @@ void ShieldDialog::render(App* app)
|
||||
ImGui::Spacing();
|
||||
}
|
||||
|
||||
// ── Per-address progress ────────────────────────────────────────────────────────────────────
|
||||
if (!s_runs.empty()) {
|
||||
bool leftovers = false;
|
||||
for (const auto& r : s_runs) {
|
||||
const std::string label = app->getAddressLabel(r.run.address);
|
||||
const char* name = label.empty() ? nullptr : label.c_str();
|
||||
const std::string shortA = shortAddr(r.run.address);
|
||||
int done = 0, failed = 0;
|
||||
for (int st : r.state) { if (st == 1) ++done; if (st == 2) ++failed; }
|
||||
const int batches = static_cast<int>(r.run.opids.size());
|
||||
char line[320];
|
||||
ImU32 col = material::OnSurfaceMedium();
|
||||
if (batches == 0) {
|
||||
std::snprintf(line, sizeof(line), TR("merge_keep_each_status_not_started"), name ? name : shortA.c_str(),
|
||||
r.run.error.c_str());
|
||||
col = material::Error();
|
||||
} else if (failed > 0) {
|
||||
std::snprintf(line, sizeof(line), TR("merge_keep_each_status_failed"), name ? name : shortA.c_str(),
|
||||
r.failure.c_str());
|
||||
col = material::Error();
|
||||
} else if (done == batches) {
|
||||
std::snprintf(line, sizeof(line), TR("merge_keep_each_status_done"), name ? name : shortA.c_str(),
|
||||
r.run.notesMerged, batches);
|
||||
col = material::Success();
|
||||
} else {
|
||||
std::snprintf(line, sizeof(line), TR("merge_keep_each_status_running"), name ? name : shortA.c_str(),
|
||||
r.run.notesMerged, done, batches);
|
||||
}
|
||||
wrappedCaption(col, line);
|
||||
if (batches > 1 || r.run.remaining >= 2) leftovers = true;
|
||||
}
|
||||
if (leftovers) {
|
||||
ImGui::Spacing();
|
||||
ImGui::PushStyleColor(ImGuiCol_Text, ImGui::ColorConvertU32ToFloat4(material::OnSurfaceMedium()));
|
||||
ImGui::TextWrapped("%s", TR("merge_keep_each_run_again"));
|
||||
ImGui::PopStyleColor();
|
||||
}
|
||||
ImGui::Spacing();
|
||||
}
|
||||
|
||||
// ── Live progress / status ──────────────────────────────────────────────────────────────────
|
||||
if (!s_status_message.empty()) {
|
||||
if (s_operation_pending && !s_op_terminal)
|
||||
@@ -447,7 +699,16 @@ void ShieldDialog::render(App* app)
|
||||
|
||||
// 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 && isMerge) {
|
||||
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<int>(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());
|
||||
@@ -457,19 +718,21 @@ 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();
|
||||
|
||||
float footerBtnW = shieldBtn.width + cancelBtn.width + ImGui::GetStyle().ItemSpacing.x;
|
||||
material::BeginOverlayDialogFooter(footerBtnW, /*drawSeparator=*/false);
|
||||
|
||||
if (!can_submit) ImGui::BeginDisabled();
|
||||
if (material::TactileButton(primaryLabel, ImVec2(shieldBtn.width, 0), S.resolveFont(shieldBtn.font))) {
|
||||
if (s_confirm) { submitOperation(app); }
|
||||
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) 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"));
|
||||
}
|
||||
|
||||
@@ -2519,6 +2519,23 @@ void I18n::loadBuiltinEnglish()
|
||||
strings_["merge_back"] = "Back";
|
||||
strings_["merge_progress"] = "Consolidating\xE2\x80\xA6 this can take a few minutes. You can close this window.";
|
||||
strings_["merge_no_spendable"] = "No spendable inputs to consolidate yet.";
|
||||
strings_["merge_where"] = "Where should the funds go?";
|
||||
strings_["merge_into_one"] = "Into one address";
|
||||
strings_["merge_keep_each"] = "Keep in each address";
|
||||
strings_["merge_keep_each_desc"] = "Each address's notes are merged back into that same address, so funds you keep apart under different labels stay apart. Shielded addresses only — transparent funds can only be shielded (Into one address).";
|
||||
strings_["merge_keep_each_none"] = "Every shielded address already holds a single note — nothing to consolidate.";
|
||||
strings_["merge_keep_each_row_fmt"] = "%s · %d notes · %s DRGX";
|
||||
strings_["merge_keep_each_too_small"] = "Too small to consolidate: the fee would use up these funds.";
|
||||
strings_["merge_keep_each_batches_fmt"] = "Addresses with more than %d notes are merged in several batches; run it again after they confirm to merge the rest.";
|
||||
strings_["merge_keep_each_confirm_fmt"] = "Consolidate %d notes in %d addresses? Each address keeps its own funds. Fees: about %s DRGX (one per batch).";
|
||||
strings_["merge_keep_each_select"] = "Tick at least one address to consolidate.";
|
||||
strings_["merge_keep_each_nothing_submitted"] = "Nothing was consolidated — the node refused every merge.";
|
||||
strings_["merge_keep_each_some_failed"] = "Some merges failed — see the list above.";
|
||||
strings_["merge_keep_each_status_not_started"] = "%s — not started: %s";
|
||||
strings_["merge_keep_each_status_failed"] = "%s — failed: %s";
|
||||
strings_["merge_keep_each_status_done"] = "%s — done: %d notes merged in %d batch(es)";
|
||||
strings_["merge_keep_each_status_running"] = "%s — merging %d notes (%d of %d batches done)…";
|
||||
strings_["merge_keep_each_run_again"] = "Addresses merged in several batches now hold one note per batch. Run this again after those confirm to merge them into one.";
|
||||
strings_["done"] = "Done";
|
||||
|
||||
// --- Transaction Details Dialog ---
|
||||
|
||||
Reference in New Issue
Block a user