Files
ObsidianDragon/src/services/chat_scan_policy.h
T
DanSandClaude Opus 5.5 b29a09eaa0 perf(chat): sends re-read by origin, chat scans under the scan budget
A z_sendmany submit set off a burst of wallet scans before anything had
changed. Ops now re-read the wallet when they finish, by who started them:
the user's sends and dialog ops get a re-read plus one forced balance once
none of their ops is pending; client auto-shield re-reads unforced; chat
messages and splits refresh only the chat note view. A pending send no
longer forces scans, and chat txids no longer enter the tracked-send set.

Chat ingest skips stored messages before decrypting and caches
undecryptable ones per identity. The inbox scan runs when getwalletinfo's
txcount changed (a cheap probe, at most 2% duty) or a cost-scaled safety
net is due; the note-buffer scan runs on need. Both are admitted by the
scan budget, on a steady clock. The chat payer comes from a per-address
view of verified notes, with debits re-applied across in-flight scans;
splits are sized to one payer and only when they can refill the buffer.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 11:06:34 -05:00

173 lines
7.6 KiB
C++

// 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 <nlohmann/json.hpp>
#include <algorithm>
#include <cstdint>
#include <string>
#include <unordered_map>
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<std::int64_t>();
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<std::string, ChatPayerFunds>;
// `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<std::uint64_t>(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