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
+375 -125
View File
@@ -760,7 +760,7 @@ void App::onConnected()
encryption_state_prefetched_ = initialPrefetchQueued;
// Addresses are unknown on fresh connect — force a fetch
addresses_dirty_ = true;
markAddressesDirty();
// Hold the wallet scans until the node knows where the chain is (or the wallet is small), and latch the
// sync profile now if the connect's getinfo already shows it behind.
armScanGate(prefetchedInfo);
@@ -842,10 +842,26 @@ void App::onDisconnected(const std::string& reason)
chat_split_outstanding_ = false;
chat_split_submitted_at_ = 0.0;
inflight_op_ = LiteInflightOp{}; // the in-flight chat send's callback was just fired above (Failed)
// The chat scans' bases describe this connection's wallet (a reconnect may be another daemon). Their in-flight
// flags are cleared by their replies; scan_governor_.reset() below turns their tickets into no-ops.
chat_inbox_basis_txcount_ = -1;
chat_inbox_scan_wanted_ = false;
chat_inbox_wanted_txcount_ = -1;
chat_inbox_probe_at_ = 0.0; // a reconnect may be another daemon: its probe cost is unknown
chat_inbox_probe_ms_ = 0.0;
chat_note_scan_tip_ = -1;
chat_verified_funds_by_addr_.clear();
chat_verified_note_budget_ = 0; // nor a budget from the previous connection's view
chat_split_hold_tip_ = -1;
chat_spend_log_.clear();
chat_note_scan_spend_seq_ = -1;
addr_pass_spend_seq_ = -1;
network_refresh_.resetJobs();
rescan_status_poll_in_progress_ = false;
opid_poll_in_progress_ = false;
opid_filter_unsupported_ = false; // a reconnect may be to a different daemon
op_origin_.clear();
send_feegap_retried_opids_.clear();
last_total_balance_zat_.reset();
history_transparent_rows_ = 0; // the history's scan state (the pass, epochs and watermark are
history_transparent_tip_ = -1; // reset with the cache session below); cost estimates are kept
@@ -1130,7 +1146,7 @@ bool App::currentPageNeedsWalletDataRefresh() const
bool App::shouldRunWalletTransactionRefresh() const
{
if (currentPageNeedsWalletDataRefresh()) return true;
if (hasTransactionSendProgress() || !send_txids_.empty()) return true;
if (!send_txids_.empty()) return true;
return transactions_dirty_;
}
@@ -1307,7 +1323,7 @@ void App::applyPendingSendDelta(const std::string& fromAddress, double signedAmo
}
}
void App::trackOperation(const std::string& opid)
void App::trackOperation(const std::string& opid, services::NetworkRefreshService::OperationOrigin origin)
{
if (opid.empty()) return;
// Touched only from the main thread (sendTransaction's MainCb and the opid poller's
@@ -1315,6 +1331,25 @@ void App::trackOperation(const std::string& opid)
if (std::find(pending_opids_.begin(), pending_opids_.end(), opid) != pending_opids_.end())
return;
pending_opids_.push_back(opid);
if (origin != services::NetworkRefreshService::OperationOrigin::User) op_origin_[opid] = origin;
}
void App::markWalletMutated(bool forceBalance)
{
noteWalletMutation(); // the change-gated streams look again
markAddressesDirty(); // explicit address pass; one already in flight can't clear it
markTransactionsDirty(); // explicit history cycle; same guard (tx_dirty_generation_)
last_tx_block_height_ = -1;
if (forceBalance) {
requestForcedBalanceRefresh(); // applied once no User op is pending (refreshCoreData)
// A user op (send, shield, merge, sweep) may have spent notes the chat payer view still lists, and the
// pending debit on state_.z_addresses omits the fee and the unverified change, so it can't stand in.
// Hold chat sends and splits until the post-commit address pass (marked above) re-feeds the view.
chat_verified_funds_by_addr_.clear();
chat_verified_note_budget_ = 0;
chat_verified_shielded_zat_ = 0;
}
network_refresh_.markWalletMutationRefresh(); // Core / Transactions / Addresses due now
}
bool App::invokeSendResultCallback(const std::string& opid, bool ok,
@@ -2041,7 +2076,7 @@ void App::refreshCoreData(bool allowBalance)
balanceIn.gateOpen = scanGateOpen();
balanceIn.background = window_background_;
balanceIn.firstReadTried = first_balance_read_tried_;
balanceIn.forced = force_balance_refresh_;
balanceIn.forced = force_balance_refresh_ && !hasPendingUserOperations(); // a batch of user ops forces once, at its end
balanceIn.neverFetched = state_.last_balance_update == 0;
balanceIn.changed = scan_governor_.needed(services::ScanStream::Balance, scanSig, scanNow);
balanceIn.visible = currentScanVisibility().balance;
@@ -2066,8 +2101,9 @@ void App::refreshCoreData(bool allowBalance)
const bool includeBalance = static_cast<bool>(balanceTicket);
// The forced request this poll carries. The result callback clears the flag only if the balance
// call was really sent and no newer request came in; clearing it here lost it whenever the enqueue
// was refused (a Core job already in flight) or the worker skipped the balance (behind, busy).
const std::uint32_t forcedSeq = (includeBalance && force_balance_refresh_) ? force_balance_seq_ : 0;
// was refused (a Core job already in flight) or the worker skipped the balance (behind, busy). Only a force
// applicable now: a routine poll admitted while it is deferred (a user op still pending) doesn't clear it.
const std::uint32_t forcedSeq = (includeBalance && balanceIn.forced) ? force_balance_seq_ : 0;
NetworkRefreshService::CoreRefreshPlan plan;
plan.includeBalance = includeBalance;
plan.fetchMempool = !balanceIn.syncing; // a change signal for the scans; takes no cs_main
@@ -2106,7 +2142,11 @@ void App::refreshCoreData(bool allowBalance)
scan_governor_.complete(t, result.balanceScanMs, outcome, scanClockNow());
}
if (result.balanceAttempted) state_.balance_last_attempt_failed = !result.balanceOk;
const std::string prevTipHash = state_.sync.best_blockhash;
NetworkRefreshService::applyCoreRefreshResult(state_, result, std::time(nullptr));
// The chat note view waits a moment after a new tip: the wallet notifier stores a block's txs
// ~1 s after it connects.
if (state_.sync.best_blockhash != prevTipHash) tip_changed_at_ = scanClockNow();
if (result.blockchainOk && result.longestChain && *result.longestChain > 0) scan_gate_.noteTipKnown();
if (result.balanceOk) {
// Fresh aggregates: re-apply the pending sends' debits to them. The per-address
@@ -2238,8 +2278,8 @@ void App::refreshCoreData(bool allowBalance)
}
if (!opid.empty()) {
DEBUG_LOGF("[AutoShield] Started: %s\n", opid.c_str());
// Surface the async result + refresh balances on completion.
trackOperation(opid);
// Surface the async result + refresh balances on completion (unforced).
trackOperation(opid, services::NetworkRefreshService::OperationOrigin::Auto);
}
};
});
@@ -2297,11 +2337,19 @@ void App::refreshAddressData(services::ScanTicket ticket)
? NetworkRefreshService::AddressRefreshSnapshot{}
: NetworkRefreshService::buildAddressRefreshSnapshot(state_);
ui::NavPage tracePage = current_page_;
auto enqueued = network_refresh_.enqueue(services::NetworkRefreshService::Job::Addresses, *worker_, [this, previousAddressCount, previousWalletIdentity, previousZ = std::move(previousZ), addressSnapshot = std::move(addressSnapshot), tracePage, ticket, listOnly]() -> rpc::RPCWorker::MainCb {
const std::uint64_t addrDirtyGen = addr_dirty_generation_;
// The tip the chat note view may claim from this pass: only one the wallet notifier has caught up with.
const std::int64_t chatFeedTip =
scanClockNow() - tip_changed_at_ >= services::kChatTipSettleSec ? state_.sync.blocks : -1;
const std::uint64_t chatSpendSeq = chat_spend_seq_;
addr_pass_spend_seq_ = static_cast<std::int64_t>(chatSpendSeq);
auto enqueued = network_refresh_.enqueue(services::NetworkRefreshService::Job::Addresses, *worker_, [this, previousAddressCount, previousWalletIdentity, previousZ = std::move(previousZ), addressSnapshot = std::move(addressSnapshot), tracePage, ticket, listOnly, addrDirtyGen, chatFeedTip, chatSpendSeq]() -> rpc::RPCWorker::MainCb {
AppRefreshRpcGateway refreshRpc(*rpc_, traceSource(tracePage, listOnly ? "Address list" : "Address refresh"));
auto result = NetworkRefreshService::collectAddressRefreshResult(refreshRpc, addressSnapshot, listOnly);
return [this, previousAddressCount, previousWalletIdentity, previousZ, ticket, listOnly, result = std::move(result)]() mutable {
return [this, previousAddressCount, previousWalletIdentity, previousZ, ticket, listOnly, addrDirtyGen, chatFeedTip,
chatSpendSeq, result = std::move(result)]() mutable {
if (addr_pass_spend_seq_ == static_cast<std::int64_t>(chatSpendSeq)) addr_pass_spend_seq_ = -1;
const bool addrListOk = result.addressListOk; // capture before the move
// What was applied: a full pass whose balance scan failed degrades to list-only (it keeps the
// previous balances rather than writing the zeros of the calls that didn't answer).
@@ -2329,9 +2377,13 @@ void App::refreshAddressData(services::ScanTicket ticket)
auto unspentNotes = std::move(result.unspentNotes);
NetworkRefreshService::applyAddressRefreshResult(state_, std::move(result)); // list-only keeps the balances
if (!appliedListOnly) {
updateChatNoteBudgetFromUnspent(unspentNotes);
// Not from a pass sent before a commit marked since: its notes predate it (the owed pass feeds it).
if (addr_dirty_generation_ == addrDirtyGen)
updateChatNoteBudgetFromUnspent(unspentNotes, chatFeedTip, chatSpendSeq);
applyPendingSendBalanceDeltas(false);
addresses_dirty_ = false; // a list-only pass still owes the full one
// A mutation marked after this pass was sent isn't in it (keeps the send progress until the
// post-commit balances). A list-only pass still owes the full one.
if (addr_dirty_generation_ == addrDirtyGen) addresses_dirty_ = false;
address_balances_loaded_ = true;
} else if (!listOnly) {
// The balance scan failed: retry after a hold scaled by what it cost (the abandoned call keeps
@@ -2840,7 +2892,7 @@ void App::refreshBalance()
void App::refreshAddresses()
{
addresses_dirty_ = true;
markAddressesDirty();
refreshAddressData();
}
@@ -3707,7 +3759,7 @@ void App::setMiningAddress(const std::string& addr, bool mining)
void App::invalidateAddressValidationCache()
{
address_validation_cache_dirty_ = true;
addresses_dirty_ = true;
markAddressesDirty();
address_retry_not_before_ = {}; // the address set changed: a refresh is owed now, whatever failed before
noteWalletMutation(); // an import or sweep: the balance changes without the tip or mempool showing it
invalidateShieldedHistoryScanProgress(true);
@@ -3822,7 +3874,24 @@ void App::resetChatSession()
chat_note_model_seen_ = false;
chat_split_outstanding_ = false;
chat_split_submitted_at_ = 0.0;
chat_note_scan_in_flight_ = false; // a stale in-flight note scan is dropped by its scanGen guard
// The chat scans' in-flight flags are cleared only by their replies, which complete their scan tickets (see
// the orphan check in update()): clearing them here would let that check drop a live ticket while the
// daemon still runs the scan. Their results are dropped by the session guard.
chat_note_rescan_requested_ = false;
chat_note_scan_tip_ = -1;
chat_note_scan_last_ = 0.0;
chat_note_first_look_sent_ = false;
chat_verified_funds_by_addr_.clear();
chat_split_hold_tip_ = -1;
chat_inbox_basis_txcount_ = -1;
chat_inbox_last_scan_at_ = 0.0;
chat_inbox_scan_wanted_ = false;
chat_inbox_wanted_txcount_ = -1;
chat_inbox_probe_at_ = 0.0;
chat_inbox_probe_ms_ = 0.0;
chat_spend_log_.clear();
chat_note_scan_spend_seq_ = -1;
addr_pass_spend_seq_ = -1;
if (orphanedSend) orphanedSend(false, "Wallet/server changed — send aborted");
if (!chat::hushChatFeatureEnabledAtBuild()) return; // constexpr — folds away in OFF builds
@@ -3834,7 +3903,6 @@ void App::resetChatSession()
chat_db_.lock();
chat_identity_provisioned_ = false;
chat_identity_fetch_in_flight_ = false;
chat_fast_scan_in_flight_ = false; // a stale in-flight fast-scan is dropped by its session guard
chat_identity_unavailable_ = false;
// Invalidate any in-flight identity fetch that captured the PREVIOUS wallet's secret, so its
// completion callback can't provision that secret under the new wallet.
@@ -3955,6 +4023,22 @@ void App::maybeProvisionChatIdentity()
// legacy/non-mnemonic wallets) the source of the seed-derived chat identity. Persisted on first use
// so the identity + reply address don't shift when new addresses are generated. Prefers a spendable
// one so replies land somewhere we control and fees can be paid.
// The chat note-buffer coordinator's constants (see below), here so the payer helpers can use them.
namespace {
constexpr int kChatBufferTarget = 10; // verified self-notes we aim to keep
constexpr int kChatRefillTrigger = 4; // (re)build only when the maturing pipeline drops below this
// A note is spendable when its witness count > ANCHOR_OFFSET, i.e. block depth >= ANCHOR_OFFSET+1 = 5
// (backend data.rs:543 needs anchor_offset+1 witnesses; ANCHOR_OFFSET=4 in lib.rs:25). Depth 4 is NOT yet
// spendable — counting it there wastes a proving round-trip on a guaranteed "insufficient verified funds".
constexpr int kChatConfsRequired = 5;
constexpr double kChatNoteSizeDrgx = 0.001; // each buffer note (change after a send stays >= fee)
constexpr double kChatSplitWatchdogSecs = 1200.0; // 20 min: clear a stuck split flag if it never mines (expiry/reorg)
constexpr int kMaxChatSendRetries = 20; // transient-funds requeues before giving up
inline std::uint64_t chatDrgxToZat(double drgx) {
return static_cast<std::uint64_t>(std::llround(drgx * 100000000.0));
}
} // namespace
std::string App::chatReplyZaddr()
{
// Reuse the previously-chosen address as long as we still own it spendably.
@@ -3983,25 +4067,62 @@ std::string App::chatReplyZaddr()
return best;
}
std::string App::chatPayFromZaddr(double fee) const
std::string App::chatPayFromZaddr(double need) const
{
// z_sendmany spends from ONE z-address, so the "from" must itself hold >= fee. Prefer the identity
// z_sendmany spends from ONE z-address, so the "from" must itself hold >= need. Prefer the identity
// reply address (keeps from == reply-to, the simplest case); otherwise pick the highest-balance
// spendable z-address that can cover the fee. The memo still advertises the identity address as
// spendable z-address that can cover it. The memo still advertises the identity address as
// reply-to, so paying from a different note doesn't change who the peer replies to.
std::string reply;
if (settings_) reply = settings_->getChatReplyZaddr();
// The chat note scan (or the address scan's feed) knows each address's verified notes; on the Chat page that
// is fresher than state_.z_addresses' balances, which only the address scan refreshes.
if (!chat_verified_funds_by_addr_.empty())
return services::chooseChatPayer(chat_verified_funds_by_addr_, reply, chatDrgxToZat(need));
for (const auto& a : state_.z_addresses)
if (a.address == reply && a.has_spending_key && a.spendableBalance >= fee) return reply;
if (a.address == reply && a.has_spending_key && a.spendableBalance >= need) return reply;
std::string best;
double bestBal = -1.0;
for (const auto& a : state_.z_addresses)
if (a.has_spending_key && !a.address.empty() && a.spendableBalance >= fee && a.spendableBalance > bestBal) {
if (a.has_spending_key && !a.address.empty() && a.spendableBalance >= need && a.spendableBalance > bestBal) {
best = a.address;
bestBal = a.spendableBalance;
}
return best; // empty → no z-address can cover the fee
return best; // empty → no z-address can cover it
}
void App::debitChatPayerLogged(const std::string& payer, services::ChatPayerSpend spend)
{
// Entries every outstanding reply already reflects (posted after them) can go; none outstanding: all.
std::int64_t keepAfter = -1;
for (std::int64_t seq : {chat_note_scan_spend_seq_, addr_pass_spend_seq_})
if (seq >= 0) keepAfter = keepAfter < 0 ? seq : std::min(keepAfter, seq);
if (keepAfter < 0) chat_spend_log_.clear();
while (!chat_spend_log_.empty() &&
(static_cast<std::int64_t>(chat_spend_log_.front().seq) <= keepAfter || chat_spend_log_.size() >= 64))
chat_spend_log_.pop_front();
chat_spend_log_.push_back({++chat_spend_seq_, payer, spend});
services::debitChatPayer(chat_verified_funds_by_addr_, payer, spend);
}
void App::reapplyChatSpendsAfter(std::uint64_t seq)
{
for (const auto& e : chat_spend_log_) {
if (e.seq <= seq) continue;
services::debitChatPayer(chat_verified_funds_by_addr_, e.payer, e.spend);
if (e.spend == services::ChatPayerSpend::OneNote)
chat_verified_note_budget_ = std::max(0, chat_verified_note_budget_ - 1);
}
}
std::uint64_t App::chatSplitAffordableZat() const
{
// What one split can spend: z_sendmany pays from ONE address, so on the full node the richest verified
// payer (lite: the verified shielded balance, as before).
if (!lite_wallet_ && !chat_verified_funds_by_addr_.empty())
return services::richestChatPayerZat(chat_verified_funds_by_addr_);
return chat_verified_shielded_zat_;
}
// A unique opaque id (hex), used for the local echo id (we never harvest our own sends) and for
@@ -4083,11 +4204,14 @@ bool App::broadcastChatMemos(const chat::OutgoingChatMemos& memos, const std::st
"HushChat / broadcast", /*markFeeGapRetry*/ true,
[this, echoLocalId, sessionGen](bool ok, const std::string& result) {
if (sessionGen != chat_session_generation_) return; // stale wallet — don't touch new session
if (!ok) chat_note_rescan_requested_ = true; // covers a submit-time failure the poller never sees
inflight_op_ = LiteInflightOp{}; // release the channel for this session
LiteInflightOp op; op.echoLocalId = echoLocalId; op.sessionGen = sessionGen;
onChatBroadcastResult(op, ok, result);
},
/*background*/ true); // status is shown on the chat message, not the global send UI
// Until the next note scan restores the truth: the send spends one whole note (largest first) of `from`.
debitChatPayerLogged(from, services::ChatPayerSpend::OneNote);
return true; // submitted (async build/broadcast; the callback resolves the final status)
}
@@ -4208,20 +4332,7 @@ void App::sendContactRequestForCid(const std::string& cid, const std::string& pe
// Design in app.h. Keeps ~kChatBufferTarget verified self-notes so a burst of messages each spends a
// separate note; refilled from change + background self-splits. Shares the controller's single broadcast
// channel with user sends (inflight_op_ tracks ownership). Pumped from App::update().
namespace {
constexpr int kChatBufferTarget = 10; // verified self-notes we aim to keep
constexpr int kChatRefillTrigger = 4; // (re)build only when the maturing pipeline drops below this
// A note is spendable when its witness count > ANCHOR_OFFSET, i.e. block depth >= ANCHOR_OFFSET+1 = 5
// (backend data.rs:543 needs anchor_offset+1 witnesses; ANCHOR_OFFSET=4 in lib.rs:25). Depth 4 is NOT yet
// spendable — counting it there wastes a proving round-trip on a guaranteed "insufficient verified funds".
constexpr int kChatConfsRequired = 5;
constexpr double kChatNoteSizeDrgx = 0.001; // each buffer note (change after a send stays >= fee)
constexpr double kChatSplitWatchdogSecs = 1200.0; // 20 min: clear a stuck split flag if it never mines (expiry/reorg)
constexpr int kMaxChatSendRetries = 20; // transient-funds requeues before giving up
inline std::uint64_t chatDrgxToZat(double drgx) {
return static_cast<std::uint64_t>(std::llround(drgx * 100000000.0));
}
} // namespace
// (The coordinator's constants and chatDrgxToZat are defined above chatReplyZaddr.)
// Confirmations a self-note needs before it's spendable: lite requires ANCHOR_OFFSET+1 witnesses (=5,
// data.rs:543); the full node's chat z_sendmany uses minconf=1 (app_network.cpp z_sendmany call).
@@ -4318,7 +4429,7 @@ void App::refreshChatNoteBudget(const wallet::LiteWalletAppRefreshModel& model)
// recovered) — or once a watchdog window elapses, so a split that never mines can't wedge refill.
if (chat_split_outstanding_ &&
(chat_pipeline_note_count_ >= kChatRefillTrigger ||
ImGui::GetTime() - chat_split_submitted_at_ > kChatSplitWatchdogSecs)) {
scanClockNow() - chat_split_submitted_at_ > kChatSplitWatchdogSecs)) {
chat_split_outstanding_ = false;
}
}
@@ -4338,42 +4449,69 @@ void App::refreshChatNoteBudgetNode() {
// chat scan here starves block connection and keeps the node from ever pinning to the tip. Chat
// send-readiness can wait until we're synced; this budget is only advisory (the send path re-checks).
// The same hard gates as every wallet scan, except that a queued chat send still drains while the window
// is in the background (the address scan that also feeds the budget waits for the window).
// is in the background (the address scan that also feeds the budget waits for the window). Not for a
// split alone: nothing needs the buffer in the background, and a split that never refills would scan
// every block for as long as the window stays minimized.
{
services::ScanContext sc = scanContext();
if (!chat_send_queue_.empty() || chat_split_outstanding_) sc.background = false;
if (!chat_send_queue_.empty()) sc.background = false;
if (!services::wholeWalletScanAllowed(sc)) return;
}
// Only pre-warm the send-budget while the user is actually in chat. Off the Chat tab this heavy
// z_listunspent is unnecessary — and on a wallet-data tab, refreshAddressData's own z_listunspent
// already feeds the budget for free (updateChatNoteBudgetFromUnspent), so this dedicated scan is a
// fallback for the Chat tab (where the address refresh doesn't run).
if (current_page_ != ui::NavPage::Chat) return;
if (chat_note_scan_in_flight_) return; // one scan at a time
const double now = ImGui::GetTime();
// Adaptive back-off keyed on the last scan's own cost: on a large wallet where the scan takes many
// seconds, this stretches the interval so the scan can't occupy more than ~10% of wall-clock (same
// duty-cycle discipline as the balance/address/tx polls). A cheap wallet stays at the 4s floor.
double minInterval = 4.0;
if (chat_note_scan_ms_ > 0.0)
minInterval = std::max(4.0, (chat_note_scan_ms_ / 1000.0) / 0.10);
if (now - chat_note_scan_last_ < minInterval) return;
if (chat_note_scan_in_flight_) return; // cleared only by its reply (which completes its ticket)
// The address pass reads the same z_listunspent and feeds this view: not a second one while it runs or is owed.
using Job = services::NetworkRefreshService::Job;
if (network_refresh_.jobInProgress(Job::Addresses) || (addresses_dirty_ && addressScanCanRunNow())) return;
using services::ScanStream;
const double now = scanClockNow();
// On need only: something can use the view (the Chat page, a queued send, a split) and it can have changed
// (first look, a chat op finished, a settled block that matters, or the fallback). How often: the budget's.
services::ChatNoteScanInputs in;
in.onChatPage = currentScanVisibility().chat;
in.queueWaiting = !chat_send_queue_.empty();
in.splitOutstanding = chat_split_outstanding_;
in.rescanRequested = chat_note_rescan_requested_;
in.modelSeen = chat_note_model_seen_;
in.verifiedBudget = chat_verified_note_budget_;
in.pipelineNotes = chat_pipeline_note_count_;
in.tipHeight = state_.sync.blocks;
in.lastScanTip = chat_note_scan_tip_;
in.nowSec = now;
in.lastScanAtSec = chat_note_scan_last_;
in.tipChangedAtSec = tip_changed_at_;
in.fallbackSec = services::chatNoteFallbackSec(scan_governor_.emaCostMs(ScanStream::ChatNotes));
if (!services::shouldScanChatNotes(in)) return;
const auto sig = currentScanSignals();
services::ScanTicket ticket;
if (!chat_note_model_seen_ && in.onChatPage && !chat_note_first_look_sent_) {
// The page can't show the buffer, or drain a send, without one look: explicit (never held back, but
// charged), once per session.
ticket = scan_governor_.beginExplicit(ScanStream::ChatNotes, sig, now);
chat_note_first_look_sent_ = true;
} else {
ticket = scan_governor_.tryAdmit(ScanStream::ChatNotes, in.onChatPage || in.queueWaiting, sig, now);
if (!ticket) return; // asks again next frame while the need holds
}
const bool servedRescan = chat_note_rescan_requested_;
chat_note_rescan_requested_ = false;
if (now - tip_changed_at_ >= services::kChatTipSettleSec) chat_note_scan_tip_ = state_.sync.blocks; // settled only
chat_note_scan_last_ = now;
chat_note_scan_in_flight_ = true;
const std::uint64_t spendSeq = chat_spend_seq_;
chat_note_scan_spend_seq_ = static_cast<std::int64_t>(spendSeq);
const int scanGen = chat_session_generation_;
const int confsNeeded = chatConfsRequired(); // == 1 for full node
const std::uint64_t minVal = chatDrgxToZat(kChatMinFeeDrgx);
worker_->post([this, scanGen, confsNeeded, minVal]() -> rpc::RPCWorker::MainCb {
worker_->post([this, scanGen, confsNeeded, minVal, ticket, servedRescan, spendSeq]() -> rpc::RPCWorker::MainCb {
int verified = 0, pipeline = 0;
std::uint64_t verifiedZat = 0;
services::ChatPayerMap funds;
bool ok = false;
rpc::RpcFailureKind failKind = rpc::RpcFailureKind::None;
const auto scanStart = std::chrono::steady_clock::now();
try {
rpc::RPCClient::TraceScope trace("HushChat / note-buffer scan");
nlohmann::json notes = rpc_->call("z_listunspent", nlohmann::json::array({0})); // 0 = include maturing
if (notes.is_array()) {
ok = true;
for (const auto& nz : notes) {
if (!nz.is_object()) continue;
if (nz.value("locked", false)) continue; // tied up by an in-flight send
@@ -4384,24 +4522,50 @@ void App::refreshChatNoteBudgetNode() {
? nz["rawconfirmations"].get<int>()
: nz.value("confirmations", 0);
++pipeline; // unspent self-note (verified or maturing)
if (confs >= confsNeeded) { ++verified; verifiedZat += amtZat; }
if (confs >= confsNeeded) {
++verified;
verifiedZat += amtZat;
if (nz.contains("address") && nz["address"].is_string()) {
auto& f = funds[nz["address"].get<std::string>()];
f.zat += amtZat;
++f.notes;
}
}
}
ok = true; // after the loop: a throw mid-loop applies nothing
}
} catch (const std::exception&) {}
} catch (const std::exception& e) {
failKind = rpc::rpcFailureKindOf(e);
} catch (...) {
failKind = rpc::RpcFailureKind::Other;
}
const double scanMs = std::chrono::duration<double, std::milli>(
std::chrono::steady_clock::now() - scanStart).count();
return [this, scanGen, ok, verified, pipeline, verifiedZat, scanMs]() {
if (scanGen != chat_session_generation_) return; // wallet switched — drop (reset cleared the flag)
return [this, scanGen, ticket, servedRescan, spendSeq, ok, failKind, verified, pipeline, verifiedZat,
funds = std::move(funds), scanMs]() mutable {
chat_note_scan_in_flight_ = false;
if (scanMs > 0.0) chat_note_scan_ms_ = scanMs; // feed the adaptive back-off
if (!ok) return; // z_listunspent unavailable — leave caches as-is
// Whatever the session: the daemon ran it. Before the guard, or a stale reply would hold the slot.
scan_governor_.complete(ticket, scanMs, services::chatScanOutcome(ok, failKind), scanClockNow());
if (scanGen != chat_session_generation_) return; // wallet switched — drop
// onDisconnected cleared the view and this scan's seq since the post: its notes are the previous
// connection's, and the spends to re-apply are gone from the log. Drop it (the reconnect's address
// pass feeds the view), like the address pass whose reply resetJobs makes stale.
if (chat_note_scan_spend_seq_ != static_cast<std::int64_t>(spendSeq)) return;
chat_note_scan_spend_seq_ = -1;
if (!ok) { // still owed (the 4 s floor paces the retry)
if (servedRescan) chat_note_rescan_requested_ = true;
chat_note_scan_tip_ = -1;
return;
}
chat_note_model_seen_ = true;
chat_verified_note_budget_ = verified;
chat_pipeline_note_count_ = pipeline;
chat_verified_shielded_zat_ = verifiedZat;
chat_verified_funds_by_addr_ = std::move(funds);
reapplyChatSpendsAfter(spendSeq); // chat sends submitted after this scan was posted
if (chat_split_outstanding_ &&
(chat_pipeline_note_count_ >= kChatRefillTrigger ||
ImGui::GetTime() - chat_split_submitted_at_ > kChatSplitWatchdogSecs)) {
scanClockNow() - chat_split_submitted_at_ > kChatSplitWatchdogSecs)) {
chat_split_outstanding_ = false;
}
};
@@ -4413,27 +4577,42 @@ void App::refreshChatNoteBudgetNode() {
// fields we need (amount / locked / TRUE confirmations), so there's no RPC and no worker hop. Mirrors the
// apply in refreshChatNoteBudgetNode and stamps chat_note_scan_last_ so the dedicated scan backs off.
void App::updateChatNoteBudgetFromUnspent(
const std::vector<services::NetworkRefreshService::UnspentNoteLite>& unspentNotes) {
const std::vector<services::NetworkRefreshService::UnspentNoteLite>& unspentNotes, std::int64_t feedTip,
std::uint64_t spendSeq) {
if (lite_wallet_ || !chat::hushChatFeatureEnabledAtBuild() || !chat_service_.hasIdentity()) return;
const int confsNeeded = chatConfsRequired();
const std::uint64_t minVal = chatDrgxToZat(kChatMinFeeDrgx);
int verified = 0, pipeline = 0;
std::uint64_t verifiedZat = 0;
services::ChatPayerMap funds;
for (const auto& nz : unspentNotes) {
if (nz.locked) continue; // tied up by an in-flight send
const std::uint64_t amtZat = chatDrgxToZat(nz.amount);
if (amtZat < minVal) continue; // skip dust below a fee
++pipeline; // unspent self-note (verified or maturing)
if (nz.confirmations >= confsNeeded) { ++verified; verifiedZat += amtZat; }
if (nz.confirmations >= confsNeeded) {
++verified;
verifiedZat += amtZat;
if (!nz.address.empty()) {
auto& f = funds[nz.address];
f.zat += amtZat;
++f.notes;
}
}
}
chat_note_model_seen_ = true;
chat_verified_note_budget_ = verified;
chat_pipeline_note_count_ = pipeline;
chat_verified_shielded_zat_ = verifiedZat;
chat_note_scan_last_ = ImGui::GetTime(); // counts as a fresh scan → dedicated scan backs off
chat_verified_funds_by_addr_ = std::move(funds);
reapplyChatSpendsAfter(spendSeq); // chat sends submitted after this pass was posted
// Counts as a fresh scan: the dedicated one backs off. Not the rescan request: this pass may predate the
// chat op that asked for it.
chat_note_scan_last_ = scanClockNow();
if (feedTip >= 0) chat_note_scan_tip_ = feedTip;
if (chat_split_outstanding_ &&
(chat_pipeline_note_count_ >= kChatRefillTrigger ||
ImGui::GetTime() - chat_split_submitted_at_ > kChatSplitWatchdogSecs)) {
scanClockNow() - chat_split_submitted_at_ > kChatSplitWatchdogSecs)) {
chat_split_outstanding_ = false;
}
}
@@ -4483,13 +4662,13 @@ bool App::broadcastSelfSplitNode(int noteCount) {
const std::string myZ = chatReplyZaddr();
if (myZ.empty()) return false;
const double fee = kChatMinFeeDrgx;
// Pay from any funded z-address; the split's OUTPUTS still go to the reply address (building the buffer
// there). The note count now includes notes at every address and is sized against the total verified
// balance, so paying from wherever the funds actually sit is consistent — and it can't stall when funds
// aren't at the reply address (which paying strictly from myZ would).
const std::string from = chatPayFromZaddr(fee);
if (from.empty()) return false; // no fundable note — pump retries after a scan
const int n = std::min(noteCount, 40); // bounded so the split tx stays under max_tx_size after decoys
// Pay from any funded z-address; the split's OUTPUTS still go to the reply address (building the buffer
// there). ONE address pays the whole split (n notes + the fee), not just the fee: the pump sizes n to the
// richest verified payer (chatSplitAffordableZat), so one covers it. Paying from wherever the funds sit
// can't stall when they aren't at the reply address (which paying strictly from myZ would).
const std::string from = chatPayFromZaddr(n * kChatNoteSizeDrgx + fee);
if (from.empty()) return false; // no fundable payer — no RPC; the pump retries after a scan
nlohmann::json recipients = nlohmann::json::array();
for (int i = 0; i < n; ++i) {
nlohmann::json r;
@@ -4506,10 +4685,16 @@ bool App::broadcastSelfSplitNode(int noteCount) {
// On success the outputs aren't mined yet — chat_split_outstanding_ clears on the
// next scan (pipeline recovered) or the watchdog. On FAILURE (e.g. a stale-scan
// insufficient-funds throw) clear it now so the pump can retry after the next scan
// instead of waiting out the 1200s watchdog. (No notes were created, so it's safe.)
if (!ok) chat_split_outstanding_ = false;
// instead of waiting out the 1200s watchdog (no notes were created, so it's safe),
// and no other split before the next block.
if (!ok) {
chat_split_outstanding_ = false;
chat_split_hold_tip_ = state_.sync.blocks;
chat_note_rescan_requested_ = true;
}
},
/*background*/ true); // autonomous buffer maintenance — no global send UI
debitChatPayerLogged(from, services::ChatPayerSpend::All);
wallet::liteLog("Chat buffer: splitting funds into " + std::to_string(n) +
" notes (self-send) so messages can be sent back-to-back");
return true;
@@ -4530,9 +4715,13 @@ bool App::shouldSplitChatBuffer() {
if (lite_wallet_ ? (bool)lite_send_callback_
: (send_submissions_in_flight_ > 0 || !pending_opids_.empty())) return false;
if (chat_split_outstanding_) return false;
if (!lite_wallet_ && chat_split_hold_tip_ >= 0 && state_.sync.blocks <= chat_split_hold_tip_) return false; // failed here: once per block
if (!chat_note_model_seen_) return false;
if (chat_pipeline_note_count_ >= kChatRefillTrigger) return false; // change refill covers it
if (chat_verified_shielded_zat_ < chatDrgxToZat(kChatNoteSizeDrgx + kChatMinFeeDrgx)) return false;
// Only a split that can lift the pipeline to the trigger by itself: once it mines, the pipeline check
// releases the flag. A smaller one never does, and the watchdog would re-split the same notes every 20 min,
// a fee each time. (A payer below that sends one message per confirmation through its change instead.)
if (chatSplitAffordableZat() < chatDrgxToZat(kChatRefillTrigger * kChatNoteSizeDrgx + kChatMinFeeDrgx)) return false;
return true;
}
@@ -4605,8 +4794,8 @@ void App::pumpChatNoteBuffer() {
const std::uint64_t noteZat = chatDrgxToZat(kChatNoteSizeDrgx);
const std::uint64_t feeZat = chatDrgxToZat(kChatMinFeeDrgx);
int affordable = 0;
if (chat_verified_shielded_zat_ > feeZat && noteZat > 0)
affordable = static_cast<int>((chat_verified_shielded_zat_ - feeZat) / noteZat);
const std::uint64_t affordableZat = chatSplitAffordableZat(); // one payer funds the whole split
if (affordableZat > feeZat && noteZat > 0) affordable = static_cast<int>((affordableZat - feeZat) / noteZat);
const int n = std::min(need, affordable);
const bool submitted = n >= 1 && (lite_wallet_ ? broadcastSelfSplitLite(n)
: broadcastSelfSplitNode(n));
@@ -4615,7 +4804,7 @@ void App::pumpChatNoteBuffer() {
// Block further splits until this one's outputs mine in (or the watchdog fires): the outputs are
// invisible to the note count for ~1 block, so without this we'd fire a fresh split every frame.
chat_split_outstanding_ = true;
chat_split_submitted_at_ = ImGui::GetTime();
chat_split_submitted_at_ = scanClockNow(); // steady: the watchdog also runs while minimized
}
}
}
@@ -4670,40 +4859,94 @@ void App::ingestLiteChatMemos(const wallet::LiteWalletAppRefreshModel& model)
}
}
bool App::chatInboxScanAllowed() const
{
// Full-node only (lite has its own harvest).
if (lite_wallet_ || !chat::hushChatFeatureEnabledAtBuild() || !chat_service_.hasIdentity()) return false;
if (!state_.connected || !rpc_ || !worker_) return false;
// The same hard gates as every wallet scan (catch-up, the connect gate, the background, busy, ...), and only
// while the Chat page is shown: off it, incoming messages still arrive through the confirmed block-tip
// harvest (+ toast) within ~1 block.
return services::wholeWalletScanAllowed(scanContext()) && currentScanVisibility().chat;
}
void App::fastScanChatMemos()
{
// Full-node only (lite has its own harvest). Re-scan JUST the chat reply address at minconf=0 every
// cycle so peers' messages surface at mempool speed. The normal, block-tip-gated harvest still
// ingests everything on confirmation (the store dedups on txid+position, so no double-insert).
if (lite_wallet_) return;
if (!chat::hushChatFeatureEnabledAtBuild() || !chat_service_.hasIdentity()) return;
if (!state_.connected || !rpc_ || !worker_) return;
if (chat_fast_scan_in_flight_) return; // don't stack RPCs if a previous scan is still running
// z_listreceivedbyaddress is O(mapWallet) (the daemon iterates every wtx even when filtered to one
// address) — seconds on a large wallet — so this 0-conf poll must NOT run while the node is catching
// up, and even when synced it backs off to ~10% wall-clock keyed on its own cost. The block-tip
// harvest still ingests every message on confirmation; this only trades away mempool-speed delivery.
// The same hard gates as every wallet scan (catch-up, the connect gate, the background, busy, ...).
if (!services::wholeWalletScanAllowed(scanContext())) return;
// Mempool-speed 0-conf delivery is only worth its heavy cost while the user is watching chat. Off the
// Chat tab, incoming messages still arrive via the confirmed block-tip harvest (+ toast) within ~1 block.
if (current_page_ != ui::NavPage::Chat) return;
const double nowT = ImGui::GetTime();
if (chat_fast_scan_ms_ > 0.0 &&
nowT - chat_fast_scan_last_ < (chat_fast_scan_ms_ / 1000.0) / 0.10) return;
const std::string addr = chatReplyZaddr();
if (addr.empty()) return;
chat_fast_scan_last_ = nowT;
// 0-conf delivery while the Chat page is shown: a cheap probe (getwalletinfo's txcount) each tick, and the
// inbox scan (z_listreceivedbyaddress of the reply address: O(mapWallet), the daemon iterates every wtx
// even when filtered to one address, under cs_main) only when the wallet gained a transaction or the safety
// net is due, and only as the scan budget admits it (ChatFast). The block-tip harvest still ingests every
// message on confirmation (the store dedups on txid+position).
if (!chatInboxScanAllowed()) return;
if (chat_fast_scan_in_flight_) return; // the probe or the scan is out
if (chat_inbox_scan_wanted_) { // keep asking (a waiter must keep asking)
admitChatInboxScan();
return;
}
// A wallet scan is in the daemon: the probe would queue behind it for cs_main, and nothing could be
// admitted after it anyway.
if (scan_governor_.anyInFlight()) return;
const double now = scanClockNow();
if (!services::chatProbeDue(chat_inbox_probe_ms_, chat_inbox_probe_at_, now)) return;
chat_inbox_probe_at_ = now;
chat_fast_scan_in_flight_ = true;
const int scanGen = chat_session_generation_; // guard: drop the result if the wallet switches/locks
worker_->post([this, addr, scanGen]() -> rpc::RPCWorker::MainCb {
worker_->post([this, scanGen]() -> rpc::RPCWorker::MainCb {
std::int64_t txCount = -1;
bool answered = true; // false: nothing learned this tick (transport, busy, warmup)
const auto t0 = std::chrono::steady_clock::now();
try {
rpc::RPCClient::TraceScope trace("HushChat / inbox probe");
txCount = services::parseWalletTxCount(rpc_->call("getwalletinfo"));
} catch (const rpc::RpcError& e) {
answered = e.kind() != rpc::RpcFailureKind::Warmup; // an app error: no count, scan as the budget allows
} catch (...) {
answered = false;
}
const double probeMs = std::chrono::duration<double, std::milli>(std::chrono::steady_clock::now() - t0).count();
return [this, scanGen, txCount, answered, probeMs]() {
chat_fast_scan_in_flight_ = false; // ours, whatever the session
// Its cost, from probes that answered only (a timed-out one measured its wait), smoothed so one slow
// probe doesn't silence the next ones.
if (answered && probeMs > 0.0) {
chat_inbox_probe_ms_ = chat_inbox_probe_ms_ <= 0.0
? probeMs : std::min(probeMs, 0.7 * chat_inbox_probe_ms_ + 0.3 * probeMs);
}
if (scanGen != chat_session_generation_ || !answered) return;
services::ChatInboxScanInputs in;
in.basisTxCount = chat_inbox_basis_txcount_;
in.txCount = txCount;
in.nowSec = scanClockNow();
in.lastScanAtSec = chat_inbox_last_scan_at_;
in.safetyNetSec = services::chatInboxSafetyNetSec(scan_governor_.emaCostMs(services::ScanStream::ChatFast));
if (!services::chatInboxScanNeeded(in)) return;
chat_inbox_scan_wanted_ = true;
chat_inbox_wanted_txcount_ = txCount; // read before the scan: the basis can only under-count (one extra scan)
admitChatInboxScan();
};
});
}
void App::admitChatInboxScan()
{
if (!chatInboxScanAllowed() || chat_fast_scan_in_flight_) return; // a gate may have closed since the probe
const std::string addr = chatReplyZaddr();
if (addr.empty()) return;
auto ticket = scan_governor_.tryAdmit(services::ScanStream::ChatFast, /*visible=*/true,
currentScanSignals(), scanClockNow());
if (!ticket) return; // still wanted: the next chat tick asks again
chat_fast_scan_in_flight_ = true;
const std::int64_t basis = chat_inbox_wanted_txcount_;
const int scanGen = chat_session_generation_; // guard: drop the result if the wallet switches/locks
worker_->post([this, addr, scanGen, ticket, basis]() -> rpc::RPCWorker::MainCb {
std::vector<chat::HushChatTransactionMetadata> metadata;
int rawMemoCount = 0; // received notes carrying a memo at the reply addr (0-conf visibility signal)
std::string scanError;
bool ok = false;
rpc::RpcFailureKind failKind = rpc::RpcFailureKind::None;
const auto scanStart = std::chrono::steady_clock::now();
try {
rpc::RPCClient::TraceScope trace("HushChat / 0-conf fast scan");
rpc::RPCClient::TraceScope trace("HushChat / 0-conf inbox scan");
nlohmann::json received = rpc_->call("z_listreceivedbyaddress", {addr, 0}); // 0 = include mempool
if (received.is_array()) {
std::unordered_map<std::string, chat::HushChatTransactionInput> byTxid;
@@ -4729,21 +4972,31 @@ void App::fastScanChatMemos()
auto extracted = chat::extractHushChatTransactionMetadata(entry.second);
for (auto& m : extracted.metadata) metadata.push_back(std::move(m));
}
ok = true;
}
} catch (const std::exception& e) { scanError = e.what(); }
} catch (const std::exception& e) {
scanError = e.what();
failKind = rpc::rpcFailureKindOf(e);
} catch (...) {
scanError = "unknown error";
failKind = rpc::RpcFailureKind::Other;
}
const int parsedCount = static_cast<int>(metadata.size());
const double scanMs = std::chrono::duration<double, std::milli>(
std::chrono::steady_clock::now() - scanStart).count();
return [this, scanGen, metadata = std::move(metadata), rawMemoCount, parsedCount, scanError, scanMs]() mutable {
// The wallet was switched/locked between post and now — this metadata belongs to the previous
// session. resetChatSession already reset the in-flight flag, so just drop; clearing it here
// would clobber a new session's own in-flight scan (mirrors the broadcast/identity guards).
if (scanGen != chat_session_generation_) return;
return [this, scanGen, ticket, basis, ok, failKind, metadata = std::move(metadata), rawMemoCount, parsedCount,
scanError, scanMs]() mutable {
chat_fast_scan_in_flight_ = false;
if (scanMs > 0.0) chat_fast_scan_ms_ = scanMs; // feed the adaptive back-off
// Diagnostic (console App/chat channel): log when the memo-note count at the reply address
// CHANGES, so a stable mempool doesn't spam every 2.5s. Reveals whether inbound 0-conf messages
// are reaching us at all, and how many parse as chat vs are unrelated memos.
// Whatever the session: the daemon ran it. Before the guard, or a stale reply would hold the slot.
scan_governor_.complete(ticket, scanMs, services::chatScanOutcome(ok, failKind), scanClockNow());
// The wallet was switched/locked between post and now — this metadata belongs to the previous
// session: drop it.
if (scanGen != chat_session_generation_) return;
if (ok) { // an empty inbox is a result too: before the early returns below
chat_inbox_scan_wanted_ = false;
chat_inbox_basis_txcount_ = basis;
chat_inbox_last_scan_at_ = ticket.dispatchedAt;
}
if (!scanError.empty()) {
wallet::liteLog("chat[0conf]: reply-addr scan failed: " + scanError);
} else if (rawMemoCount != chat_fast_scan_last_seen_) {
@@ -6347,26 +6600,23 @@ void App::submitZSendMany(const std::string& from, const std::string& to, double
return [this, callback, ok, result_str, from, to, amount, fee, memo, markFeeGapRetry, background]() {
if (send_submissions_in_flight_ > 0) --send_submissions_in_flight_;
if (ok) {
// A send changes address balances — refresh on next cycle
addresses_dirty_ = true;
// Force transaction list refresh so the sent tx appears immediately
markTransactionsDirty();
last_tx_block_height_ = -1;
requestForcedBalanceRefresh(); // the user's own send must update the balance now, past the throttle
noteWalletMutation();
network_refresh_.markWalletMutationRefresh();
// z_sendmany only returned an opid: the transaction is built/signed/
// broadcast asynchronously by the daemon. Defer the user-facing
// success/failure to the opid poller (app.cpp) so we don't report
// "sent successfully" for an operation that may still fail.
// "sent successfully" for an operation that may still fail. The wallet changes
// when the op commits, and the poller re-reads it then, by origin
// (markWalletMutated); meanwhile the pending row and the local spendable debit
// show the send. `background` = a chat message or buffer split.
if (!result_str.empty()) {
pending_opids_.push_back(result_str);
if (background) op_origin_[result_str] = services::NetworkRefreshService::OperationOrigin::Chat;
if (markFeeGapRetry)
send_feegap_retried_opids_.insert(result_str); // a retry of a retry is a real error
upsertPendingSendTransaction(result_str, from, to, amount, memo, fee);
if (callback) pending_send_callbacks_[result_str] = callback;
} else if (callback) {
callback(true, result_str); // no opid to track — report as-is
} else {
if (!background) markWalletMutated(/*forceBalance=*/true); // no opid: no completion event
if (callback) callback(true, result_str); // no opid to track — report as-is
}
} else {
if (!background) send_progress_active_ = false;