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:
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user