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>
173 lines
7.6 KiB
C++
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
|