From ef247c95ffbe218aa7a79c8da14ed6102e792d98 Mon Sep 17 00:00:00 2001 From: DanS Date: Thu, 16 Jul 2026 20:27:43 -0500 Subject: [PATCH] feat(chat): fee floor, real delivery status, pay-from-funded, funds pre-check MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Chat sends move 0 value, so the network fee is structurally load-bearing (it's the only thing that forces a real shielded input; 0-value + 0-fee builds a degenerate, unrelayable tx). Three gaps addressed: 1. Fee floor — broadcastChatMemos now uses max(getDefaultFee(), kChatMinFeeDrgx), so a 0 / too-low global default-fee setting can't silently break chat. 2. Real delivery status — the echo was marked Sent on SUBMIT regardless of the on-chain outcome (the z_sendmany callback was empty), so failures were invisible and the Retry affordance never fired for async failures. Add a third ChatDelivery::Sending state (appended so persisted 0=Sent stays valid); record the echo in-memory as Sending, and resolve it to Sent/Failed from the z_sendmany completion callback — persisting only the final status (so a restart never shows a stuck spinner; a stray persisted Sending loads as Sent). A subtle "sending…" label shows while in flight. 3. Pay-from-funded + pre-check — z_sendmany spends from one z-address, and the identity reply address may be unfunded while funds sit elsewhere. chatPayFromZaddr picks a spendable z-address that can cover the fee (preferring the identity address); the memo still advertises the identity address as reply-to, so paying from a different note is transport-transparent. If nothing can cover the fee, a clear "need a small shielded balance" toast replaces the cryptic failure. Full node only for the callback path; lite resolves optimistically on queue. 8-language strings + CJK subset (+1 glyph 賄). Co-Authored-By: Claude Opus 4.8 (1M context) --- res/fonts/NotoSansCJK-Subset.ttf | Bin 637620 -> 638208 bytes res/lang/de.json | 2 + res/lang/es.json | 2 + res/lang/fr.json | 2 + res/lang/ja.json | 2 + res/lang/ko.json | 2 + res/lang/pt.json | 2 + res/lang/ru.json | 2 + res/lang/zh.json | 2 + src/app.h | 9 +++- src/app_network.cpp | 74 ++++++++++++++++++++++++------- src/chat/chat_database.cpp | 5 ++- src/chat/chat_message.h | 8 ++-- src/chat/chat_service.cpp | 11 +++++ src/chat/chat_service.h | 6 +++ src/chat/chat_store.cpp | 10 +++++ src/chat/chat_store.h | 4 ++ src/ui/windows/chat_tab.cpp | 10 +++++ src/util/i18n.cpp | 2 + 19 files changed, 133 insertions(+), 22 deletions(-) diff --git a/res/fonts/NotoSansCJK-Subset.ttf b/res/fonts/NotoSansCJK-Subset.ttf index d6f9d1f0cffa6dc23a6a26485b5c35fd73caedef..a6d186fe6a416210f1411c1a4951748ea3a3c2a2 100644 GIT binary patch delta 13356 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 BZ>0bL delta 12905 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 swap wallet.dat -> restart with -rescan void provisionChatIdentityFromSecret(std::string secret); - std::string chatReplyZaddr(); // a stable (persisted) wallet z-addr for chat + std::string chatReplyZaddr(); // a stable (persisted) wallet z-addr — chat IDENTITY (reply-to) + // A spendable z-address that can actually PAY the fee (balance >= fee), preferring the identity + // reply address. The reply-to in the memo stays the identity address, so paying from a different + // funded note is transport-transparent. Empty if no z-address can cover the fee. + std::string chatPayFromZaddr(double fee) const; std::string generateChatLocalId(const char* prefix, int numBytes) const; // unique echo id / cid - bool broadcastChatMemos(const chat::OutgoingChatMemos& memos); // returns true if submitted + // Broadcast the memos and, when the async op resolves, flip the echo (echoLocalId) to Sent/Failed. + 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 // Lite first-run welcome prompt: dismissed for the session once the user picks an action. diff --git a/src/app_network.cpp b/src/app_network.cpp index 85ac2c7..2ec5144 100644 --- a/src/app_network.cpp +++ b/src/app_network.cpp @@ -2885,6 +2885,27 @@ std::string App::chatReplyZaddr() return best; } +std::string App::chatPayFromZaddr(double fee) const +{ + // z_sendmany spends from ONE z-address, so the "from" must itself hold >= fee. 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 + // reply-to, so paying from a different note doesn't change who the peer replies to. + std::string reply; + if (settings_) reply = settings_->getChatReplyZaddr(); + for (const auto& a : state_.z_addresses) + if (a.address == reply && a.has_spending_key && a.balance >= fee) 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.balance >= fee && a.balance > bestBal) { + best = a.address; + bestBal = a.balance; + } + return best; // empty → no z-address can cover the fee +} + // A unique opaque id (hex), used for the local echo id (we never harvest our own sends) and for // new conversation ids. Random — collisions are astronomically unlikely. std::string App::generateChatLocalId(const char* prefix, int numBytes) const @@ -2904,21 +2925,35 @@ std::string App::generateChatLocalId(const char* prefix, int numBytes) const // LIVE-VERIFY (cannot be proven from source): that dragonxd returns the memo under `memoStr` verbatim // on receive, that recipient-array order maps to note position (the receive pairing needs the header // at a lower position), that a 0-value memo-only tx relays, and full SDXL<->DragonX interop. -bool App::broadcastChatMemos(const chat::OutgoingChatMemos& memos) +// A chat send moves 0 value, so the fee is the ONLY thing that forces a real shielded input — a +// 0-value + 0-fee tx builds a degenerate, unrelayable tx (see z_sendmany targetAmount). Never let the +// global default-fee setting drop chat below a working minimum (well above the node's min relay fee). +static constexpr double kChatMinFeeDrgx = 0.0001; + +bool App::broadcastChatMemos(const chat::OutgoingChatMemos& memos, const std::string& echoLocalId) { if (memos.recipientZaddr.empty()) return false; if (lite_wallet_) { - return broadcastChatMemosLite(memos); + const bool ok = broadcastChatMemosLite(memos); + // The lite backend has no per-send completion callback here; resolve optimistically on a + // successful queue (its own broadcast log surfaces a later failure). + if (ok) chat_service_.resolveOutgoing(echoLocalId, chat::ChatDelivery::Sent); + return ok; } if (!state_.connected || !rpc_ || !worker_) { ui::Notifications::instance().error(TR("chat_toast_not_connected")); return false; } - const std::string from = chatReplyZaddr(); // spendable + advertised as the reply-to address + + const double fee = std::max(settings_ ? settings_->getDefaultFee() : kChatMinFeeDrgx, kChatMinFeeDrgx); + + // 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. + const std::string from = chatPayFromZaddr(fee); if (from.empty()) { - ui::Notifications::instance().error(TR("chat_toast_no_zaddr")); + ui::Notifications::instance().error(TR("chat_toast_need_funds")); return false; } @@ -2933,12 +2968,16 @@ bool App::broadcastChatMemos(const chat::OutgoingChatMemos& memos) recipients.push_back(std::move(recipient)); } - const double fee = settings_ ? settings_->getDefaultFee() : 0.0001; // 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. + // The callback flips the echo to Sent/Failed once the async op resolves — real delivery status. submitZSendMany(from, memos.recipientZaddr, 0.0, fee, /*memo*/"", recipients, - "HushChat / broadcast", /*markFeeGapRetry*/ true, /*callback*/{}); - return true; // submitted (async build/broadcast; a later failure surfaces via the opid poller) + "HushChat / broadcast", /*markFeeGapRetry*/ true, + [this, echoLocalId](bool ok, const std::string& /*result*/) { + chat_service_.resolveOutgoing(echoLocalId, + ok ? chat::ChatDelivery::Sent : chat::ChatDelivery::Failed); + }); + return true; // submitted (async build/broadcast; the callback resolves the final status) } // Lite variant: two 0-value recipients to the same z-address, RAW memos (the backend does @@ -2997,8 +3036,6 @@ void App::sendChatMessage(const std::string& conversationId, const std::string& ui::Notifications::instance().error(TR("chat_toast_compose_failed")); return; } - const bool submitted = broadcastChatMemos(memos); - chat::ChatMessage echo; echo.direction = chat::ChatDirection::Outgoing; echo.kind = chat::ChatMessageKind::Message; @@ -3009,8 +3046,12 @@ void App::sendChatMessage(const std::string& conversationId, const std::string& echo.timestamp = std::time(nullptr); echo.txid = generateChatLocalId("out:", 8); echo.payload_position = 0; - echo.delivery = submitted ? chat::ChatDelivery::Sent : chat::ChatDelivery::Failed; - chat_service_.recordOutgoing(echo); + echo.delivery = chat::ChatDelivery::Sending; // shows immediately; the broadcast callback resolves it + 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 + // affordance appears; otherwise the async callback flips it to Sent/Failed. + if (!broadcastChatMemos(memos, echo.txid)) + chat_service_.resolveOutgoing(echo.txid, chat::ChatDelivery::Failed); } void App::startChatConversation(const std::string& peerZaddr, const std::string& text) @@ -3030,8 +3071,6 @@ void App::startChatConversation(const std::string& peerZaddr, const std::string& ui::Notifications::instance().error(TR("chat_toast_request_compose_failed")); return; } - const bool submitted = broadcastChatMemos(memos); - chat::ChatMessage echo; echo.direction = chat::ChatDirection::Outgoing; echo.kind = chat::ChatMessageKind::ContactRequest; @@ -3041,9 +3080,12 @@ void App::startChatConversation(const std::string& peerZaddr, const std::string& echo.timestamp = std::time(nullptr); echo.txid = generateChatLocalId("out:", 8); echo.payload_position = 0; - echo.delivery = submitted ? chat::ChatDelivery::Sent : chat::ChatDelivery::Failed; - chat_service_.recordOutgoing(echo); - if (submitted) ui::Notifications::instance().success(TR("chat_toast_request_queued")); + echo.delivery = chat::ChatDelivery::Sending; + chat_service_.recordOutgoingPending(echo); + if (broadcastChatMemos(memos, echo.txid)) + ui::Notifications::instance().success(TR("chat_toast_request_queued")); + else + chat_service_.resolveOutgoing(echo.txid, chat::ChatDelivery::Failed); } // HushChat (lite variant): the full-node harvest works off z_viewtransaction, but the lite wallet's diff --git a/src/chat/chat_database.cpp b/src/chat/chat_database.cpp index 29f767c..243247c 100644 --- a/src/chat/chat_database.cpp +++ b/src/chat/chat_database.cpp @@ -267,7 +267,10 @@ bool ChatDatabase::deserialize(const std::string& json, ChatMessage& out) const out.body = parsed.value("b", std::string()); out.timestamp = parsed.value("ts", static_cast(0)); out.payload_position = static_cast(parsed.value("pos", static_cast(0))); - out.delivery = static_cast(parsed.value("dl", 0)); // old rows → Sent + out.delivery = static_cast(parsed.value("dl", 0)); // old rows → Sent (0) + // A persisted "Sending" means we crashed mid-broadcast; the outcome is unknown. Resolve it + // optimistically to Sent on load so it can't show a stuck spinner forever. + if (out.delivery == ChatDelivery::Sending) out.delivery = ChatDelivery::Sent; return true; } catch (const std::exception&) { return false; diff --git a/src/chat/chat_message.h b/src/chat/chat_message.h index d55e1e0..811dce7 100644 --- a/src/chat/chat_message.h +++ b/src/chat/chat_message.h @@ -10,9 +10,11 @@ namespace dragonx::chat { enum class ChatDirection { Incoming, Outgoing }; enum class ChatMessageKind { Message, ContactRequest }; -// Outgoing delivery: Sent = the broadcast was submitted; Failed = it wasn't (not connected, no -// spendable address, a send already in progress). Always Sent for incoming. -enum class ChatDelivery { Sent, Failed }; +// Outgoing delivery status. Sending = broadcast in flight (async op not yet resolved); Sent = the +// daemon accepted + broadcast the tx; Failed = it didn't (not connected, no funded address, rejected). +// Always Sent for incoming. NB: the values are persisted (chat DB serializes the int), so Sent MUST +// stay 0 and new states are APPENDED — never reordered. +enum class ChatDelivery { Sent, Failed, Sending }; struct ChatMessage { ChatDirection direction = ChatDirection::Incoming; diff --git a/src/chat/chat_service.cpp b/src/chat/chat_service.cpp index c994cd6..8e3bf4c 100644 --- a/src/chat/chat_service.cpp +++ b/src/chat/chat_service.cpp @@ -109,4 +109,15 @@ bool ChatService::recordOutgoing(const ChatMessage& message) { return false; } +bool ChatService::recordOutgoingPending(const ChatMessage& message) { + // In-memory only — deliberately NOT persisted yet, so "Sending" never survives to a restart; the + // row is written by resolveOutgoing() once the broadcast resolves to Sent/Failed. + return store_.append(message); +} + +void ChatService::resolveOutgoing(const std::string& txid, ChatDelivery delivery) { + const ChatMessage* updated = store_.updateDelivery(txid, delivery); + if (updated && db_) db_->append(*updated); // first persist, carrying the final status +} + } // namespace dragonx::chat diff --git a/src/chat/chat_service.h b/src/chat/chat_service.h index 2366606..b51a438 100644 --- a/src/chat/chat_service.h +++ b/src/chat/chat_service.h @@ -76,6 +76,12 @@ public: // only local record of what we sent.) bool recordOutgoing(const ChatMessage& message); + // Two-phase echo for delivery tracking: record in-memory only (shows immediately as Sending), + // then resolveOutgoing() flips the status once the broadcast completes AND persists it with the + // final status — so a restart never shows a stuck "Sending". + bool recordOutgoingPending(const ChatMessage& message); + void resolveOutgoing(const std::string& txid, ChatDelivery delivery); + const ChatStore& store() const { return store_; } ChatStore& store() { return store_; } diff --git a/src/chat/chat_store.cpp b/src/chat/chat_store.cpp index 3dbb0bd..82cf76b 100644 --- a/src/chat/chat_store.cpp +++ b/src/chat/chat_store.cpp @@ -34,6 +34,16 @@ std::vector ChatStore::conversation(const std::string& conversation return out; } +const ChatMessage* ChatStore::updateDelivery(const std::string& txid, ChatDelivery delivery) { + for (auto& message : messages_) { + if (message.txid == txid) { + message.delivery = delivery; + return &message; + } + } + return nullptr; +} + std::vector ChatStore::conversationIds() const { std::vector ids; std::unordered_set seenIds; diff --git a/src/chat/chat_store.h b/src/chat/chat_store.h index 4795fc7..60559a0 100644 --- a/src/chat/chat_store.h +++ b/src/chat/chat_store.h @@ -21,6 +21,10 @@ public: // Messages in a conversation, in insertion order. std::vector conversation(const std::string& conversationId) const; + // Update an outgoing echo's delivery status by its local txid. Returns a pointer to the updated + // message (for the caller to persist), or nullptr if no message has that txid. + const ChatMessage* updateDelivery(const std::string& txid, ChatDelivery delivery); + // Distinct conversation ids, in first-seen order. std::vector conversationIds() const; diff --git a/src/ui/windows/chat_tab.cpp b/src/ui/windows/chat_tab.cpp index 36a4f2c..978c723 100644 --- a/src/ui/windows/chat_tab.cpp +++ b/src/ui/windows/chat_tab.cpp @@ -460,6 +460,7 @@ void RenderChatTab(App* app) const bool outgoing = (m.direction == chat::ChatDirection::Outgoing); const bool request = (m.kind == chat::ChatMessageKind::ContactRequest); const bool failed = outgoing && m.delivery == chat::ChatDelivery::Failed; + const bool sending = outgoing && m.delivery == chat::ChatDelivery::Sending; const std::int64_t minute = m.timestamp / 60; const bool startGroup = (prevDir != static_cast(outgoing)) || (minute != prevMin); @@ -514,6 +515,15 @@ void RenderChatTab(App* app) ImGui::SameLine(); if (ImGui::SmallButton(TR("chat_retry"))) { app->sendChatMessage(sel->cid, m.body); s_scroll_to_cid = sel->cid; } } + // In-flight send → subtle right-aligned "sending…"; the op callback resolves it. + if (sending) { + ImGui::Dummy(ImVec2(0.0f, 1.0f * dp)); + const float sw = metaFont->CalcTextSizeA(metaSz, FLT_MAX, 0.0f, TR("chat_sending")).x; + ImGui::SetCursorPosX(ImGui::GetCursorPosX() + std::max(0.0f, availW - sw)); + ImGui::PushFont(metaFont); ImGui::PushStyleColor(ImGuiCol_Text, material::OnSurfaceMedium()); + ImGui::TextUnformatted(TR("chat_sending")); + ImGui::PopStyleColor(); ImGui::PopFont(); + } ImGui::Dummy(ImVec2(0.0f, 5.0f * dp)); ImGui::PopID(); prevMin = minute; prevDir = static_cast(outgoing); diff --git a/src/util/i18n.cpp b/src/util/i18n.cpp index 8d9f768..f951f58 100644 --- a/src/util/i18n.cpp +++ b/src/util/i18n.cpp @@ -236,6 +236,8 @@ void I18n::loadBuiltinEnglish() strings_["chat_toast_compose_failed"] = "Could not compose the message (too long?)."; strings_["chat_toast_request_compose_failed"] = "Could not compose the contact request (invalid address / text?)."; strings_["chat_toast_request_queued"] = "Contact request queued."; + strings_["chat_toast_need_funds"] = "Need a small shielded balance to send chat (to cover the fee)."; + strings_["chat_sending"] = "sending\xE2\x80\xA6"; strings_["chat_time_now"] = "now"; strings_["chat_retry"] = "Retry"; strings_["chat_jump_latest"] = "Latest";