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
+2
View File
@@ -120,6 +120,8 @@ inherits the GUI's environment.
| `FAKE_MINING` | 0 | internal miner on: `stop` waits for cs_main first (`GenerateBitcoins` join) |
| `FAKE_REBUILD_SILENT` | 0 | hold cs_main like a rebuild but log no rebuild markers (a busy node whose output says nothing) |
| `FAKE_STUCK_SHUTDOWN` | 0 | the Sep 29 orphan: hold the datadir lock, no listener, ignore SIGTERM, never exit |
| `FAKE_WALLET_NOTES` | (off) | a note wallet: `addr:count:amount,...` shielded notes; enables `z_mergetoaddress` + its operations (dragonxd rules: confirmed unlocked notes, a batch locked on return, merged note next block) |
| `FAKE_MERGE_SECS` | 6 | how long a `z_mergetoaddress` operation runs before it succeeds |
| `FAKE_HEIGHT`, `FAKE_REBUILD_TOTAL`, `FAKE_NOTES`, `FAKE_VERSION` | 3327145, 62583, 13632, 1030050 | cosmetic numbers |
## Test cases
+189
View File
@@ -407,6 +407,187 @@ struct Canned {
std::string errMsg;
};
// ---------------------------------------------------------------------------------------------
// Optional note wallet (FAKE_WALLET_NOTES="addr:count:amount,..."): shielded notes per address, plus
// z_mergetoaddress and its async operations with dragonxd's rules — confirmed, unlocked notes only;
// a batch's notes are locked when the call returns; "only source == destination with one note" is
// refused; the merged note appears one block later. Everything here runs under g_csMain (execOne).
// ---------------------------------------------------------------------------------------------
struct FakeNote { long id; std::string address; double amount; long height; bool locked; };
struct FakeOp {
std::string id;
std::vector<long> noteIds;
std::string to;
double fee = 0.0;
double finishAt = 0.0; // unixMs()/1000.0
std::string status = "executing";
long long created = 0;
};
bool g_noteWallet = false;
std::vector<FakeNote> g_notes;
std::vector<FakeOp> g_ops;
long g_noteSeq = 0;
long g_opSeq = 0;
double g_mergeSecs = 6.0;
void loadNoteWallet()
{
const char* spec = std::getenv("FAKE_WALLET_NOTES");
if (!spec || !*spec) return;
g_noteWallet = true;
g_mergeSecs = envDouble("FAKE_MERGE_SECS", 6.0);
std::string s(spec);
size_t pos = 0;
while (pos <= s.size()) {
size_t comma = s.find(',', pos);
if (comma == std::string::npos) comma = s.size();
const std::string item = s.substr(pos, comma - pos);
const size_t c1 = item.find(':'), c2 = item.find(':', c1 == std::string::npos ? 0 : c1 + 1);
if (c1 != std::string::npos && c2 != std::string::npos) {
const std::string addr = item.substr(0, c1);
const long count = std::strtol(item.c_str() + c1 + 1, nullptr, 10);
const double amount = std::strtod(item.c_str() + c2 + 1, nullptr);
for (long i = 0; i < count; ++i)
g_notes.push_back({++g_noteSeq, addr, amount, g_height.load() - 10 - i, false});
}
pos = comma + 1;
}
}
std::vector<std::string> noteAddresses()
{
std::vector<std::string> out;
for (const auto& n : g_notes)
if (std::find(out.begin(), out.end(), n.address) == out.end()) out.push_back(n.address);
return out;
}
// Operations past their run time succeed: their notes are spent, one merged note lands next block.
void settleOps()
{
const double now = unixMs() / 1000.0;
for (auto& op : g_ops) {
if (op.status != "executing" || now < op.finishAt) continue;
double sum = 0.0;
for (long id : op.noteIds) {
auto it = std::find_if(g_notes.begin(), g_notes.end(), [&](const FakeNote& n) { return n.id == id; });
if (it != g_notes.end()) { sum += it->amount; g_notes.erase(it); }
}
g_notes.push_back({++g_noteSeq, op.to, sum - op.fee, g_height.load() + 1, false});
op.status = "success";
event("merge-done", op.id + " notes=" + std::to_string(op.noteIds.size()) + " to=" + op.to);
}
}
json opStatus(const FakeOp& op)
{
json o = {{"id", op.id}, {"status", op.status}, {"creation_time", op.created}, {"method", "z_mergetoaddress"}};
if (op.status == "success") o["result"] = {{"txid", fakeHash(1000000 + std::stol(op.id.substr(5)))}};
return o;
}
// The note-wallet answer for `m`, or c.known = false when it isn't a note-wallet method.
Canned noteWalletReply(const std::string& m, const json& params)
{
Canned c;
settleOps();
const long h = g_height.load();
if (m == "z_listunspent") {
json arr = json::array();
for (const auto& n : g_notes) {
const long conf = std::max(0L, h - n.height + 1);
arr.push_back({{"txid", fakeHash(5000000 + n.id)}, {"outindex", 0}, {"confirmations", conf},
{"spendable", true}, {"address", n.address}, {"amount", n.amount}, {"memo", "f6"},
{"change", false}});
}
c.result = arr;
} else if (m == "z_listaddresses") {
json arr = json::array({kFakeZAddr});
for (const auto& a : noteAddresses()) arr.push_back(a);
c.result = arr;
} else if (m == "z_gettotalbalance" || m == "z_getbalance") {
const std::string only = (m == "z_getbalance" && !params.empty() && params[0].is_string())
? params[0].get<std::string>() : std::string();
double sum = 0.0;
for (const auto& n : g_notes)
if (only.empty() || n.address == only) sum += n.amount;
if (m == "z_getbalance") c.result = sum;
else {
char buf[64];
std::snprintf(buf, sizeof(buf), "%.8f", sum);
c.result = {{"transparent", "0.00"}, {"private", buf}, {"total", buf}};
}
} else if (m == "z_mergetoaddress") {
if (params.size() < 2 || !params[0].is_array() || !params[1].is_string()) {
c.errCode = -8; c.errMsg = "Invalid parameter";
return c;
}
std::vector<std::string> from;
bool anySapling = false;
for (const auto& a : params[0]) {
if (!a.is_string()) continue;
if (a.get<std::string>() == "ANY_SAPLING" || a.get<std::string>() == "*") anySapling = true;
else from.push_back(a.get<std::string>());
}
const std::string to = params[1].get<std::string>();
const double fee = params.size() > 2 && params[2].is_number() ? params[2].get<double>() : 0.0001;
long limit = params.size() > 4 && params[4].is_number() ? params[4].get<long>() : 90;
if (limit <= 0) limit = 1000000;
std::vector<long> picked;
double merging = 0.0, remainingValue = 0.0;
long counter = 0;
for (const auto& n : g_notes) {
if (n.locked || h - n.height + 1 < 1) continue;
if (!anySapling && std::find(from.begin(), from.end(), n.address) == from.end()) continue;
++counter;
if (static_cast<long>(picked.size()) < limit) { picked.push_back(n.id); merging += n.amount; }
else remainingValue += n.amount;
}
if (picked.empty()) { c.errCode = -6; c.errMsg = "Could not find any funds to merge."; return c; }
if (from.size() == 1 && !anySapling && from[0] == to && picked.size() == 1) {
c.errCode = -8;
c.errMsg = "Destination address is also the only source address, and all its funds are already merged.";
return c;
}
if (merging - fee < fee) {
c.errCode = -6; c.errMsg = "Insufficient funds to pay the fee"; return c;
}
for (auto& n : g_notes)
if (std::find(picked.begin(), picked.end(), n.id) != picked.end()) n.locked = true;
FakeOp op;
op.id = "opid-" + std::to_string(++g_opSeq);
op.noteIds = picked;
op.to = to;
op.fee = fee;
op.created = unixMs() / 1000;
op.finishAt = unixMs() / 1000.0 + g_mergeSecs;
g_ops.push_back(op);
event("merge", op.id + " notes=" + std::to_string(picked.size()) + " to=" + to);
c.result = {{"remainingUTXOs", 0}, {"remainingTransparentValue", 0.0},
{"remainingNotes", counter - static_cast<long>(picked.size())},
{"remainingShieldedValue", remainingValue}, {"mergingUTXOs", 0},
{"mergingTransparentValue", 0.0}, {"mergingNotes", static_cast<long>(picked.size())},
{"mergingShieldedValue", merging}, {"opid", op.id}};
} else if (m == "z_getoperationstatus" || m == "z_getoperationresult") {
std::vector<std::string> filter;
if (!params.empty() && params[0].is_array())
for (const auto& id : params[0]) if (id.is_string()) filter.push_back(id.get<std::string>());
json arr = json::array();
std::vector<FakeOp> keep;
for (const auto& op : g_ops) {
const bool match = filter.empty() || std::find(filter.begin(), filter.end(), op.id) != filter.end();
const bool finished = op.status != "executing";
if (match && (m == "z_getoperationstatus" || finished)) arr.push_back(opStatus(op));
if (!(m == "z_getoperationresult" && match && finished)) keep.push_back(op);
}
g_ops.swap(keep);
c.result = arr;
} else {
c.known = false;
}
return c;
}
Canned cannedReply(const std::string& m, const json& params)
{
Canned c;
@@ -500,6 +681,9 @@ Canned cannedReply(const std::string& m, const json& params)
c.result = json::array();
} else if (m == "setban" || m == "clearbanned") {
c.result = nullptr;
} else if (m == "z_mergetoaddress") {
c.errCode = -8; // only the note wallet (FAKE_WALLET_NOTES) can merge; execOne answers it there
c.errMsg = "fake_dragonxd: set FAKE_WALLET_NOTES to merge notes";
} else if (m == "z_listaddresses") {
c.result = json::array({kFakeZAddr});
} else if (m == "getaddressesbyaccount" || m == "listaddresses") {
@@ -589,6 +773,10 @@ json execOne(const json& req, int& status)
std::lock_guard<std::mutex> lk(g_csMain);
c = cannedReply(m, params);
if (m == "setgenerate") g_minerOn = !params.empty() && params[0].is_boolean() && params[0].get<bool>();
if (g_noteWallet) {
Canned w = noteWalletReply(m, params);
if (w.known) c = w;
}
}
if (c.errCode != 0) return fail(c.errCode, c.errMsg);
return rpcReply(c.result, nullptr, id);
@@ -1240,6 +1428,7 @@ int main(int argc, char** argv)
}
g_expectedAuth = "Basic " + base64(g_cfg.rpcUser + ":" + g_cfg.rpcPassword);
g_height = g_cfg.height;
loadNoteWallet(); // FAKE_WALLET_NOTES (optional)
g_minerOn = g_cfg.mining;
const socket_t listener = bindListener(g_cfg.rpcPort);