From 7320a98505151b979ca05eb0b0b3ddcc2ed3cc9e Mon Sep 17 00:00:00 2001 From: DanS Date: Tue, 20 Oct 2026 00:16:23 -0500 Subject: [PATCH] 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 --- src/chat/chat_outgoing.cpp | 37 ++++++++++++++++++++++++++++++++++--- src/chat/chat_outgoing.h | 11 +++++++++++ tests/test_phase4.cpp | 24 ++++++++++++++++++++++++ 3 files changed, 69 insertions(+), 3 deletions(-) diff --git a/src/chat/chat_outgoing.cpp b/src/chat/chat_outgoing.cpp index 3350d0c..e535642 100644 --- a/src/chat/chat_outgoing.cpp +++ b/src/chat/chat_outgoing.cpp @@ -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::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 diff --git a/src/chat/chat_outgoing.h b/src/chat/chat_outgoing.h index 42644ae..3af039c 100644 --- a/src/chat/chat_outgoing.h +++ b/src/chat/chat_outgoing.h @@ -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 diff --git a/tests/test_phase4.cpp b/tests/test_phase4.cpp index 7a50302..2719c90 100644 --- a/tests/test_phase4.cpp +++ b/tests/test_phase4.cpp @@ -15049,6 +15049,30 @@ void testHushChatControlMemo() tx3.outputs.push_back(HushChatMemoOutput{0, unknown}); tx3.outputs.push_back(HushChatMemoOutput{1, "x"}); EXPECT_EQ((int)extractHushChatTransactionMetadata(tx3, true).metadata.size(), 0); + + // Compose round-trip: buildOutgoingControl -> extract yields the same op/cid/label, self-addressed. + OutgoingChatMemos cm; + EXPECT_TRUE(buildOutgoingControl(r.public_key_hex, "zs-self", "conv-42", "del", "Bob", cm) + == ChatComposeStatus::Ok); + EXPECT_EQ(cm.recipientZaddr, std::string("zs-self")); + HushChatTransactionInput rt; rt.txid = "txid-ctrl-rt"; + rt.outputs.push_back({0, cm.headerMemo}); + rt.outputs.push_back({1, cm.payloadMemo}); + auto rx = extractHushChatTransactionMetadata(rt, true); + EXPECT_EQ((int)rx.metadata.size(), 1); + EXPECT_TRUE(rx.metadata[0].type == HushChatHeaderType::Control); + EXPECT_EQ(rx.metadata[0].op, std::string("del")); + EXPECT_EQ(rx.metadata[0].conversation_id, std::string("conv-42")); + EXPECT_EQ(rx.metadata[0].payload_memo, std::string("Bob")); + + // Compose guards: empty op -> MissingField, empty label -> EmptyBody, '{' label -> BadRequestText. + OutgoingChatMemos dummy; + EXPECT_TRUE(buildOutgoingControl(r.public_key_hex, "zs-self", "c", "", "Bob", dummy) + == ChatComposeStatus::MissingField); + EXPECT_TRUE(buildOutgoingControl(r.public_key_hex, "zs-self", "c", "del", "", dummy) + == ChatComposeStatus::EmptyBody); + EXPECT_TRUE(buildOutgoingControl(r.public_key_hex, "zs-self", "c", "del", "{x", dummy) + == ChatComposeStatus::BadRequestText); } // ChatService: metadata batch -> decrypt -> threaded, deduped in-memory store.