feat(chat): message model, in-memory store, and receive service

Phase 1 Steps 4-6 (the receive pipeline, minus the App/sync wiring which
lands next).

- chat_message: the in-memory ChatMessage model (direction, kind, txid, cid,
  peer zaddr/pubkey, body, timestamp, payload_position). No libsodium.
- chat_store: threads messages by conversation_id and dedups by
  (txid, payload_position) so re-scanning the chain never double-inserts.
- chat_service: owns the long-lived chat identity keypair (move-disabled,
  wiped on destruction/clear) and the store. ingest() decrypts each Message
  (drops undecryptable ones silently — no plaintext/memo logging), passes a
  ContactRequest's plaintext through, and threads the result. No-op without an
  identity.

Tests: a metadata batch decrypts into a threaded conversation; re-ingest
dedups; a contact request carries through; a wrong identity decrypts nothing;
no-identity ingest is a no-op.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-07-05 20:11:05 -05:00
parent d043538e2f
commit ba03de938e
7 changed files with 282 additions and 0 deletions

View File

@@ -413,6 +413,8 @@ set(APP_SOURCES
src/chat/chat_protocol.cpp
src/chat/chat_crypto.cpp
src/chat/chat_identity.cpp
src/chat/chat_store.cpp
src/chat/chat_service.cpp
src/wallet/lite_owned_string.cpp
src/wallet/lite_rollout_policy.cpp
src/wallet/lite_client_bridge.cpp
@@ -561,6 +563,9 @@ set(APP_HEADERS
src/chat/chat_protocol.h
src/chat/chat_crypto.h
src/chat/chat_identity.h
src/chat/chat_message.h
src/chat/chat_store.h
src/chat/chat_service.h
src/config/version.h
src/data/wallet_state.h
src/data/transaction_history_cache.h
@@ -1011,6 +1016,8 @@ if(BUILD_TESTING)
src/chat/chat_protocol.cpp
src/chat/chat_crypto.cpp
src/chat/chat_identity.cpp
src/chat/chat_store.cpp
src/chat/chat_service.cpp
src/wallet/lite_owned_string.cpp
src/wallet/lite_rollout_policy.cpp
src/wallet/lite_client_bridge.cpp

26
src/chat/chat_message.h Normal file
View File

@@ -0,0 +1,26 @@
#pragma once
// DragonX Wallet - HushChat in-memory message model (no libsodium, no persistence).
// Phase 1 keeps decrypted messages in memory only; sqlite persistence is Phase 2.
#include <cstdint>
#include <string>
namespace dragonx::chat {
enum class ChatDirection { Incoming, Outgoing };
enum class ChatMessageKind { Message, ContactRequest };
struct ChatMessage {
ChatDirection direction = ChatDirection::Incoming;
ChatMessageKind kind = ChatMessageKind::Message;
std::string txid;
std::string conversation_id; // cid — the conversation thread key
std::string peer_zaddr; // header "z": peer's reply z-address
std::string peer_public_key_hex; // header "p": peer's crypto_kx public key
std::string body; // decrypted plaintext (Message) or request text (ContactRequest)
std::int64_t timestamp = 0; // tx time in seconds; set by the ingesting caller
std::size_t payload_position = 0; // together with txid, the dedup key
};
} // namespace dragonx::chat

56
src/chat/chat_service.cpp Normal file
View File

@@ -0,0 +1,56 @@
// DragonX Wallet - HushChat service (implementation).
#include "chat_service.h"
#include <utility>
namespace dragonx::chat {
ChatService::~ChatService() {
clearIdentity();
}
void ChatService::setIdentity(const ChatKeyPair& keys) {
identity_ = keys;
has_identity_ = true;
}
void ChatService::clearIdentity() {
wipeChatKeyPair(identity_);
has_identity_ = false;
}
int ChatService::ingest(const std::vector<HushChatTransactionMetadata>& metadata,
std::int64_t txTimestamp) {
if (!has_identity_) return 0;
int added = 0;
for (const auto& meta : metadata) {
ChatMessage message;
message.direction = ChatDirection::Incoming;
message.txid = meta.txid;
message.conversation_id = meta.conversation_id;
message.peer_zaddr = meta.reply_zaddr;
message.peer_public_key_hex = meta.sender_public_key_hex;
message.timestamp = txTimestamp;
message.payload_position = meta.payload_position;
if (meta.type == HushChatHeaderType::ContactRequest) {
message.kind = ChatMessageKind::ContactRequest;
message.body = meta.payload_memo; // plaintext request text
} else {
message.kind = ChatMessageKind::Message;
std::string plaintext;
const ChatCryptoStatus status = decryptIncoming(
identity_, meta.sender_public_key_hex, meta.secretstream_header_hex,
meta.payload_memo, plaintext);
if (status != ChatCryptoStatus::Ok) continue; // drop undecryptable silently
message.body = std::move(plaintext);
}
if (store_.append(message)) ++added;
}
return added;
}
} // namespace dragonx::chat

47
src/chat/chat_service.h Normal file
View File

@@ -0,0 +1,47 @@
#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_store.h"
#include <cstdint>
#include <vector>
namespace dragonx::chat {
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. `txTimestamp` is stamped onto every
// message in this batch. Returns the number of NEW messages added. Returns 0 with no
// identity. Undecryptable Messages are dropped silently (no logging of memo/plaintext).
int ingest(const std::vector<HushChatTransactionMetadata>& metadata, std::int64_t txTimestamp = 0);
const ChatStore& store() const { return store_; }
ChatStore& store() { return store_; }
private:
ChatKeyPair identity_{};
bool has_identity_ = false;
ChatStore store_;
};
} // namespace dragonx::chat

39
src/chat/chat_store.cpp Normal file
View File

@@ -0,0 +1,39 @@
// DragonX Wallet - HushChat in-memory message store (implementation).
#include "chat_store.h"
namespace dragonx::chat {
std::string ChatStore::dedupKey(const ChatMessage& message) {
return message.txid + ":" + std::to_string(message.payload_position);
}
bool ChatStore::append(const ChatMessage& message) {
if (!seen_.insert(dedupKey(message)).second) return false;
messages_.push_back(message);
return true;
}
std::vector<ChatMessage> ChatStore::conversation(const std::string& conversationId) const {
std::vector<ChatMessage> out;
for (const auto& message : messages_) {
if (message.conversation_id == conversationId) out.push_back(message);
}
return out;
}
std::vector<std::string> ChatStore::conversationIds() const {
std::vector<std::string> ids;
std::unordered_set<std::string> seenIds;
for (const auto& message : messages_) {
if (seenIds.insert(message.conversation_id).second) ids.push_back(message.conversation_id);
}
return ids;
}
void ChatStore::clear() {
messages_.clear();
seen_.clear();
}
} // namespace dragonx::chat

37
src/chat/chat_store.h Normal file
View File

@@ -0,0 +1,37 @@
#pragma once
// DragonX Wallet - HushChat in-memory message store (Phase 1; sqlite persistence is Phase 2).
#include "chat_message.h"
#include <string>
#include <unordered_set>
#include <vector>
namespace dragonx::chat {
// Threads messages by conversation_id (cid) and deduplicates by (txid, payload_position) so
// re-scanning the chain never double-inserts. Not thread-safe — drive from the main thread.
class ChatStore {
public:
// Returns true if newly inserted, false if a duplicate was ignored.
bool append(const ChatMessage& message);
// Messages in a conversation, in insertion order.
std::vector<ChatMessage> conversation(const std::string& conversationId) const;
// Distinct conversation ids, in first-seen order.
std::vector<std::string> conversationIds() const;
std::size_t size() const { return messages_.size(); }
bool empty() const { return messages_.empty(); }
void clear();
private:
static std::string dedupKey(const ChatMessage& message);
std::vector<ChatMessage> messages_;
std::unordered_set<std::string> seen_;
};
} // namespace dragonx::chat

View File

@@ -1,5 +1,6 @@
#include "chat/chat_crypto.h"
#include "chat/chat_identity.h"
#include "chat/chat_service.h"
#include "daemon/daemon_controller.h"
#include "data/transaction_history_cache.h"
#include "daemon/lifecycle_adapters.h"
@@ -5662,6 +5663,74 @@ void testHushChatReceivePath()
EXPECT_EQ((int)off.metadata.size(), 0);
}
// ChatService: metadata batch -> decrypt -> threaded, deduped in-memory store.
void testHushChatService()
{
using namespace dragonx::chat;
ChatKeyPair alice, bob;
ChatIdentityResult ra = deriveChatIdentityFromSecret("alice-svc", alice, true);
ChatIdentityResult rb = deriveChatIdentityFromSecret("bob-svc", bob, true);
// Two Alice->Bob messages in one conversation, as harvested metadata.
std::vector<HushChatTransactionMetadata> batch;
for (int i = 0; i < 2; ++i) {
std::string e, ct;
EXPECT_TRUE(encryptOutgoing(alice, rb.public_key_hex, i == 0 ? "first" : "second", e, ct) == ChatCryptoStatus::Ok);
HushChatTransactionMetadata m;
m.txid = std::string("tx") + std::to_string(i);
m.type = HushChatHeaderType::Message;
m.conversation_id = "conv-x";
m.reply_zaddr = "zs-alice";
m.sender_public_key_hex = ra.public_key_hex;
m.secretstream_header_hex = e;
m.payload_memo = ct;
m.payload_position = 1;
batch.push_back(m);
}
ChatService svc;
EXPECT_TRUE(!svc.hasIdentity());
EXPECT_EQ(svc.ingest(batch), 0); // no identity -> nothing ingested
svc.setIdentity(bob);
EXPECT_TRUE(svc.hasIdentity());
EXPECT_EQ(svc.ingest(batch, 1000), 2);
EXPECT_EQ((int)svc.store().size(), 2);
EXPECT_EQ(svc.ingest(batch, 1000), 0); // re-scan dedups
EXPECT_EQ((int)svc.store().size(), 2);
std::vector<ChatMessage> conv = svc.store().conversation("conv-x");
EXPECT_EQ((int)conv.size(), 2);
EXPECT_EQ(conv[0].body, std::string("first"));
EXPECT_EQ(conv[1].body, std::string("second"));
EXPECT_TRUE(conv[0].direction == ChatDirection::Incoming);
EXPECT_EQ(conv[0].peer_public_key_hex, ra.public_key_hex);
EXPECT_EQ(conv[0].timestamp, (std::int64_t)1000);
// A contact request carries plaintext through without decryption.
std::vector<HushChatTransactionMetadata> creq(1);
creq[0].txid = "txc";
creq[0].type = HushChatHeaderType::ContactRequest;
creq[0].conversation_id = "conv-y";
creq[0].sender_public_key_hex = ra.public_key_hex;
creq[0].payload_memo = "hi, add me";
creq[0].payload_position = 1;
EXPECT_EQ(svc.ingest(creq), 1);
std::vector<ChatMessage> cy = svc.store().conversation("conv-y");
EXPECT_EQ((int)cy.size(), 1);
EXPECT_TRUE(cy[0].kind == ChatMessageKind::ContactRequest);
EXPECT_EQ(cy[0].body, std::string("hi, add me"));
// Wrong identity can't decrypt -> messages dropped silently.
ChatService svc2;
ChatKeyPair mallory;
deriveChatIdentityFromSecret("mallory-svc", mallory, true);
svc2.setIdentity(mallory);
EXPECT_EQ(svc2.ingest(batch, 1000), 0);
EXPECT_TRUE(svc2.store().empty());
}
} // namespace
int main()
@@ -5754,6 +5823,7 @@ int main()
testAtomicFileWrite();
testHushChatCrypto();
testHushChatReceivePath();
testHushChatService();
testAddressChecksumValidation();
testLiteServerProbeLive();
testXmrigLiveInstall();