perf(net): one scan budget for all wallet scans, gated on what changed

The balance, address scan, history and probe were throttled one by one at
10% each, which added up to 30-40% of dragonxd's cs_main with all of them
due. services::WalletScanGovernor runs one wallet scan at a time across
both RPC lanes and keeps one shared quiet clock at 15% of wall-clock (cut
when the node is behind). Waiters are served first in, first out, with a
head start for what the page shows and a starvation floor of max(30 s
visible / 900 s hidden, 15x the stream's cost). Explicit work (a dirty
set, the user's send, a first load) is never refused but is charged.

Routine scans run only when the tip, the mempool size (getmempoolinfo:
no cs_main) or a wallet-mutation epoch changed since their last good scan,
or a safety sweep is due. Pages that don't show the balance poll it at
most every max(10 min, 50x its cost) (2 min with client-side auto-shield);
coming back into view with an old value fetches it at once. The staleness
badge counts from the last poll that confirmed nothing changed, and hides
while the budget holds the balance within its guarantee.

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 879de37d18
commit 323143e111
19 changed files with 1470 additions and 121 deletions
+527
View File
@@ -28,6 +28,7 @@
#include "rpc/rpc_busy_gate.h"
#include "resources/embedded_resources.h"
#include "services/history_scan_policy.h"
#include "services/wallet_scan_governor.h"
#include "services/network_refresh_service.h"
#include "services/refresh_scheduler.h"
#include "services/note_consolidation.h"
@@ -10476,6 +10477,526 @@ void testHistoryCollectorSweepAndEnrichment()
}
}
// C37: the scan governor's admission, floors, charging, change gate and lifecycle.
void testWalletScanGovernor()
{
using namespace dragonx::services;
using G = WalletScanGovernor;
ScanSignals sig;
sig.tipHash = "tip-a";
sig.mempoolKnown = true;
sig.mempoolTxs = 1;
sig.mempoolBytes = 100;
{ // One scan at a time; after it, quiet until it was `budget` (15%) of the time since it started.
G gov;
auto t = gov.tryAdmit(ScanStream::Balance, true, sig, 0.0);
EXPECT_TRUE(static_cast<bool>(t));
EXPECT_FALSE(static_cast<bool>(gov.tryAdmit(ScanStream::Addresses, true, sig, 0.5))); // one at a time
gov.complete(t, 1000.0, ScanOutcome::Ok, 1.0);
EXPECT_NEAR(gov.quietUntil(), 1.0 + 1.0 * (1.0 / 0.15 - 1.0), 1e-6); // ~6.67
EXPECT_FALSE(static_cast<bool>(gov.tryAdmit(ScanStream::Balance, true, sig, 6.5))); // quiet
EXPECT_FALSE(static_cast<bool>(gov.tryAdmit(ScanStream::Balance, true, sig, 6.7))); // Addresses waited longer
EXPECT_TRUE(static_cast<bool>(gov.tryAdmit(ScanStream::Addresses, true, sig, 6.8)));
}
{ // FIFO among waiters; a visible one gets a 60 s head start; a stream that stopped asking isn't waiting.
G gov;
auto busy = gov.beginExplicit(ScanStream::Adhoc, sig, 0.0); // keeps the slot taken while they queue
gov.tryAdmit(ScanStream::Addresses, false, sig, 1.0);
gov.tryAdmit(ScanStream::History, false, sig, 2.0);
gov.complete(busy, 0.0, ScanOutcome::Skipped, 3.0);
gov.tryAdmit(ScanStream::History, false, sig, 3.0); // still waiting, but asked later
EXPECT_FALSE(static_cast<bool>(gov.tryAdmit(ScanStream::History, false, sig, 3.5)));
EXPECT_TRUE(static_cast<bool>(gov.tryAdmit(ScanStream::Addresses, false, sig, 3.5))); // first in
G head;
auto slot = head.beginExplicit(ScanStream::Adhoc, sig, 0.0);
head.tryAdmit(ScanStream::Addresses, false, sig, 1.0); // hidden, waiting since 1
head.tryAdmit(ScanStream::Balance, true, sig, 30.0); // visible, waiting since 30 (ranks as -30)
head.complete(slot, 0.0, ScanOutcome::Skipped, 31.0);
head.tryAdmit(ScanStream::Addresses, false, sig, 31.0);
EXPECT_TRUE(static_cast<bool>(head.tryAdmit(ScanStream::Balance, true, sig, 31.5)));
G ttl;
auto held = ttl.beginExplicit(ScanStream::Adhoc, sig, 0.0);
ttl.tryAdmit(ScanStream::Addresses, false, sig, 1.0); // then stops asking
ttl.complete(held, 0.0, ScanOutcome::Skipped, 20.0);
EXPECT_TRUE(static_cast<bool>(ttl.tryAdmit(ScanStream::History, false, sig, 20.0))); // Addresses lapsed
}
{ // During quiet, a waiter gets in only via its floor: max(30 s visible / 900 s hidden, 15x its cost).
G gov;
auto big = gov.tryAdmit(ScanStream::History, true, sig, 0.0);
gov.complete(big, 200000.0, ScanOutcome::Ok, 200.0); // quiet capped at now + 900
EXPECT_NEAR(gov.quietUntil(), 1100.0, 1e-6);
double t = 200.0;
bool admitted = false;
for (; t < 1100.0 && !admitted; t += 2.0) admitted = static_cast<bool>(gov.tryAdmit(ScanStream::Balance, true, sig, t));
EXPECT_TRUE(admitted);
EXPECT_NEAR(t - 2.0, 230.0, 2.1); // waited 30 s (EMA 0)
G costly;
auto first = costly.tryAdmit(ScanStream::Addresses, true, sig, 0.0);
costly.complete(first, 40000.0, ScanOutcome::Ok, 40.0); // EMA 40 s: floor 600 s
auto again = costly.tryAdmit(ScanStream::History, true, sig, 300.0); // after that quiet
EXPECT_TRUE(static_cast<bool>(again));
costly.complete(again, 200000.0, ScanOutcome::Ok, 500.0); // quiet to 500 + 900
admitted = false;
for (t = 500.0; t < 1400.0 && !admitted; t += 2.0) admitted = static_cast<bool>(costly.tryAdmit(ScanStream::Addresses, true, sig, t));
EXPECT_TRUE(admitted);
EXPECT_NEAR(t - 2.0, 1100.0, 2.1); // 600 s
G hidden;
auto h = hidden.tryAdmit(ScanStream::History, true, sig, 0.0);
hidden.complete(h, 200000.0, ScanOutcome::Ok, 200.0); // quiet to 1100
auto blocker = hidden.beginExplicit(ScanStream::Adhoc, sig, 1050.0);
hidden.complete(blocker, 200000.0, ScanOutcome::Ok, 1060.0); // explicit: quiet only to +120 = 1180
admitted = false;
for (t = 200.0; t < 2000.0 && !admitted; t += 2.0) admitted = static_cast<bool>(hidden.tryAdmit(ScanStream::Balance, false, sig, t));
EXPECT_TRUE(admitted);
EXPECT_TRUE(t - 2.0 >= 1100.0 - 0.1); // hidden: not before its 900 s floor
}
{ // Charging: routine debt accumulates but quiet never runs past now + 900; explicit debt only to
// now + 120 and never shortens quiet; TimedOut charges at least the usual cost; Skipped nothing.
G gov;
double now = 0.0;
for (int i = 0; i < 5; ++i) {
auto t = gov.beginExplicit(ScanStream::History, sig, now);
gov.complete(t, 0.0, ScanOutcome::Skipped, now);
auto r = gov.tryAdmit(ScanStream::Balance, true, sig, gov.quietUntil() + 1.0);
now = gov.quietUntil() + 1.0;
if (r) gov.complete(r, 200000.0, ScanOutcome::Ok, now);
EXPECT_TRUE(gov.quietUntil() - now <= 900.0 + 1e-6);
}
G ex;
auto e = ex.beginExplicit(ScanStream::Balance, sig, 0.0);
ex.complete(e, 100000.0, ScanOutcome::Ok, 10.0);
EXPECT_NEAR(ex.quietUntil(), 130.0, 1e-6);
auto r = ex.beginExplicit(ScanStream::Addresses, sig, 10.0);
ex.complete(r, 1.0, ScanOutcome::Ok, 11.0);
EXPECT_TRUE(ex.quietUntil() >= 130.0 && ex.quietUntil() <= 131.0); // adds its own bit, never shortens
G timed;
auto a = timed.beginExplicit(ScanStream::Balance, sig, 0.0);
timed.complete(a, 10000.0, ScanOutcome::Ok, 10.0);
const double q1 = timed.quietUntil();
auto b = timed.beginExplicit(ScanStream::Balance, sig, 200.0);
timed.complete(b, 1000.0, ScanOutcome::TimedOut, 201.0); // charged the 10 s EMA, not 1 s
EXPECT_TRUE(timed.quietUntil() >= 201.0 + 10.0 * (1.0 / 0.15 - 1.0) - 1e-6 || timed.quietUntil() >= 201.0 + 120.0 - 1e-6);
EXPECT_TRUE(q1 > 0.0);
auto c = timed.beginExplicit(ScanStream::Addresses, sig, 1000.0);
const double before = timed.quietUntil();
timed.complete(c, 50000.0, ScanOutcome::Skipped, 1001.0);
EXPECT_NEAR(timed.quietUntil(), before, 1e-9); // Skipped: nothing sent, nothing charged
G ema;
auto e1 = ema.beginExplicit(ScanStream::Balance, sig, 0.0);
ema.complete(e1, 10000.0, ScanOutcome::Ok, 1.0);
auto e2 = ema.beginExplicit(ScanStream::Balance, sig, 2.0);
ema.complete(e2, 30000.0, ScanOutcome::Ok, 3.0);
EXPECT_NEAR(ema.emaCostMs(ScanStream::Balance), 30000.0, 1e-6); // rises at once
auto e3 = ema.beginExplicit(ScanStream::Balance, sig, 4.0);
ema.complete(e3, 10000.0, ScanOutcome::Ok, 5.0);
EXPECT_NEAR(ema.emaCostMs(ScanStream::Balance), 24000.0, 1e-6); // decays slowly (0.7 / 0.3)
G lag;
EXPECT_NEAR(lag.currentBudget(), 0.15, 1e-12);
lag.setTipLag(1);
EXPECT_NEAR(lag.currentBudget(), 0.10, 1e-12);
lag.setTipLag(5);
EXPECT_NEAR(lag.currentBudget(), 0.05, 1e-12);
}
{ // The change gate.
G gov;
EXPECT_TRUE(gov.needed(ScanStream::Balance, sig, 0.0)); // no basis yet
auto t = gov.beginExplicit(ScanStream::Balance, sig, 0.0);
gov.complete(t, 100.0, ScanOutcome::Ok, 1.0);
EXPECT_FALSE(gov.needed(ScanStream::Balance, sig, 10.0));
ScanSignals pool = sig;
pool.mempoolTxs = 2;
EXPECT_TRUE(gov.needed(ScanStream::Balance, pool, 10.0));
ScanSignals epoch = sig;
epoch.walletEpoch = 1;
EXPECT_TRUE(gov.needed(ScanStream::Balance, epoch, 10.0));
EXPECT_TRUE(gov.needed(ScanStream::Balance, sig, 300.0)); // the safety sweep
ScanSignals unknown = sig;
unknown.mempoolKnown = false;
EXPECT_TRUE(gov.needed(ScanStream::Balance, unknown, 10.0)); // unknown never matches
ScanSignals noTip = sig;
noTip.tipHash.clear();
EXPECT_TRUE(gov.needed(ScanStream::Balance, noTip, 10.0));
// History compares the tip and the epoch only.
auto h = gov.beginExplicit(ScanStream::History, sig, 20.0);
gov.complete(h, 100.0, ScanOutcome::Ok, 21.0);
EXPECT_FALSE(gov.needed(ScanStream::History, pool, 30.0));
ScanSignals tip = sig;
tip.tipHash = "tip-b";
EXPECT_TRUE(gov.needed(ScanStream::History, tip, 30.0));
// A failure keeps the basis.
auto f = gov.beginExplicit(ScanStream::Balance, tip, 40.0);
gov.complete(f, 100.0, ScanOutcome::Failed, 41.0);
EXPECT_TRUE(gov.needed(ScanStream::Balance, tip, 50.0));
EXPECT_FALSE(gov.needed(ScanStream::Balance, sig, 50.0));
// A change that lands while the scan runs is still needed afterwards (the basis is the dispatch's).
auto d = gov.beginExplicit(ScanStream::Balance, pool, 60.0);
ScanSignals later = pool;
later.mempoolTxs = 3;
gov.complete(d, 100.0, ScanOutcome::Ok, 61.0);
EXPECT_FALSE(gov.needed(ScanStream::Balance, pool, 62.0));
EXPECT_TRUE(gov.needed(ScanStream::Balance, later, 62.0));
// A pass: Partial then Ok adopts the first dispatch's basis; a failure inside poisons it.
G pass;
ScanSignals p1 = sig, p2 = sig;
p1.tipHash = "p1";
p2.tipHash = "p2";
auto pa = pass.beginExplicit(ScanStream::Addresses, p1, 0.0);
pass.complete(pa, 10.0, ScanOutcome::Partial, 1.0);
auto pb = pass.beginExplicit(ScanStream::Addresses, p2, 2.0);
pass.complete(pb, 10.0, ScanOutcome::Ok, 3.0);
EXPECT_FALSE(pass.needed(ScanStream::Addresses, p1, 4.0));
EXPECT_TRUE(pass.needed(ScanStream::Addresses, p2, 4.0));
G poisoned;
auto qa = poisoned.beginExplicit(ScanStream::Addresses, p1, 0.0);
poisoned.complete(qa, 10.0, ScanOutcome::Partial, 1.0);
auto qb = poisoned.beginExplicit(ScanStream::Addresses, p1, 2.0);
poisoned.complete(qb, 10.0, ScanOutcome::Failed, 3.0);
auto qc = poisoned.beginExplicit(ScanStream::Addresses, p1, 4.0);
poisoned.complete(qc, 10.0, ScanOutcome::Ok, 5.0);
EXPECT_TRUE(poisoned.needed(ScanStream::Addresses, p1, 6.0));
gov.invalidate(ScanStream::Balance);
EXPECT_TRUE(gov.needed(ScanStream::Balance, sig, 70.0));
}
{ // Lifecycle.
G gov;
auto t = gov.tryAdmit(ScanStream::Balance, true, sig, 0.0);
gov.cancel(t); // not sent: no charge, wait restored
EXPECT_FALSE(gov.anyInFlight());
EXPECT_NEAR(gov.quietUntil(), 0.0, 1e-12);
EXPECT_FALSE(static_cast<bool>(gov.tryAdmit(ScanStream::Addresses, true, sig, 1.0))); // Balance is ahead
EXPECT_TRUE(static_cast<bool>(gov.tryAdmit(ScanStream::Balance, true, sig, 2.0)));
G reset;
auto old = reset.beginExplicit(ScanStream::Balance, sig, 0.0);
reset.reset();
reset.complete(old, 1e6, ScanOutcome::Ok, 1.0); // from the old connection: no-op
EXPECT_NEAR(reset.quietUntil(), 0.0, 1e-12);
EXPECT_TRUE(reset.needed(ScanStream::Balance, sig, 2.0));
G orphan;
auto o1 = orphan.beginExplicit(ScanStream::Addresses, sig, 0.0);
orphan.complete(o1, 10000.0, ScanOutcome::Ok, 10.0);
const double q = orphan.quietUntil();
orphan.beginExplicit(ScanStream::Addresses, sig, 200.0);
EXPECT_TRUE(orphan.inFlight(ScanStream::Addresses));
orphan.dropOrphans(ScanStream::Addresses, 300.0); // its job is gone: freed, charged
EXPECT_FALSE(orphan.inFlight(ScanStream::Addresses));
EXPECT_TRUE(orphan.quietUntil() > q);
G lease;
auto stuck = lease.tryAdmit(ScanStream::History, true, sig, 0.0);
EXPECT_TRUE(static_cast<bool>(stuck));
EXPECT_FALSE(static_cast<bool>(lease.tryAdmit(ScanStream::Balance, true, sig, 599.0)));
EXPECT_TRUE(static_cast<bool>(lease.tryAdmit(ScanStream::Balance, true, sig, 601.0))); // presumed lost
lease.complete(stuck, 1e6, ScanOutcome::Ok, 602.0); // a late result: ignored
EXPECT_NEAR(lease.quietUntil(), 0.0, 1e-12);
G adhoc;
auto a1 = adhoc.beginExplicit(ScanStream::Adhoc, sig, 0.0);
auto a2 = adhoc.beginExplicit(ScanStream::Adhoc, sig, 0.0);
adhoc.complete(a1, 0.0, ScanOutcome::Skipped, 1.0);
EXPECT_TRUE(adhoc.inFlight(ScanStream::Adhoc));
adhoc.complete(a2, 0.0, ScanOutcome::Skipped, 1.0);
EXPECT_FALSE(adhoc.inFlight(ScanStream::Adhoc));
}
{ // A stream that stopped asking for over 60 s starts a new wait: no floor bypass from the old one.
G lapse;
auto big = lapse.tryAdmit(ScanStream::History, true, sig, 0.0);
lapse.complete(big, 200000.0, ScanOutcome::Ok, 200.0); // quiet to 1100
EXPECT_FALSE(static_cast<bool>(lapse.tryAdmit(ScanStream::Balance, true, sig, 200.0)));
double t = 261.0; // silent for 61 s
bool admitted = false;
for (; t < 1100.0 && !admitted; t += 2.0) admitted = static_cast<bool>(lapse.tryAdmit(ScanStream::Balance, true, sig, t));
EXPECT_TRUE(admitted);
EXPECT_NEAR(t - 2.0, 291.0, 2.1); // 30 s from 261 (without the rule: 261)
}
{ // An explicit charge never shortens a routine quiet.
G keep;
auto r = keep.tryAdmit(ScanStream::History, true, sig, 0.0);
keep.complete(r, 200000.0, ScanOutcome::Ok, 200.0); // quiet to 1100
auto e = keep.beginExplicit(ScanStream::Balance, sig, 300.0);
keep.complete(e, 50000.0, ScanOutcome::Ok, 310.0); // its own cap is 430
EXPECT_NEAR(keep.quietUntil(), 1100.0, 1e-6);
}
{ // An explicit ticket that doesn't do the routine work (a Light history cycle): charged, but the stream
// keeps waiting for that work, and its cost estimate stays the routine scans'.
G gov;
auto h = gov.tryAdmit(ScanStream::History, true, sig, 0.0);
gov.complete(h, 120000.0, ScanOutcome::Ok, 0.0); // EMA 120 s, quiet to 680
auto slot = gov.beginExplicit(ScanStream::Adhoc, sig, 700.0);
gov.tryAdmit(ScanStream::History, true, sig, 701.0);
gov.tryAdmit(ScanStream::Addresses, true, sig, 702.0);
auto light = gov.beginExplicit(ScanStream::History, sig, 703.0, false);
gov.complete(light, 1000.0, ScanOutcome::Ok, 704.0); // quiet to ~709.7
EXPECT_NEAR(gov.emaCostMs(ScanStream::History), 120000.0, 1e-6);
EXPECT_TRUE(gov.quietUntil() > 709.0);
gov.complete(slot, 0.0, ScanOutcome::Skipped, 705.0);
EXPECT_FALSE(static_cast<bool>(gov.tryAdmit(ScanStream::Addresses, true, sig, 712.0))); // History is older
EXPECT_TRUE(static_cast<bool>(gov.tryAdmit(ScanStream::History, true, sig, 712.5)));
}
{ // Only scans that were sent count: a cancelled or Skipped ticket doesn't restart the hidden interval,
// and a Skipped routine ticket keeps its place in line.
G gov;
auto c = gov.tryAdmit(ScanStream::Balance, false, sig, 0.0);
gov.cancel(c);
EXPECT_TRUE(gov.secondsSinceScan(ScanStream::Balance, 10.0) > 1e8);
auto s1 = gov.tryAdmit(ScanStream::Balance, false, sig, 10.0);
EXPECT_TRUE(static_cast<bool>(s1));
gov.complete(s1, 0.0, ScanOutcome::Skipped, 11.0);
EXPECT_TRUE(gov.secondsSinceScan(ScanStream::Balance, 20.0) > 1e8);
auto o = gov.tryAdmit(ScanStream::Balance, false, sig, 20.0);
EXPECT_TRUE(static_cast<bool>(o));
gov.complete(o, 1000.0, ScanOutcome::Ok, 21.0);
EXPECT_NEAR(gov.secondsSinceScan(ScanStream::Balance, 30.0), 10.0, 1e-9);
auto f = gov.beginExplicit(ScanStream::Balance, sig, 40.0);
gov.complete(f, 1000.0, ScanOutcome::Failed, 41.0); // sent, so it counts
EXPECT_NEAR(gov.secondsSinceScan(ScanStream::Balance, 50.0), 10.0, 1e-9);
G fifo;
auto busy = fifo.beginExplicit(ScanStream::Adhoc, sig, 0.0);
fifo.tryAdmit(ScanStream::Balance, true, sig, 1.0);
fifo.tryAdmit(ScanStream::Addresses, true, sig, 5.0);
fifo.complete(busy, 0.0, ScanOutcome::Skipped, 9.0);
auto b = fifo.tryAdmit(ScanStream::Balance, true, sig, 10.0);
EXPECT_TRUE(static_cast<bool>(b));
fifo.complete(b, 0.0, ScanOutcome::Skipped, 11.0); // the node was behind: nothing sent
EXPECT_FALSE(static_cast<bool>(fifo.tryAdmit(ScanStream::Addresses, true, sig, 12.0))); // Balance kept its place
EXPECT_TRUE(static_cast<bool>(fifo.tryAdmit(ScanStream::Balance, true, sig, 12.5)));
}
{ // waiting / heldWithinFloor / visibleFloorSecFor.
G gov;
EXPECT_NEAR(gov.visibleFloorSecFor(ScanStream::Balance), 30.0, 1e-9);
auto first = gov.beginExplicit(ScanStream::Balance, sig, 0.0);
gov.complete(first, 21000.0, ScanOutcome::Ok, 21.0); // EMA 21 s: floor 315 s
EXPECT_NEAR(gov.visibleFloorSecFor(ScanStream::Balance), 315.0, 1e-9);
auto slot = gov.beginExplicit(ScanStream::Adhoc, sig, 30.0);
EXPECT_FALSE(gov.waiting(ScanStream::Balance, 30.0));
EXPECT_FALSE(gov.heldWithinFloor(ScanStream::Balance, 30.0));
EXPECT_FALSE(static_cast<bool>(gov.tryAdmit(ScanStream::Balance, true, sig, 31.0))); // denied: waits
EXPECT_TRUE(gov.waiting(ScanStream::Balance, 32.0));
EXPECT_TRUE(gov.heldWithinFloor(ScanStream::Balance, 32.0));
EXPECT_FALSE(gov.heldWithinFloor(ScanStream::Balance, 92.0)); // stopped asking (> 60 s)
double t = 31.0;
for (; t < 360.0; t += 2.0) gov.tryAdmit(ScanStream::Balance, true, sig, t); // the slot stays taken
EXPECT_TRUE(gov.heldWithinFloor(ScanStream::Balance, 360.0)); // 329 s < 315 + 21
for (; t < 370.0; t += 2.0) gov.tryAdmit(ScanStream::Balance, true, sig, t);
EXPECT_FALSE(gov.heldWithinFloor(ScanStream::Balance, 370.0)); // past floor + one scan: something's wrong
gov.complete(slot, 0.0, ScanOutcome::Skipped, 371.0);
EXPECT_TRUE(static_cast<bool>(gov.tryAdmit(ScanStream::Balance, true, sig, 372.0)));
EXPECT_FALSE(gov.waiting(ScanStream::Balance, 373.0)); // admitted: no longer waiting
}
}
// The between-blocks probe's rotation: every z-address once (oldest-scanned first) before it is done.
void testProbeRotation()
{
using dragonx::services::ProbeRotation;
const std::vector<std::string> addresses{"za", "zb", "zc"};
std::unordered_map<std::string, int> heights{{"za", 100}, {"zb", 90}, {"zc", 100}};
ProbeRotation rotation;
EXPECT_TRUE(rotation.complete());
EXPECT_EQ(rotation.next(addresses, heights), std::string("zb")); // oldest first
rotation.probed("zb");
heights["zb"] = 101;
EXPECT_FALSE(rotation.complete());
EXPECT_EQ(rotation.next(addresses, heights), std::string("za")); // tie: list order
rotation.probed("za");
heights["za"] = 101;
EXPECT_EQ(rotation.next(addresses, heights), std::string("zc"));
rotation.probed("zc");
EXPECT_TRUE(rotation.complete());
// A new rotation after a change: all heights equal now, still every address once.
heights["zc"] = 101;
EXPECT_EQ(rotation.next(addresses, heights), std::string("za"));
rotation.probed("za");
EXPECT_EQ(rotation.next(addresses, heights), std::string("zb"));
// An address that left the list is dropped; one never scanned goes first.
const std::vector<std::string> changed{"zb", "zd"};
EXPECT_EQ(rotation.next(changed, heights), std::string("zb")); // za/zc gone, zd new but not in this rotation
rotation.probed("zb");
EXPECT_TRUE(rotation.complete());
EXPECT_EQ(rotation.next(changed, heights), std::string("zd")); // new rotation: never scanned first
rotation.reset();
EXPECT_TRUE(rotation.complete());
EXPECT_EQ(rotation.next({}, heights), std::string());
}
// C37 under overload: three visible streams that always want to scan (21 s, 25 s and 120 s per scan, a
// large wallet) for 2 h. Together they stay near the budget (the old per-stream 10% rules added up to
// 30-40%), none starves, and quiet never runs away; hidden, each still gets in within its floor.
void testWalletScanGovernorOverload()
{
using namespace dragonx::services;
WalletScanGovernor gov;
const ScanStream streams[3] = {ScanStream::Balance, ScanStream::Addresses, ScanStream::History};
const double cost[3] = {21.0, 25.0, 120.0};
double lastAdmit[3] = {0.0, 0.0, 0.0};
double maxGapVisible[3] = {0.0, 0.0, 0.0};
double maxGapHidden[3] = {0.0, 0.0, 0.0};
ScanTicket live;
int liveIndex = -1;
double liveEnd = 0.0, scanSec = 0.0, maxQuietAhead = 0.0;
const ScanSignals sig;
const double visibleEnd = 7200.0, end = 10800.0;
for (double now = 0.0; now < end; now += 2.0) {
const bool visible = now < visibleEnd;
if (live && now >= liveEnd) {
gov.complete(live, cost[liveIndex] * 1000.0, ScanOutcome::Ok, now);
live = ScanTicket{};
}
for (int i = 0; i < 3; ++i) {
auto t = gov.tryAdmit(streams[i], visible, sig, now);
if (!t) continue;
live = t;
liveIndex = i;
liveEnd = now + cost[i];
if (now < visibleEnd) scanSec += cost[i];
const double gap = now - lastAdmit[i];
if (now < visibleEnd) maxGapVisible[i] = std::max(maxGapVisible[i], gap);
else if (lastAdmit[i] >= visibleEnd) maxGapHidden[i] = std::max(maxGapHidden[i], gap);
lastAdmit[i] = now;
}
maxQuietAhead = std::max(maxQuietAhead, gov.quietUntil() - now);
}
EXPECT_TRUE(scanSec / visibleEnd <= 0.22);
EXPECT_TRUE(scanSec / visibleEnd >= 0.10);
EXPECT_TRUE(maxQuietAhead <= 900.0 + 1e-6);
// A stream's longest wait: its floor, plus the other two scans if both are queued ahead of it.
const double others[3] = {25.0 + 120.0, 21.0 + 120.0, 21.0 + 25.0};
const double visibleFloor[3] = {315.0, 375.0, 1800.0}; // max(300 s, 15x cost)
for (int i = 0; i < 3; ++i) EXPECT_TRUE(maxGapVisible[i] <= visibleFloor[i] + others[i] + 4.0);
const double hiddenFloor[3] = {900.0, 900.0, 1800.0}; // max(900 s, 15x cost)
for (int i = 0; i < 3; ++i) EXPECT_TRUE(maxGapHidden[i] <= hiddenFloor[i] + others[i] + 4.0);
}
void testBalancePollDecisionAndVisibility()
{
using namespace dragonx::services;
using D = BalancePollDecision;
EXPECT_NEAR(hiddenBalanceIntervalSec(0.05, false), 600.0, 1e-9);
EXPECT_NEAR(hiddenBalanceIntervalSec(20.0, false), 1000.0, 1e-9);
EXPECT_NEAR(hiddenBalanceIntervalSec(0.05, true), 120.0, 1e-9);
BalancePollInputs in;
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.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); }
{ auto x = in; x.visible = false; x.sinceLastScanSec = 100.0; EXPECT_TRUE(decideBalancePoll(x) == D::Skip); }
{ auto x = in; x.visible = false; x.sinceLastScanSec = 700.0; EXPECT_TRUE(decideBalancePoll(x) == D::AskGovernor); }
{ auto x = in; x.visible = false; x.autoShieldArmed = true; x.sinceLastScanSec = 130.0;
EXPECT_TRUE(decideBalancePoll(x) == D::AskGovernor); }
// Coming into view with an old value: one explicit poll. Each guard alone flips it back.
BalancePollInputs back = in;
back.shownAgeSec = 400.0;
back.visibleFloorSec = 315.0;
back.sinceLastScanSec = 400.0;
EXPECT_TRUE(decideBalancePoll(back) == D::Explicit);
{ auto x = back; x.waiting = true; EXPECT_TRUE(decideBalancePoll(x) == D::AskGovernor); } // already queued
{ auto x = back; x.shownAgeSec = 100.0; EXPECT_TRUE(decideBalancePoll(x) == D::AskGovernor); }
{ auto x = back; x.sinceLastScanSec = 100.0; EXPECT_TRUE(decideBalancePoll(x) == D::AskGovernor); } // just tried
{ 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
using NP = dragonx::ui::NavPage;
auto overview = scanVisibilityForPage(NP::Overview, false);
EXPECT_TRUE(overview.balance && overview.addresses && overview.history && !overview.chat);
for (auto page : {NP::Send, NP::Receive}) {
auto v = scanVisibilityForPage(page, false);
EXPECT_TRUE(!v.balance && v.addresses && v.history);
}
auto history = scanVisibilityForPage(NP::History, false);
EXPECT_TRUE(!history.balance && !history.addresses && history.history);
auto market = scanVisibilityForPage(NP::Market, false);
EXPECT_TRUE(market.balance && !market.addresses && !market.history);
auto chat = scanVisibilityForPage(NP::Chat, false);
EXPECT_TRUE(chat.chat && !chat.balance);
auto mining = scanVisibilityForPage(NP::Mining, false);
EXPECT_TRUE(!mining.balance && !mining.addresses && !mining.history && !mining.chat);
auto background = scanVisibilityForPage(NP::Overview, true);
EXPECT_TRUE(!background.balance && !background.addresses && !background.history && !background.chat);
}
// C36: the Core poll reads the mempool's size (no cs_main) right after the chain, for the change gate.
void testCoreRefreshMempool()
{
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"}};
Refresh::CoreRefreshPlan plan;
plan.fetchMempool = true;
plan.needSpendable = false;
plan.prevTotalZat = 425000000;
{
MockRefreshRpc rpc;
rpc.addResponse("getblockchaininfo", chain);
rpc.addResponse("getmempoolinfo", json{{"size", 3}, {"bytes", 4500}, {"usage", 9000}});
rpc.addResponse("z_gettotalbalance", total);
auto result = Refresh::collectCoreRefreshResult(rpc, plan);
EXPECT_TRUE(rpc.methodNames() == std::vector<std::string>({"getblockchaininfo", "getmempoolinfo", "z_gettotalbalance"}));
EXPECT_EQ(rpc.calls[1].params, json::array());
EXPECT_TRUE(result.mempoolFetched && result.mempoolOk);
EXPECT_EQ(result.mempoolTxs, static_cast<std::int64_t>(3));
EXPECT_EQ(result.mempoolBytes, static_cast<std::int64_t>(4500));
}
{ // A failed read leaves the mempool unknown (counts as changed); the balance still runs.
MockRefreshRpc rpc;
rpc.addResponse("getblockchaininfo", chain);
rpc.addFailure("getmempoolinfo", "boom");
rpc.addResponse("z_gettotalbalance", total);
auto result = Refresh::collectCoreRefreshResult(rpc, plan);
EXPECT_TRUE(result.mempoolFetched && !result.mempoolOk);
EXPECT_TRUE(result.balanceOk);
}
{ // Behind: chain + mempool only.
MockRefreshRpc rpc;
rpc.addResponse("getblockchaininfo", json{{"blocks", 100}, {"headers", 150}, {"longestchain", 150}});
rpc.addResponse("getmempoolinfo", json{{"size", 0}, {"bytes", 0}});
Refresh::collectCoreRefreshResult(rpc, plan);
EXPECT_TRUE(rpc.methodNames() == std::vector<std::string>({"getblockchaininfo", "getmempoolinfo"}));
}
{ // The chain read found the node busy: nothing else.
MockRefreshRpc rpc;
rpc.addException("getblockchaininfo", std::make_exception_ptr(RpcTransportError(
RpcFailureKind::QueueFull, "RPC error: HTTP 500: Work queue depth exceeded", CURLE_OK, 500, true)));
auto result = Refresh::collectCoreRefreshResult(rpc, plan);
EXPECT_TRUE(rpc.methodNames() == std::vector<std::string>({"getblockchaininfo"}));
EXPECT_FALSE(result.mempoolFetched);
}
}
void testRefreshSchedulerRetrySoon()
{
using dragonx::services::RefreshScheduler;
using Timer = RefreshScheduler::Timer;
RefreshScheduler early;
early.setIntervals({5.0f, 10.0f, 15.0f, 20.0f});
early.reset(Timer::Transactions);
early.retrySoon(Timer::Transactions, 2.0f);
EXPECT_FALSE(early.isDue(Timer::Transactions));
early.tick(1.9f);
EXPECT_FALSE(early.isDue(Timer::Transactions));
RefreshScheduler due;
due.setIntervals({5.0f, 10.0f, 15.0f, 20.0f});
due.reset(Timer::Transactions);
due.retrySoon(Timer::Transactions, 2.0f);
due.tick(2.0f);
EXPECT_TRUE(due.isDue(Timer::Transactions));
RefreshScheduler syncing; // the sync profile disables the timer: a retry never fires mid-sync
syncing.setIntervals(RefreshScheduler::kSyncProfile);
syncing.retrySoon(Timer::Transactions, 2.0f);
syncing.tick(60.0f);
EXPECT_FALSE(syncing.isDue(Timer::Transactions));
// getmempoolinfo takes no cs_main: always sent, and its replies say nothing about busyness.
dragonx::rpc::RpcBusyGate gate;
gate.record("z_gettotalbalance", dragonx::rpc::RpcFailureKind::ReplyTimeout);
EXPECT_TRUE(gate.busy());
EXPECT_TRUE(gate.admit("getmempoolinfo"));
gate.record("getmempoolinfo", dragonx::rpc::RpcFailureKind::None);
EXPECT_TRUE(gate.busy());
}
int main()
{
testConnectionConfig();
@@ -10504,6 +11025,12 @@ int main()
testHistoryPassWithCollector();
testHistoryCollectorSweepAndEnrichment();
testHistoryDutyModel();
testWalletScanGovernor();
testWalletScanGovernorOverload();
testProbeRotation();
testBalancePollDecisionAndVisibility();
testCoreRefreshMempool();
testRefreshSchedulerRetrySoon();
testNetworkRefreshResultModels();
testTrackedSendRetirement();
testBalanceDisplayLerp();