feat(chat): reduce on-chain Control memos into per-conversation hide state
ingest() now routes Control memos (self-addressed, so processed ahead of the "skip our own memos" guard) into a per-cid reducer that keeps the latest op by (compose-time, txid) — monotonic, so a replayed older op never flips state. A conversation whose latest op is "del" is hidden: its messages are dropped from ingest and erased from the store view (even rows loaded from the DB before the signal was reduced), on every device and on a fresh seed import, until a later "res" restores it. The DB rows are left intact so restore (or a rescan) brings the thread back. State is re-derived from chain each session; no local persistence. Adds conversationHiddenOnChain() + onChainDeletedConversations() (the restore picker's source), fully reducer-tested. Phase A, step 3 (reducer) of on-chain chat delete/restore. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -40,6 +40,16 @@ int ChatService::ingest(const std::vector<HushChatTransactionMetadata>& metadata
|
|||||||
int added = 0;
|
int added = 0;
|
||||||
std::vector<ChatMessage> fresh; // persisted once after the loop (one transaction)
|
std::vector<ChatMessage> fresh; // persisted once after the loop (one transaction)
|
||||||
for (const auto& meta : metadata) {
|
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
|
// Already stored: append would refuse it. Every scan re-sends the whole history, so this skips the
|
||||||
// decrypt for all but the new messages.
|
// decrypt for all but the new messages.
|
||||||
if (store_.contains(meta.txid, meta.payload_position)) continue;
|
if (store_.contains(meta.txid, meta.payload_position)) continue;
|
||||||
@@ -77,6 +87,7 @@ int ChatService::ingest(const std::vector<HushChatTransactionMetadata>& metadata
|
|||||||
// deleted with "revive on new message", so only that exact message is skipped — a new message
|
// 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.
|
// in the same conversation has a different txid and flows through normally.
|
||||||
if (blocked_pred_ && blocked_pred_(message.conversation_id)) continue;
|
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 (db_ && db_->isTombstoned(message)) continue;
|
||||||
|
|
||||||
if (meta.type == HushChatHeaderType::ContactRequest) {
|
if (meta.type == HushChatHeaderType::ContactRequest) {
|
||||||
@@ -116,6 +127,13 @@ int ChatService::ingest(const std::vector<HushChatTransactionMetadata>& metadata
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (db_ && !fresh.empty()) db_->appendBatch(fresh);
|
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;
|
return added;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -146,6 +164,33 @@ bool ChatService::deleteConversation(const std::string& conversationId, bool blo
|
|||||||
return true;
|
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<std::pair<std::string, std::string>> ChatService::onChainDeletedConversations() const {
|
||||||
|
std::vector<std::pair<std::string, std::string>> 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 {
|
std::string ChatService::identityPublicKeyHex() const {
|
||||||
if (!has_identity_) return {};
|
if (!has_identity_) return {};
|
||||||
return chatIdentityPublicKeyHex(identity_);
|
return chatIdentityPublicKeyHex(identity_);
|
||||||
|
|||||||
@@ -15,6 +15,7 @@
|
|||||||
#include <string>
|
#include <string>
|
||||||
#include <unordered_map>
|
#include <unordered_map>
|
||||||
#include <unordered_set>
|
#include <unordered_set>
|
||||||
|
#include <utility>
|
||||||
#include <vector>
|
#include <vector>
|
||||||
|
|
||||||
namespace dragonx::chat {
|
namespace dragonx::chat {
|
||||||
@@ -70,6 +71,16 @@ public:
|
|||||||
// persisted rows couldn't be removed, so the caller can avoid a store/DB divergence.
|
// persisted rows couldn't be removed, so the caller can avoid a store/DB divergence.
|
||||||
bool deleteConversation(const std::string& conversationId, bool block);
|
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<std::pair<std::string, std::string>> onChainDeletedConversations() const;
|
||||||
|
|
||||||
// --- Outgoing (compose) ---
|
// --- Outgoing (compose) ---
|
||||||
// My chat public key (hex), or "" without an identity — goes in an outgoing header's "p".
|
// My chat public key (hex), or "" without an identity — goes in an outgoing header's "p".
|
||||||
std::string identityPublicKeyHex() const;
|
std::string identityPublicKeyHex() const;
|
||||||
@@ -112,6 +123,18 @@ private:
|
|||||||
ChatStore store_;
|
ChatStore store_;
|
||||||
ChatDatabase* db_ = nullptr; // optional; not owned
|
ChatDatabase* db_ = nullptr; // optional; not owned
|
||||||
std::function<bool(const std::string&)> blocked_pred_; // true => cid is blocked (drop its messages)
|
std::function<bool(const std::string&)> 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<std::string, ControlState> 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
|
} // namespace dragonx::chat
|
||||||
|
|||||||
@@ -15075,6 +15075,66 @@ void testHushChatControlMemo()
|
|||||||
== ChatComposeStatus::BadRequestText);
|
== 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::int64_t>(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.
|
// ChatService: metadata batch -> decrypt -> threaded, deduped in-memory store.
|
||||||
void testHushChatService()
|
void testHushChatService()
|
||||||
{
|
{
|
||||||
@@ -20026,6 +20086,7 @@ int main()
|
|||||||
testHushChatCrypto();
|
testHushChatCrypto();
|
||||||
testHushChatReceivePath();
|
testHushChatReceivePath();
|
||||||
testHushChatControlMemo();
|
testHushChatControlMemo();
|
||||||
|
testHushChatControlReducer();
|
||||||
testHushChatService();
|
testHushChatService();
|
||||||
testHushChatDatabase();
|
testHushChatDatabase();
|
||||||
testHushChatDeleteConversation();
|
testHushChatDeleteConversation();
|
||||||
|
|||||||
Reference in New Issue
Block a user