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:
2026-10-26 12:36:40 -05:00
co-authored by Claude Opus 4.8
parent 5e33bf2c41
commit e61772a825
4 changed files with 61 additions and 8 deletions
+4
View File
@@ -1095,6 +1095,10 @@ private:
};
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
// 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
// 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.
+37 -8
View File
@@ -4255,6 +4255,7 @@ void App::resetChatSession()
auto orphanedSend = std::move(lite_send_callback_);
lite_send_callback_ = nullptr;
chat_send_queue_.clear();
chat_send_parts_remaining_.clear(); // no queued parts survive a reset — drop their outstanding counts
inflight_op_ = LiteInflightOp{};
chat_verified_note_budget_ = 0;
chat_pipeline_note_count_ = 0;
@@ -4659,10 +4660,12 @@ void App::sendChatMessage(const std::string& conversationId, const std::string&
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 =
chat_service_.composeMessage(myReply, peerPubKey, peerZaddr, conversationId, text, memos);
if (composeStatus != chat::ChatComposeStatus::Ok) {
chat_service_.composeMessageParts(myReply, peerPubKey, peerZaddr, conversationId, text, parts);
if (composeStatus != chat::ChatComposeStatus::Ok || parts.empty()) {
ui::Notifications::instance().error(TR(composeStatus == chat::ChatComposeStatus::TooLong
? "chat_toast_too_long" : "chat_toast_compose_failed"));
return;
@@ -4679,9 +4682,14 @@ void App::sendChatMessage(const std::string& conversationId, const std::string&
echo.payload_position = 0;
echo.delivery = chat::ChatDelivery::Sending; // shows immediately; the broadcast callback resolves it
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.
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)
@@ -5233,8 +5241,19 @@ void App::onChatBroadcastResult(const LiteInflightOp& op, bool ok, const std::st
const bool frontMatches = !chat_send_queue_.empty()
&& chat_send_queue_.front().echoLocalId == op.echoLocalId;
if (ok) {
chat_service_.resolveOutgoing(op.echoLocalId, chat::ChatDelivery::Sent);
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()) +
" still queued");
return;
@@ -5242,16 +5261,26 @@ void App::onChatBroadcastResult(const LiteInflightOp& op, bool ok, const std::st
if (frontMatches && isTransientVerifiedFundsError(error)
&& chat_send_queue_.front().retries < kMaxChatSendRetries) {
// 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_send_queue_.front().retries;
wallet::liteLog("chat: send deferred — funds still confirming (retry " +
std::to_string(chat_send_queue_.front().retries) + "); message stays 'Sending'");
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,
util::friendlyRpcError(error).substr(0, 160)); // hard failure -> Retry affordance + reason
if (frontMatches) chat_send_queue_.pop_front();
wallet::liteLog("chat: send FAILED: " + error);
}
+11
View File
@@ -304,6 +304,17 @@ ChatComposeStatus ChatService::composeMessage(const std::string& myReplyZaddr,
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,
const std::string& peerZaddr,
const std::string& conversationId,
+9
View File
@@ -110,6 +110,15 @@ public:
const std::string& conversationId,
const std::string& plaintext,
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).
ChatComposeStatus composeContactRequest(const std::string& myReplyZaddr,
const std::string& peerZaddr,