perf(net): hold wallet scans at connect, in the background and on tab entry
- Connect gate: after the node starts answering, wallet scans wait until the lock state is known and the node knows its tip (or the wallet has fewer than 500 transactions), at most 20 s. A node already behind latches the sync profile from the connect's getinfo. Meanwhile a list-only pass keeps the address list and the cached history up, and the session's first balance and per-address balances are read once even while catching up, so the wallet shows its last-synced values instead of 0. - Background: minimized, hidden, or covered without focus (3 s debounce, any input cancels) polls only the chain state every 15 s. Wallet scans, the mining poll, price, nudges and auto-lock interaction tracking pause; auto-lock now fires while minimized. Leaving a held profile marks every timer due again. - Tab entry marks the timers due instead of forcing scans; a stale list or a never-loaded history still loads on entry. - The scan entry points own the hard gates, failed enumerations keep the previous lists behind a retry hold, and failed balance calls never overwrite known balances with 0. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
+349
-6
@@ -29,6 +29,8 @@
|
||||
#include "resources/embedded_resources.h"
|
||||
#include "services/history_scan_policy.h"
|
||||
#include "services/wallet_scan_governor.h"
|
||||
#include "services/scan_policy.h"
|
||||
#include "services/connect_scan_gate.h"
|
||||
#include "services/network_refresh_service.h"
|
||||
#include "services/refresh_scheduler.h"
|
||||
#include "services/note_consolidation.h"
|
||||
@@ -1791,11 +1793,11 @@ void testNetworkRefreshRpcCollectors()
|
||||
auto addresses = Refresh::collectAddressRefreshResult(addressRpc);
|
||||
EXPECT_TRUE(addressRpc.methodNames() == std::vector<std::string>({
|
||||
"z_listaddresses", "z_validateaddress", "z_validateaddress",
|
||||
"z_listunspent", "getaddressesbyaccount", "listunspent"
|
||||
"getaddressesbyaccount", "z_listunspent", "listunspent" // both lists first, then the balances
|
||||
}));
|
||||
EXPECT_EQ(addressRpc.calls[1].params, json::array({"zs-one"}));
|
||||
EXPECT_EQ(addressRpc.calls[2].params, json::array({"zs-two"}));
|
||||
EXPECT_EQ(addressRpc.calls[4].params, json::array({""}));
|
||||
EXPECT_EQ(addressRpc.calls[3].params, json::array({""}));
|
||||
EXPECT_EQ(addresses.shieldedAddresses.size(), static_cast<size_t>(2));
|
||||
EXPECT_TRUE(addresses.shieldedAddresses[0].has_spending_key);
|
||||
EXPECT_FALSE(addresses.shieldedAddresses[1].has_spending_key);
|
||||
@@ -1812,8 +1814,8 @@ void testNetworkRefreshRpcCollectors()
|
||||
fallbackRpc.addResponse("listunspent", json::array());
|
||||
auto fallbackAddresses = Refresh::collectAddressRefreshResult(fallbackRpc);
|
||||
EXPECT_TRUE(fallbackRpc.methodNames() == std::vector<std::string>({
|
||||
"z_listaddresses", "z_validateaddress", "z_listunspent",
|
||||
"z_getbalance", "z_getbalance", "getaddressesbyaccount", "listunspent" // display (minconf=0) + spendable (minconf=1)
|
||||
"z_listaddresses", "z_validateaddress", "getaddressesbyaccount", "z_listunspent",
|
||||
"z_getbalance", "z_getbalance", "listunspent" // display (minconf=0) + spendable (minconf=1)
|
||||
}));
|
||||
EXPECT_TRUE(fallbackAddresses.shieldedAddresses[0].has_spending_key);
|
||||
EXPECT_NEAR(fallbackAddresses.shieldedAddresses[0].balance, 4.75, 0.00000001);
|
||||
@@ -1829,8 +1831,8 @@ void testNetworkRefreshRpcCollectors()
|
||||
cachedAddressRpc.addResponse("listunspent", json::array());
|
||||
auto cachedAddresses = Refresh::collectAddressRefreshResult(cachedAddressRpc, addressSnapshot);
|
||||
EXPECT_TRUE(cachedAddressRpc.methodNames() == std::vector<std::string>({
|
||||
"z_listaddresses", "z_validateaddress", "z_listunspent",
|
||||
"getaddressesbyaccount", "listunspent"
|
||||
"z_listaddresses", "z_validateaddress", "getaddressesbyaccount",
|
||||
"z_listunspent", "listunspent"
|
||||
}));
|
||||
EXPECT_FALSE(cachedAddresses.shieldedAddresses[0].has_spending_key);
|
||||
EXPECT_TRUE(cachedAddresses.shieldedAddresses[1].has_spending_key);
|
||||
@@ -10876,6 +10878,14 @@ void testBalancePollDecisionAndVisibility()
|
||||
EXPECT_TRUE(decideBalancePoll(in) == D::AskGovernor);
|
||||
{ auto x = in; x.allowBalance = false; x.forced = true; EXPECT_TRUE(decideBalancePoll(x) == D::Skip); }
|
||||
{ auto x = in; x.syncing = true; x.forced = true; EXPECT_TRUE(decideBalancePoll(x) == D::Skip); }
|
||||
{ auto x = in; x.syncing = true; x.neverFetched = true; x.changed = false; // the first read: while behind too
|
||||
EXPECT_TRUE(decideBalancePoll(x) == D::Explicit); }
|
||||
{ auto x = in; x.syncing = true; x.neverFetched = true; x.firstReadTried = true; // once per connection
|
||||
EXPECT_TRUE(decideBalancePoll(x) == D::Skip); }
|
||||
{ auto x = in; x.syncing = true; x.neverFetched = true; x.background = true; // only when it can be seen
|
||||
EXPECT_TRUE(decideBalancePoll(x) == D::Skip); }
|
||||
{ auto x = in; x.neverFetched = true; x.firstReadTried = true; x.background = true; // at the tip: as before
|
||||
EXPECT_TRUE(decideBalancePoll(x) == D::Explicit); }
|
||||
{ auto x = in; x.forced = true; x.changed = false; EXPECT_TRUE(decideBalancePoll(x) == D::Explicit); }
|
||||
{ auto x = in; x.neverFetched = true; x.changed = false; EXPECT_TRUE(decideBalancePoll(x) == D::Explicit); }
|
||||
{ auto x = in; x.changed = false; EXPECT_TRUE(decideBalancePoll(x) == D::Skip); }
|
||||
@@ -10955,6 +10965,29 @@ void testCoreRefreshMempool()
|
||||
Refresh::collectCoreRefreshResult(rpc, plan);
|
||||
EXPECT_TRUE(rpc.methodNames() == std::vector<std::string>({"getblockchaininfo", "getmempoolinfo"}));
|
||||
}
|
||||
{ // The first read while behind skips the spendable (minconf=1) call: nothing can be sent mid-catch-up.
|
||||
MockRefreshRpc rpc;
|
||||
rpc.addResponse("getblockchaininfo", json{{"blocks", 100}, {"headers", 150}, {"longestchain", 150}});
|
||||
rpc.addResponse("getmempoolinfo", json{{"size", 0}, {"bytes", 0}});
|
||||
rpc.addResponse("z_gettotalbalance", total);
|
||||
auto first = plan;
|
||||
first.evenIfBehind = true;
|
||||
first.needSpendable = true;
|
||||
first.prevTotalZat.reset();
|
||||
Refresh::collectCoreRefreshResult(rpc, first);
|
||||
EXPECT_TRUE(rpc.methodNames() == std::vector<std::string>({"getblockchaininfo", "getmempoolinfo", "z_gettotalbalance"}));
|
||||
}
|
||||
{ // ...unless it is the session's first read.
|
||||
MockRefreshRpc rpc;
|
||||
rpc.addResponse("getblockchaininfo", json{{"blocks", 100}, {"headers", 150}, {"longestchain", 150}});
|
||||
rpc.addResponse("getmempoolinfo", json{{"size", 0}, {"bytes", 0}});
|
||||
rpc.addResponse("z_gettotalbalance", total);
|
||||
auto first = plan;
|
||||
first.evenIfBehind = true;
|
||||
auto result = Refresh::collectCoreRefreshResult(rpc, first);
|
||||
EXPECT_TRUE(rpc.methodNames() == std::vector<std::string>({"getblockchaininfo", "getmempoolinfo", "z_gettotalbalance"}));
|
||||
EXPECT_TRUE(result.balanceOk);
|
||||
}
|
||||
{ // The chain read found the node busy: nothing else.
|
||||
MockRefreshRpc rpc;
|
||||
rpc.addException("getblockchaininfo", std::make_exception_ptr(RpcTransportError(
|
||||
@@ -10997,6 +11030,311 @@ void testRefreshSchedulerRetrySoon()
|
||||
EXPECT_TRUE(gate.busy());
|
||||
}
|
||||
|
||||
// C134: the refresh profile in force (background > syncing > page), and why leaving a held profile re-marks.
|
||||
void testRefreshProfileSelection()
|
||||
{
|
||||
using dragonx::services::RefreshScheduler;
|
||||
using Timer = RefreshScheduler::Timer;
|
||||
using NP = dragonx::ui::NavPage;
|
||||
auto same = [](const RefreshScheduler::Intervals& a, const RefreshScheduler::Intervals& b) {
|
||||
return a.core == b.core && a.transactions == b.transactions && a.addresses == b.addresses && a.peers == b.peers;
|
||||
};
|
||||
EXPECT_TRUE(same(RefreshScheduler::kBackgroundProfile, RefreshScheduler::Intervals{15.0f, 0.0f, 0.0f, 0.0f}));
|
||||
for (auto page : {NP::Overview, NP::Send, NP::History, NP::Mining, NP::Console}) {
|
||||
EXPECT_TRUE(same(RefreshScheduler::selectIntervals(page, false, false), RefreshScheduler::intervalsForPage(page)));
|
||||
EXPECT_TRUE(same(RefreshScheduler::selectIntervals(page, true, false), RefreshScheduler::kSyncProfile));
|
||||
EXPECT_TRUE(same(RefreshScheduler::selectIntervals(page, true, true), RefreshScheduler::kBackgroundProfile));
|
||||
EXPECT_TRUE(same(RefreshScheduler::selectIntervals(page, false, true), RefreshScheduler::kBackgroundProfile));
|
||||
// The sync settle window outlasts two Core polls in every profile.
|
||||
EXPECT_TRUE(RefreshScheduler::selectIntervals(page, false, false).core * 2.0f <= RefreshScheduler::kSyncSettleSeconds);
|
||||
}
|
||||
{ // In the background only Core comes due; back on a page, everything that accumulated is due.
|
||||
RefreshScheduler scheduler;
|
||||
scheduler.setIntervals(RefreshScheduler::kBackgroundProfile);
|
||||
scheduler.tick(600.0f);
|
||||
EXPECT_TRUE(scheduler.isDue(Timer::Core));
|
||||
EXPECT_FALSE(scheduler.isDue(Timer::Transactions));
|
||||
EXPECT_FALSE(scheduler.isDue(Timer::Addresses));
|
||||
EXPECT_FALSE(scheduler.isDue(Timer::Peers));
|
||||
scheduler.setIntervals(RefreshScheduler::intervalsForPage(NP::Overview));
|
||||
EXPECT_TRUE(scheduler.isDue(Timer::Transactions));
|
||||
EXPECT_TRUE(scheduler.isDue(Timer::Addresses));
|
||||
EXPECT_TRUE(scheduler.isDue(Timer::Peers));
|
||||
}
|
||||
{ // A mark during a held profile zeroes the timer (lost): leaving it must mark again.
|
||||
RefreshScheduler scheduler;
|
||||
scheduler.setIntervals(RefreshScheduler::kBackgroundProfile);
|
||||
scheduler.tick(600.0f);
|
||||
scheduler.markDue(Timer::Addresses);
|
||||
scheduler.setIntervals(RefreshScheduler::intervalsForPage(NP::Overview));
|
||||
EXPECT_FALSE(scheduler.isDue(Timer::Addresses));
|
||||
scheduler.markImmediateRefresh();
|
||||
EXPECT_TRUE(scheduler.isDue(Timer::Addresses));
|
||||
}
|
||||
}
|
||||
|
||||
// C44: what the connect's getinfo says about the tip, and the txcount from getwalletinfo.
|
||||
void testConnectionInfoSeeding()
|
||||
{
|
||||
using Refresh = dragonx::services::NetworkRefreshService;
|
||||
using json = nlohmann::json;
|
||||
auto info = [](int blocks, int longest) {
|
||||
return Refresh::parseConnectionInfoResult(json{{"version", 120000}, {"blocks", blocks}, {"longestchain", longest}});
|
||||
};
|
||||
EXPECT_FALSE(Refresh::connectionInfoTipKnown(info(100, 0))); // no UpdateTip yet
|
||||
EXPECT_FALSE(Refresh::connectionInfoShowsBehind(info(100, 0)));
|
||||
EXPECT_TRUE(Refresh::connectionInfoTipKnown(info(100, 110)));
|
||||
EXPECT_TRUE(Refresh::connectionInfoShowsBehind(info(100, 110)));
|
||||
EXPECT_FALSE(Refresh::connectionInfoShowsBehind(info(108, 110))); // within the 2-block tolerance
|
||||
EXPECT_FALSE(Refresh::connectionInfoTipKnown(Refresh::ConnectionInfoResult{})); // not ok
|
||||
// Applying it leaves the sync state to the Core poll.
|
||||
dragonx::WalletState state;
|
||||
Refresh::applyConnectionInfoResult(state, info(100, 110));
|
||||
EXPECT_FALSE(state.sync.syncing);
|
||||
EXPECT_FALSE(state.sync.tip_known);
|
||||
|
||||
auto encrypted = Refresh::parseWalletEncryptionResult(json{{"unlocked_until", 0}, {"txcount", 5123}});
|
||||
EXPECT_TRUE(encrypted.txCount && *encrypted.txCount == 5123);
|
||||
auto plain = Refresh::parseWalletEncryptionResult(json{{"txcount", 42}});
|
||||
EXPECT_TRUE(plain.txCount && *plain.txCount == 42);
|
||||
EXPECT_FALSE(static_cast<bool>(Refresh::parseWalletEncryptionResult(json::object()).txCount));
|
||||
dragonx::WalletState wallet;
|
||||
EXPECT_EQ(wallet.wallet_tx_count, -1);
|
||||
Refresh::applyWalletEncryptionResult(wallet, plain);
|
||||
EXPECT_EQ(wallet.wallet_tx_count, 42);
|
||||
Refresh::applyWalletEncryptionResult(wallet, Refresh::parseWalletEncryptionResult(json::object()));
|
||||
EXPECT_EQ(wallet.wallet_tx_count, 42); // unknown doesn't erase it
|
||||
wallet.clear();
|
||||
EXPECT_EQ(wallet.wallet_tx_count, -1);
|
||||
}
|
||||
|
||||
void testConnectScanGate()
|
||||
{
|
||||
using dragonx::services::ConnectScanGate;
|
||||
using Clock = ConnectScanGate::Clock;
|
||||
const auto t0 = Clock::now();
|
||||
auto at = [&](double s) { return t0 + std::chrono::duration_cast<Clock::duration>(std::chrono::duration<double>(s)); };
|
||||
ConnectScanGate gate;
|
||||
EXPECT_TRUE(gate.open(at(0), false, -1)); // unarmed
|
||||
EXPECT_FALSE(gate.consumeOpenEdge(at(0), true, 10));
|
||||
gate.arm(at(0), false);
|
||||
EXPECT_FALSE(gate.open(at(19.9), true, -1)); // tip unknown, size unknown: the grace
|
||||
EXPECT_TRUE(gate.open(at(20.0), false, -1));
|
||||
gate.arm(at(0), true);
|
||||
EXPECT_FALSE(gate.open(at(1), false, -1)); // tip known, lock state not yet
|
||||
EXPECT_TRUE(gate.open(at(1), true, -1));
|
||||
gate.arm(at(0), false);
|
||||
EXPECT_TRUE(gate.open(at(1), true, 100)); // small wallet: no wait for the tip
|
||||
EXPECT_FALSE(gate.open(at(1), true, 600));
|
||||
EXPECT_FALSE(gate.open(at(1), false, 100)); // ...but the lock state first
|
||||
gate.noteTipKnown();
|
||||
EXPECT_TRUE(gate.open(at(1), true, 600));
|
||||
// The edge fires once per arm.
|
||||
gate.arm(at(0), false);
|
||||
EXPECT_FALSE(gate.consumeOpenEdge(at(5), true, 600));
|
||||
EXPECT_TRUE(gate.consumeOpenEdge(at(21), true, 600));
|
||||
EXPECT_FALSE(gate.consumeOpenEdge(at(22), true, 600));
|
||||
gate.arm(at(30), false); // re-arm restarts the grace
|
||||
EXPECT_FALSE(gate.open(at(45), true, 600));
|
||||
EXPECT_TRUE(gate.consumeOpenEdge(at(50), true, 600));
|
||||
gate.arm(at(60), false);
|
||||
gate.disarm();
|
||||
EXPECT_TRUE(gate.open(at(61), false, -1));
|
||||
EXPECT_FALSE(gate.consumeOpenEdge(at(61), true, 100));
|
||||
}
|
||||
|
||||
void testScanPolicy()
|
||||
{
|
||||
using namespace dragonx::services;
|
||||
using NP = dragonx::ui::NavPage;
|
||||
ScanContext ok;
|
||||
ok.connected = true;
|
||||
EXPECT_TRUE(wholeWalletScanAllowed(ok));
|
||||
{
|
||||
auto c = ok; c.connected = false; EXPECT_FALSE(wholeWalletScanAllowed(c));
|
||||
c = ok; c.locked = true; EXPECT_FALSE(wholeWalletScanAllowed(c)); EXPECT_TRUE(nodeCanAnswerScans(c));
|
||||
c = ok; c.warmingUp = true; EXPECT_FALSE(wholeWalletScanAllowed(c));
|
||||
c = ok; c.runtimeRescan = true; EXPECT_FALSE(wholeWalletScanAllowed(c));
|
||||
c = ok; c.rpcBusy = true; EXPECT_FALSE(wholeWalletScanAllowed(c));
|
||||
c = ok; c.syncing = true; EXPECT_FALSE(wholeWalletScanAllowed(c)); EXPECT_TRUE(nodeCanAnswerScans(c));
|
||||
c = ok; c.gateOpen = false; EXPECT_FALSE(wholeWalletScanAllowed(c)); EXPECT_TRUE(nodeCanAnswerScans(c));
|
||||
c = ok; c.background = true; EXPECT_FALSE(wholeWalletScanAllowed(c)); EXPECT_TRUE(nodeCanAnswerScans(c));
|
||||
}
|
||||
EXPECT_TRUE(planAddressScan(ok, false) == ScanTier::Full);
|
||||
for (auto field : {0, 1, 2, 3}) { // held scans: a stale list still loads, list-only
|
||||
auto c = ok;
|
||||
if (field == 0) c.syncing = true;
|
||||
if (field == 1) c.gateOpen = false;
|
||||
if (field == 2) c.locked = true;
|
||||
if (field == 3) c.background = true;
|
||||
EXPECT_TRUE(planAddressScan(c, true) == ScanTier::ListOnly);
|
||||
EXPECT_TRUE(planAddressScan(c, false) == ScanTier::None);
|
||||
}
|
||||
for (auto field : {0, 1, 2, 3}) { // the node can't answer: nothing at all
|
||||
auto c = ok;
|
||||
if (field == 0) c.connected = false;
|
||||
if (field == 1) c.warmingUp = true;
|
||||
if (field == 2) c.runtimeRescan = true;
|
||||
if (field == 3) c.rpcBusy = true;
|
||||
EXPECT_TRUE(planAddressScan(c, true, true) == ScanTier::None);
|
||||
}
|
||||
{ // The session's first balances: read once even while the node catches up, not before the gate opens,
|
||||
// nor in the background or locked (a stale list still loads list-only there).
|
||||
auto behind = ok; behind.syncing = true;
|
||||
EXPECT_TRUE(planAddressScan(behind, false, true) == ScanTier::Full);
|
||||
EXPECT_TRUE(planAddressScan(behind, true, true) == ScanTier::Full);
|
||||
EXPECT_TRUE(planAddressScan(behind, false, false) == ScanTier::None);
|
||||
auto gated = behind; gated.gateOpen = false;
|
||||
EXPECT_TRUE(planAddressScan(gated, true, true) == ScanTier::ListOnly);
|
||||
auto hidden = behind; hidden.background = true;
|
||||
EXPECT_TRUE(planAddressScan(hidden, true, true) == ScanTier::ListOnly);
|
||||
auto locked = behind; locked.locked = true;
|
||||
EXPECT_TRUE(planAddressScan(locked, false, true) == ScanTier::None);
|
||||
EXPECT_TRUE(firstReadAllowed(behind) && !wholeWalletScanAllowed(behind));
|
||||
}
|
||||
auto syncing = ok;
|
||||
syncing.syncing = true;
|
||||
{ // Receive, synced, list loaded: just mark the timer.
|
||||
auto p = planPageEntry(NP::Receive, ok, false, false);
|
||||
EXPECT_TRUE(p.markAddresses && p.addressesNow == ScanTier::None && !p.transactionsNow && !p.markTransactions);
|
||||
}
|
||||
{ // Receive, stale list, syncing: the list now, list-only.
|
||||
auto p = planPageEntry(NP::Receive, syncing, true, false);
|
||||
EXPECT_TRUE(p.addressesNow == ScanTier::ListOnly && !p.markAddresses);
|
||||
}
|
||||
{ // History, list loaded, history never loaded: the history now.
|
||||
auto p = planPageEntry(NP::History, ok, false, true);
|
||||
EXPECT_TRUE(p.transactionsNow && !p.markAddresses && p.addressesNow == ScanTier::None);
|
||||
}
|
||||
{ // History, stale list: the addresses first (their callback brings the history).
|
||||
auto p = planPageEntry(NP::History, ok, true, true);
|
||||
EXPECT_TRUE(p.addressesNow == ScanTier::Full && p.markTransactions && !p.transactionsNow);
|
||||
}
|
||||
{ // History while syncing, never loaded: wait (marked).
|
||||
auto p = planPageEntry(NP::History, syncing, false, true);
|
||||
EXPECT_TRUE(!p.transactionsNow && p.markTransactions);
|
||||
}
|
||||
{
|
||||
auto busy = ok; busy.rpcBusy = true;
|
||||
auto rescan = ok; rescan.runtimeRescan = true;
|
||||
EXPECT_FALSE(planPageEntry(NP::Overview, busy, false, false).core);
|
||||
EXPECT_FALSE(planPageEntry(NP::Overview, rescan, false, false).core);
|
||||
EXPECT_TRUE(planPageEntry(NP::Overview, ok, false, false).core);
|
||||
EXPECT_FALSE(planPageEntry(NP::Mining, rescan, false, false).mining);
|
||||
EXPECT_TRUE(planPageEntry(NP::Mining, ok, false, false).mining);
|
||||
EXPECT_FALSE(planPageEntry(NP::Peers, busy, false, false).peers);
|
||||
ScanContext off;
|
||||
auto p = planPageEntry(NP::Receive, off, true, true);
|
||||
EXPECT_TRUE(!p.core && !p.markAddresses && p.addressesNow == ScanTier::None && !p.markTransactions);
|
||||
}
|
||||
EXPECT_TRUE(windowLooksBackground(true, false, false, true));
|
||||
EXPECT_TRUE(windowLooksBackground(false, true, false, true));
|
||||
EXPECT_TRUE(windowLooksBackground(false, false, true, false));
|
||||
EXPECT_FALSE(windowLooksBackground(false, false, true, true)); // covered, but the app has focus
|
||||
EXPECT_FALSE(windowLooksBackground(false, false, false, false)); // focus loss alone
|
||||
EXPECT_FALSE(isBackgroundWindow(true, 2.9, 10.0));
|
||||
EXPECT_FALSE(isBackgroundWindow(true, 3.0, 1.0)); // input a second ago
|
||||
EXPECT_TRUE(isBackgroundWindow(true, 3.0, 2.0));
|
||||
EXPECT_FALSE(isBackgroundWindow(false, 100.0, 100.0));
|
||||
EXPECT_NEAR(addressRetryDelaySeconds(0.0), 10.0, 1e-9);
|
||||
EXPECT_NEAR(addressRetryDelaySeconds(20000.0), 200.0, 1e-9);
|
||||
EXPECT_NEAR(addressRetryDelaySeconds(30000.0, true), 10.0, 1e-9); // busy: the wait isn't its cost
|
||||
|
||||
// The balance is held while the connect gate is closed, even when forced or never read.
|
||||
BalancePollInputs in;
|
||||
in.gateOpen = false;
|
||||
in.forced = true;
|
||||
EXPECT_TRUE(decideBalancePoll(in) == BalancePollDecision::Skip);
|
||||
in.forced = false;
|
||||
in.neverFetched = true;
|
||||
EXPECT_TRUE(decideBalancePoll(in) == BalancePollDecision::Skip);
|
||||
}
|
||||
|
||||
// C44: the list-only address pass, and a failed enumeration not scanning balances for a short list.
|
||||
void testAddressListOnlyCollector()
|
||||
{
|
||||
using Refresh = dragonx::services::NetworkRefreshService;
|
||||
using json = nlohmann::json;
|
||||
{
|
||||
MockRefreshRpc rpc;
|
||||
rpc.addResponse("z_listaddresses", json::array({"zs-known", "zs-new"}));
|
||||
rpc.addResponse("z_validateaddress", json{{"ismine", true}});
|
||||
rpc.addResponse("getaddressesbyaccount", json::array({"R-known"}));
|
||||
dragonx::WalletState state;
|
||||
state.z_addresses.push_back({"zs-known", 1.5, "shielded", true});
|
||||
state.z_addresses.back().spendableBalance = 1.25;
|
||||
state.t_addresses.push_back({"R-known", 2.0, "transparent", true});
|
||||
auto snapshot = Refresh::buildAddressRefreshSnapshot(state);
|
||||
auto result = Refresh::collectAddressRefreshResult(rpc, snapshot, true);
|
||||
EXPECT_TRUE(rpc.methodNames() == std::vector<std::string>({"z_listaddresses", "z_validateaddress", "getaddressesbyaccount"}));
|
||||
EXPECT_TRUE(result.listOnly && result.addressListOk);
|
||||
Refresh::applyAddressRefreshResult(state, std::move(result));
|
||||
EXPECT_EQ(state.z_addresses.size(), static_cast<size_t>(2));
|
||||
EXPECT_NEAR(state.z_addresses[0].balance, 1.5, 1e-9); // carried over
|
||||
EXPECT_NEAR(state.z_addresses[0].spendableBalance, 1.25, 1e-9);
|
||||
EXPECT_NEAR(state.z_addresses[1].balance, 0.0, 1e-9); // new: 0 until the full pass
|
||||
EXPECT_NEAR(state.t_addresses[0].balance, 2.0, 1e-9);
|
||||
}
|
||||
{ // A failed enumeration scans no balances: the caller discards the whole pass.
|
||||
MockRefreshRpc rpc;
|
||||
rpc.addFailure("z_listaddresses", "Loading block index...");
|
||||
rpc.addResponse("getaddressesbyaccount", json::array({"R-one"}));
|
||||
auto result = Refresh::collectAddressRefreshResult(rpc);
|
||||
EXPECT_TRUE(rpc.methodNames() == std::vector<std::string>({"z_listaddresses", "getaddressesbyaccount"}));
|
||||
EXPECT_FALSE(result.addressListOk);
|
||||
EXPECT_FALSE(result.nodeBusy);
|
||||
}
|
||||
{
|
||||
MockRefreshRpc rpc;
|
||||
rpc.addResponse("z_listaddresses", json::array());
|
||||
rpc.addFailure("getaddressesbyaccount", "boom");
|
||||
auto result = Refresh::collectAddressRefreshResult(rpc);
|
||||
EXPECT_TRUE(rpc.methodNames() == std::vector<std::string>({"z_listaddresses", "getaddressesbyaccount"}));
|
||||
EXPECT_FALSE(result.addressListOk);
|
||||
}
|
||||
{ // A busy balance scan: no z_getbalance fallback (each would be held back and read 0), no listunspent,
|
||||
// and the pass degrades to list-only so applying it keeps the known balances.
|
||||
using dragonx::rpc::RpcFailureKind;
|
||||
using dragonx::rpc::RpcTransportError;
|
||||
MockRefreshRpc rpc;
|
||||
rpc.addResponse("z_listaddresses", json::array({"zs-a"}));
|
||||
rpc.addResponse("z_validateaddress", json{{"ismine", true}});
|
||||
rpc.addResponse("getaddressesbyaccount", json::array({"R-a"}));
|
||||
rpc.addException("z_listunspent", std::make_exception_ptr(RpcTransportError(
|
||||
RpcFailureKind::ReplyTimeout, "RPC error: reply timeout", CURLE_OPERATION_TIMEDOUT, 0, true)));
|
||||
auto result = Refresh::collectAddressRefreshResult(rpc);
|
||||
EXPECT_TRUE(rpc.methodNames() == std::vector<std::string>({"z_listaddresses", "z_validateaddress",
|
||||
"getaddressesbyaccount", "z_listunspent"}));
|
||||
EXPECT_TRUE(result.listOnly && !result.balancesOk && result.nodeBusy && result.unspentNotes.empty());
|
||||
EXPECT_TRUE(result.addressListOk);
|
||||
dragonx::WalletState state;
|
||||
state.z_addresses.push_back({"zs-a", 1.5, "shielded", true});
|
||||
state.t_addresses.push_back({"R-a", 2.0, "transparent", true});
|
||||
Refresh::applyAddressRefreshResult(state, std::move(result));
|
||||
EXPECT_NEAR(state.z_addresses[0].balance, 1.5, 1e-9);
|
||||
EXPECT_NEAR(state.t_addresses[0].balance, 2.0, 1e-9);
|
||||
}
|
||||
{ // A failed transparent balance call degrades the pass the same way.
|
||||
MockRefreshRpc rpc;
|
||||
rpc.addResponse("z_listaddresses", json::array());
|
||||
rpc.addResponse("getaddressesbyaccount", json::array({"R-a"}));
|
||||
rpc.addResponse("z_listunspent", json::array());
|
||||
rpc.addFailure("listunspent", "boom");
|
||||
auto result = Refresh::collectAddressRefreshResult(rpc);
|
||||
EXPECT_TRUE(result.listOnly && !result.balancesOk && !result.nodeBusy);
|
||||
}
|
||||
{ // A busy node's failure is marked so (its retry hold is the floor, not 10x the wait).
|
||||
using dragonx::rpc::RpcFailureKind;
|
||||
using dragonx::rpc::RpcTransportError;
|
||||
MockRefreshRpc rpc;
|
||||
rpc.addException("z_listaddresses", std::make_exception_ptr(RpcTransportError(
|
||||
RpcFailureKind::ReplyTimeout, "RPC error: reply timeout", CURLE_OPERATION_TIMEDOUT, 0, true)));
|
||||
rpc.addResponse("getaddressesbyaccount", json::array());
|
||||
auto result = Refresh::collectAddressRefreshResult(rpc);
|
||||
EXPECT_TRUE(!result.addressListOk && result.nodeBusy && result.replyTimedOut);
|
||||
}
|
||||
}
|
||||
|
||||
int main()
|
||||
{
|
||||
testConnectionConfig();
|
||||
@@ -11031,6 +11369,11 @@ int main()
|
||||
testBalancePollDecisionAndVisibility();
|
||||
testCoreRefreshMempool();
|
||||
testRefreshSchedulerRetrySoon();
|
||||
testRefreshProfileSelection();
|
||||
testConnectionInfoSeeding();
|
||||
testConnectScanGate();
|
||||
testScanPolicy();
|
||||
testAddressListOnlyCollector();
|
||||
testNetworkRefreshResultModels();
|
||||
testTrackedSendRetirement();
|
||||
testBalanceDisplayLerp();
|
||||
|
||||
Reference in New Issue
Block a user