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:
@@ -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
|
||||
Reference in New Issue
Block a user