feat(chat): broadcast a tier-3 message across its transactions under one echo
Send side of tier-3 larger messages. sendChatMessage now composes via composeMessageParts (splitOutgoingMessage), which returns one OutgoingChatMemos per transaction, and enqueues each on the existing note-buffer coordinator — so the parts drain sequentially against verified notes exactly like ordinary chat sends, with no fund-path change (each part is the same pinned 0-value chatSendOutputs z_sendmany). A single echo bubble represents the whole message. A new per-echo outstanding- part counter holds it at "Sending" until the LAST part is broadcast, then flips it to Sent; if any part hits a hard failure the remaining queued parts are purged (a message missing a slice can never reassemble, so sending the rest only wastes fees) and the whole message is failed once — its existing Retry recomposes and re-splits from the body. A transient-funds deferral still retries the part in place. The counter map is cleared alongside the send queue on a wallet reset. Inert until the composer cap is raised (the current 4000-char cap yields a single tier-2 transaction); that + the multi-transaction composer indicator land next. Build + tests green. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -1095,6 +1095,10 @@ private:
|
|||||||
};
|
};
|
||||||
LiteInflightOp inflight_op_; // the single chat/split op currently on the send channel
|
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
|
std::deque<QueuedChatOp> chat_send_queue_; // chat/contact sends awaiting a free channel + verified note
|
||||||
|
// Larger-messages tier 3: a message split across several transactions shares one echo. This counts the
|
||||||
|
// not-yet-broadcast parts per echo id; the echo flips to Sent only when the last part lands, and to
|
||||||
|
// Failed (with the remaining parts purged) if any part fails. Absent/one ⇒ an ordinary single-tx send.
|
||||||
|
std::unordered_map<std::string, int> chat_send_parts_remaining_;
|
||||||
// Note-availability estimate between refreshes/scans: reset from a fresh count, decremented on each chat
|
// 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
|
// 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.
|
// a transient-funds failure so we stop draining until the next refresh/scan restores the truth.
|
||||||
|
|||||||
+37
-8
@@ -4255,6 +4255,7 @@ void App::resetChatSession()
|
|||||||
auto orphanedSend = std::move(lite_send_callback_);
|
auto orphanedSend = std::move(lite_send_callback_);
|
||||||
lite_send_callback_ = nullptr;
|
lite_send_callback_ = nullptr;
|
||||||
chat_send_queue_.clear();
|
chat_send_queue_.clear();
|
||||||
|
chat_send_parts_remaining_.clear(); // no queued parts survive a reset — drop their outstanding counts
|
||||||
inflight_op_ = LiteInflightOp{};
|
inflight_op_ = LiteInflightOp{};
|
||||||
chat_verified_note_budget_ = 0;
|
chat_verified_note_budget_ = 0;
|
||||||
chat_pipeline_note_count_ = 0;
|
chat_pipeline_note_count_ = 0;
|
||||||
@@ -4659,10 +4660,12 @@ void App::sendChatMessage(const std::string& conversationId, const std::string&
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
chat::OutgoingChatMemos memos;
|
// Compose into one OutgoingChatMemos per transaction: short messages yield one (tier 1/2), a long one
|
||||||
|
// several (tier 3), each a self-contained chat send carrying a slice of the one ciphertext.
|
||||||
|
std::vector<chat::OutgoingChatMemos> parts;
|
||||||
const auto composeStatus =
|
const auto composeStatus =
|
||||||
chat_service_.composeMessage(myReply, peerPubKey, peerZaddr, conversationId, text, memos);
|
chat_service_.composeMessageParts(myReply, peerPubKey, peerZaddr, conversationId, text, parts);
|
||||||
if (composeStatus != chat::ChatComposeStatus::Ok) {
|
if (composeStatus != chat::ChatComposeStatus::Ok || parts.empty()) {
|
||||||
ui::Notifications::instance().error(TR(composeStatus == chat::ChatComposeStatus::TooLong
|
ui::Notifications::instance().error(TR(composeStatus == chat::ChatComposeStatus::TooLong
|
||||||
? "chat_toast_too_long" : "chat_toast_compose_failed"));
|
? "chat_toast_too_long" : "chat_toast_compose_failed"));
|
||||||
return;
|
return;
|
||||||
@@ -4679,9 +4682,14 @@ 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
|
||||||
// Both variants: hand to the note-buffer coordinator, which drains the queue against verified notes
|
// A tier-3 message rides several transactions under one echo: track the remaining parts so the echo
|
||||||
|
// flips to Sent only when the LAST part lands (see onChatBroadcastResult). A single-part send is left
|
||||||
|
// out of the map and resolves immediately as before.
|
||||||
|
if (parts.size() > 1) chat_send_parts_remaining_[echo.txid] = static_cast<int>(parts.size());
|
||||||
|
// Hand each part to the note-buffer coordinator, which drains the queue against verified notes
|
||||||
// (building/refilling the buffer as needed) and resolves the echo Sent/Failed from the REAL result.
|
// (building/refilling the buffer as needed) and resolves the echo Sent/Failed from the REAL result.
|
||||||
enqueueChatSend(LiteOpKind::ChatSend, memos, echo.txid);
|
for (auto& p : parts)
|
||||||
|
enqueueChatSend(LiteOpKind::ChatSend, p, echo.txid);
|
||||||
}
|
}
|
||||||
|
|
||||||
void App::sendChatControl(const std::string& conversationId, const std::string& op, const std::string& label)
|
void App::sendChatControl(const std::string& conversationId, const std::string& op, const std::string& label)
|
||||||
@@ -5233,8 +5241,19 @@ void App::onChatBroadcastResult(const LiteInflightOp& op, bool ok, const std::st
|
|||||||
const bool frontMatches = !chat_send_queue_.empty()
|
const bool frontMatches = !chat_send_queue_.empty()
|
||||||
&& chat_send_queue_.front().echoLocalId == op.echoLocalId;
|
&& chat_send_queue_.front().echoLocalId == op.echoLocalId;
|
||||||
if (ok) {
|
if (ok) {
|
||||||
chat_service_.resolveOutgoing(op.echoLocalId, chat::ChatDelivery::Sent);
|
|
||||||
if (frontMatches) chat_send_queue_.pop_front();
|
if (frontMatches) chat_send_queue_.pop_front();
|
||||||
|
// Tier-3: a message split across several transactions keeps the echo at "Sending" until its LAST
|
||||||
|
// part has been broadcast. Decrement the outstanding-part count and only resolve on the last one.
|
||||||
|
auto it = chat_send_parts_remaining_.find(op.echoLocalId);
|
||||||
|
if (it != chat_send_parts_remaining_.end()) {
|
||||||
|
if (--it->second > 0) {
|
||||||
|
wallet::liteLog("chat: tier-3 part broadcast; " + std::to_string(it->second) +
|
||||||
|
" part(s) of this message still to send");
|
||||||
|
return; // not fully sent yet — echo stays "Sending"
|
||||||
|
}
|
||||||
|
chat_send_parts_remaining_.erase(it);
|
||||||
|
}
|
||||||
|
chat_service_.resolveOutgoing(op.echoLocalId, chat::ChatDelivery::Sent);
|
||||||
wallet::liteLog("chat: message broadcast (Sent); " + std::to_string(chat_send_queue_.size()) +
|
wallet::liteLog("chat: message broadcast (Sent); " + std::to_string(chat_send_queue_.size()) +
|
||||||
" still queued");
|
" still queued");
|
||||||
return;
|
return;
|
||||||
@@ -5242,16 +5261,26 @@ void App::onChatBroadcastResult(const LiteInflightOp& op, bool ok, const std::st
|
|||||||
if (frontMatches && isTransientVerifiedFundsError(error)
|
if (frontMatches && isTransientVerifiedFundsError(error)
|
||||||
&& chat_send_queue_.front().retries < kMaxChatSendRetries) {
|
&& chat_send_queue_.front().retries < kMaxChatSendRetries) {
|
||||||
// The optimistic budget was wrong (change not confirmed yet). Stop draining until the next refresh
|
// 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.
|
// restores the true verified-note count; keep the message queued ("Sending") and retry then. A
|
||||||
|
// tier-3 part defers in place like any other — its outstanding-part count is untouched.
|
||||||
chat_verified_note_budget_ = 0;
|
chat_verified_note_budget_ = 0;
|
||||||
++chat_send_queue_.front().retries;
|
++chat_send_queue_.front().retries;
|
||||||
wallet::liteLog("chat: send deferred — funds still confirming (retry " +
|
wallet::liteLog("chat: send deferred — funds still confirming (retry " +
|
||||||
std::to_string(chat_send_queue_.front().retries) + "); message stays 'Sending'");
|
std::to_string(chat_send_queue_.front().retries) + "); message stays 'Sending'");
|
||||||
return; // leave at front
|
return; // leave at front
|
||||||
}
|
}
|
||||||
|
// Hard failure. For a tier-3 message, drop its remaining queued parts: a message missing any slice can
|
||||||
|
// never reassemble, so broadcasting the rest only wastes fees. Fail the whole message once.
|
||||||
|
auto it = chat_send_parts_remaining_.find(op.echoLocalId);
|
||||||
|
if (it != chat_send_parts_remaining_.end()) {
|
||||||
|
chat_send_parts_remaining_.erase(it);
|
||||||
|
chat_send_queue_.erase(std::remove_if(chat_send_queue_.begin(), chat_send_queue_.end(),
|
||||||
|
[&](const QueuedChatOp& q) { return q.echoLocalId == op.echoLocalId; }), chat_send_queue_.end());
|
||||||
|
} else if (frontMatches) {
|
||||||
|
chat_send_queue_.pop_front();
|
||||||
|
}
|
||||||
chat_service_.resolveOutgoing(op.echoLocalId, chat::ChatDelivery::Failed,
|
chat_service_.resolveOutgoing(op.echoLocalId, chat::ChatDelivery::Failed,
|
||||||
util::friendlyRpcError(error).substr(0, 160)); // hard failure -> Retry affordance + reason
|
util::friendlyRpcError(error).substr(0, 160)); // hard failure -> Retry affordance + reason
|
||||||
if (frontMatches) chat_send_queue_.pop_front();
|
|
||||||
wallet::liteLog("chat: send FAILED: " + error);
|
wallet::liteLog("chat: send FAILED: " + error);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -304,6 +304,17 @@ ChatComposeStatus ChatService::composeMessage(const std::string& myReplyZaddr,
|
|||||||
peerPublicKeyHex, peerZaddr, conversationId, plaintext, out);
|
peerPublicKeyHex, peerZaddr, conversationId, plaintext, out);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
ChatComposeStatus ChatService::composeMessageParts(const std::string& myReplyZaddr,
|
||||||
|
const std::string& peerPublicKeyHex,
|
||||||
|
const std::string& peerZaddr,
|
||||||
|
const std::string& conversationId,
|
||||||
|
const std::string& plaintext,
|
||||||
|
std::vector<OutgoingChatMemos>& out) const {
|
||||||
|
if (!has_identity_) return ChatComposeStatus::MissingField;
|
||||||
|
return splitOutgoingMessage(identity_, chatIdentityPublicKeyHex(identity_), myReplyZaddr,
|
||||||
|
peerPublicKeyHex, peerZaddr, conversationId, plaintext, out);
|
||||||
|
}
|
||||||
|
|
||||||
ChatComposeStatus ChatService::composeContactRequest(const std::string& myReplyZaddr,
|
ChatComposeStatus ChatService::composeContactRequest(const std::string& myReplyZaddr,
|
||||||
const std::string& peerZaddr,
|
const std::string& peerZaddr,
|
||||||
const std::string& conversationId,
|
const std::string& conversationId,
|
||||||
|
|||||||
@@ -110,6 +110,15 @@ public:
|
|||||||
const std::string& conversationId,
|
const std::string& conversationId,
|
||||||
const std::string& plaintext,
|
const std::string& plaintext,
|
||||||
OutgoingChatMemos& out) const;
|
OutgoingChatMemos& out) const;
|
||||||
|
// Construct the outgoing memos for an ENCRYPTED message, splitting it across as many transactions as
|
||||||
|
// its length needs (larger-messages tier 3). Returns one OutgoingChatMemos per transaction to send in
|
||||||
|
// order; a short message yields a single element (identical to composeMessage).
|
||||||
|
ChatComposeStatus composeMessageParts(const std::string& myReplyZaddr,
|
||||||
|
const std::string& peerPublicKeyHex,
|
||||||
|
const std::string& peerZaddr,
|
||||||
|
const std::string& conversationId,
|
||||||
|
const std::string& plaintext,
|
||||||
|
std::vector<OutgoingChatMemos>& out) const;
|
||||||
// Construct the outgoing memos for a plaintext contact request (no peer key needed yet).
|
// Construct the outgoing memos for a plaintext contact request (no peer key needed yet).
|
||||||
ChatComposeStatus composeContactRequest(const std::string& myReplyZaddr,
|
ChatComposeStatus composeContactRequest(const std::string& myReplyZaddr,
|
||||||
const std::string& peerZaddr,
|
const std::string& peerZaddr,
|
||||||
|
|||||||
Reference in New Issue
Block a user