#pragma once // DragonX Wallet - HushChat service: turns harvested memo metadata into decrypted, threaded // messages. Owns the long-lived chat identity keypair (a secret) + the in-memory store. // Move-disabled (the secret stays pinned); wipes the secret on destruction/clear. // Not thread-safe — drive from the main thread (where refresh results are applied). #include "chat_crypto.h" // ChatKeyPair #include "chat_protocol.h" // HushChatTransactionMetadata #include "chat_outgoing.h" // OutgoingChatMemos, ChatComposeStatus #include "chat_store.h" #include #include #include #include #include #include #include namespace dragonx::chat { class ChatDatabase; // optional persistent backing (Phase 2); set via setPersistence class ChatService { public: ChatService() = default; ~ChatService(); ChatService(const ChatService&) = delete; ChatService& operator=(const ChatService&) = delete; ChatService(ChatService&&) = delete; ChatService& operator=(ChatService&&) = delete; // Provision (or replace) the chat identity. Copies the keypair — the caller should wipe // its own copy afterwards (see chat_identity.h ownership note). void setIdentity(const ChatKeyPair& keys); bool hasIdentity() const { return has_identity_; } void clearIdentity(); // wipes the held secret key // Decrypt/record each metadata entry (a Message is decrypted; a ContactRequest carries its // plaintext through) and thread it into the store. Each message is stamped with its own // transaction time via `txTimestamps` (keyed by txid), falling back to `fallbackTimestamp` // when the txid isn't present. Newly-added messages are also persisted (if a database is // attached). Returns the number of NEW messages added; 0 with no identity. Undecryptable // Messages are dropped silently (no logging of memo/plaintext). // When `newIncomingCids` is non-null it is filled with the conversation ids of the genuinely-new // incoming (non-request) messages appended this call — a reliable "a new message arrived here" // signal for notifications that doesn't depend on any timestamp/seen-watermark comparison. int ingest(const std::vector& metadata, const std::unordered_map& txTimestamps, std::int64_t fallbackTimestamp = 0, std::vector* newIncomingCids = nullptr); // Attach a persistent backing store (Phase 2). Not owned. Pass nullptr to detach. New messages // from ingest() are written through; loadFromDatabase() rehydrates the in-memory store from it. // Also wires the DB's per-conversation sensitivity predicate to isConversationSensitive(). void setPersistence(ChatDatabase* db); // Load previously-persisted messages (already decrypted at ingest, re-encrypted at rest under // the seed-derived key) into the in-memory store. No-op without an unlocked database. void loadFromDatabase(); // Set a predicate returning true for a BLOCKED conversation id. ingest() drops those messages // entirely (they are never stored or persisted) until the predicate stops returning true — this is // how "delete & block" suppresses old and future messages. Typically wired to Settings::isChatBlocked. void setBlockedPredicate(std::function pred) { blocked_pred_ = std::move(pred); } // Locally delete a conversation: remove its messages from the store and the database. When `block` // is false, the removed messages are tombstoned so a chain re-scan won't re-import them, but a // genuinely NEW message (new txid) revives the thread. When `block` is true, the rows are removed // WITHOUT a tombstone (the caller also records the cid as blocked via the predicate above); unblocking // later lets the conversation re-import from chain. Returns false (leaving the store untouched) if the // 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; // Sensitive axis (independent of hide/restore): a conversation whose latest on-chain control op is // "sensitive" is marked sensitive — its local rows are stored under the local vault key and withheld // on a device without that key (including a fresh seed import), until "unsensitive". Re-derived from // chain each session, like the hide axis. bool conversationSensitiveOnChain(const std::string& conversationId) const; // Conversations whose latest on-chain control op is "sensitive", each paired with its control label. std::vector> onChainSensitiveConversations() const; // A local "sensitive" predicate (typically Settings::isChatSensitive) giving INSTANT effect the moment // the user marks a conversation, before the on-chain signal is harvested back. OR-combined with the // on-chain reduction by isConversationSensitive(). void setLocalSensitivePredicate(std::function pred) { local_sensitive_pred_ = std::move(pred); } // True if this conversation is sensitive (locally marked OR on-chain). Its rows are stored under the // local vault key; while that key is absent its messages are withheld from the store on ingest/load. bool isConversationSensitive(const std::string& conversationId) const; // Larger-messages tier 3: a message still being received across transactions (some chunks arrived, // not yet all). Lets the UI show an "incoming large message — N of M parts" hint until it completes // and surfaces as an ordinary message. One entry per in-progress message; empty when none. struct Tier3Progress { std::string conversation_id; int received = 0; // chunks gathered so far int total = 0; // chunks the whole message needs }; std::vector pendingLargeMessages() const; // --- Outgoing (compose) --- // My chat public key (hex), or "" without an identity — goes in an outgoing header's "p". std::string identityPublicKeyHex() const; // Construct the outgoing memos for an ENCRYPTED message, using the held identity to encrypt. ChatComposeStatus composeMessage(const std::string& myReplyZaddr, const std::string& peerPublicKeyHex, const std::string& peerZaddr, const std::string& conversationId, const std::string& plaintext, OutgoingChatMemos& out) const; // Construct the outgoing memos for an ENCRYPTED message, splitting it across as many transactions as // its length needs (larger-messages tier 3). Returns one OutgoingChatMemos per transaction to send in // order; a short message yields a single element (identical to composeMessage). ChatComposeStatus composeMessageParts(const std::string& myReplyZaddr, const std::string& peerPublicKeyHex, const std::string& peerZaddr, const std::string& conversationId, const std::string& plaintext, std::vector& out) const; // Construct the outgoing memos for a plaintext contact request (no peer key needed yet). ChatComposeStatus composeContactRequest(const std::string& myReplyZaddr, const std::string& peerZaddr, const std::string& conversationId, const std::string& requestText, OutgoingChatMemos& out) const; // Echo a locally-composed outgoing message into the store (and DB). Returns true if new. (We // never harvest our own sent memos — they land on the peer's address — so this echo is the // only local record of what we sent.) bool recordOutgoing(const ChatMessage& message); // Two-phase echo for delivery tracking: record + persist immediately as Sending (survives an app // quit), then resolveOutgoing() upserts the final status once the broadcast completes. A stray // persisted Sending (crash mid-broadcast) loads back as Sent. bool recordOutgoingPending(const ChatMessage& message); void resolveOutgoing(const std::string& txid, ChatDelivery delivery, const std::string& reason = {}); const ChatStore& store() const { return store_; } ChatStore& store() { return store_; } std::uint64_t decryptAttempts() const { return decrypt_attempts_; } // tests / diagnostics private: ChatKeyPair identity_{}; bool has_identity_ = false; // Decrypt failures, keyed by what the decrypt reads (bounded). A rescan re-sends every message ever // received, so without it each poll re-ran the crypto for every undecryptable memo. std::unordered_set undecryptable_; std::uint64_t decrypt_attempts_ = 0; ChatStore store_; ChatDatabase* db_ = nullptr; // optional; not owned std::function blocked_pred_; // true => cid is blocked (drop its messages) std::function local_sensitive_pred_; // true => cid locally marked sensitive // 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_; // hide axis: del / res std::unordered_map sensitive_control_; // sensitive axis: sensitive / unsensitive // Larger-messages tier 3: a big message split across several transactions arrives one slice at a // time (siblings share a message id). We accumulate the slices here until every chunk is present, // then reassemble + decrypt + emit the whole message. Not persisted — a rescan re-sends every slice // so the buffer re-derives from chain (like the control reducer); a completed group is retired. struct Tier3Pending { int total_parts = 0; std::string secretstream_header_hex; // "e", shared by every slice std::string sender_public_key_hex; // "p", shared std::string conversation_id; std::string reply_zaddr; std::int64_t sent_at = 0; std::string canonical_txid; // smallest sibling txid — the reassembled message's stable key std::unordered_map chunks; // global index -> ciphertext-hex chunk }; std::unordered_map tier3_pending_; // message id -> accumulating slices // Monotonic reduce: route the op to its axis (hide vs sensitive), ignore unknown ops (forward-compat), // and within an axis a later (timestamp, txid) wins so 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