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:
@@ -28,6 +28,7 @@
|
||||
#include "resources/embedded_resources.h"
|
||||
#include "services/network_refresh_service.h"
|
||||
#include "services/refresh_scheduler.h"
|
||||
#include "services/note_consolidation.h"
|
||||
#include "services/wallet_security_controller.h"
|
||||
#include "services/wallet_security_workflow.h"
|
||||
#include "services/wallet_security_workflow_executor.h"
|
||||
@@ -4456,6 +4457,18 @@ void testDaemonStopStringsTranslated()
|
||||
});
|
||||
}
|
||||
|
||||
// Per-address consolidation strings.
|
||||
void testConsolidationStringsTranslated()
|
||||
{
|
||||
expectKeysTranslated({
|
||||
"merge_where", "merge_into_one", "merge_keep_each", "merge_keep_each_desc", "merge_keep_each_none",
|
||||
"merge_keep_each_row_fmt", "merge_keep_each_too_small", "merge_keep_each_batches_fmt",
|
||||
"merge_keep_each_confirm_fmt", "merge_keep_each_select", "merge_keep_each_nothing_submitted",
|
||||
"merge_keep_each_some_failed", "merge_keep_each_status_not_started", "merge_keep_each_status_failed",
|
||||
"merge_keep_each_status_done", "merge_keep_each_status_running", "merge_keep_each_run_again",
|
||||
});
|
||||
}
|
||||
|
||||
// Mining addresses: the strings that explain transparent addresses only receive mining rewards.
|
||||
void testMiningAddressStringsTranslated()
|
||||
{
|
||||
@@ -4616,6 +4629,84 @@ void testDebugLogTailRules()
|
||||
EXPECT_FALSE(EmbeddedDaemon::processIsDaemon(0));
|
||||
}
|
||||
|
||||
// Per-address consolidation: each address's notes merge back into that same address, so separately
|
||||
// labelled funds stay separate while the wallet sheds notes.
|
||||
void testNoteConsolidation()
|
||||
{
|
||||
using namespace dragonx::services;
|
||||
const nlohmann::json unspent = nlohmann::json::parse(R"([
|
||||
{"address":"zsA","amount":1.0,"confirmations":5,"spendable":true},
|
||||
{"address":"zsA","amount":2.0,"confirmations":5,"spendable":true},
|
||||
{"address":"zsA","amount":0.5,"confirmations":1},
|
||||
{"address":"zsA","amount":9.0,"confirmations":0,"spendable":true},
|
||||
{"address":"zsB","amount":3.0,"confirmations":9,"spendable":true},
|
||||
{"address":"zsW","amount":4.0,"confirmations":9,"spendable":false},
|
||||
{"address":"zsW","amount":4.0,"confirmations":9,"spendable":false},
|
||||
{"amount":1.0,"confirmations":9}
|
||||
])");
|
||||
const auto groups = groupSpendableNotes(unspent);
|
||||
EXPECT_EQ(groups.size(), size_t(2)); // watch-only and address-less left out
|
||||
EXPECT_EQ(groups[0].address, std::string("zsA")); // most notes first
|
||||
EXPECT_EQ(groups[0].notes, 3); // the unconfirmed note is not spendable yet
|
||||
EXPECT_NEAR(groups[0].amount, 3.5, 1e-9);
|
||||
EXPECT_EQ(groups[1].notes, 1);
|
||||
EXPECT_TRUE(groupSpendableNotes(nlohmann::json::object()).empty());
|
||||
|
||||
const double fee = 0.0001;
|
||||
EXPECT_TRUE(worthConsolidating(groups[0], fee));
|
||||
EXPECT_FALSE(worthConsolidating(groups[1], fee)); // a single note: nothing to merge
|
||||
EXPECT_FALSE(worthConsolidating(NoteGroup{"zsD", 4, 0.00015}, fee)); // the fee would eat it
|
||||
|
||||
EXPECT_EQ(estimateBatches(0, 90), 0);
|
||||
EXPECT_EQ(estimateBatches(1, 90), 0);
|
||||
EXPECT_EQ(estimateBatches(2, 90), 1);
|
||||
EXPECT_EQ(estimateBatches(90, 90), 1);
|
||||
EXPECT_EQ(estimateBatches(91, 90), 1); // the lone leftover note is not a batch
|
||||
EXPECT_EQ(estimateBatches(92, 90), 2);
|
||||
EXPECT_EQ(estimateBatches(182, 90), 3);
|
||||
EXPECT_EQ(estimateBatches(5, 1), 2); // a limit below 2 is treated as 2
|
||||
|
||||
// A stand-in daemon: each call merges up to `limit` of the address's unlocked notes into itself and
|
||||
// refuses a lone note, like z_mergetoaddress.
|
||||
auto daemonFor = [](int notes, int limit, int failOnCall) {
|
||||
auto left = std::make_shared<int>(notes);
|
||||
auto calls = std::make_shared<int>(0);
|
||||
return [=](const std::string&) {
|
||||
MergeCallResult r;
|
||||
if (++*calls == failOnCall) { r.error = "Insufficient funds"; return r; }
|
||||
if (*left < 2) { r.error = "Destination address is also the only source address"; return r; }
|
||||
r.merging = std::min(limit, *left);
|
||||
*left -= r.merging;
|
||||
r.remaining = *left;
|
||||
r.opid = "opid-" + std::to_string(*calls);
|
||||
return r;
|
||||
};
|
||||
};
|
||||
for (int notes : {2, 90, 91, 92, 250}) {
|
||||
const AddressRun run = consolidateInPlace("zsA", daemonFor(notes, 90, -1));
|
||||
EXPECT_TRUE(run.error.empty());
|
||||
EXPECT_EQ(static_cast<int>(run.opids.size()), estimateBatches(notes, 90));
|
||||
EXPECT_TRUE(run.remaining < 2);
|
||||
EXPECT_EQ(run.notesMerged + run.remaining, notes);
|
||||
}
|
||||
{ // the first call fails: nothing submitted
|
||||
const AddressRun run = consolidateInPlace("zsA", daemonFor(10, 90, 1));
|
||||
EXPECT_TRUE(run.opids.empty());
|
||||
EXPECT_FALSE(run.error.empty());
|
||||
}
|
||||
{ // a later call fails: the earlier batch stands, the error is reported
|
||||
const AddressRun run = consolidateInPlace("zsA", daemonFor(200, 90, 2));
|
||||
EXPECT_EQ(run.opids.size(), size_t(1));
|
||||
EXPECT_EQ(run.notesMerged, 90);
|
||||
EXPECT_FALSE(run.error.empty());
|
||||
}
|
||||
{ // bounded
|
||||
const AddressRun run = consolidateInPlace("zsA", daemonFor(1000, 2, -1), /*maxBatches=*/5);
|
||||
EXPECT_EQ(run.opids.size(), size_t(5));
|
||||
EXPECT_TRUE(run.remaining >= 2);
|
||||
}
|
||||
}
|
||||
|
||||
void testDatadirLockGate()
|
||||
{
|
||||
using dragonx::daemon::EmbeddedDaemon;
|
||||
@@ -9233,6 +9324,7 @@ int main()
|
||||
testConfSetsKey();
|
||||
testOwnershipRecord();
|
||||
testDebugLogTailRules();
|
||||
testNoteConsolidation();
|
||||
testStopDeliveryPolicy();
|
||||
testProcStatParse();
|
||||
testStopProgressTracker();
|
||||
@@ -9245,6 +9337,7 @@ int main()
|
||||
testChooseDaemonStatus();
|
||||
testDaemonStopStringsTranslated();
|
||||
testMiningAddressStringsTranslated();
|
||||
testConsolidationStringsTranslated();
|
||||
#ifdef _WIN32
|
||||
testWindowsStopNeverKillsForeignDaemon();
|
||||
#endif
|
||||
|
||||
Reference in New Issue
Block a user