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
+24
View File
@@ -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.