perf(balance): stop large-wallet balance polls from saturating the node

On a wallet with a very large shielded note set, the daemon re-decrypts all
notes under cs_main on every z_gettotalbalance / z_listunspent (GetFilteredNotes),
~15-33s per pass. The GUI issued these roughly every 15s and timed them out at
the 30s RPC limit, so the node looked permanently "busy" and per-address
balances read 0 (nothing to carry on a fresh session) while the total was fine.

Two wallet-side mitigations:
- A cost-proportional floor on VISIBLE balance polls (wallet_scan_governor): the
  adaptive backoff previously applied only when the balance was off-screen. It
  now also holds a visible poll until ~3x the measured scan cost has elapsed, so
  a wallet whose sweep costs tens of seconds can't re-poll before the node
  recovers. Negligible for small wallets (cost ~ms); forced / first-read bypass it.
- The heavy balance RPCs (z_gettotalbalance, z_listunspent) now use a 120s reply
  timeout via the existing callLong path, so a slow pass completes and populates
  per-address balances instead of aborting at 30s and leaving them at 0.

The root fix is daemon-side (cache decrypted notes so GetFilteredNotes isn't
O(notes x decrypt) per call); specced separately. Adds governor test coverage.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-10-16 16:48:24 -05:00
co-authored by Claude Opus 4.8
parent 3cda8c9e31
commit 46371c69df
3 changed files with 33 additions and 3 deletions
+12
View File
@@ -16991,6 +16991,18 @@ void testBalancePollDecisionAndVisibility()
{ auto x = back; x.changed = false; EXPECT_TRUE(decideBalancePoll(x) == D::Skip); }
{ auto x = back; x.visible = false; EXPECT_TRUE(decideBalancePoll(x) == D::Skip); } // hidden: its own interval
// Visible balance on a costly wallet: a whole-wallet sweep is floored to ~3x its own cost, so it can't
// re-poll before the node recovers (what saturates cs_main on huge note sets). Small wallets (cost ~ms)
// are effectively unaffected, and the forced/first-read paths bypass the floor entirely.
{ auto x = in; x.emaCostSec = 20.0; x.sinceLastScanSec = 30.0; // 30 < 3*20 = 60 -> hold off
EXPECT_TRUE(decideBalancePoll(x) == D::Skip); }
{ auto x = in; x.emaCostSec = 20.0; x.sinceLastScanSec = 70.0; // 70 >= 60 -> allowed
EXPECT_TRUE(decideBalancePoll(x) == D::AskGovernor); }
{ auto x = in; x.emaCostSec = 0.01; x.sinceLastScanSec = 5.0; // cheap wallet: floor ~0, unchanged
EXPECT_TRUE(decideBalancePoll(x) == D::AskGovernor); }
{ auto x = in; x.emaCostSec = 20.0; x.sinceLastScanSec = 1.0; x.forced = true; // user's own send bypasses it
EXPECT_TRUE(decideBalancePoll(x) == D::Explicit); }
using NP = dragonx::ui::NavPage;
auto overview = scanVisibilityForPage(NP::Overview, false);
EXPECT_TRUE(overview.balance && overview.addresses && overview.history && !overview.chat);