From 529286777f66d83e614e1f1578a3ce05ac374be9 Mon Sep 17 00:00:00 2001 From: DanS Date: Tue, 20 Oct 2026 00:12:23 -0500 Subject: [PATCH] feat(chat): add a Control memo header type for on-chain conversation signals MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Introduces HushChatHeaderType::Control — a self-addressed control signal (delete / restore / mark-sensitive) that rides a chat tx as a header memo carrying an `op`, paired with a plaintext label payload so it groups like a normal header/payload pair. The op VALUE is not restricted at parse time so a future op reaches newer clients and is ignored by older ones; unknown header types still fail-closed (grouped as an invalid header, never surfaced, never a crash), preserving forward-compat. Foundation for the on-chain chat delete/restore feature (Phase A). Co-Authored-By: Claude Opus 4.8 --- src/chat/chat_protocol.cpp | 11 ++++++++++ src/chat/chat_protocol.h | 5 ++++- tests/test_phase4.cpp | 43 ++++++++++++++++++++++++++++++++++++++ 3 files changed, 58 insertions(+), 1 deletion(-) diff --git a/src/chat/chat_protocol.cpp b/src/chat/chat_protocol.cpp index 13b4b92..2cfc9b7 100644 --- a/src/chat/chat_protocol.cpp +++ b/src/chat/chat_protocol.cpp @@ -81,6 +81,8 @@ const char* hushChatHeaderTypeName(HushChatHeaderType type) return "Memo"; case HushChatHeaderType::ContactRequest: return "Cont"; + case HushChatHeaderType::Control: + return "Ctrl"; } return "Unknown"; } @@ -138,7 +140,15 @@ HushChatHeaderParseResult parseHushChatHeaderMemo(const std::string& memo) header.type = HushChatHeaderType::Message; } else if (type == "Cont") { header.type = HushChatHeaderType::ContactRequest; + } else if (type == "Ctrl") { + header.type = HushChatHeaderType::Control; + // A control signal carries a non-empty op ("del"/"res"/...); the op VALUE isn't restricted here so + // a future op reaches newer clients and is ignored by the reducer on older ones (forward-compat). + if (!readRequiredString(object, "op", header.op, error)) return fail(error); + if (header.op.empty()) return fail("control op is empty"); } else { + // Unknown type (older client seeing a newer memo type): fail here so it is grouped as an invalid + // header and ignored, never crashing. Keep this the catch-all. return fail("unknown HushChat header type"); } @@ -299,6 +309,7 @@ HushChatTransactionExtractionResult extractHushChatTransactionMetadata( HushChatTransactionMetadata metadata; metadata.txid = transaction.txid; metadata.type = pair.header.type; + metadata.op = pair.header.op; // Control only; empty for Message/ContactRequest metadata.conversation_id = pair.header.conversation_id; metadata.reply_zaddr = pair.header.reply_zaddr; metadata.header_position = pair.header_position; diff --git a/src/chat/chat_protocol.h b/src/chat/chat_protocol.h index e7bc77f..f10e909 100644 --- a/src/chat/chat_protocol.h +++ b/src/chat/chat_protocol.h @@ -12,7 +12,8 @@ namespace dragonx::chat { enum class HushChatHeaderType { Message, - ContactRequest + ContactRequest, + Control // a self-addressed control signal (delete / restore / mark sensitive); carries an `op` }; struct HushChatHeader { @@ -21,6 +22,7 @@ struct HushChatHeader { std::string reply_zaddr; std::string conversation_id; HushChatHeaderType type = HushChatHeaderType::Message; + std::string op; // Control only ("del" / "res" / "sensitive" / "unsensitive" / future ops); empty otherwise std::string secretstream_header_hex; std::string public_key_hex; // Optional sender-stamped compose time (header "ts", Unix seconds). 0 = absent (older sender) → the @@ -73,6 +75,7 @@ struct HushChatTransactionInput { struct HushChatTransactionMetadata { std::string txid; HushChatHeaderType type = HushChatHeaderType::Message; + std::string op; // Control only: the control op ("del"/"res"/...); empty otherwise std::string conversation_id; std::string reply_zaddr; std::size_t header_position = 0; diff --git a/tests/test_phase4.cpp b/tests/test_phase4.cpp index d0ce1bd..7a50302 100644 --- a/tests/test_phase4.cpp +++ b/tests/test_phase4.cpp @@ -15009,6 +15009,48 @@ void testHushChatReceivePath() EXPECT_EQ((int)off.metadata.size(), 0); } +// Control memos (self-addressed delete/restore/sensitive signal): a new header type carrying an `op`, +// parsed + surfaced like a message pair, with unknown future types ignored (forward-compat). +void testHushChatControlMemo() +{ + using namespace dragonx::chat; + ChatKeyPair me; + ChatIdentityResult r = deriveChatIdentityFromSecret("ctrl-self", me, true); + EXPECT_TRUE(r.status == ChatIdentityStatus::Ready); + + // A self-addressed control memo: header (t:"Ctrl", op:"del", e:"") + a plaintext label payload. + std::string headerMemo = std::string("{\"cid\":\"conv-42\",\"e\":\"\",\"h\":1,\"op\":\"del\",\"p\":\"") + + r.public_key_hex + "\",\"t\":\"Ctrl\",\"v\":0,\"z\":\"zs-self\"}"; + HushChatTransactionInput tx; + tx.txid = "txid-ctrl-1"; + tx.outputs.push_back(HushChatMemoOutput{0, headerMemo}); + tx.outputs.push_back(HushChatMemoOutput{1, "Bob"}); // plaintext label payload + + auto ext = extractHushChatTransactionMetadata(tx, true); + EXPECT_EQ((int)ext.metadata.size(), 1); + const auto& m = ext.metadata[0]; + EXPECT_TRUE(m.type == HushChatHeaderType::Control); + EXPECT_EQ(m.op, std::string("del")); + EXPECT_EQ(m.conversation_id, std::string("conv-42")); + EXPECT_EQ(m.payload_memo, std::string("Bob")); + + // A control header with no "op" is rejected (invalid header -> no metadata). + std::string noOp = std::string("{\"cid\":\"c\",\"e\":\"\",\"h\":1,\"p\":\"") + + r.public_key_hex + "\",\"t\":\"Ctrl\",\"v\":0,\"z\":\"zs-self\"}"; + HushChatTransactionInput tx2; tx2.txid = "txid-ctrl-2"; + tx2.outputs.push_back(HushChatMemoOutput{0, noOp}); + tx2.outputs.push_back(HushChatMemoOutput{1, "x"}); + EXPECT_EQ((int)extractHushChatTransactionMetadata(tx2, true).metadata.size(), 0); + + // Forward-compat: an unknown future header type is ignored, never surfaced or crashing. + std::string unknown = std::string("{\"cid\":\"c\",\"e\":\"\",\"h\":1,\"p\":\"") + + r.public_key_hex + "\",\"t\":\"Zzz9\",\"v\":0,\"z\":\"zs-self\"}"; + HushChatTransactionInput tx3; tx3.txid = "txid-ctrl-3"; + tx3.outputs.push_back(HushChatMemoOutput{0, unknown}); + tx3.outputs.push_back(HushChatMemoOutput{1, "x"}); + EXPECT_EQ((int)extractHushChatTransactionMetadata(tx3, true).metadata.size(), 0); +} + // ChatService: metadata batch -> decrypt -> threaded, deduped in-memory store. void testHushChatService() { @@ -19959,6 +20001,7 @@ int main() #endif testHushChatCrypto(); testHushChatReceivePath(); + testHushChatControlMemo(); testHushChatService(); testHushChatDatabase(); testHushChatDeleteConversation();