feat(chat): pre-split note buffer for rapid sends + status + console logging

Each chat message is a shielded tx that spends a verified note, so a burst of
messages hit "insufficient verified funds" once the single note is spent. Add a
per-frame note-buffer coordinator (both variants) that keeps ~10 verified
spendable notes: it serializes sends through the one broadcast channel, counts
verified notes by block depth (lite) or a rate-limited z_listunspent scan
(full node), self-splits in the background to refill toward the target, and
queues overflow to drain as change matures — with honest Sent/Failed status
instead of the prior optimistic "Sent". Guardrails: single split in flight with
a watchdog, cooldown, and session-generation guards so a wallet switch can't
drain another wallet's queue. Surface a "Chat buffer: N/10 ready" indicator in
the status bar while the Chat tab is active, and route chat diagnostics through
the console on both variants (the full-node console now drains the shared ring).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-07-20 22:00:00 -05:00
parent 4471f54842
commit b0a4333cdf
5 changed files with 591 additions and 46 deletions

View File

@@ -735,20 +735,40 @@ void App::update()
// HushChat (lite): harvest chat memos from the refreshed transactions and thread them
// (no-op when the feature is off or no identity; the store dedups across refreshes).
ingestLiteChatMemos(liteModel);
// Chat note-buffer: recompute the verified/maturing self-note estimates from the fresh model.
refreshChatNoteBudget(liteModel);
}
// Deliver a completed async send/shield result to the waiting send_tab callback.
// Deliver a completed async send/shield result. Route by the in-flight op that owns the single
// broadcast channel: a user Send-tab send goes to lite_send_callback_; a chat send / contact
// request resolves its echo (real status — not the old optimistic "Sent"); a note-buffer split
// just logs. Because the controller runs one broadcast at a time, inflight_op_ correlates the
// one global result with no txid matching.
wallet::LiteBroadcastResult broadcast;
if (lite_wallet_->takeBroadcastResult(broadcast)) {
// Mirror failures into the lite Console (copyable) in addition to the toast the send UI
// shows — transient toasts are easy to miss and impossible to copy.
if (!broadcast.ok)
wallet::liteLog("Send/shield failed: " + broadcast.error);
if (lite_send_callback_) {
lite_send_callback_(broadcast.ok, broadcast.ok ? broadcast.txid : broadcast.error);
lite_send_callback_ = nullptr;
const LiteInflightOp finished = inflight_op_;
inflight_op_ = LiteInflightOp{}; // channel is free again
switch (finished.kind) {
case LiteOpKind::ChatSend:
case LiteOpKind::ContactRequest:
onChatBroadcastResult(finished, broadcast.ok, broadcast.error);
break;
case LiteOpKind::Split:
// Buffer split done; the next refresh re-counts the new (maturing) notes.
break;
case LiteOpKind::UserSend:
case LiteOpKind::None:
default:
if (lite_send_callback_) {
lite_send_callback_(broadcast.ok, broadcast.ok ? broadcast.txid : broadcast.error);
lite_send_callback_ = nullptr;
}
break;
}
}
// Startup lock screen: once the first refresh reveals the (auto-opened) wallet is
// encrypted, prompt to unlock if it's locked. Soft by design — balances stay viewable via
// viewing keys while locked; only spending needs the passphrase, so the user may dismiss
@@ -765,6 +785,13 @@ 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();
pumpChatNoteBuffer();
// One-time reminder to back up the wallet's seed phrase (mnemonic wallets only).
maybeRemindSeedBackup();
@@ -2324,17 +2351,32 @@ void App::renderStatusBar()
// Connection / daemon status sits to the left of the version string
// with a small gap.
float gap = sbSectionGap;
float occupiedX = versionX; // leftmost X used by the version + connection status so far
if (!connection_status_.empty() && connection_status_ != "Connected") {
float statusW = ImGui::CalcTextSize(connection_status_.c_str()).x;
float statusX = versionX - statusW - gap;
ImGui::SameLine(statusX);
ImGui::TextDisabled("%s", connection_status_.c_str());
occupiedX = statusX;
} else if (!daemon_status_.empty() && daemon_status_.find("Error") != std::string::npos) {
const char* errText = TR("sb_daemon_not_found");
float statusW = ImGui::CalcTextSize(errText).x;
float statusX = versionX - statusW - gap;
ImGui::SameLine(statusX);
ImGui::TextColored(ImVec4(1.0f, 0.5f, 0.5f, 1.0f), "%s", errText);
occupiedX = statusX;
}
// Chat note-buffer status — only while the Chat tab is active. Sits left of the version/connection
// block. Surfaces the buffer filling/draining (and doubles as a diagnostic for send readiness).
if (current_page_ == ui::NavPage::Chat) {
const std::string cb = chatBufferStatusText();
if (!cb.empty()) {
float cbW = ImGui::CalcTextSize(cb.c_str()).x;
float cbX = occupiedX - cbW - gap;
ImGui::SameLine(cbX);
ImGui::TextUnformatted(cb.c_str());
}
}
// Version always at far right