From 30bd0d99ffc555dc886484c87f3164e669e24405 Mon Sep 17 00:00:00 2001 From: DanS Date: Fri, 17 Jul 2026 00:38:41 -0500 Subject: [PATCH] feat(chat): 0-conf fast-scan so incoming messages show before a block MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The receive harvest gates each z-address behind scannedAtTip — it only re-scans when a new block advances the tip — so incoming chat waited ~1 confirmation even though the daemon already exposes mempool notes (FindMySaplingNotes runs on mempool txs; z_listreceivedbyaddress(addr,0) returns them). Add App::fastScanChatMemos(): every transaction-refresh cycle, re-scan JUST the chat reply address (where peers send) at minconf=0, extract chat metadata, and ingest — so messages surface at mempool speed (a few seconds) instead of waiting for a block. Full-node only (lite has its own harvest). An in-flight guard avoids stacking RPCs; the store dedups on txid+position, so the confirmed harvest never double-inserts. Hidden conversations are deliberately skipped by the fast path — they don't get the mempool speed-up and still come back through the normal confirmed harvest (which un-hides on a new message). New non-muted messages toast off-tab as usual. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/app.cpp | 3 +++ src/app.h | 5 ++++ src/app_network.cpp | 66 +++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 74 insertions(+) diff --git a/src/app.cpp b/src/app.cpp index 2048e00..4230117 100644 --- a/src/app.cpp +++ b/src/app.cpp @@ -1168,6 +1168,9 @@ void App::update() } else if (walletDataPage && shouldRefreshRecentTransactions()) { refreshRecentTransactionData(); } + // 0-conf chat: the normal harvest above only re-scans on a new block, so also do a light + // mempool scan of the chat address every cycle to surface messages before confirmation. + fastScanChatMemos(); } if (network_refresh_.consumeDue(RefreshTimer::Addresses)) { if (walletDataPage || addresses_dirty_ || hasTransactionSendProgress()) { diff --git a/src/app.h b/src/app.h index ccdb255..f597db3 100644 --- a/src/app.h +++ b/src/app.h @@ -730,6 +730,11 @@ private: bool broadcastChatMemos(const chat::OutgoingChatMemos& memos, const std::string& echoLocalId); // true if submitted bool broadcastChatMemosLite(const chat::OutgoingChatMemos& memos); // lite two-recipient send void ingestLiteChatMemos(const wallet::LiteWalletAppRefreshModel& model); // lite chat receive harvest + // Full-node 0-conf fast path: re-scan just the chat reply address at minconf=0 every refresh cycle + // so incoming messages surface at mempool speed (before a block). Hidden conversations are skipped + // (they still un-hide via the normal confirmed harvest). Self-gated; no-op without a chat identity. + void fastScanChatMemos(); + bool chat_fast_scan_in_flight_ = false; // guard against overlapping fast-scan RPCs // Lite first-run welcome prompt: dismissed for the session once the user picks an action. bool lite_firstrun_dismissed_ = false; // Lite send-time unlock: set to show the unlock modal when a spend is attempted while locked. diff --git a/src/app_network.cpp b/src/app_network.cpp index 3a213df..c2ad22f 100644 --- a/src/app_network.cpp +++ b/src/app_network.cpp @@ -3165,6 +3165,72 @@ void App::ingestLiteChatMemos(const wallet::LiteWalletAppRefreshModel& model) } } +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 + const std::string addr = chatReplyZaddr(); + if (addr.empty()) return; + + chat_fast_scan_in_flight_ = true; + worker_->post([this, addr]() -> rpc::RPCWorker::MainCb { + std::vector metadata; + try { + rpc::RPCClient::TraceScope trace("HushChat / 0-conf fast scan"); + nlohmann::json received = rpc_->call("z_listreceivedbyaddress", {addr, 0}); // 0 = include mempool + if (received.is_array()) { + std::unordered_map byTxid; + std::size_t noteIndex = 0; + for (const auto& note : received) { + const std::size_t fallbackPos = noteIndex++; + if (!note.is_object()) continue; + const std::string txid = note.value("txid", std::string()); + const std::string memo = note.value("memoStr", std::string()); + if (txid.empty() || memo.empty()) continue; + std::size_t pos = fallbackPos; + for (const char* key : {"position", "outputIndex", "outindex"}) + if (note.contains(key) && note[key].is_number_integer() && note[key].get() >= 0) { + pos = static_cast(note[key].get()); + break; + } + auto& in = byTxid[txid]; + in.txid = txid; + in.outputs.push_back(chat::HushChatMemoOutput{pos, memo}); + } + for (auto& entry : byTxid) { + auto extracted = chat::extractHushChatTransactionMetadata(entry.second); + for (auto& m : extracted.metadata) metadata.push_back(std::move(m)); + } + } + } catch (const std::exception&) {} + return [this, metadata = std::move(metadata)]() mutable { + chat_fast_scan_in_flight_ = false; + if (metadata.empty()) return; + // 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). + std::vector visible; + visible.reserve(metadata.size()); + for (auto& m : metadata) + if (!(settings_ && settings_->isChatHidden(m.conversation_id))) + visible.push_back(std::move(m)); + if (visible.empty()) return; + + std::unordered_map noTimes; // mempool: no block time → ingest uses now + std::vector newChatCids; + chat_service_.ingest(visible, noTimes, std::time(nullptr), &newChatCids); + if (current_page_ != ui::NavPage::Chat && + std::any_of(newChatCids.begin(), newChatCids.end(), + [this](const std::string& cid){ return !(settings_ && settings_->isChatMuted(cid)); })) + ui::Notifications::instance().info(TR("chat_new_message_toast")); + }; + }); +} + bool App::lockLiteWallet() { if (!lite_wallet_) return false;