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:
@@ -17,16 +17,18 @@ std::string buildHeaderMemo(const std::string& replyZaddr,
|
|||||||
const char* type,
|
const char* type,
|
||||||
const std::string& streamHeaderHex,
|
const std::string& streamHeaderHex,
|
||||||
const std::string& publicKeyHex,
|
const std::string& publicKeyHex,
|
||||||
std::int64_t sentAt)
|
std::int64_t sentAt,
|
||||||
|
const std::string& op = "")
|
||||||
{
|
{
|
||||||
nlohmann::json header;
|
nlohmann::json header;
|
||||||
header["h"] = 1; // header number (>= 1)
|
header["h"] = 1; // header number (>= 1)
|
||||||
header["v"] = kHushChatSupportedVersion; // 0
|
header["v"] = kHushChatSupportedVersion; // 0
|
||||||
header["z"] = replyZaddr; // where the peer should reply (my address)
|
header["z"] = replyZaddr; // where the peer should reply (my address)
|
||||||
header["cid"] = conversationId;
|
header["cid"] = conversationId;
|
||||||
header["t"] = type; // "Memo" or "Cont"
|
header["t"] = type; // "Memo" / "Cont" / "Ctrl"
|
||||||
header["e"] = streamHeaderHex; // 48-hex secretstream header (Memo) / "" (Cont)
|
header["e"] = streamHeaderHex; // 48-hex secretstream header (Memo) / "" (Cont, Ctrl)
|
||||||
header["p"] = publicKeyHex; // my 64-hex crypto_kx public key
|
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
|
if (sentAt > 0) header["ts"] = sentAt; // optional sender compose time (Unix s) — receiver shows this
|
||||||
return header.dump();
|
return header.dump();
|
||||||
}
|
}
|
||||||
@@ -109,4 +111,33 @@ ChatComposeStatus buildOutgoingContactRequest(const std::string& myPublicKeyHex,
|
|||||||
return ChatComposeStatus::Ok;
|
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
|
} // namespace dragonx::chat
|
||||||
|
|||||||
@@ -63,4 +63,15 @@ ChatComposeStatus buildOutgoingContactRequest(const std::string& myPublicKeyHex,
|
|||||||
const std::string& requestText,
|
const std::string& requestText,
|
||||||
OutgoingChatMemos& out);
|
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
|
} // namespace dragonx::chat
|
||||||
|
|||||||
@@ -15049,6 +15049,30 @@ void testHushChatControlMemo()
|
|||||||
tx3.outputs.push_back(HushChatMemoOutput{0, unknown});
|
tx3.outputs.push_back(HushChatMemoOutput{0, unknown});
|
||||||
tx3.outputs.push_back(HushChatMemoOutput{1, "x"});
|
tx3.outputs.push_back(HushChatMemoOutput{1, "x"});
|
||||||
EXPECT_EQ((int)extractHushChatTransactionMetadata(tx3, true).metadata.size(), 0);
|
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.
|
// ChatService: metadata batch -> decrypt -> threaded, deduped in-memory store.
|
||||||
|
|||||||
Reference in New Issue
Block a user