perf(net): one balance scan per poll, filtered opstatus, a guarded merge scope

The Core poll ran z_gettotalbalance twice (minconf 0 and 1), each a whole-
wallet scan under dragonxd's cs_main. The spendable (minconf=1) call now
runs only after the display call succeeded, and only when something is
unconfirmed or pending or the exact total moved: about 95% of polls make
one scan instead of two. A forced post-send balance can no longer be lost
to a refused enqueue, and pending-send debits aren't re-applied each tick.

z_getoperationstatus is filtered to our opids with its real argument form
([[id,...]]; the old ["id"] was rejected), falling back to the unfiltered
call if a daemon refuses the filter. The Shield/Merge dialog's own polls
are filtered and session-guarded too, and finally reach Done; the dead
RPCClient wrappers are deleted.

The Merge/Consolidate scope scan becomes a tested state machine: one scan
at a time, failures latch behind a Retry button, only "node not ready"
retries by itself (10/30/60 s, at most 3), Lite never scans. Its 120 s
calls are maintenance calls: every main-thread disconnect aborts one in
flight first, so a lifecycle action no longer freezes the UI behind it.
An aborted merge that was already sent says it may still go through.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-09 11:06:34 -05:00
co-authored by Claude Opus 5.5
parent 4eed4283d6
commit 8b9a498eee
23 changed files with 1044 additions and 128 deletions
+351 -2
View File
@@ -1652,9 +1652,10 @@ void testNetworkRefreshRpcCollectors()
coreFallbackRpc.addResponse("getblockchaininfo", json{{"blocks", 8}, {"headers", 9}});
auto partialCore = Refresh::collectCoreRefreshResult(coreFallbackRpc);
// No longestchain in the response → not classified as "behind" → balance is still attempted (here it
// fails). getblockchaininfo is still issued first.
// fails). getblockchaininfo is still issued first. The minconf=1 call follows only a minconf=0 call
// that succeeded (its result would be dropped anyway).
EXPECT_TRUE(coreFallbackRpc.methodNames() == std::vector<std::string>({
"getblockchaininfo", "z_gettotalbalance", "z_gettotalbalance"
"getblockchaininfo", "z_gettotalbalance"
}));
EXPECT_FALSE(partialCore.balanceOk);
EXPECT_TRUE(partialCore.blockchainOk);
@@ -4504,6 +4505,8 @@ void testConsolidationStringsTranslated()
"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",
"merge_waiting_node", "merge_wait_locked", "merge_scope_failed_fmt", "retry",
"merge_keep_each_status_unconfirmed", "merge_submit_uncertain",
});
}
@@ -9496,6 +9499,347 @@ void testBalanceDisplayLerp()
}
}
// C47: the spendable (minconf=1) z_gettotalbalance is a second whole-wallet scan under cs_main. It runs
// only after the minconf=0 call succeeded, and only when its answer can differ from it: something is
// unconfirmed or pending (needSpendable), or the exact total moved since the last poll.
void testCoreRefreshBalancePlan()
{
using Refresh = dragonx::services::NetworkRefreshService;
using json = nlohmann::json;
using dragonx::rpc::RpcFailureKind;
using dragonx::rpc::RpcTransportError;
const json chain = json{{"blocks", 150}, {"headers", 150}, {"longestchain", 150}, {"bestblockhash", "tip-150"}};
const json total = json{{"private", "3.00000000"}, {"transparent", "1.25000000"}, {"total", "4.25000000"}};
const json spendable = json{{"private", "2.00000000"}, {"transparent", "1.25000000"}, {"total", "3.25000000"}};
const std::vector<std::string> oneCall = {"getblockchaininfo", "z_gettotalbalance"};
const std::vector<std::string> twoCalls = {"getblockchaininfo", "z_gettotalbalance", "z_gettotalbalance"};
{ // Nothing pending and the total unchanged: one balance call, and spendable is the display total.
MockRefreshRpc rpc;
rpc.addResponse("getblockchaininfo", chain);
rpc.addResponse("z_gettotalbalance", total);
Refresh::CoreRefreshPlan plan;
plan.needSpendable = false;
plan.prevTotalZat = 425000000;
auto result = Refresh::collectCoreRefreshResult(rpc, plan);
EXPECT_TRUE(rpc.methodNames() == oneCall);
EXPECT_EQ(rpc.calls[1].params, json::array({0}));
EXPECT_TRUE(result.balanceOk);
EXPECT_TRUE(result.balanceAttempted);
EXPECT_FALSE(result.spendableQueried);
EXPECT_TRUE(result.totalBalanceZat == std::optional<std::int64_t>(425000000));
EXPECT_TRUE(Refresh::nextTotalBaseline(result, 425000000) == std::optional<std::int64_t>(425000000));
dragonx::WalletState state;
Refresh::applyCoreRefreshResult(state, result, 1000);
EXPECT_NEAR(state.spendableTotalBalance, 4.25, 1e-9);
EXPECT_NEAR(state.unconfirmedBalance, 0.0, 1e-9);
}
{ // The total moved (how a new 0-conf receive first shows): the spendable call runs.
MockRefreshRpc rpc;
rpc.addResponse("getblockchaininfo", chain);
rpc.addResponse("z_gettotalbalance", total);
rpc.addResponse("z_gettotalbalance", spendable);
Refresh::CoreRefreshPlan plan;
plan.needSpendable = false;
plan.prevTotalZat = 325000000;
auto result = Refresh::collectCoreRefreshResult(rpc, plan);
EXPECT_TRUE(rpc.methodNames() == twoCalls);
EXPECT_EQ(rpc.calls[2].params, json::array({1}));
EXPECT_TRUE(result.spendableQueried);
dragonx::WalletState state;
Refresh::applyCoreRefreshResult(state, result, 1000);
EXPECT_NEAR(state.spendableTotalBalance, 3.25, 1e-9);
EXPECT_NEAR(state.unconfirmedBalance, 1.0, 1e-9);
}
{ // Something unconfirmed or pending: the spendable call runs although the total held still.
MockRefreshRpc rpc;
rpc.addResponse("getblockchaininfo", chain);
rpc.addResponse("z_gettotalbalance", total);
rpc.addResponse("z_gettotalbalance", spendable);
Refresh::CoreRefreshPlan plan;
plan.needSpendable = true;
plan.prevTotalZat = 425000000;
Refresh::collectCoreRefreshResult(rpc, plan);
EXPECT_TRUE(rpc.methodNames() == twoCalls);
}
{ // No previous total (first poll, or after a reconnect): the spendable call runs.
MockRefreshRpc rpc;
rpc.addResponse("getblockchaininfo", chain);
rpc.addResponse("z_gettotalbalance", total);
rpc.addResponse("z_gettotalbalance", spendable);
Refresh::CoreRefreshPlan plan;
plan.needSpendable = false;
Refresh::collectCoreRefreshResult(rpc, plan);
EXPECT_TRUE(rpc.methodNames() == twoCalls);
}
{ // The display call failed: its result is dropped, so the spendable scan isn't run either.
MockRefreshRpc rpc;
rpc.addResponse("getblockchaininfo", chain);
rpc.addFailure("z_gettotalbalance", "boom");
auto result = Refresh::collectCoreRefreshResult(rpc, Refresh::CoreRefreshPlan{});
EXPECT_TRUE(rpc.methodNames() == oneCall);
EXPECT_FALSE(result.balanceOk);
EXPECT_TRUE(result.balanceAttempted); // it reached the daemon
EXPECT_FALSE(result.spendableQueried);
EXPECT_FALSE(result.totalBalanceZat.has_value());
}
{ // Held back by the busy gate: never reached the daemon, so a forced refresh must not count it.
MockRefreshRpc rpc;
rpc.addResponse("getblockchaininfo", chain);
rpc.addException("z_gettotalbalance", std::make_exception_ptr(
RpcTransportError(RpcFailureKind::Deferred, "held back: the node is busy")));
auto result = Refresh::collectCoreRefreshResult(rpc, Refresh::CoreRefreshPlan{});
EXPECT_FALSE(result.balanceOk);
EXPECT_FALSE(result.balanceAttempted);
EXPECT_TRUE(result.balanceErrKind == RpcFailureKind::Deferred);
}
{ // Behind, or no balance asked for: the chain call only, nothing attempted.
MockRefreshRpc rpc;
rpc.addResponse("getblockchaininfo", json{{"blocks", 100}, {"headers", 150}, {"longestchain", 150}});
auto behind = Refresh::collectCoreRefreshResult(rpc, Refresh::CoreRefreshPlan{});
EXPECT_TRUE(rpc.methodNames() == std::vector<std::string>({"getblockchaininfo"}));
EXPECT_FALSE(behind.balanceAttempted);
MockRefreshRpc chainOnly;
chainOnly.addResponse("getblockchaininfo", chain);
Refresh::CoreRefreshPlan plan;
plan.includeBalance = false;
auto skipped = Refresh::collectCoreRefreshResult(chainOnly, plan);
EXPECT_TRUE(chainOnly.methodNames() == std::vector<std::string>({"getblockchaininfo"}));
EXPECT_FALSE(skipped.balanceAttempted);
EXPECT_TRUE(Refresh::nextTotalBaseline(skipped, 425000000) == std::optional<std::int64_t>(425000000));
}
// The total moved but the minconf=1 call brought nothing back (an error, or held back by the busy gate):
// spendable degrades to display, so the baseline is dropped and the next poll measures it again
// instead of skipping it while the total holds still.
for (int heldBack = 0; heldBack < 2; ++heldBack) {
MockRefreshRpc rpc;
rpc.addResponse("getblockchaininfo", chain);
rpc.addResponse("z_gettotalbalance", total);
if (heldBack) {
rpc.addException("z_gettotalbalance", std::make_exception_ptr(
RpcTransportError(RpcFailureKind::Deferred, "held back: the node is busy")));
} else {
rpc.addFailure("z_gettotalbalance", "boom");
}
Refresh::CoreRefreshPlan plan;
plan.needSpendable = false;
plan.prevTotalZat = 325000000;
auto result = Refresh::collectCoreRefreshResult(rpc, plan);
EXPECT_TRUE(rpc.methodNames() == twoCalls);
EXPECT_TRUE(result.balanceOk);
EXPECT_TRUE(result.spendableQueried);
EXPECT_FALSE(result.spendableTotalBalance.has_value());
dragonx::WalletState state;
Refresh::applyCoreRefreshResult(state, result, 1000);
EXPECT_NEAR(state.spendableTotalBalance, 4.25, 1e-9);
EXPECT_NEAR(state.unconfirmedBalance, 0.0, 1e-9);
const auto baseline = Refresh::nextTotalBaseline(result, plan.prevTotalZat);
EXPECT_FALSE(baseline.has_value());
// The next poll: no baseline, so the spendable call runs again and finds the unconfirmed funds.
MockRefreshRpc retry;
retry.addResponse("getblockchaininfo", chain);
retry.addResponse("z_gettotalbalance", total);
retry.addResponse("z_gettotalbalance", spendable);
Refresh::CoreRefreshPlan next;
next.needSpendable = false;
next.prevTotalZat = baseline;
auto healed = Refresh::collectCoreRefreshResult(retry, next);
EXPECT_TRUE(retry.methodNames() == twoCalls);
dragonx::WalletState healedState;
Refresh::applyCoreRefreshResult(healedState, healed, 1001);
EXPECT_NEAR(healedState.unconfirmedBalance, 1.0, 1e-9);
EXPECT_TRUE(Refresh::nextTotalBaseline(healed, baseline) == std::optional<std::int64_t>(425000000));
}
}
// Exact daemon amounts in zatoshis, so "did the total move" never trips on floating-point rounding.
void testParseMoneyZat()
{
using Refresh = dragonx::services::NetworkRefreshService;
using Z = std::optional<std::int64_t>;
EXPECT_TRUE(Refresh::parseMoneyZat("4.25000000") == Z(425000000));
EXPECT_TRUE(Refresh::parseMoneyZat("0.00000000") == Z(0));
EXPECT_TRUE(Refresh::parseMoneyZat("12") == Z(1200000000));
EXPECT_TRUE(Refresh::parseMoneyZat("0.1") == Z(10000000));
EXPECT_TRUE(Refresh::parseMoneyZat("0.00000001") == Z(1));
EXPECT_TRUE(Refresh::parseMoneyZat("-1.50000000") == Z(-150000000));
EXPECT_TRUE(Refresh::parseMoneyZat("92233720367.99999999") == Z(9223372036799999999LL));
EXPECT_FALSE(Refresh::parseMoneyZat("9999999999999999999").has_value()); // would overflow
EXPECT_FALSE(Refresh::parseMoneyZat("").has_value());
EXPECT_FALSE(Refresh::parseMoneyZat("-").has_value());
EXPECT_FALSE(Refresh::parseMoneyZat(".5").has_value());
EXPECT_FALSE(Refresh::parseMoneyZat("1.").has_value());
EXPECT_FALSE(Refresh::parseMoneyZat("1.123456789").has_value()); // finer than a zatoshi
EXPECT_FALSE(Refresh::parseMoneyZat("1e5").has_value());
EXPECT_FALSE(Refresh::parseMoneyZat(" 1.0").has_value());
EXPECT_FALSE(Refresh::parseMoneyZat("1.0 ").has_value());
}
void testSpendableBalanceNeeded()
{
using Refresh = dragonx::services::NetworkRefreshService;
dragonx::WalletState state;
EXPECT_TRUE(Refresh::spendableBalanceNeeded(state, false, false)); // never fetched
state.last_balance_update = 1000;
EXPECT_FALSE(Refresh::spendableBalanceNeeded(state, false, false));
EXPECT_TRUE(Refresh::spendableBalanceNeeded(state, true, false)); // a send in flight
EXPECT_TRUE(Refresh::spendableBalanceNeeded(state, false, true)); // forced (the user's own send)
state.unconfirmedBalance = 0.5;
EXPECT_TRUE(Refresh::spendableBalanceNeeded(state, false, false));
state.unconfirmedBalance = 0.0;
dragonx::AddressInfo z;
z.address = "zs-one";
z.balance = 2.0;
z.spendableBalance = 1.0; // the address scan saw unconfirmed funds
state.z_addresses.push_back(z);
EXPECT_TRUE(Refresh::spendableBalanceNeeded(state, false, false));
state.z_addresses[0].spendableBalance = 2.0;
EXPECT_FALSE(Refresh::spendableBalanceNeeded(state, false, false));
dragonx::AddressInfo t;
t.address = "R-one";
t.balance = 1.0;
t.spendableBalance = 0.0;
state.t_addresses.push_back(t);
EXPECT_TRUE(Refresh::spendableBalanceNeeded(state, false, false));
}
// C34: z_getoperationstatus filtered to our opids. Its one argument is an ARRAY of ids, so the params are
// [[id, ...]]; the old ["id"] attempt was rejected. An empty filter would mean every op, so none at all.
void testOperationFilterParams()
{
using Refresh = dragonx::services::NetworkRefreshService;
auto one = Refresh::buildOperationFilterParams({"opid-1"});
EXPECT_TRUE(one.has_value());
EXPECT_EQ(one->dump(), std::string("[[\"opid-1\"]]"));
auto two = Refresh::buildOperationFilterParams({"a", "", "b"});
EXPECT_TRUE(two.has_value());
EXPECT_EQ(two->dump(), std::string("[[\"a\",\"b\"]]"));
EXPECT_FALSE(Refresh::buildOperationFilterParams({}).has_value());
EXPECT_FALSE(Refresh::buildOperationFilterParams({""}).has_value());
}
// C211: the Merge/Consolidate dialog's scope scan (listunspent + z_listunspent, two whole-wallet scans)
// used to be re-posted on the frame after any failure, forever on Lite. One scan at a time, a failure
// latches until Retry, and only "node not ready" failures retry by themselves (backed off, 3 times).
void testScopeScanAndLockedNotes()
{
using namespace dragonx::services;
using dragonx::rpc::RpcFailureKind;
ScopeScan scan;
ScopeInputs in;
in.mergeMode = true;
in.nodeReady = true;
in.now = 100.0;
scopeScanOpen(scan, 100.0);
EXPECT_TRUE(scopeScanWant(scan, in) == ScopeWant::Post);
in.mergeMode = false;
EXPECT_TRUE(scopeScanWant(scan, in) == ScopeWant::Nothing);
in.mergeMode = true;
in.nodeReady = false;
EXPECT_TRUE(scopeScanWant(scan, in) == ScopeWant::WaitNode);
in.nodeReady = true;
in.busyWithOp = true;
EXPECT_TRUE(scopeScanWant(scan, in) == ScopeWant::Nothing);
in.busyWithOp = false;
auto gen = scopeScanBegin(scan, 100.0);
EXPECT_TRUE(scan.phase == ScopePhase::Loading);
EXPECT_TRUE(scan.inFlight);
EXPECT_TRUE(scopeScanWant(scan, in) == ScopeWant::Nothing); // one scan at a time
// A real error latches until Retry: no re-scan by itself, whenever the next frame comes.
EXPECT_FALSE(scopeScanFinish(scan, gen, "Insufficient permissions", RpcFailureKind::AppError, 101.0));
EXPECT_TRUE(scan.phase == ScopePhase::Failed);
EXPECT_FALSE(scan.inFlight);
EXPECT_FALSE(scan.autoRetry);
in.now = 10000.0;
EXPECT_TRUE(scopeScanWant(scan, in) == ScopeWant::Nothing);
scopeScanRetry(scan);
EXPECT_TRUE(scopeScanWant(scan, in) == ScopeWant::Post);
// ...as does a reply timeout (the daemon is still running that scan).
gen = scopeScanBegin(scan, 10000.0);
EXPECT_FALSE(scopeScanFinish(scan, gen, "Timeout was reached", RpcFailureKind::ReplyTimeout, 10001.0));
EXPECT_FALSE(scan.autoRetry);
in.now = 20000.0;
EXPECT_TRUE(scopeScanWant(scan, in) == ScopeWant::Nothing);
// Node not ready: retries by itself after 10, 30 and 60 s, then latches.
scopeScanRetry(scan);
gen = scopeScanBegin(scan, 20000.0);
scopeScanFinish(scan, gen, "Could not connect", RpcFailureKind::NotListening, 20000.0);
EXPECT_TRUE(scan.autoRetry);
in.now = 20009.0;
EXPECT_TRUE(scopeScanWant(scan, in) == ScopeWant::WaitBackoff);
in.now = 20010.0;
EXPECT_TRUE(scopeScanWant(scan, in) == ScopeWant::Post);
gen = scopeScanBegin(scan, 20010.0);
scopeScanFinish(scan, gen, "Could not connect", RpcFailureKind::NotListening, 20010.0);
in.now = 20039.0;
EXPECT_TRUE(scopeScanWant(scan, in) == ScopeWant::WaitBackoff);
in.now = 20040.0;
EXPECT_TRUE(scopeScanWant(scan, in) == ScopeWant::Post);
gen = scopeScanBegin(scan, 20040.0);
scopeScanFinish(scan, gen, "Could not connect", RpcFailureKind::NotListening, 20040.0);
EXPECT_NEAR(scan.retryAt, 20100.0, 1e-9);
in.now = 20100.0;
gen = scopeScanBegin(scan, 20100.0);
scopeScanFinish(scan, gen, "Could not connect", RpcFailureKind::NotListening, 20100.0);
EXPECT_FALSE(scan.autoRetry); // a 4th failure: wait for Retry
in.now = 30000.0;
EXPECT_TRUE(scopeScanWant(scan, in) == ScopeWant::Nothing);
EXPECT_TRUE(scopeFailureRetries(RpcFailureKind::Other, "Not connected"));
EXPECT_FALSE(scopeFailureRetries(RpcFailureKind::Other, "[json.exception.type_error.302] type must be number"));
// A result from an earlier session (the connection changed meanwhile) is dropped, but frees the guard.
ScopeScan old;
scopeScanOpen(old, 0.0);
const auto oldGen = scopeScanBegin(old, 0.0);
scopeScanInvalidate(old);
EXPECT_TRUE(old.inFlight);
in.now = 1.0;
EXPECT_TRUE(scopeScanWant(old, in) == ScopeWant::Nothing); // the old scan still runs in the daemon
EXPECT_FALSE(scopeScanFinish(old, oldGen, "", RpcFailureKind::None, 1.0));
EXPECT_FALSE(old.inFlight);
EXPECT_TRUE(old.phase == ScopePhase::Idle);
EXPECT_TRUE(scopeScanWant(old, in) == ScopeWant::Post);
// Closing and reopening while a scan loads adopts it (no second pair of scans)...
ScopeScan adopt;
scopeScanOpen(adopt, 0.0);
const auto adoptGen = scopeScanBegin(adopt, 0.0);
scopeScanOpen(adopt, 5.0);
EXPECT_TRUE(adopt.gen == adoptGen);
EXPECT_TRUE(adopt.inFlight);
EXPECT_TRUE(scopeScanFinish(adopt, adoptGen, "", RpcFailureKind::None, 6.0));
EXPECT_TRUE(adopt.phase == ScopePhase::Loaded);
// ...unless it has been outstanding past the limit: presumed lost, a new session starts.
ScopeScan lost;
scopeScanOpen(lost, 0.0);
const auto lostGen = scopeScanBegin(lost, 0.0);
scopeScanOpen(lost, kScopeInFlightMaxSec + 1.0);
EXPECT_TRUE(lost.gen != lostGen);
EXPECT_FALSE(lost.inFlight);
const auto freshGen = scopeScanBegin(lost, kScopeInFlightMaxSec + 2.0);
EXPECT_FALSE(scopeScanFinish(lost, lostGen, "", RpcFailureKind::None, kScopeInFlightMaxSec + 3.0));
EXPECT_TRUE(lost.inFlight); // the late result doesn't clear the fresh scan's guard
EXPECT_TRUE(scopeScanFinish(lost, freshGen, "", RpcFailureKind::None, kScopeInFlightMaxSec + 4.0));
// Locked notes (held by an operation in flight) can't be merged, so they aren't counted.
using json = nlohmann::json;
const json notes = json::array({
json{{"address", "zs-a"}, {"confirmations", 3}, {"amount", 1.0}},
json{{"address", "zs-a"}, {"confirmations", 3}, {"amount", 1.0}, {"locked", true}},
json{{"address", "zs-a"}, {"confirmations", 3}, {"amount", 1.0}, {"locked", false}},
});
const auto groups = groupSpendableNotes(notes);
EXPECT_EQ(groups.size(), static_cast<size_t>(1));
EXPECT_EQ(groups[0].notes, 2);
EXPECT_TRUE(noteIsLocked(json{{"locked", true}}));
EXPECT_FALSE(noteIsLocked(json{{"locked", "yes"}}));
EXPECT_FALSE(noteIsLocked(json::array()));
}
int main()
{
testConnectionConfig();
@@ -9515,6 +9859,10 @@ int main()
testNetworkRefreshSnapshotHelpers();
testParseDaemonRescanOutput();
testNetworkRefreshRpcCollectors();
testCoreRefreshBalancePlan();
testParseMoneyZat();
testSpendableBalanceNeeded();
testOperationFilterParams();
testNetworkRefreshResultModels();
testTrackedSendRetirement();
testBalanceDisplayLerp();
@@ -9530,6 +9878,7 @@ int main()
testOwnershipRecord();
testDebugLogTailRules();
testNoteConsolidation();
testScopeScanAndLockedNotes();
testStopDeliveryPolicy();
testProcStatParse();
testStopProgressTracker();