fix(chat): address adversarial review of the send-path change

Four confirmed findings from the review of ef247c9:

1. Persistence regression — the deferred-persist echo (in-memory Sending, written
   only when the async callback resolved) meant a message broadcast on-chain but
   whose callback hadn't fired yet was LOST from history if the app quit/crashed
   in that window. Persist the echo immediately as Sending and UPSERT the final
   status on resolve (new ChatDatabase::upsert with ON CONFLICT DO UPDATE, since
   append is INSERT-OR-IGNORE). A stray persisted Sending still loads as Sent.

2. Fee ceiling — dragonxd REJECTS a 0-value tx whose fee exceeds the default
   miners fee (0.0001), and max(getDefaultFee(), 0.0001) can only raise it, so a
   default_fee > 0.0001 broke every chat send. Pin chat to exactly kChatMinFeeDrgx,
   dropping getDefaultFee() from this path (chat always moves 0 value).

3. Lifetime — the resolve callback had no generation guard, so a wallet lock (which
   doesn't disconnect) between submit and callback could resolve against a cleared
   store. Capture chat_session_generation_ and bail on mismatch (both the full-node
   callback and the lite optimistic resolve), matching the identity-fetch pattern.

4. Retry misdirect — Retry on a failed CONTACT REQUEST called sendChatMessage,
   which (no peer key yet) just showed "waiting for reply". Route it to
   sendContactRequestForCid() (refactored out of startChatConversation) so it
   re-sends the request into the SAME conversation.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-07-16 20:42:59 -05:00
parent ef247c95ff
commit 76093fe82d
7 changed files with 77 additions and 12 deletions

View File

@@ -2934,11 +2934,16 @@ bool App::broadcastChatMemos(const chat::OutgoingChatMemos& memos, const std::st
{
if (memos.recipientZaddr.empty()) return false;
// Guard the async resolve against a wallet lock/switch between submit and callback: if the chat
// session was reset in between, this echo belongs to a stale session — don't touch the new store.
const auto sessionGen = chat_session_generation_;
if (lite_wallet_) {
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);
if (ok && sessionGen == chat_session_generation_)
chat_service_.resolveOutgoing(echoLocalId, chat::ChatDelivery::Sent);
return ok;
}
@@ -2947,7 +2952,10 @@ bool App::broadcastChatMemos(const chat::OutgoingChatMemos& memos, const std::st
return false;
}
const double fee = std::max(settings_ ? settings_->getDefaultFee() : kChatMinFeeDrgx, kChatMinFeeDrgx);
// 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
// (which may be higher). It's also well above the node's min relay fee.
const double fee = 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.
@@ -2973,7 +2981,8 @@ bool App::broadcastChatMemos(const chat::OutgoingChatMemos& memos, const std::st
// 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,
[this, echoLocalId](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
chat_service_.resolveOutgoing(echoLocalId,
ok ? chat::ChatDelivery::Sent : chat::ChatDelivery::Failed);
});
@@ -3058,13 +3067,22 @@ void App::startChatConversation(const std::string& peerZaddr, const std::string&
{
if (!chat::hushChatFeatureEnabledAtBuild() || !chat_service_.hasIdentity()) return;
if (peerZaddr.empty() || text.empty()) return;
sendContactRequestForCid(generateChatLocalId("", 16), peerZaddr, text); // new opaque cid
}
// Compose + broadcast a contact request into a SPECIFIC conversation. startChatConversation() mints a
// fresh cid; a Retry of a failed request reuses its existing cid so it stays in the same thread.
void App::sendContactRequestForCid(const std::string& cid, const std::string& peerZaddr,
const std::string& text)
{
if (!chat::hushChatFeatureEnabledAtBuild() || !chat_service_.hasIdentity()) return;
if (cid.empty() || peerZaddr.empty() || text.empty()) return;
const std::string myReply = chatReplyZaddr();
if (myReply.empty()) {
ui::Notifications::instance().error(TR("chat_toast_no_zaddr"));
return;
}
const std::string cid = generateChatLocalId("", 16); // opaque per-conversation id
chat::OutgoingChatMemos memos;
if (chat_service_.composeContactRequest(myReply, peerZaddr, cid, text, memos)
!= chat::ChatComposeStatus::Ok) {