diff --git a/src/chat/chat_service.cpp b/src/chat/chat_service.cpp index acdcc69..fa93f08 100644 --- a/src/chat/chat_service.cpp +++ b/src/chat/chat_service.cpp @@ -40,6 +40,16 @@ int ChatService::ingest(const std::vector& metadata int added = 0; std::vector fresh; // persisted once after the loop (one transaction) for (const auto& meta : metadata) { + // A self-addressed control signal (delete / restore / ...). Reduce it and move on: it is never a + // message, and it must be processed even though WE are its sender — unlike ordinary self-sent memos, + // which the guard below skips. The label (peer hint) rides the payload. + if (meta.type == HushChatHeaderType::Control) { + const auto ctlTimeIt = txTimestamps.find(meta.txid); + const std::int64_t ctlRef = ctlTimeIt != txTimestamps.end() ? ctlTimeIt->second : fallbackTimestamp; + applyControl(meta.conversation_id, meta.op, meta.payload_memo, + meta.sent_at > 0 ? meta.sent_at : ctlRef, meta.txid); + continue; + } // Already stored: append would refuse it. Every scan re-sends the whole history, so this skips the // decrypt for all but the new messages. if (store_.contains(meta.txid, meta.payload_position)) continue; @@ -77,6 +87,7 @@ int ChatService::ingest(const std::vector& metadata // deleted with "revive on new message", so only that exact message is skipped — a new message // in the same conversation has a different txid and flows through normally. if (blocked_pred_ && blocked_pred_(message.conversation_id)) continue; + if (conversationHiddenOnChain(message.conversation_id)) continue; // hidden by an on-chain delete if (db_ && db_->isTombstoned(message)) continue; if (meta.type == HushChatHeaderType::ContactRequest) { @@ -116,6 +127,13 @@ int ChatService::ingest(const std::vector& metadata } } if (db_ && !fresh.empty()) db_->appendBatch(fresh); + // Enforce on-chain deletions against the store view: erase any conversation whose latest control op is + // "del". This catches rows appended earlier in this very batch (before their delete signal was seen in + // the metadata order) and rows rehydrated from the DB at startup before any control was reduced. The DB + // rows are left intact so a later "res" — or a rescan — can restore the thread. + for (const auto& entry : control_) { + if (entry.second.op == "del") store_.eraseConversation(entry.first); + } return added; } @@ -146,6 +164,33 @@ bool ChatService::deleteConversation(const std::string& conversationId, bool blo return true; } +void ChatService::applyControl(const std::string& cid, const std::string& op, const std::string& label, + std::int64_t timestamp, const std::string& txid) { + auto it = control_.find(cid); + if (it != control_.end()) { + const ControlState& cur = it->second; + // A later signal wins; equal timestamps break by txid so the reduction is deterministic and a + // replay of an older op can never flip the resolved state. + if (timestamp < cur.timestamp || (timestamp == cur.timestamp && txid <= cur.txid)) return; + it->second = ControlState{op, label, timestamp, txid}; + } else { + control_.emplace(cid, ControlState{op, label, timestamp, txid}); + } +} + +bool ChatService::conversationHiddenOnChain(const std::string& conversationId) const { + auto it = control_.find(conversationId); + return it != control_.end() && it->second.op == "del"; +} + +std::vector> ChatService::onChainDeletedConversations() const { + std::vector> out; + for (const auto& entry : control_) { + if (entry.second.op == "del") out.emplace_back(entry.first, entry.second.label); + } + return out; +} + std::string ChatService::identityPublicKeyHex() const { if (!has_identity_) return {}; return chatIdentityPublicKeyHex(identity_); diff --git a/src/chat/chat_service.h b/src/chat/chat_service.h index dee791d..e7284f5 100644 --- a/src/chat/chat_service.h +++ b/src/chat/chat_service.h @@ -15,6 +15,7 @@ #include #include #include +#include #include namespace dragonx::chat { @@ -70,6 +71,16 @@ public: // persisted rows couldn't be removed, so the caller can avoid a store/DB divergence. bool deleteConversation(const std::string& conversationId, bool block); + // On-chain control signals (self-addressed Control memos) reduced per conversation: the latest op, + // by sender compose time with a txid tiebreak, wins. A conversation whose latest op is "del" is hidden + // on-chain — ingest() drops its messages and erases it from the store view, on every device and on a + // fresh seed import — until a later "res" restores it. The state is re-derived from chain each session + // (the control memos re-harvest on every scan), so it needs no local persistence. + bool conversationHiddenOnChain(const std::string& conversationId) const; + // Conversations whose latest on-chain control op is "del" (for the restore picker), each paired with + // the label carried in its control memo (the peer address / display name at delete time). + std::vector> onChainDeletedConversations() const; + // --- Outgoing (compose) --- // My chat public key (hex), or "" without an identity — goes in an outgoing header's "p". std::string identityPublicKeyHex() const; @@ -112,6 +123,18 @@ private: ChatStore store_; ChatDatabase* db_ = nullptr; // optional; not owned std::function blocked_pred_; // true => cid is blocked (drop its messages) + + // Reduced on-chain control state, cid -> latest signal. Not persisted (re-derived from chain). + struct ControlState { + std::string op; // "del" / "res" / future ops + std::string label; // the control memo's payload hint (peer address / display name) + std::int64_t timestamp = 0; + std::string txid; + }; + std::unordered_map control_; + // Monotonic reduce: a later (timestamp, txid) wins; a replay of an older op never flips the state. + void applyControl(const std::string& cid, const std::string& op, const std::string& label, + std::int64_t timestamp, const std::string& txid); }; } // namespace dragonx::chat diff --git a/tests/test_phase4.cpp b/tests/test_phase4.cpp index 2719c90..f327a9a 100644 --- a/tests/test_phase4.cpp +++ b/tests/test_phase4.cpp @@ -15075,6 +15075,66 @@ void testHushChatControlMemo() == ChatComposeStatus::BadRequestText); } +// Control reducer: a self-addressed delete hides a conversation (dropped from ingest + erased from the +// store view); a later restore un-hides it; the reduction is monotonic by (timestamp, txid). +void testHushChatControlReducer() +{ + using namespace dragonx::chat; + ChatKeyPair me, peer; + ChatIdentityResult rm = deriveChatIdentityFromSecret("ctrl-reduce", me, true); + ChatIdentityResult rp = deriveChatIdentityFromSecret("ctrl-peer", peer, true); + EXPECT_TRUE(rm.status == ChatIdentityStatus::Ready && rp.status == ChatIdentityStatus::Ready); + ChatService svc; svc.setIdentity(me); + const std::string cid = "conv-x"; + const std::int64_t now = static_cast(std::time(nullptr)); + + auto feed = [&](const OutgoingChatMemos& m, const std::string& txid, std::int64_t ts) { + HushChatTransactionInput tx; tx.txid = txid; + tx.outputs.push_back({0, m.headerMemo}); + tx.outputs.push_back({1, m.payloadMemo}); + auto ex = extractHushChatTransactionMetadata(tx, true); + return svc.ingest(ex.metadata, {{txid, ts}}, ts); + }; + + // A peer -> me message is stored and not hidden. + OutgoingChatMemos pm; + EXPECT_TRUE(buildOutgoingMessage(peer, rp.public_key_hex, "zs-peer", rm.public_key_hex, "zs-me", cid, + "hi", pm) == ChatComposeStatus::Ok); + EXPECT_EQ(feed(pm, "m1", now), 1); + EXPECT_EQ((int)svc.store().conversation(cid).size(), 1); + EXPECT_TRUE(!svc.conversationHiddenOnChain(cid)); + + // A self-addressed delete hides it and erases it from the store view (adds no message). + OutgoingChatMemos dm; + EXPECT_TRUE(buildOutgoingControl(rm.public_key_hex, "zs-me", cid, "del", "Peer", dm) + == ChatComposeStatus::Ok); + EXPECT_EQ(feed(dm, "d1", now + 10), 0); + EXPECT_TRUE(svc.conversationHiddenOnChain(cid)); + EXPECT_EQ((int)svc.store().conversation(cid).size(), 0); + auto del = svc.onChainDeletedConversations(); + EXPECT_EQ((int)del.size(), 1); + EXPECT_EQ(del[0].first, cid); + EXPECT_EQ(del[0].second, std::string("Peer")); + + // Re-ingesting the peer message while hidden does not resurface it. + EXPECT_EQ(feed(pm, "m1", now), 0); + EXPECT_EQ((int)svc.store().conversation(cid).size(), 0); + + // A later restore un-hides it; a rescan then re-imports the message. + OutgoingChatMemos rmemo; + EXPECT_TRUE(buildOutgoingControl(rm.public_key_hex, "zs-me", cid, "res", "Peer", rmemo) + == ChatComposeStatus::Ok); + feed(rmemo, "r1", now + 20); + EXPECT_TRUE(!svc.conversationHiddenOnChain(cid)); + EXPECT_EQ(feed(pm, "m1", now), 1); + EXPECT_EQ((int)svc.store().conversation(cid).size(), 1); + + // Monotonic: replaying the older delete does not re-hide. + feed(dm, "d1", now + 10); + EXPECT_TRUE(!svc.conversationHiddenOnChain(cid)); + EXPECT_EQ((int)svc.store().conversation(cid).size(), 1); +} + // ChatService: metadata batch -> decrypt -> threaded, deduped in-memory store. void testHushChatService() { @@ -20026,6 +20086,7 @@ int main() testHushChatCrypto(); testHushChatReceivePath(); testHushChatControlMemo(); + testHushChatControlReducer(); testHushChatService(); testHushChatDatabase(); testHushChatDeleteConversation();