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:
@@ -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
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
}};
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user