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