// DragonX Wallet - ImGui Edition // Copyright 2024-2026 The Hush Developers // Released under the GPLv3 #pragma once // When the HushChat scans are worth sending. Both are wallet-wide (they hold dragonxd's cs_main): the inbox // scan (z_listreceivedbyaddress of the chat address, every note ever) and the note-buffer scan // (z_listunspent). They used to run on a timer whether or not anything had changed. Here they run when // something they depend on can have changed, and the WalletScanGovernor decides when (the scan budget). // // Pure, header-only. #include "rpc/rpc_failure.h" #include "services/wallet_scan_governor.h" #include #include #include #include #include namespace dragonx { namespace services { constexpr double kChatTipSettleSec = 3.0; // the wallet notifier stores a block's txs ~1 s after it connects constexpr double kChatNoteScanFloorSec = 4.0; // spacing between note scans (a failing need can't spin), not a duty rule constexpr double kChatProbeMaxDuty = 0.02; constexpr double kChatProbeMaxSpacingSec = 60.0; // the probe is the inbox safety net's only path // getwalletinfo's txcount (mapWallet.size(), read under cs_wallet): it changes only after the daemon's wallet // notifier has stored a tx and its notes. A mempool or tip change can run ahead of that (the notifier runs // about once a second), so the inbox is gated on this instead. -1 when absent, malformed or negative. inline std::int64_t parseWalletTxCount(const nlohmann::json& reply) { if (!reply.is_object() || !reply.contains("txcount") || !reply["txcount"].is_number_integer()) return -1; const auto v = reply["txcount"].get(); return v < 0 ? -1 : v; } // The inbox probe (getwalletinfo: three passes over the wallet under cs_main) isn't a governed scan: at most 2% // of wall-clock, and at least once a minute (it is the only way the inbox safety net is checked). inline bool chatProbeDue(double lastProbeMs, double lastProbeAtSec, double nowSec) { return nowSec - lastProbeAtSec >= std::min((std::max(0.0, lastProbeMs) / 1000.0) / kChatProbeMaxDuty, kChatProbeMaxSpacingSec); } // Safety nets when no change signal fired: a minute, stretched with the scan's own cost (the inbox is gated on // txcount, so its net is only a backstop; the note fallback refreshes the buffer status and the payer view). inline double chatInboxSafetyNetSec(double emaCostMs) { return std::max(60.0, 50.0 * emaCostMs / 1000.0); } inline double chatNoteFallbackSec(double emaCostMs) { return std::max(60.0, 20.0 * emaCostMs / 1000.0); } struct ChatInboxScanInputs { std::int64_t basisTxCount = -1; // the count the last successful scan is current for (-1: none) std::int64_t txCount = -1; // the probe's read double nowSec = 0.0; double lastScanAtSec = 0.0; double safetyNetSec = 60.0; }; // The inbox needs a scan: the count is unknown (no basis yet, or a daemon without txcount: as the budget // allows), the wallet gained or lost a transaction, or the safety net is due. inline bool chatInboxScanNeeded(const ChatInboxScanInputs& in) { if (in.txCount < 0 || in.basisTxCount < 0) return true; if (in.txCount != in.basisTxCount) return true; return in.nowSec - in.lastScanAtSec >= in.safetyNetSec; } struct ChatNoteScanInputs { bool onChatPage = false; // the Chat page is shown (and the window isn't in the background) bool queueWaiting = false; // a chat send waits for a verified note bool splitOutstanding = false; // a self-split is confirming bool rescanRequested = false; // a chat operation finished bool modelSeen = false; // a scan (or the address scan's feed) succeeded this session int verifiedBudget = 0; int pipelineNotes = 0; std::int64_t tipHeight = 0; std::int64_t lastScanTip = -1; double nowSec = 0.0; double lastScanAtSec = 0.0; double tipChangedAtSec = 0.0; double fallbackSec = 60.0; double floorSec = kChatNoteScanFloorSec; double tipSettleSec = kChatTipSettleSec; }; // The note view is worth refreshing: something can use it (the Chat page, a waiting send, a split) and it // can have changed (first look, a chat op finished, a block that matters once the notifier caught up, or the // fallback). How often it actually runs is the scan budget's call. inline bool shouldScanChatNotes(const ChatNoteScanInputs& in) { if (!in.onChatPage && !in.queueWaiting && !in.splitOutstanding) return false; // nothing can use it const bool ever = in.lastScanAtSec > 0.0; if (ever && in.nowSec - in.lastScanAtSec < in.floorSec) return false; if (!in.modelSeen || in.rescanRequested) return true; const bool tipMoved = in.tipHeight != in.lastScanTip && in.nowSec - in.tipChangedAtSec >= in.tipSettleSec; const bool starved = in.queueWaiting && in.verifiedBudget < 1; const bool maturing = in.onChatPage && in.verifiedBudget < in.pipelineNotes; // notes still confirming if (tipMoved && (starved || in.splitOutstanding || maturing)) return true; return !ever || in.nowSec - in.lastScanAtSec >= in.fallbackSec; } // Verified notes (at least a fee, unlocked, with the confirmations a chat send needs) by z-address. // z_sendmany pays from ONE address and spends its largest notes first. struct ChatPayerFunds { std::uint64_t zat = 0; int notes = 0; }; using ChatPayerMap = std::unordered_map; // `preferred` if it has a verified note and covers needZat, else the richest that does (ties: the smallest // address). "" if none does. inline std::string chooseChatPayer(const ChatPayerMap& funds, const std::string& preferred, std::uint64_t needZat) { auto eligible = [&](const ChatPayerFunds& f) { return f.notes > 0 && f.zat >= needZat; }; if (!preferred.empty()) { const auto it = funds.find(preferred); if (it != funds.end() && eligible(it->second)) return preferred; } std::string best; std::uint64_t bestZat = 0; for (const auto& [address, f] : funds) { if (address.empty() || !eligible(f)) continue; if (best.empty() || f.zat > bestZat || (f.zat == bestZat && address < best)) { best = address; bestZat = f.zat; } } return best; } inline std::uint64_t richestChatPayerZat(const ChatPayerMap& funds) { std::uint64_t best = 0; for (const auto& [address, f] : funds) { if (!address.empty() && f.notes > 0) best = std::max(best, f.zat); } return best; } enum class ChatPayerSpend { OneNote, All }; // Until the next scan restores the truth: a chat send spends one whole note (its change is unverified for a // block); a split may spend all of the payer's verified notes. inline void debitChatPayer(ChatPayerMap& funds, const std::string& payer, ChatPayerSpend spend) { const auto it = funds.find(payer); if (it == funds.end()) return; auto& f = it->second; if (spend == ChatPayerSpend::All || f.notes <= 1) { f = {}; return; } f.zat -= f.zat / static_cast(f.notes); --f.notes; } // How a chat scan's result is charged to the scan budget. inline ScanOutcome chatScanOutcome(bool ok, rpc::RpcFailureKind kind) { if (ok) return ScanOutcome::Ok; if (kind == rpc::RpcFailureKind::ReplyTimeout) return ScanOutcome::TimedOut; // the daemon still runs it if (kind == rpc::RpcFailureKind::Deferred || kind == rpc::RpcFailureKind::QueueFull) return ScanOutcome::Skipped; // never ran return ScanOutcome::Failed; } } // namespace services } // namespace dragonx