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