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>
This commit is contained in:
2026-10-09 11:06:34 -05:00
co-authored by Claude Opus 5.5
parent f86cc4d6b7
commit b29a09eaa0
15 changed files with 1037 additions and 177 deletions
+172
View File
@@ -0,0 +1,172 @@
// 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
+33
View File
@@ -1704,6 +1704,39 @@ std::optional<json> NetworkRefreshService::buildOperationFilterParams(const std:
return params;
}
NetworkRefreshService::OperationBatchPlan NetworkRefreshService::planOperationBatch(
const OperationStatusPollResult& parsed, const std::unordered_map<std::string, OperationOrigin>& origins)
{
auto originOf = [&](const std::string& id) {
const auto it = origins.find(id);
return it == origins.end() ? OperationOrigin::User : it->second;
};
OperationBatchPlan plan;
auto terminal = [&](const std::string& id) {
if (originOf(id) == OperationOrigin::Chat) plan.chatTerminal = true;
else plan.walletTerminal = true;
};
for (const auto& id : parsed.doneOpids) { // success, failed or cancelled
terminal(id);
if (parsed.failureByOpid.count(id)) continue; // failed: nothing committed
const auto origin = originOf(id);
if (origin == OperationOrigin::User) plan.userSucceeded = true; // a success without a txid too
else if (origin == OperationOrigin::Auto) plan.autoSucceeded = true;
}
for (const auto& id : parsed.staleOpids) terminal(id);
return plan;
}
bool NetworkRefreshService::anyUserOperationPending(const std::vector<std::string>& pendingOpids,
const std::unordered_map<std::string, OperationOrigin>& origins)
{
for (const auto& id : pendingOpids) {
const auto it = origins.find(id);
if (it == origins.end() || it->second == OperationOrigin::User) return true;
}
return false;
}
void NetworkRefreshService::applyConnectionInfoResult(WalletState& state, const ConnectionInfoResult& result)
{
if (!result.ok) return;
+16
View File
@@ -311,6 +311,16 @@ public:
bool anySuccess = false;
};
// Who started a tracked async operation, i.e. what its completion re-reads (App::markWalletMutated).
// Absent from the App's origin map = User.
enum class OperationOrigin : std::uint8_t { User, Auto, Chat };
struct OperationBatchPlan {
bool userSucceeded = false; // >= 1 User op succeeded: re-read the wallet, force the balance
bool autoSucceeded = false; // >= 1 Auto op (client auto-shield) succeeded: re-read, unforced
bool walletTerminal = false; // >= 1 non-chat op finished (any outcome) or went stale
bool chatTerminal = false; // >= 1 chat op finished or went stale: refresh the chat note view
};
struct TransactionCacheUpdate {
TransactionViewCache& viewTxCache;
std::unordered_set<std::string>& sendTxids;
@@ -435,6 +445,12 @@ public:
// retained. Built explicitly, because a one-element braced list of a json ({ids}) collapses to the
// inner array, which the daemon rejects ("JSON value is not an array as expected").
static std::optional<nlohmann::json> buildOperationFilterParams(const std::vector<std::string>& opids);
// Classify a poll's terminal ops by origin (absent from the map = User).
static OperationBatchPlan planOperationBatch(const OperationStatusPollResult& parsed,
const std::unordered_map<std::string, OperationOrigin>& origins);
// Any pending id is a User op (absent from the map = User).
static bool anyUserOperationPending(const std::vector<std::string>& pendingOpids,
const std::unordered_map<std::string, OperationOrigin>& origins);
struct DaemonRescanScan {
bool foundRescan = false;
+3 -1
View File
@@ -52,7 +52,9 @@ BalancePollDecision decideBalancePoll(const BalancePollInputs& in)
// attempt failed) the balance waits for the sync to finish, a forced one too.
if (in.neverFetched && !(in.syncing && (in.background || in.firstReadTried))) return BalancePollDecision::Explicit;
if (in.syncing) return BalancePollDecision::Skip;
if (in.forced) return BalancePollDecision::Explicit;
// The user's own send, now committed. In the background it waits for the window (the routine rules below
// still apply, e.g. the auto-shield poll); coming back forces it.
if (in.forced && !in.background) return BalancePollDecision::Explicit;
if (!in.changed) return BalancePollDecision::Skip;
if (in.visible) {
// Just came into view with an old value: fetch it now rather than queue behind the other streams.
+5 -3
View File
@@ -82,7 +82,7 @@ struct BalancePollInputs {
bool background = false; // the window is in the background: no first read while syncing
bool firstReadTried = false; // the first read while syncing was already sent this connection
bool gateOpen = true; // the connect gate (C44): closed right after a connect, at most ~20 s
bool forced = false; // the user's own send
bool forced = false; // the user's own send (waits while the window is in the background)
bool neverFetched = false; // no balance yet this session
bool changed = true; // the governor's change gate
bool visible = true; // the page shows the aggregate balance
@@ -123,8 +123,10 @@ public:
{kScanSignalAll, 300}, // Addresses
{kScanSignalTip | kScanSignalEpoch, 0}, // History
{kScanSignalMempool | kScanSignalEpoch, 0}, // RecentTx (between blocks: the tip is History's)
{kScanSignalAll, 60}, // ChatNotes
{kScanSignalAll, 60}, // ChatFast
{0, 0}, // ChatNotes: admission only; the need is services::shouldScanChatNotes (tip settle, chat ops)
{0, 0}, // ChatFast: admission only; the need is getwalletinfo's txcount (chatInboxScanNeeded). Not
// the tip or mempool: they move before the wallet notifier stores the tx, so a scan fired
// on them can read the old wallet and consume the change.
{0, 0}, // Adhoc
}};
};