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:
52
src/app.cpp
52
src/app.cpp
@@ -735,20 +735,40 @@ void App::update()
|
|||||||
// HushChat (lite): harvest chat memos from the refreshed transactions and thread them
|
// 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).
|
// (no-op when the feature is off or no identity; the store dedups across refreshes).
|
||||||
ingestLiteChatMemos(liteModel);
|
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;
|
wallet::LiteBroadcastResult broadcast;
|
||||||
if (lite_wallet_->takeBroadcastResult(broadcast)) {
|
if (lite_wallet_->takeBroadcastResult(broadcast)) {
|
||||||
// Mirror failures into the lite Console (copyable) in addition to the toast the send UI
|
// 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.
|
// shows — transient toasts are easy to miss and impossible to copy.
|
||||||
if (!broadcast.ok)
|
if (!broadcast.ok)
|
||||||
wallet::liteLog("Send/shield failed: " + broadcast.error);
|
wallet::liteLog("Send/shield failed: " + broadcast.error);
|
||||||
if (lite_send_callback_) {
|
const LiteInflightOp finished = inflight_op_;
|
||||||
lite_send_callback_(broadcast.ok, broadcast.ok ? broadcast.txid : broadcast.error);
|
inflight_op_ = LiteInflightOp{}; // channel is free again
|
||||||
lite_send_callback_ = nullptr;
|
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
|
// 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
|
// 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
|
// 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).
|
// it can act; compiled away when the feature is off (constexpr gate inside).
|
||||||
maybeProvisionChatIdentity();
|
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).
|
// One-time reminder to back up the wallet's seed phrase (mnemonic wallets only).
|
||||||
maybeRemindSeedBackup();
|
maybeRemindSeedBackup();
|
||||||
|
|
||||||
@@ -2324,17 +2351,32 @@ void App::renderStatusBar()
|
|||||||
// Connection / daemon status sits to the left of the version string
|
// Connection / daemon status sits to the left of the version string
|
||||||
// with a small gap.
|
// with a small gap.
|
||||||
float gap = sbSectionGap;
|
float gap = sbSectionGap;
|
||||||
|
float occupiedX = versionX; // leftmost X used by the version + connection status so far
|
||||||
if (!connection_status_.empty() && connection_status_ != "Connected") {
|
if (!connection_status_.empty() && connection_status_ != "Connected") {
|
||||||
float statusW = ImGui::CalcTextSize(connection_status_.c_str()).x;
|
float statusW = ImGui::CalcTextSize(connection_status_.c_str()).x;
|
||||||
float statusX = versionX - statusW - gap;
|
float statusX = versionX - statusW - gap;
|
||||||
ImGui::SameLine(statusX);
|
ImGui::SameLine(statusX);
|
||||||
ImGui::TextDisabled("%s", connection_status_.c_str());
|
ImGui::TextDisabled("%s", connection_status_.c_str());
|
||||||
|
occupiedX = statusX;
|
||||||
} else if (!daemon_status_.empty() && daemon_status_.find("Error") != std::string::npos) {
|
} else if (!daemon_status_.empty() && daemon_status_.find("Error") != std::string::npos) {
|
||||||
const char* errText = TR("sb_daemon_not_found");
|
const char* errText = TR("sb_daemon_not_found");
|
||||||
float statusW = ImGui::CalcTextSize(errText).x;
|
float statusW = ImGui::CalcTextSize(errText).x;
|
||||||
float statusX = versionX - statusW - gap;
|
float statusX = versionX - statusW - gap;
|
||||||
ImGui::SameLine(statusX);
|
ImGui::SameLine(statusX);
|
||||||
ImGui::TextColored(ImVec4(1.0f, 0.5f, 0.5f, 1.0f), "%s", errText);
|
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
|
// Version always at far right
|
||||||
|
|||||||
72
src/app.h
72
src/app.h
@@ -13,6 +13,7 @@
|
|||||||
#include <chrono>
|
#include <chrono>
|
||||||
#include <unordered_map>
|
#include <unordered_map>
|
||||||
#include <unordered_set>
|
#include <unordered_set>
|
||||||
|
#include <deque>
|
||||||
#include <nlohmann/json_fwd.hpp>
|
#include <nlohmann/json_fwd.hpp>
|
||||||
#include "data/transaction_history_cache.h"
|
#include "data/transaction_history_cache.h"
|
||||||
#include "data/address_book.h"
|
#include "data/address_book.h"
|
||||||
@@ -605,10 +606,13 @@ private:
|
|||||||
// the recipient `to`/`amount`/`memo`/`fee` used to record the optimistic pending-send row).
|
// the recipient `to`/`amount`/`memo`/`fee` used to record the optimistic pending-send row).
|
||||||
// When markFeeGapRetry is set, the returned opid is recorded in send_feegap_retried_opids_ so a
|
// When markFeeGapRetry is set, the returned opid is recorded in send_feegap_retried_opids_ so a
|
||||||
// retry of a retry is reported as a real error.
|
// retry of a retry is reported as a real error.
|
||||||
|
// background=true (autonomous chat sends / note-buffer splits): keep the single-flight + opid
|
||||||
|
// accounting but DON'T raise the global "transaction in progress" UI (status is on the chat message).
|
||||||
void submitZSendMany(const std::string& from, const std::string& to, double amount, double fee,
|
void submitZSendMany(const std::string& from, const std::string& to, double amount, double fee,
|
||||||
const std::string& memo, const nlohmann::json& recipients,
|
const std::string& memo, const nlohmann::json& recipients,
|
||||||
const char* traceLabel, bool markFeeGapRetry,
|
const char* traceLabel, bool markFeeGapRetry,
|
||||||
std::function<void(bool, const std::string&)> callback);
|
std::function<void(bool, const std::string&)> callback,
|
||||||
|
bool background = false);
|
||||||
void markPendingSendTransactionSucceeded(const std::string& opid,
|
void markPendingSendTransactionSucceeded(const std::string& opid,
|
||||||
const std::string& txid);
|
const std::string& txid);
|
||||||
void removePendingSendTransactions(const std::vector<std::string>& opids,
|
void removePendingSendTransactions(const std::vector<std::string>& opids,
|
||||||
@@ -695,6 +699,72 @@ private:
|
|||||||
// thread is viewed (markChatConversationSeen); wiped in resetChatSession so unread doesn't leak across
|
// thread is viewed (markChatConversationSeen); wiped in resetChatSession so unread doesn't leak across
|
||||||
// wallets. In-memory only (resets on app restart).
|
// wallets. In-memory only (resets on app restart).
|
||||||
std::map<std::string, std::int64_t> chat_seen_watermark_;
|
std::map<std::string, std::int64_t> chat_seen_watermark_;
|
||||||
|
|
||||||
|
// ── Chat note buffer (BOTH variants) ────────────────────────────────────────────────────────
|
||||||
|
// Each chat message is a shielded tx that spends a note; its change needs a few confirmations before
|
||||||
|
// it's spendable again (lite: backend ANCHOR_OFFSET+1 = 5; full node: z_sendmany minconf = 1), so
|
||||||
|
// rapid sends run out of verified funds. We keep a buffer of ~kChatBufferTarget small self-notes so a
|
||||||
|
// burst of messages each spends a separate verified note, refilled from change + background self-
|
||||||
|
// splits. Chat sends, self-splits and user sends share the wallet's single send channel; inflight_op_
|
||||||
|
// + (lite) lite_send_callback_ / (full node) send_submissions_in_flight_+pending_opids_ serialize them
|
||||||
|
// so exactly one send is ever outstanding. Implemented in app_network.cpp; pumped from update().
|
||||||
|
enum class LiteOpKind { None, ChatSend, ContactRequest, Split, UserSend };
|
||||||
|
struct LiteInflightOp {
|
||||||
|
LiteOpKind kind = LiteOpKind::None;
|
||||||
|
std::string echoLocalId; // ChatSend/ContactRequest: the echo to resolve when it completes
|
||||||
|
int sessionGen = 0; // chat_session_generation_ snapshot at submit (stale-guard)
|
||||||
|
double submittedAt = 0.0;
|
||||||
|
};
|
||||||
|
struct QueuedChatOp {
|
||||||
|
LiteOpKind kind = LiteOpKind::ChatSend;
|
||||||
|
chat::OutgoingChatMemos memos; // kept so a transient-funds retry re-broadcasts, never recomposes
|
||||||
|
std::string echoLocalId;
|
||||||
|
int sessionGen = 0;
|
||||||
|
int retries = 0;
|
||||||
|
};
|
||||||
|
LiteInflightOp inflight_op_; // the single chat/split op currently on the send channel
|
||||||
|
std::deque<QueuedChatOp> chat_send_queue_; // chat/contact sends awaiting a free channel + verified note
|
||||||
|
// Note-availability estimate between refreshes/scans: reset from a fresh count, decremented on each chat
|
||||||
|
// submit (the count lags a spend by a cycle, but the wallet still picks a fresh note per send). Zeroed on
|
||||||
|
// a transient-funds failure so we stop draining until the next refresh/scan restores the truth.
|
||||||
|
int chat_verified_note_budget_ = 0;
|
||||||
|
int chat_pipeline_note_count_ = 0; // verified + maturing self-notes (drives shouldSplit)
|
||||||
|
std::uint64_t chat_verified_shielded_zat_ = 0; // verified shielded balance (split affordability)
|
||||||
|
bool chat_note_model_seen_ = false; // saw a refresh/scan carrying per-note visibility
|
||||||
|
// Single-split-in-flight guard: a self-split's OUTPUT notes are invisible until mined (~1 block), far
|
||||||
|
// longer than any wall-clock cooldown — so we permit only ONE outstanding split and clear the flag when
|
||||||
|
// the pipeline recovers (outputs mined) or a watchdog expires (a split that never mines mustn't wedge
|
||||||
|
// refill forever). Prevents runaway splitting that would drain balance into fees.
|
||||||
|
bool chat_split_outstanding_ = false;
|
||||||
|
double chat_split_submitted_at_ = 0.0; // ImGui time the outstanding split was submitted (watchdog)
|
||||||
|
// Full-node only: a coordinator-owned z_listunspent worker scan feeds the per-note counts (the shared
|
||||||
|
// balance poll discards per-note data). Mirrors chat_fast_scan_in_flight_.
|
||||||
|
bool chat_note_scan_in_flight_ = false;
|
||||||
|
double chat_note_scan_last_ = 0.0; // ImGui time of the last note scan (rate limit)
|
||||||
|
// Most-recent chain tip, cached across refreshes: a lite refresh model that carries spendableOutputs
|
||||||
|
// may NOT carry sync status that same cycle (tolerated partial refresh), so verifiedSelfNoteCount reads
|
||||||
|
// this cache rather than requiring the current model to have both — else the budget flickers to 0.
|
||||||
|
std::int64_t chat_last_chain_height_ = 0;
|
||||||
|
int chat_fast_scan_last_seen_ = -1; // dedup: last memo-note count logged by the 0-conf scan
|
||||||
|
|
||||||
|
// Coordinator helpers (both variants unless noted; see app_network.cpp).
|
||||||
|
void refreshChatNoteBudget(const wallet::LiteWalletAppRefreshModel& model); // lite: recompute caches on a fresh model
|
||||||
|
void refreshChatNoteBudgetNode(); // full node: rate-limited z_listunspent worker scan
|
||||||
|
void pumpChatNoteBuffer(); // per-frame: drain the queue / build the buffer
|
||||||
|
int verifiedSelfNoteCount(const wallet::LiteWalletAppRefreshModel& model); // lite
|
||||||
|
int pipelineSelfNoteCount(const wallet::LiteWalletAppRefreshModel& model); // lite
|
||||||
|
bool shouldSplitChatBuffer();
|
||||||
|
bool broadcastSelfSplitLite(int noteCount); // lite: self-send minting noteCount reply-address notes
|
||||||
|
bool broadcastSelfSplitNode(int noteCount); // full node: z_sendmany self-send minting noteCount notes
|
||||||
|
void enqueueChatSend(LiteOpKind kind, const chat::OutgoingChatMemos& memos, const std::string& echoLocalId);
|
||||||
|
void onChatBroadcastResult(const LiteInflightOp& op, bool ok, const std::string& error);
|
||||||
|
static bool isTransientVerifiedFundsError(const std::string& error);
|
||||||
|
int chatConfsRequired() const; // verified-note confs threshold: 5 (lite) / 1 (full node)
|
||||||
|
public:
|
||||||
|
// Status-bar summary of the chat note buffer (empty when not applicable). Shown while the Chat tab is
|
||||||
|
// active so the buffer's state (ready / building / sending) is visible.
|
||||||
|
std::string chatBufferStatusText();
|
||||||
|
private:
|
||||||
public:
|
public:
|
||||||
// Total unread incoming chat messages across all conversations (for the sidebar badge). 0 when the
|
// Total unread incoming chat messages across all conversations (for the sidebar badge). 0 when the
|
||||||
// feature is off / no identity.
|
// feature is off / no identity.
|
||||||
|
|||||||
@@ -40,6 +40,7 @@
|
|||||||
#include "config/settings.h"
|
#include "config/settings.h"
|
||||||
#include "wallet/lite_wallet_controller.h" // lite send/new-address routing
|
#include "wallet/lite_wallet_controller.h" // lite send/new-address routing
|
||||||
#include "wallet/lite_wallet_state_mapper.h" // LiteWalletAppRefreshModel for lite chat harvest
|
#include "wallet/lite_wallet_state_mapper.h" // LiteWalletAppRefreshModel for lite chat harvest
|
||||||
|
#include "wallet/lite_diagnostics.h" // liteLog — chat note-buffer coordinator diagnostics
|
||||||
#include "config/version.h"
|
#include "config/version.h"
|
||||||
#include "daemon/daemon_controller.h"
|
#include "daemon/daemon_controller.h"
|
||||||
#include "daemon/embedded_daemon.h"
|
#include "daemon/embedded_daemon.h"
|
||||||
@@ -629,6 +630,11 @@ void App::onDisconnected(const std::string& reason)
|
|||||||
consecutive_core_failures_ = 0;
|
consecutive_core_failures_ = 0;
|
||||||
send_progress_active_ = false;
|
send_progress_active_ = false;
|
||||||
send_submissions_in_flight_ = 0;
|
send_submissions_in_flight_ = 0;
|
||||||
|
// Release the chat note-buffer's single-split guard on a plain disconnect (which doesn't run
|
||||||
|
// resetChatSession) so a re-split isn't suppressed until the 1200s watchdog after reconnect.
|
||||||
|
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)
|
||||||
network_refresh_.resetJobs();
|
network_refresh_.resetJobs();
|
||||||
rescan_status_poll_in_progress_ = false;
|
rescan_status_poll_in_progress_ = false;
|
||||||
opid_poll_in_progress_ = false;
|
opid_poll_in_progress_ = false;
|
||||||
@@ -2756,6 +2762,25 @@ void App::provisionChatIdentityFromSecret(std::string secret)
|
|||||||
// local — peers only ever exchange public keys).
|
// local — peers only ever exchange public keys).
|
||||||
void App::resetChatSession()
|
void App::resetChatSession()
|
||||||
{
|
{
|
||||||
|
// Lite send channel — reset UNCONDITIONALLY (independent of the chat feature flag; user sends set
|
||||||
|
// inflight_op_/lite_send_callback_ even with chat off). Clear the note-buffer coordinator so wallet A's
|
||||||
|
// queued sends can't drain under wallet B, and DON'T orphan a user Send callback across a controller
|
||||||
|
// rebuild: rebuildLiteWallet(force) destroys the controller holding the in-flight send, so its result
|
||||||
|
// slot dies with it and the callback would hang the Send dialog forever. Fire the orphaned callback on a
|
||||||
|
// moved-out copy AFTER the channel state is reset, so a re-entrant sendTransaction stays clean.
|
||||||
|
auto orphanedSend = std::move(lite_send_callback_);
|
||||||
|
lite_send_callback_ = nullptr;
|
||||||
|
chat_send_queue_.clear();
|
||||||
|
inflight_op_ = LiteInflightOp{};
|
||||||
|
chat_verified_note_budget_ = 0;
|
||||||
|
chat_pipeline_note_count_ = 0;
|
||||||
|
chat_verified_shielded_zat_ = 0;
|
||||||
|
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
|
||||||
|
if (orphanedSend) orphanedSend(false, "Wallet/server changed — send aborted");
|
||||||
|
|
||||||
if (!chat::hushChatFeatureEnabledAtBuild()) return; // constexpr — folds away in OFF builds
|
if (!chat::hushChatFeatureEnabledAtBuild()) return; // constexpr — folds away in OFF builds
|
||||||
// Drop identity + decrypted plaintext in RAM, lock the seed-encrypted DB, re-arm provisioning.
|
// Drop identity + decrypted plaintext in RAM, lock the seed-encrypted DB, re-arm provisioning.
|
||||||
chat_service_.clearIdentity();
|
chat_service_.clearIdentity();
|
||||||
@@ -2955,18 +2980,19 @@ bool App::broadcastChatMemos(const chat::OutgoingChatMemos& memos, const std::st
|
|||||||
const auto sessionGen = chat_session_generation_;
|
const auto sessionGen = chat_session_generation_;
|
||||||
|
|
||||||
if (lite_wallet_) {
|
if (lite_wallet_) {
|
||||||
const bool ok = broadcastChatMemosLite(memos);
|
// Lite chat is owned by the note-buffer coordinator (enqueue + drain + resolve from the real
|
||||||
// The lite backend has no per-send completion callback here; resolve optimistically on a
|
// broadcast result). Route any caller here through the queue rather than resolving optimistically
|
||||||
// successful queue (its own broadcast log surfaces a later failure).
|
// (the old code marked a merely-queued send "Sent", masking real failures). Kept for safety —
|
||||||
if (ok && sessionGen == chat_session_generation_)
|
// the send/Retry paths call enqueueChatSend directly and don't hit this branch.
|
||||||
chat_service_.resolveOutgoing(echoLocalId, chat::ChatDelivery::Sent);
|
(void)sessionGen;
|
||||||
return ok;
|
enqueueChatSend(LiteOpKind::ChatSend, memos, echoLocalId);
|
||||||
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!state_.connected || !rpc_ || !worker_) {
|
// Coordinator-owned primitive (pumpChatNoteBuffer): the pump already gates on connected/unlocked and a
|
||||||
ui::Notifications::instance().error(TR("chat_toast_not_connected"));
|
// verified-note budget, so these refusals are belt-and-suspenders. Return false SILENTLY (no per-frame
|
||||||
return false;
|
// toast) — the pump leaves the message queued ("Sending") and retries when the guard clears.
|
||||||
}
|
if (!state_.connected || !rpc_ || !worker_) return false;
|
||||||
|
|
||||||
// Chat moves 0 value, and dragonxd REJECTS a 0-value tx whose fee exceeds the default miners fee
|
// Chat moves 0 value, and dragonxd REJECTS a 0-value tx whose fee exceeds the default miners fee
|
||||||
// (0.0001) — so pin chat to exactly kChatMinFeeDrgx, independent of the user's default-fee setting
|
// (0.0001) — so pin chat to exactly kChatMinFeeDrgx, independent of the user's default-fee setting
|
||||||
@@ -2976,10 +3002,7 @@ bool App::broadcastChatMemos(const chat::OutgoingChatMemos& memos, const std::st
|
|||||||
// Pay from a z-address that can actually cover the fee. The memo still advertises the identity
|
// Pay from a z-address that can actually cover the fee. The memo still advertises the identity
|
||||||
// reply address, so the peer replies to the right place regardless of which note paid.
|
// reply address, so the peer replies to the right place regardless of which note paid.
|
||||||
const std::string from = chatPayFromZaddr(fee);
|
const std::string from = chatPayFromZaddr(fee);
|
||||||
if (from.empty()) {
|
if (from.empty()) return false; // no fundable note yet — pump keeps it queued, retries after a scan
|
||||||
ui::Notifications::instance().error(TR("chat_toast_need_funds"));
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Full-node: each memo needs the daemon's "utf8:" prefix (raw JSON/hex is otherwise rejected).
|
// Full-node: each memo needs the daemon's "utf8:" prefix (raw JSON/hex is otherwise rejected).
|
||||||
const auto outputs = chat::chatSendOutputs(memos, /*utf8Prefix=*/true);
|
const auto outputs = chat::chatSendOutputs(memos, /*utf8Prefix=*/true);
|
||||||
@@ -2994,14 +3017,20 @@ bool App::broadcastChatMemos(const chat::OutgoingChatMemos& memos, const std::st
|
|||||||
|
|
||||||
// markFeeGapRetry=true is deliberate: it suppresses the fee-gap auto-retry, which rebuilds a
|
// markFeeGapRetry=true is deliberate: it suppresses the fee-gap auto-retry, which rebuilds a
|
||||||
// SINGLE-recipient tx from the scalar to/amount/memo and would drop the second (payload) output.
|
// SINGLE-recipient tx from the scalar to/amount/memo and would drop the second (payload) output.
|
||||||
// The callback flips the echo to Sent/Failed once the async op resolves — real delivery status.
|
// This is now the note-buffer coordinator's full-node submit primitive (called only from the pump).
|
||||||
|
// The terminal callback (fired once by the opid poller) releases the send channel and routes the
|
||||||
|
// result through onChatBroadcastResult, so a transient "Insufficient shielded funds" requeues instead
|
||||||
|
// of hard-failing. The sessionGen guard drops a stale wallet's echo AND protects the inflight_op_
|
||||||
|
// clear from a callback landing after a wallet switch (which would otherwise clear a NEW op's token).
|
||||||
submitZSendMany(from, memos.recipientZaddr, 0.0, fee, /*memo*/"", recipients,
|
submitZSendMany(from, memos.recipientZaddr, 0.0, fee, /*memo*/"", recipients,
|
||||||
"HushChat / broadcast", /*markFeeGapRetry*/ true,
|
"HushChat / broadcast", /*markFeeGapRetry*/ true,
|
||||||
[this, echoLocalId, sessionGen](bool ok, const std::string& /*result*/) {
|
[this, echoLocalId, sessionGen](bool ok, const std::string& result) {
|
||||||
if (sessionGen != chat_session_generation_) return; // wallet locked/switched — stale
|
if (sessionGen != chat_session_generation_) return; // stale wallet — don't touch new session
|
||||||
chat_service_.resolveOutgoing(echoLocalId,
|
inflight_op_ = LiteInflightOp{}; // release the channel for this session
|
||||||
ok ? chat::ChatDelivery::Sent : chat::ChatDelivery::Failed);
|
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
|
||||||
return true; // submitted (async build/broadcast; the callback resolves the final status)
|
return true; // submitted (async build/broadcast; the callback resolves the final status)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -3021,11 +3050,9 @@ bool App::broadcastChatMemosLite(const chat::OutgoingChatMemos& memos)
|
|||||||
recipient.memo = out.memo;
|
recipient.memo = out.memo;
|
||||||
req.recipients.push_back(std::move(recipient));
|
req.recipients.push_back(std::move(recipient));
|
||||||
}
|
}
|
||||||
if (!lite_wallet_->sendTransaction(req)) {
|
// No busy toast here: the coordinator (pumpChatNoteBuffer) owns this call, retries next frame when the
|
||||||
ui::Notifications::instance().error(TR("chat_toast_lite_busy"));
|
// single broadcast channel frees, and surfaces status on the message itself.
|
||||||
return false;
|
return lite_wallet_->sendTransaction(req);
|
||||||
}
|
|
||||||
return true;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
void App::sendChatMessage(const std::string& conversationId, const std::string& text)
|
void App::sendChatMessage(const std::string& conversationId, const std::string& text)
|
||||||
@@ -3073,10 +3100,9 @@ void App::sendChatMessage(const std::string& conversationId, const std::string&
|
|||||||
echo.payload_position = 0;
|
echo.payload_position = 0;
|
||||||
echo.delivery = chat::ChatDelivery::Sending; // shows immediately; the broadcast callback resolves it
|
echo.delivery = chat::ChatDelivery::Sending; // shows immediately; the broadcast callback resolves it
|
||||||
chat_service_.recordOutgoingPending(echo); // in-memory now; persisted with the final status
|
chat_service_.recordOutgoingPending(echo); // in-memory now; persisted with the final status
|
||||||
// A synchronous refusal (not connected / no funds) resolves to Failed right away so the Retry
|
// Both variants: hand to the note-buffer coordinator, which drains the queue against verified notes
|
||||||
// affordance appears; otherwise the async callback flips it to Sent/Failed.
|
// (building/refilling the buffer as needed) and resolves the echo Sent/Failed from the REAL result.
|
||||||
if (!broadcastChatMemos(memos, echo.txid))
|
enqueueChatSend(LiteOpKind::ChatSend, memos, echo.txid);
|
||||||
chat_service_.resolveOutgoing(echo.txid, chat::ChatDelivery::Failed);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
void App::startChatConversation(const std::string& peerZaddr, const std::string& text)
|
void App::startChatConversation(const std::string& peerZaddr, const std::string& text)
|
||||||
@@ -3116,10 +3142,363 @@ void App::sendContactRequestForCid(const std::string& cid, const std::string& pe
|
|||||||
echo.payload_position = 0;
|
echo.payload_position = 0;
|
||||||
echo.delivery = chat::ChatDelivery::Sending;
|
echo.delivery = chat::ChatDelivery::Sending;
|
||||||
chat_service_.recordOutgoingPending(echo);
|
chat_service_.recordOutgoingPending(echo);
|
||||||
if (broadcastChatMemos(memos, echo.txid))
|
// Both variants: enqueue through the coordinator (resolves the echo from the real result).
|
||||||
ui::Notifications::instance().success(TR("chat_toast_request_queued"));
|
enqueueChatSend(LiteOpKind::ContactRequest, memos, echo.txid);
|
||||||
else
|
ui::Notifications::instance().success(TR("chat_toast_request_queued"));
|
||||||
chat_service_.resolveOutgoing(echo.txid, chat::ChatDelivery::Failed);
|
}
|
||||||
|
|
||||||
|
// ── Chat note-buffer coordinator (lite only) ─────────────────────────────────────────────────────
|
||||||
|
// 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
|
||||||
|
|
||||||
|
// 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).
|
||||||
|
int App::chatConfsRequired() const { return lite_wallet_ ? kChatConfsRequired : 1; }
|
||||||
|
|
||||||
|
// Status-bar summary of the chat note buffer (shown while the Chat tab is active). Empty unless chat has
|
||||||
|
// an identity. Surfaces the buffer state so the user can see it filling / draining, and it doubles as a
|
||||||
|
// diagnostic: "…" = no note data yet; "0/10 ready" = counted but no verified notes; "N/10 ready" = armed.
|
||||||
|
std::string App::chatBufferStatusText() {
|
||||||
|
if (!chat::hushChatFeatureEnabledAtBuild() || !chat_service_.hasIdentity()) return "";
|
||||||
|
const int queued = static_cast<int>(chat_send_queue_.size());
|
||||||
|
const int verified = std::max(0, std::min(chat_verified_note_budget_, kChatBufferTarget));
|
||||||
|
const std::string tgt = std::to_string(kChatBufferTarget);
|
||||||
|
if (queued > 0)
|
||||||
|
return "Chat: sending " + std::to_string(queued) + (queued == 1 ? " message…" : " messages…");
|
||||||
|
if (chat_split_outstanding_)
|
||||||
|
return "Chat buffer: preparing " + std::to_string(verified) + "/" + tgt + "…";
|
||||||
|
if (!chat_note_model_seen_)
|
||||||
|
return "Chat buffer: …";
|
||||||
|
return "Chat buffer: " + std::to_string(verified) + "/" + tgt + " ready";
|
||||||
|
}
|
||||||
|
|
||||||
|
bool App::isTransientVerifiedFundsError(const std::string& error) {
|
||||||
|
// Both variants' "only unconfirmed change is short" phrasing, treated as transient (retry after the
|
||||||
|
// buffer matures): lite = "Insufficient verified funds" (lightwallet.rs:2402); full node =
|
||||||
|
// "Insufficient shielded funds" (asyncrpcoperation_sendmany, parsed at parseInsufficientShielded).
|
||||||
|
return error.find("Insufficient verified funds") != std::string::npos
|
||||||
|
|| error.find("Insufficient shielded funds") != std::string::npos;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verified notes = ANY spendable shielded note (>= a fee) with >= kChatConfsRequired confirmations — the
|
||||||
|
// ones a chat send can spend RIGHT NOW. We count notes at ALL addresses, not just the reply address,
|
||||||
|
// because a chat send pays via chatPayFromZaddr (any funded z-addr) — gating on reply-address-only notes
|
||||||
|
// would queue a sendable message whenever funds sit elsewhere. NB: the model's per-note `spendable` flag
|
||||||
|
// means "unspent", NOT verified (lite_result_parsers.cpp recomputes it), so we derive it from depth here.
|
||||||
|
int App::verifiedSelfNoteCount(const wallet::LiteWalletAppRefreshModel& model) {
|
||||||
|
if (!model.hasSpendableOutputs) return 0;
|
||||||
|
// Use the cached tip (refreshChatNoteBudget updates it), not this model's — a notes-carrying model may
|
||||||
|
// not carry sync status that cycle, which would otherwise zero the whole count.
|
||||||
|
const std::int64_t tip = chat_last_chain_height_;
|
||||||
|
if (tip <= 0) return 0;
|
||||||
|
const std::uint64_t minVal = chatDrgxToZat(kChatMinFeeDrgx);
|
||||||
|
int n = 0;
|
||||||
|
for (const auto& o : model.spendableOutputs) {
|
||||||
|
if (o.kind != wallet::LiteSpendableOutputKind::UnspentNote) continue; // shielded notes only
|
||||||
|
if (o.spent || o.unconfirmedSpent || o.pending) continue;
|
||||||
|
if (o.valueZatoshis < minVal) continue; // skip dust below a fee
|
||||||
|
if (!o.createdInBlock.has_value()) continue; // unknown depth -> not verified
|
||||||
|
if (tip - *o.createdInBlock + 1 >= chatConfsRequired()) ++n;
|
||||||
|
}
|
||||||
|
return n;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Notes that are verified OR still maturing (any unspent shielded note of usable size) — i.e. notes that
|
||||||
|
// will become spendable WITHOUT another split. Drives the refill trigger so we don't over-split while
|
||||||
|
// change is confirming or when the wallet already holds enough notes anywhere.
|
||||||
|
int App::pipelineSelfNoteCount(const wallet::LiteWalletAppRefreshModel& model) {
|
||||||
|
if (!model.hasSpendableOutputs) return 0;
|
||||||
|
const std::uint64_t minVal = chatDrgxToZat(kChatMinFeeDrgx);
|
||||||
|
int n = 0;
|
||||||
|
for (const auto& o : model.spendableOutputs) {
|
||||||
|
if (o.kind != wallet::LiteSpendableOutputKind::UnspentNote) continue;
|
||||||
|
if (o.spent || o.unconfirmedSpent) continue; // already consumed
|
||||||
|
if (o.valueZatoshis < minVal) continue;
|
||||||
|
++n; // verified or maturing (any addr)
|
||||||
|
}
|
||||||
|
return n;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Recompute the cached note estimates from a fresh refresh model (called from update() when one arrives).
|
||||||
|
void App::refreshChatNoteBudget(const wallet::LiteWalletAppRefreshModel& model) {
|
||||||
|
if (!lite_wallet_ || !chat::hushChatFeatureEnabledAtBuild()) return;
|
||||||
|
// Cache the chain tip from whatever height source THIS model carries (sync status or chain info) — a
|
||||||
|
// model with notes may lack sync that cycle, so verifiedSelfNoteCount reads the cache below.
|
||||||
|
if (model.hasSyncStatus && model.sync.chainHeight > 0)
|
||||||
|
chat_last_chain_height_ = static_cast<std::int64_t>(model.sync.chainHeight);
|
||||||
|
else if (model.hasChainInfo && model.chain.latestBlockHeight.has_value() &&
|
||||||
|
*model.chain.latestBlockHeight > 0)
|
||||||
|
chat_last_chain_height_ = static_cast<std::int64_t>(*model.chain.latestBlockHeight);
|
||||||
|
if (model.hasSpendableOutputs) {
|
||||||
|
chat_note_model_seen_ = true;
|
||||||
|
chat_verified_note_budget_ = verifiedSelfNoteCount(model);
|
||||||
|
chat_pipeline_note_count_ = pipelineSelfNoteCount(model);
|
||||||
|
}
|
||||||
|
if (model.hasBalance)
|
||||||
|
chat_verified_shielded_zat_ = model.balance.verifiedShieldedZatoshis;
|
||||||
|
// Release the single-split guard once the outstanding split's outputs are mined+visible (pipeline
|
||||||
|
// 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)) {
|
||||||
|
chat_split_outstanding_ = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Full-node twin of refreshChatNoteBudget: the shared balance poll discards per-note data, so run our own
|
||||||
|
// rate-limited z_listunspent worker scan (mirrors fastScanChatMemos) to count verified/maturing spendable
|
||||||
|
// notes. z_listunspent returns only the wallet's OWN notes, and a chat send pays from any of them
|
||||||
|
// (chatPayFromZaddr), so we count ALL addresses — gating on the reply address only would queue a sendable
|
||||||
|
// message when funds sit elsewhere. minconf=0 exposes maturing change so the pipeline count doesn't over-
|
||||||
|
// split. On an older daemon that rejects z_listunspent it leaves chat_note_model_seen_ false → the pump
|
||||||
|
// won't drain a phantom budget or split (graceful degradation to naive one-at-a-time sends).
|
||||||
|
void App::refreshChatNoteBudgetNode() {
|
||||||
|
if (lite_wallet_ || !chat::hushChatFeatureEnabledAtBuild() || !chat_service_.hasIdentity()) return;
|
||||||
|
if (!state_.connected || !rpc_ || !worker_ || state_.isLocked()) return;
|
||||||
|
if (chat_note_scan_in_flight_) return; // one scan at a time
|
||||||
|
const double now = ImGui::GetTime();
|
||||||
|
if (now - chat_note_scan_last_ < 4.0) return; // rate limit; self-throttles via the in-flight flag too
|
||||||
|
|
||||||
|
chat_note_scan_last_ = now;
|
||||||
|
chat_note_scan_in_flight_ = true;
|
||||||
|
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 {
|
||||||
|
int verified = 0, pipeline = 0;
|
||||||
|
std::uint64_t verifiedZat = 0;
|
||||||
|
bool ok = false;
|
||||||
|
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
|
||||||
|
const std::uint64_t amtZat = chatDrgxToZat(nz.value("amount", 0.0));
|
||||||
|
if (amtZat < minVal) continue; // skip dust below a fee
|
||||||
|
// rawconfirmations is the TRUE depth; `confirmations` is dPoW-clamped to 1 and understates it.
|
||||||
|
const int confs = (nz.contains("rawconfirmations") && nz["rawconfirmations"].is_number_integer())
|
||||||
|
? nz["rawconfirmations"].get<int>()
|
||||||
|
: nz.value("confirmations", 0);
|
||||||
|
++pipeline; // unspent self-note (verified or maturing)
|
||||||
|
if (confs >= confsNeeded) { ++verified; verifiedZat += amtZat; }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (const std::exception&) {}
|
||||||
|
return [this, scanGen, ok, verified, pipeline, verifiedZat]() {
|
||||||
|
if (scanGen != chat_session_generation_) return; // wallet switched — drop (reset cleared the flag)
|
||||||
|
chat_note_scan_in_flight_ = false;
|
||||||
|
if (!ok) return; // z_listunspent unavailable — leave caches as-is
|
||||||
|
chat_note_model_seen_ = true;
|
||||||
|
chat_verified_note_budget_ = verified;
|
||||||
|
chat_pipeline_note_count_ = pipeline;
|
||||||
|
chat_verified_shielded_zat_ = verifiedZat;
|
||||||
|
if (chat_split_outstanding_ &&
|
||||||
|
(chat_pipeline_note_count_ >= kChatRefillTrigger ||
|
||||||
|
ImGui::GetTime() - chat_split_submitted_at_ > kChatSplitWatchdogSecs)) {
|
||||||
|
chat_split_outstanding_ = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
void App::enqueueChatSend(LiteOpKind kind, const chat::OutgoingChatMemos& memos, const std::string& echoLocalId) {
|
||||||
|
QueuedChatOp op;
|
||||||
|
op.kind = kind;
|
||||||
|
op.memos = memos; // kept so a transient-funds retry re-broadcasts, never recomposes
|
||||||
|
op.echoLocalId = echoLocalId;
|
||||||
|
op.sessionGen = chat_session_generation_;
|
||||||
|
chat_send_queue_.push_back(std::move(op));
|
||||||
|
wallet::liteLog("chat: queued outgoing " +
|
||||||
|
std::string(kind == LiteOpKind::ContactRequest ? "contact request" : "message") +
|
||||||
|
" (queue depth " + std::to_string(chat_send_queue_.size()) +
|
||||||
|
", verified notes ~" + std::to_string(chat_verified_note_budget_) + ")");
|
||||||
|
// pumpChatNoteBuffer() (next update tick) submits it when the channel is free + a verified note exists.
|
||||||
|
}
|
||||||
|
|
||||||
|
// Self-send that mints `noteCount` reply-address notes (funds stay in the wallet), building/refilling the
|
||||||
|
// buffer. Returns false if the single broadcast channel is busy (the pump retries next frame).
|
||||||
|
bool App::broadcastSelfSplitLite(int noteCount) {
|
||||||
|
if (!lite_wallet_ || noteCount < 1) return false;
|
||||||
|
const std::string myZ = chatReplyZaddr();
|
||||||
|
if (myZ.empty()) return false;
|
||||||
|
wallet::LiteSendRequest req;
|
||||||
|
const std::uint64_t noteZat = chatDrgxToZat(kChatNoteSizeDrgx);
|
||||||
|
for (int i = 0; i < noteCount; ++i) {
|
||||||
|
wallet::LiteSendRecipient r;
|
||||||
|
r.address = myZ;
|
||||||
|
r.amountZatoshis = noteZat;
|
||||||
|
req.recipients.push_back(std::move(r));
|
||||||
|
}
|
||||||
|
if (!lite_wallet_->sendTransaction(req)) return false; // channel busy — retry next frame
|
||||||
|
wallet::liteLog("Chat buffer: splitting funds into " + std::to_string(noteCount) +
|
||||||
|
" notes (self-send) so messages can be sent back-to-back");
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Full-node twin of broadcastSelfSplitLite: a z_sendmany self-send with `noteCount` outputs to our reply
|
||||||
|
// address (funds stay in the wallet), building/refilling the buffer. Caps outputs to stay well under the
|
||||||
|
// per-tx size limit after Sietch decoy padding. markFeeGapRetry=true is load-bearing — the fee-gap retry
|
||||||
|
// rebuilds a SINGLE-recipient tx and would collapse the multi-output split. The terminal callback releases
|
||||||
|
// the send channel; chat_split_outstanding_ stays set until a scan sees the outputs.
|
||||||
|
bool App::broadcastSelfSplitNode(int noteCount) {
|
||||||
|
if (lite_wallet_ || noteCount < 1) return false;
|
||||||
|
if (!state_.connected || !rpc_ || !worker_) return false;
|
||||||
|
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
|
||||||
|
nlohmann::json recipients = nlohmann::json::array();
|
||||||
|
for (int i = 0; i < n; ++i) {
|
||||||
|
nlohmann::json r;
|
||||||
|
r["address"] = myZ;
|
||||||
|
r["amount"] = util::formatAmountFixed(kChatNoteSizeDrgx);
|
||||||
|
recipients.push_back(std::move(r));
|
||||||
|
}
|
||||||
|
const auto sessionGen = chat_session_generation_;
|
||||||
|
submitZSendMany(from, myZ, 0.0, fee, /*memo*/"", recipients,
|
||||||
|
"HushChat / note-buffer split", /*markFeeGapRetry*/ true,
|
||||||
|
[this, sessionGen](bool ok, const std::string& /*result*/) {
|
||||||
|
if (sessionGen != chat_session_generation_) return; // stale wallet
|
||||||
|
inflight_op_ = LiteInflightOp{}; // release the channel
|
||||||
|
// 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;
|
||||||
|
},
|
||||||
|
/*background*/ true); // autonomous buffer maintenance — no global send UI
|
||||||
|
wallet::liteLog("Chat buffer: splitting funds into " + std::to_string(n) +
|
||||||
|
" notes (self-send) so messages can be sent back-to-back");
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool App::shouldSplitChatBuffer() {
|
||||||
|
// Only for engaged chat users (has a conversation or a queued send) — never split for someone who
|
||||||
|
// never chats. Connected, unlocked, channel free, no split already outstanding, pipeline genuinely
|
||||||
|
// depleted, funded. NB: a wall-clock cooldown is deliberately NOT used — a split's outputs stay
|
||||||
|
// invisible until mined (~1 block, far longer than any UI cooldown), so we gate on a single-split-
|
||||||
|
// in-flight flag (cleared when the pipeline recovers or the watchdog fires) to prevent runaway splits.
|
||||||
|
if (chat_service_.store().conversationIds().empty() && chat_send_queue_.empty()) return false;
|
||||||
|
if (!state_.connected || state_.isLocked()) return false;
|
||||||
|
// Channel busy = our own op inflight, OR a user send holds the shared channel. Lite user sends set
|
||||||
|
// lite_send_callback_; full-node user sends don't touch inflight_op_, so they're detected via the
|
||||||
|
// shared single-flight counter + any outstanding opid (a chat/user/sweep z_sendmany not yet resolved).
|
||||||
|
if (inflight_op_.kind != LiteOpKind::None) return false;
|
||||||
|
if (lite_wallet_ ? (bool)lite_send_callback_
|
||||||
|
: (send_submissions_in_flight_ > 0 || !pending_opids_.empty())) return false;
|
||||||
|
if (chat_split_outstanding_) return false;
|
||||||
|
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;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Resolve the just-completed chat/contact broadcast onto its echo. The in-flight op is the queue FRONT
|
||||||
|
// (the pump submits it without popping) so we can requeue it in place on a transient-funds failure.
|
||||||
|
void App::onChatBroadcastResult(const LiteInflightOp& op, bool ok, const std::string& error) {
|
||||||
|
if (op.sessionGen != chat_session_generation_) return; // wallet switched since submit — echo is gone
|
||||||
|
const bool frontMatches = !chat_send_queue_.empty()
|
||||||
|
&& chat_send_queue_.front().echoLocalId == op.echoLocalId;
|
||||||
|
if (ok) {
|
||||||
|
chat_service_.resolveOutgoing(op.echoLocalId, chat::ChatDelivery::Sent);
|
||||||
|
if (frontMatches) chat_send_queue_.pop_front();
|
||||||
|
wallet::liteLog("chat: message broadcast (Sent); " + std::to_string(chat_send_queue_.size()) +
|
||||||
|
" still queued");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (frontMatches && isTransientVerifiedFundsError(error)
|
||||||
|
&& chat_send_queue_.front().retries < kMaxChatSendRetries) {
|
||||||
|
// The optimistic budget was wrong (change not confirmed yet). Stop draining until the next refresh
|
||||||
|
// restores the true verified-note count; keep the message queued ("Sending") and retry then.
|
||||||
|
chat_verified_note_budget_ = 0;
|
||||||
|
++chat_send_queue_.front().retries;
|
||||||
|
wallet::liteLog("chat: send deferred — funds still confirming (retry " +
|
||||||
|
std::to_string(chat_send_queue_.front().retries) + "); message stays 'Sending'");
|
||||||
|
return; // leave at front
|
||||||
|
}
|
||||||
|
chat_service_.resolveOutgoing(op.echoLocalId, chat::ChatDelivery::Failed); // hard failure -> Retry affordance
|
||||||
|
if (frontMatches) chat_send_queue_.pop_front();
|
||||||
|
wallet::liteLog("chat: send FAILED: " + error);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Per-frame pump: drain the chat queue against verified notes (priority 1); otherwise build/refill the
|
||||||
|
// buffer during idle (priority 2). One op per frame — the single broadcast channel serializes the rest.
|
||||||
|
void App::pumpChatNoteBuffer() {
|
||||||
|
if (!chat::hushChatFeatureEnabledAtBuild()) return;
|
||||||
|
if (!chat_service_.hasIdentity() || !state_.connected) return;
|
||||||
|
// A locked wallet can't spend: submitting would return a non-transient lock error and flap the queued
|
||||||
|
// message to Failed. Hold everything (send + split) until unlock — messages stay "Sending".
|
||||||
|
if (state_.isLocked()) return;
|
||||||
|
// Channel busy — exactly one send must be outstanding. Lite serializes via inflight_op_ + the send
|
||||||
|
// callback; the full node has no single-broadcast channel, so we also honour the shared single-flight
|
||||||
|
// counter and any outstanding opid (a chat/user/sweep z_sendmany not yet terminally resolved). Without
|
||||||
|
// the latter two a chat send could overlap a user Send-tab send (which never sets inflight_op_).
|
||||||
|
if (inflight_op_.kind != LiteOpKind::None) return;
|
||||||
|
if (lite_wallet_ ? (bool)lite_send_callback_
|
||||||
|
: (send_submissions_in_flight_ > 0 || !pending_opids_.empty())) return;
|
||||||
|
|
||||||
|
// Priority 1: drain a queued chat send while we (optimistically) still have a verified note.
|
||||||
|
if (!chat_send_queue_.empty()) {
|
||||||
|
QueuedChatOp& front = chat_send_queue_.front();
|
||||||
|
if (front.sessionGen != chat_session_generation_) { chat_send_queue_.pop_front(); return; }
|
||||||
|
if (chat_verified_note_budget_ >= 1) {
|
||||||
|
// Submit onto the wallet's send channel; NOT popped until the async result resolves (so a
|
||||||
|
// transient-funds failure can requeue it in place). Full-node broadcastChatMemos registers a
|
||||||
|
// terminal opid callback that clears inflight_op_ + routes onChatBroadcastResult.
|
||||||
|
const bool submitted = lite_wallet_ ? broadcastChatMemosLite(front.memos)
|
||||||
|
: broadcastChatMemos(front.memos, front.echoLocalId);
|
||||||
|
if (submitted) {
|
||||||
|
inflight_op_ = LiteInflightOp{ front.kind, front.echoLocalId, front.sessionGen, ImGui::GetTime() };
|
||||||
|
--chat_verified_note_budget_; // this note is now (optimistically) spent
|
||||||
|
}
|
||||||
|
return; // one op per frame; the front waits until resolved
|
||||||
|
}
|
||||||
|
// No verified note available: leave it queued ("Sending"); fall through to maybe build the buffer.
|
||||||
|
}
|
||||||
|
|
||||||
|
// Priority 2: build/refill the buffer during idle.
|
||||||
|
if (shouldSplitChatBuffer()) {
|
||||||
|
const int need = kChatBufferTarget - chat_pipeline_note_count_;
|
||||||
|
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 int n = std::min(need, affordable);
|
||||||
|
const bool submitted = n >= 1 && (lite_wallet_ ? broadcastSelfSplitLite(n)
|
||||||
|
: broadcastSelfSplitNode(n));
|
||||||
|
if (submitted) {
|
||||||
|
inflight_op_ = LiteInflightOp{ LiteOpKind::Split, {}, chat_session_generation_, ImGui::GetTime() };
|
||||||
|
// 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// HushChat (lite variant): the full-node harvest works off z_viewtransaction, but the lite wallet's
|
// HushChat (lite variant): the full-node harvest works off z_viewtransaction, but the lite wallet's
|
||||||
@@ -3133,6 +3512,7 @@ void App::ingestLiteChatMemos(const wallet::LiteWalletAppRefreshModel& model)
|
|||||||
|
|
||||||
std::unordered_map<std::string, chat::HushChatTransactionInput> byTxid;
|
std::unordered_map<std::string, chat::HushChatTransactionInput> byTxid;
|
||||||
std::unordered_map<std::string, std::int64_t> txTimestamps;
|
std::unordered_map<std::string, std::int64_t> txTimestamps;
|
||||||
|
bool anyUnconfirmedChatTx = false; // did a chat-carrying tx arrive still-unconfirmed (0-conf mempool)?
|
||||||
for (const auto& tx : model.transactions) {
|
for (const auto& tx : model.transactions) {
|
||||||
if (tx.kind != wallet::LiteWalletAppTransactionKind::Receive) continue; // only incoming carry chat
|
if (tx.kind != wallet::LiteWalletAppTransactionKind::Receive) continue; // only incoming carry chat
|
||||||
if (tx.memo.empty() || !tx.position) continue;
|
if (tx.memo.empty() || !tx.position) continue;
|
||||||
@@ -3140,6 +3520,7 @@ void App::ingestLiteChatMemos(const wallet::LiteWalletAppRefreshModel& model)
|
|||||||
input.txid = tx.txid;
|
input.txid = tx.txid;
|
||||||
input.outputs.push_back({static_cast<std::size_t>(*tx.position), tx.memo});
|
input.outputs.push_back({static_cast<std::size_t>(*tx.position), tx.memo});
|
||||||
txTimestamps[tx.txid] = tx.timestamp;
|
txTimestamps[tx.txid] = tx.timestamp;
|
||||||
|
if (tx.unconfirmed) anyUnconfirmedChatTx = true;
|
||||||
}
|
}
|
||||||
if (byTxid.empty()) return;
|
if (byTxid.empty()) return;
|
||||||
|
|
||||||
@@ -3151,6 +3532,10 @@ void App::ingestLiteChatMemos(const wallet::LiteWalletAppRefreshModel& model)
|
|||||||
if (!metadata.empty()) {
|
if (!metadata.empty()) {
|
||||||
std::vector<std::string> newChatCids;
|
std::vector<std::string> newChatCids;
|
||||||
chat_service_.ingest(metadata, txTimestamps, std::time(nullptr), &newChatCids);
|
chat_service_.ingest(metadata, txTimestamps, std::time(nullptr), &newChatCids);
|
||||||
|
if (!newChatCids.empty())
|
||||||
|
wallet::liteLog("chat: harvested " + std::to_string(newChatCids.size()) + " new message(s) " +
|
||||||
|
(anyUnconfirmedChatTx ? "(0-conf / mempool — monitor working)"
|
||||||
|
: "(confirmed only — not seen at mempool speed)"));
|
||||||
// Mirror the full-node paths: a new incoming message un-hides a hidden conversation (you can't
|
// Mirror the full-node paths: a new incoming message un-hides a hidden conversation (you can't
|
||||||
// un-receive), and a non-muted new message toasts when we're off the Chat tab.
|
// un-receive), and a non-muted new message toasts when we're off the Chat tab.
|
||||||
if (settings_) {
|
if (settings_) {
|
||||||
@@ -3182,6 +3567,8 @@ void App::fastScanChatMemos()
|
|||||||
const int scanGen = chat_session_generation_; // guard: drop the result if the wallet switches/locks
|
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, addr, scanGen]() -> rpc::RPCWorker::MainCb {
|
||||||
std::vector<chat::HushChatTransactionMetadata> metadata;
|
std::vector<chat::HushChatTransactionMetadata> metadata;
|
||||||
|
int rawMemoCount = 0; // received notes carrying a memo at the reply addr (0-conf visibility signal)
|
||||||
|
std::string scanError;
|
||||||
try {
|
try {
|
||||||
rpc::RPCClient::TraceScope trace("HushChat / 0-conf fast scan");
|
rpc::RPCClient::TraceScope trace("HushChat / 0-conf fast scan");
|
||||||
nlohmann::json received = rpc_->call("z_listreceivedbyaddress", {addr, 0}); // 0 = include mempool
|
nlohmann::json received = rpc_->call("z_listreceivedbyaddress", {addr, 0}); // 0 = include mempool
|
||||||
@@ -3194,6 +3581,7 @@ void App::fastScanChatMemos()
|
|||||||
const std::string txid = note.value("txid", std::string());
|
const std::string txid = note.value("txid", std::string());
|
||||||
const std::string memo = note.value("memoStr", std::string());
|
const std::string memo = note.value("memoStr", std::string());
|
||||||
if (txid.empty() || memo.empty()) continue;
|
if (txid.empty() || memo.empty()) continue;
|
||||||
|
++rawMemoCount;
|
||||||
std::size_t pos = fallbackPos;
|
std::size_t pos = fallbackPos;
|
||||||
for (const char* key : {"position", "outputIndex", "outindex"})
|
for (const char* key : {"position", "outputIndex", "outindex"})
|
||||||
if (note.contains(key) && note[key].is_number_integer() && note[key].get<int>() >= 0) {
|
if (note.contains(key) && note[key].is_number_integer() && note[key].get<int>() >= 0) {
|
||||||
@@ -3209,13 +3597,25 @@ void App::fastScanChatMemos()
|
|||||||
for (auto& m : extracted.metadata) metadata.push_back(std::move(m));
|
for (auto& m : extracted.metadata) metadata.push_back(std::move(m));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch (const std::exception&) {}
|
} catch (const std::exception& e) { scanError = e.what(); }
|
||||||
return [this, scanGen, metadata = std::move(metadata)]() mutable {
|
const int parsedCount = static_cast<int>(metadata.size());
|
||||||
|
return [this, scanGen, metadata = std::move(metadata), rawMemoCount, parsedCount, scanError]() mutable {
|
||||||
// The wallet was switched/locked between post and now — this metadata belongs to the previous
|
// 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
|
// 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).
|
// would clobber a new session's own in-flight scan (mirrors the broadcast/identity guards).
|
||||||
if (scanGen != chat_session_generation_) return;
|
if (scanGen != chat_session_generation_) return;
|
||||||
chat_fast_scan_in_flight_ = false;
|
chat_fast_scan_in_flight_ = false;
|
||||||
|
// 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.
|
||||||
|
if (!scanError.empty()) {
|
||||||
|
wallet::liteLog("chat[0conf]: reply-addr scan failed: " + scanError);
|
||||||
|
} else if (rawMemoCount != chat_fast_scan_last_seen_) {
|
||||||
|
chat_fast_scan_last_seen_ = rawMemoCount;
|
||||||
|
if (rawMemoCount > 0)
|
||||||
|
wallet::liteLog("chat[0conf]: reply-addr scan sees " + std::to_string(rawMemoCount) +
|
||||||
|
" memo note(s), " + std::to_string(parsedCount) + " parse as chat");
|
||||||
|
}
|
||||||
if (metadata.empty()) return;
|
if (metadata.empty()) return;
|
||||||
// Skip HIDDEN conversations — the mempool fast path deliberately doesn't surface them; they
|
// Skip HIDDEN conversations — the mempool fast path deliberately doesn't surface them; they
|
||||||
// still come back through the normal confirmed harvest (which un-hides on a new message).
|
// still come back through the normal confirmed harvest (which un-hides on a new message).
|
||||||
@@ -3229,6 +3629,9 @@ void App::fastScanChatMemos()
|
|||||||
std::unordered_map<std::string, std::int64_t> noTimes; // mempool: no block time → ingest uses now
|
std::unordered_map<std::string, std::int64_t> noTimes; // mempool: no block time → ingest uses now
|
||||||
std::vector<std::string> newChatCids;
|
std::vector<std::string> newChatCids;
|
||||||
chat_service_.ingest(visible, noTimes, std::time(nullptr), &newChatCids);
|
chat_service_.ingest(visible, noTimes, std::time(nullptr), &newChatCids);
|
||||||
|
if (!newChatCids.empty())
|
||||||
|
wallet::liteLog("chat[0conf]: harvested " + std::to_string(newChatCids.size()) +
|
||||||
|
" new message(s) from the mempool");
|
||||||
if (current_page_ != ui::NavPage::Chat &&
|
if (current_page_ != ui::NavPage::Chat &&
|
||||||
std::any_of(newChatCids.begin(), newChatCids.end(),
|
std::any_of(newChatCids.begin(), newChatCids.end(),
|
||||||
[this](const std::string& cid){ return !(settings_ && settings_->isChatMuted(cid)); }))
|
[this](const std::string& cid){ return !(settings_ && settings_->isChatMuted(cid)); }))
|
||||||
@@ -4087,6 +4490,9 @@ void App::sendTransaction(const std::string& from, const std::string& to,
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
lite_send_callback_ = std::move(callback); // delivered from update()
|
lite_send_callback_ = std::move(callback); // delivered from update()
|
||||||
|
// Claim the shared broadcast channel so the chat note-buffer coordinator yields to this user send
|
||||||
|
// and routes the one global broadcast result back to lite_send_callback_ (not a chat echo).
|
||||||
|
inflight_op_ = LiteInflightOp{ LiteOpKind::UserSend, {}, chat_session_generation_, ImGui::GetTime() };
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -4149,12 +4555,13 @@ void App::sendTransaction(const std::string& from, const std::string& to,
|
|||||||
void App::submitZSendMany(const std::string& from, const std::string& to, double amount, double fee,
|
void App::submitZSendMany(const std::string& from, const std::string& to, double amount, double fee,
|
||||||
const std::string& memo, const nlohmann::json& recipients,
|
const std::string& memo, const nlohmann::json& recipients,
|
||||||
const char* traceLabel, bool markFeeGapRetry,
|
const char* traceLabel, bool markFeeGapRetry,
|
||||||
std::function<void(bool, const std::string&)> callback)
|
std::function<void(bool, const std::string&)> callback,
|
||||||
|
bool background)
|
||||||
{
|
{
|
||||||
send_progress_active_ = true;
|
if (!background) send_progress_active_ = true; // autonomous chat/split: status shown on the message
|
||||||
++send_submissions_in_flight_;
|
++send_submissions_in_flight_;
|
||||||
worker_->post([this, from, to, amount, fee, memo, recipients, callback, traceLabel,
|
worker_->post([this, from, to, amount, fee, memo, recipients, callback, traceLabel,
|
||||||
markFeeGapRetry]() -> rpc::RPCWorker::MainCb {
|
markFeeGapRetry, background]() -> rpc::RPCWorker::MainCb {
|
||||||
bool ok = false;
|
bool ok = false;
|
||||||
std::string result_str;
|
std::string result_str;
|
||||||
try {
|
try {
|
||||||
@@ -4169,7 +4576,7 @@ void App::submitZSendMany(const std::string& from, const std::string& to, double
|
|||||||
// below would never run, leaving a stuck "Sending…" spinner.
|
// below would never run, leaving a stuck "Sending…" spinner.
|
||||||
result_str = "Send failed (unknown error)";
|
result_str = "Send failed (unknown error)";
|
||||||
}
|
}
|
||||||
return [this, callback, ok, result_str, from, to, amount, fee, memo, markFeeGapRetry]() {
|
return [this, callback, ok, result_str, from, to, amount, fee, memo, markFeeGapRetry, background]() {
|
||||||
if (send_submissions_in_flight_ > 0) --send_submissions_in_flight_;
|
if (send_submissions_in_flight_ > 0) --send_submissions_in_flight_;
|
||||||
if (ok) {
|
if (ok) {
|
||||||
// A send changes address balances — refresh on next cycle
|
// A send changes address balances — refresh on next cycle
|
||||||
@@ -4192,7 +4599,7 @@ void App::submitZSendMany(const std::string& from, const std::string& to, double
|
|||||||
callback(true, result_str); // no opid to track — report as-is
|
callback(true, result_str); // no opid to track — report as-is
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
send_progress_active_ = false;
|
if (!background) send_progress_active_ = false;
|
||||||
if (callback) callback(false, result_str);
|
if (callback) callback(false, result_str);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -29,6 +29,10 @@ namespace ui {
|
|||||||
|
|
||||||
using namespace material;
|
using namespace material;
|
||||||
|
|
||||||
|
// Classifies a diagnostics line into a console channel (defined below in an anonymous namespace); both
|
||||||
|
// executors drain the same LiteDiagnostics ring, so declare it up front for the full-node drain.
|
||||||
|
namespace { ConsoleChannel liteLogChannel(const std::string& line); }
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
// FullNodeConsoleExecutor
|
// FullNodeConsoleExecutor
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
@@ -173,6 +177,25 @@ void FullNodeConsoleExecutor::pollLogLines(const ConsoleAddLineFn& add)
|
|||||||
} else {
|
} else {
|
||||||
last_xmrig_output_size_ = 0; // reset so we get fresh output when it restarts
|
last_xmrig_output_size_ = 0; // reset so we get fresh output when it restarts
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Chat diagnostics ring: chat code logs via wallet::liteLog on both variants (send lifecycle, the
|
||||||
|
// 0-conf harvest, the note buffer). Surface those lines here as App messages so the full-node console
|
||||||
|
// shows chat activity too. Same generation-cursor drain the lite console uses.
|
||||||
|
{
|
||||||
|
auto& diag = wallet::LiteDiagnostics::instance();
|
||||||
|
const std::uint64_t gen = diag.generation();
|
||||||
|
if (gen != diag_gen_) {
|
||||||
|
const auto snap = diag.snapshot();
|
||||||
|
std::size_t startIdx = 0;
|
||||||
|
if (diag_gen_ != static_cast<std::uint64_t>(-1)) {
|
||||||
|
const std::uint64_t added = gen - diag_gen_;
|
||||||
|
startIdx = (added >= snap.size()) ? 0 : snap.size() - static_cast<std::size_t>(added);
|
||||||
|
}
|
||||||
|
for (std::size_t i = startIdx; i < snap.size(); ++i)
|
||||||
|
add(snap[i], liteLogChannel(snap[i]));
|
||||||
|
diag_gen_ = gen;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
void FullNodeConsoleExecutor::printHelp(const ConsoleAddLineFn& add)
|
void FullNodeConsoleExecutor::printHelp(const ConsoleAddLineFn& add)
|
||||||
|
|||||||
@@ -113,6 +113,9 @@ private:
|
|||||||
bool last_rpc_connected_ = false;
|
bool last_rpc_connected_ = false;
|
||||||
std::deque<std::pair<std::string, bool>> results_; // {text, isError}
|
std::deque<std::pair<std::string, bool>> results_; // {text, isError}
|
||||||
std::mutex results_mutex_;
|
std::mutex results_mutex_;
|
||||||
|
// Chat diagnostics ring cursor: chat code logs via wallet::liteLog on BOTH variants, so the full-node
|
||||||
|
// console surfaces those lines (as App messages) too — mirrors LiteConsoleExecutor's diag drain.
|
||||||
|
std::uint64_t diag_gen_ = static_cast<std::uint64_t>(-1);
|
||||||
};
|
};
|
||||||
|
|
||||||
// ── Lite: diagnostics ring + backend console command ─────────────────────────
|
// ── Lite: diagnostics ring + backend console command ─────────────────────────
|
||||||
|
|||||||
Reference in New Issue
Block a user