feat(chat): compose self-addressed Control memos (buildOutgoingControl)

Adds buildOutgoingControl: builds the two 0-value memo outputs for a control
signal (a "Ctrl" header carrying `op` + a plaintext label payload) addressed to
the sender's OWN z-address, so only this wallet's seed harvests it and it never
reaches the peer. buildHeaderMemo gains an optional `op` (emitted only for
Control; existing Memo/Cont headers stay byte-identical). Round-trip + guard
tested through the receive parser.

Phase A, step 2 (compose) of on-chain chat delete/restore.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-10-20 00:16:23 -05:00
co-authored by Claude Opus 4.8
parent 529286777f
commit 7320a98505
3 changed files with 69 additions and 3 deletions
+34 -3
View File
@@ -17,16 +17,18 @@ std::string buildHeaderMemo(const std::string& replyZaddr,
const char* type,
const std::string& streamHeaderHex,
const std::string& publicKeyHex,
std::int64_t sentAt)
std::int64_t sentAt,
const std::string& op = "")
{
nlohmann::json header;
header["h"] = 1; // header number (>= 1)
header["v"] = kHushChatSupportedVersion; // 0
header["z"] = replyZaddr; // where the peer should reply (my address)
header["cid"] = conversationId;
header["t"] = type; // "Memo" or "Cont"
header["e"] = streamHeaderHex; // 48-hex secretstream header (Memo) / "" (Cont)
header["t"] = type; // "Memo" / "Cont" / "Ctrl"
header["e"] = streamHeaderHex; // 48-hex secretstream header (Memo) / "" (Cont, Ctrl)
header["p"] = publicKeyHex; // my 64-hex crypto_kx public key
if (!op.empty()) header["op"] = op; // Control only; alphabetically sorts to cid,e,h,op,p,t,v,z
if (sentAt > 0) header["ts"] = sentAt; // optional sender compose time (Unix s) — receiver shows this
return header.dump();
}
@@ -109,4 +111,33 @@ ChatComposeStatus buildOutgoingContactRequest(const std::string& myPublicKeyHex,
return ChatComposeStatus::Ok;
}
ChatComposeStatus buildOutgoingControl(const std::string& myPublicKeyHex,
const std::string& selfZaddr,
const std::string& conversationId,
const std::string& op,
const std::string& label,
OutgoingChatMemos& out)
{
// A control signal is addressed to SELF: only this wallet (any device holding the seed) harvests
// it, and it never reaches the peer. op carries the action; the label is a plaintext hint (peer
// address / display name) so a fresh import can show the conversation it refers to.
if (label.empty()) return ChatComposeStatus::EmptyBody;
if (label.front() == '{') return ChatComposeStatus::BadRequestText; // would parse as a header
if (!present(op) || !present(myPublicKeyHex) || !present(selfZaddr) || !present(conversationId)) {
return ChatComposeStatus::MissingField;
}
OutgoingChatMemos memos;
memos.recipientZaddr = selfZaddr;
memos.headerMemo = buildHeaderMemo(selfZaddr, conversationId, "Ctrl", "", myPublicKeyHex,
static_cast<std::int64_t>(std::time(nullptr)), op);
memos.payloadMemo = label;
if (memos.headerMemo.size() > kHushChatMemoByteLimit ||
memos.payloadMemo.size() > kHushChatMemoByteLimit) {
return ChatComposeStatus::TooLong;
}
out = std::move(memos);
return ChatComposeStatus::Ok;
}
} // namespace dragonx::chat
+11
View File
@@ -63,4 +63,15 @@ ChatComposeStatus buildOutgoingContactRequest(const std::string& myPublicKeyHex,
const std::string& requestText,
OutgoingChatMemos& out);
// Build a self-addressed control signal (delete / restore / mark-sensitive). It is sent to your OWN
// z-address, so only this wallet's seed can harvest it and it never reaches the peer. `op` is the
// action ("del"/"res"/...); `label` is a plaintext hint (peer address or name) carried as the payload
// so a fresh seed import can name the conversation the signal refers to.
ChatComposeStatus buildOutgoingControl(const std::string& myPublicKeyHex,
const std::string& selfZaddr,
const std::string& conversationId,
const std::string& op,
const std::string& label,
OutgoingChatMemos& out);
} // namespace dragonx::chat