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
+46 -25
View File
@@ -832,6 +832,10 @@ void App::update()
const double scanNow = scanClockNow();
if (!network_refresh_.jobInProgress(Job::Core)) scan_governor_.dropOrphans(ScanStream::Balance, scanNow);
if (!network_refresh_.jobInProgress(Job::Addresses)) scan_governor_.dropOrphans(ScanStream::Addresses, scanNow);
// The chat scans aren't refresh jobs: their in-flight flag is set with the ticket and cleared by the reply
// that completes it.
if (!chat_fast_scan_in_flight_) scan_governor_.dropOrphans(ScanStream::ChatFast, scanNow);
if (!chat_note_scan_in_flight_) scan_governor_.dropOrphans(ScanStream::ChatNotes, scanNow);
if (!network_refresh_.jobInProgress(Job::Transactions)) {
scan_governor_.dropOrphans(ScanStream::History, scanNow);
scan_governor_.dropOrphans(ScanStream::RecentTx, scanNow);
@@ -945,12 +949,14 @@ void App::update()
// it can act; compiled away when the feature is off (constexpr gate inside).
maybeProvisionChatIdentity();
// Chat note-buffer coordinator (BOTH variants). Full-node only: refresh the per-note counts via a
// rate-limited z_listunspent worker scan (lite gets its counts from the refresh model, above). Then
// pump: drain queued chat sends against verified notes and build/refill the buffer during idle. Both
// self-gate to no-ops unless chat is engaged; the pump serializes to one send outstanding at a time.
refreshChatNoteBudgetNode();
// Chat note-buffer coordinator (BOTH variants). Pump: drain queued chat sends against verified notes and
// build/refill the buffer during idle. Then, full-node only, refresh the per-note counts via the governed
// z_listunspent scan (lite gets its counts from the refresh model, above). The pump goes first: a scan
// posted in the same frame then queues on worker_ behind the new z_sendmany, which locks its notes, so
// the scan's reply already reflects the spend. Both self-gate to no-ops unless chat is engaged; the pump
// serializes to one send outstanding at a time.
pumpChatNoteBuffer();
refreshChatNoteBudgetNode();
// One-time reminder to back up the wallet's seed phrase (mnemonic wallets only).
maybeRemindSeedBackup();
@@ -1337,14 +1343,25 @@ void App::update()
}
return [this, parsed = std::move(parsed)]() mutable {
opid_poll_in_progress_ = false;
using Origin = services::NetworkRefreshService::OperationOrigin;
// Classify first: the moves below consume the parse.
const auto batch = services::NetworkRefreshService::planOperationBatch(parsed, op_origin_);
auto originOf = [this](const std::string& id) {
const auto it = op_origin_.find(id);
return it == op_origin_.end() ? Origin::User : it->second;
};
// Successes: hand the real txid to any waiting send UI callback.
std::unordered_set<std::string> successfulOpids;
for (const auto& [opid, txid] : parsed.successTxidsByOpid) {
successfulOpids.insert(opid);
markPendingSendTransactionSucceeded(opid, txid);
send_txids_.insert(txid);
send_txid_heights_.emplace(txid, state_.sync.blocks);
// A chat op's record is its echo: tracking it would refresh the history every block and keep
// the balance poll reading minconf=1 until it settles.
if (originOf(opid) != Origin::Chat) {
send_txids_.insert(txid);
send_txid_heights_.emplace(txid, state_.sync.blocks);
}
invokeSendResultCallback(opid, true, txid);
}
@@ -1373,9 +1390,6 @@ void App::update()
std::remove(pending_opids_.begin(), pending_opids_.end(), id),
pending_opids_.end());
}
// An op finished (or vanished): whatever it did to the wallet, the scans gated on change
// signals must look again.
if (!terminalOpids.empty()) noteWalletMutation();
// Stale opids (no longer reported by the daemon): let any waiting send UI
// know the outcome couldn't be confirmed rather than spinning forever.
@@ -1392,15 +1406,21 @@ void App::update()
}
removePendingSendTransactions(successOpids, false);
removePendingSendTransactions(failedOrStaleOpids, true);
// Transaction confirmed by daemon — force immediate data refresh
markTransactionsDirty();
addresses_dirty_ = true;
last_tx_block_height_ = -1;
network_refresh_.markWalletMutationRefresh();
} else {
removePendingSendTransactions(terminalOpids, true);
maybeFinishTransactionSendProgress();
}
// After the failure loop: maybeRetrySendForFeeGap reads the fee-gap marker there.
for (const auto& id : terminalOpids) {
op_origin_.erase(id);
send_feegap_retried_opids_.erase(id);
}
// A user op re-reads the wallet and forces the headline balance (once no user op is pending);
// the client auto-shield re-reads it unforced. A chat op only refreshes the chat note view: its
// tx moves the mempool signal, and its record is the echo.
if (batch.userSucceeded || batch.autoSucceeded) markWalletMutated(/*forceBalance=*/batch.userSucceeded);
else if (batch.walletTerminal) noteWalletMutation(); // failed or vanished: look again (conservative)
if (batch.chatTerminal) chat_note_rescan_requested_ = true; // the chat note view (payer, budget) changed
maybeFinishTransactionSendProgress();
};
});
}
@@ -1459,13 +1479,14 @@ void App::update()
shouldRunWalletTransactionRefresh()) {
if (refreshTransactionData()) network_refresh_.retrySoon(RefreshTimer::Transactions, kScanRetrySec);
}
// 0-conf chat: a DEDICATED ~2.5s mempool scan of the chat address, independent of the page's
// (slower, 10–15s) Transactions cadence — so incoming messages surface in a few seconds. The
// normal harvest only re-scans on a new block. Self-gated (no-op without a chat identity /
// connection / on lite); the in-flight guard keeps overlapping RPCs from stacking.
// 0-conf chat on the Chat page: a cheap txcount probe each tick (the user's chat poll rate), and the
// inbox scan when the wallet gained a transaction, as the scan budget admits it. A scan the budget held
// back asks again within kScanRetrySec (a waiting stream must keep asking). The normal harvest only
// re-scans on a new block. Self-gated (no-op without a chat identity / connection / on lite).
chat_fast_scan_accum_ += logic_dt_;
const float chatPoll = settings_ ? settings_->getChatPollRateSec() : 2.5f; // user-configurable
if (chat_fast_scan_accum_ >= chatPoll) {
const float chatTick = chat_inbox_scan_wanted_ ? std::min(chatPoll, kScanRetrySec) : chatPoll;
if (chat_fast_scan_accum_ >= chatTick) {
chat_fast_scan_accum_ = 0.0f;
fastScanChatMemos();
}
@@ -1487,12 +1508,12 @@ void App::update()
refreshAddressData();
}
} else if (addressTier != services::ScanTier::None && network_refresh_.isDue(RefreshTimer::Addresses)) {
// Explicit need (a changed address set, or an in-flight send tracking its change output)
// refreshes now (list-only while the scans are held). The routine poll on a wallet page runs
// Explicit need (a changed address set, a committed send or other mutation) refreshes now
// (list-only while the scans are held). The routine poll on a wallet page runs
// only when something it depends on changed (tip, mempool, a wallet mutation) or its safety
// sweep is due, and only as the scan budget allows; held back, it asks again soon.
bool retry = false;
if (addresses_dirty_ || hasTransactionSendProgress()) {
if (addresses_dirty_) {
refreshAddressData();
} else if (walletDataPage && addressTier == services::ScanTier::Full) {
using services::ScanStream;
@@ -5325,7 +5346,7 @@ void App::refreshNow()
noteWalletMutation(); // the user asked: the change-gated scans run, the balance too
requestForcedBalanceRefresh();
markTransactionsDirty(); // Force transaction list update
addresses_dirty_ = true; // Force address/balance update
markAddressesDirty(); // Force address/balance update
last_tx_block_height_ = -1; // Reset tx cache
// A full receive re-scan (explicit, so it runs back to back), but keep the sent sweep's watermark:
// every press would otherwise cost a whole-wallet z_listsentbyaddress.