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:
2026-10-09 11:06:34 -05:00
co-authored by Claude Opus 5.5
parent 323143e111
commit f86cc4d6b7
26 changed files with 1083 additions and 159 deletions
+349 -6
View File
@@ -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();