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:
2026-10-02 22:15:56 -05:00
co-authored by Claude Opus 5.5
parent e367eb47ff
commit 71ed92b9f1
19 changed files with 1016 additions and 6 deletions
+269 -6
View File
@@ -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"));
}