Compare commits
22 Commits
061db16004
...
b75801f5c4
| Author | SHA1 | Date | |
|---|---|---|---|
| b75801f5c4 | |||
| 6ddca6aed0 | |||
| ca1ac22b17 | |||
| 6bfb56aeb9 | |||
| 6c08a08127 | |||
| f5bde67f64 | |||
| 516e5ec688 | |||
| 008cc82ee5 | |||
| 2e6188a36d | |||
| e191680782 | |||
| 4db609fb52 | |||
| 980a100edd | |||
| 46eec37013 | |||
| 1738468f8c | |||
| ba03de938e | |||
| d043538e2f | |||
| 2502487e44 | |||
| cec330ee47 | |||
| 0ff56bf5a4 | |||
| ddd53dc006 | |||
| ee59a9e2cd | |||
| 62998179ec |
@@ -55,7 +55,7 @@ There is no per-test filtering — it is one binary that runs every assertion. T
|
||||
|
||||
> ⚠️ **Do not regrow the `_plan`/`_batch` churn.** This directory previously held ~160 dead `lite_wallet_*_plan` / `*_batch*_receipt_custody_acceptance_confirmation_archive_handoff_*` files (filenames up to 250 chars) — auto-generated scaffolding that never reached the shipping binary. They were deleted. When extending lite-wallet behavior, **edit the named service/bridge/runtime files in place**; never add another "promotion/receipt/custody/handoff/stewardship" wrapper layer. `scripts/check-source-hygiene.sh` (wired as a `.git/hooks/pre-commit` hook) blocks >80-char filenames and chained churn-token names — run it in CI too.
|
||||
|
||||
**Chat** (`src/chat/chat_protocol.cpp`): experimental HushChat protocol, compiled in only when `DRAGONX_ENABLE_CHAT=ON`.
|
||||
**Chat** (`src/chat/*`): the HushChat protocol port (Contacts/Chat tabs, seed-derived identity, secretstream crypto, seed-encrypted sqlite store, two-variant send/receive transport). Runtime behavior is gated by `DRAGONX_ENABLE_CHAT`, now **default ON** (the sources always compile; the flag folds the feature away at runtime via `hushChatFeatureEnabledAtBuild()`). Full-node chat derives its identity from the wallet's mnemonic (`z_exportmnemonic`, portable/SDXLite-compatible) or, for legacy/non-mnemonic wallets, a stable z-address spending key (`z_exportkey`).
|
||||
|
||||
## Build variants & feature gating
|
||||
|
||||
|
||||
@@ -44,7 +44,7 @@ option(DRAGONX_USE_SYSTEM_SDL3 "Use system SDL3 instead of fetching" ON)
|
||||
option(DRAGONX_ENABLE_EMBEDDED_DAEMON "Enable embedded dragonxd support" ON)
|
||||
option(DRAGONX_BUILD_LITE "Build ObsidianDragonLite variant without full-node features" OFF)
|
||||
option(DRAGONX_ENABLE_LITE_BACKEND "Enable real lite wallet backend integration" OFF)
|
||||
option(DRAGONX_ENABLE_CHAT "Enable experimental HushChat protocol/UI integration" OFF)
|
||||
option(DRAGONX_ENABLE_CHAT "Enable the HushChat protocol/UI integration" ON)
|
||||
set(DRAGONX_LITE_BACKEND_LIBRARY "" CACHE FILEPATH "Path to a prebuilt SDXL-compatible lite backend library")
|
||||
set(DRAGONX_LITE_BACKEND_INCLUDE_DIR "" CACHE PATH "Optional include directory for SDXL-compatible lite backend headers")
|
||||
set(DRAGONX_LITE_BACKEND_EXTRA_LIBS "" CACHE STRING "Additional libraries needed by the SDXL-compatible lite backend")
|
||||
@@ -411,6 +411,12 @@ set(APP_SOURCES
|
||||
src/services/wallet_security_workflow.cpp
|
||||
src/services/wallet_security_workflow_executor.cpp
|
||||
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/chat/chat_database.cpp
|
||||
src/chat/chat_outgoing.cpp
|
||||
src/wallet/lite_owned_string.cpp
|
||||
src/wallet/lite_rollout_policy.cpp
|
||||
src/wallet/lite_client_bridge.cpp
|
||||
@@ -470,7 +476,8 @@ set(APP_SOURCES
|
||||
src/ui/windows/transaction_details_dialog.cpp
|
||||
src/ui/windows/qr_popup_dialog.cpp
|
||||
src/ui/windows/validate_address_dialog.cpp
|
||||
src/ui/windows/address_book_dialog.cpp
|
||||
src/ui/windows/contacts_tab.cpp
|
||||
src/ui/windows/chat_tab.cpp
|
||||
src/ui/windows/shield_dialog.cpp
|
||||
src/ui/windows/request_payment_dialog.cpp
|
||||
src/ui/windows/block_info_dialog.cpp
|
||||
@@ -557,6 +564,13 @@ set(APP_HEADERS
|
||||
src/wallet/lite_wallet_server_selection_adapter.h
|
||||
src/wallet/lite_wallet_lifecycle_service.h
|
||||
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/chat/chat_database.h
|
||||
src/chat/chat_outgoing.h
|
||||
src/config/version.h
|
||||
src/data/wallet_state.h
|
||||
src/data/transaction_history_cache.h
|
||||
@@ -594,7 +608,9 @@ set(APP_HEADERS
|
||||
src/ui/windows/transaction_details_dialog.h
|
||||
src/ui/windows/qr_popup_dialog.h
|
||||
src/ui/windows/validate_address_dialog.h
|
||||
src/ui/windows/address_book_dialog.h
|
||||
src/ui/windows/contacts_tab.h
|
||||
src/ui/windows/chat_tab.h
|
||||
src/ui/windows/contact_picker.h
|
||||
src/ui/windows/shield_dialog.h
|
||||
src/ui/windows/request_payment_dialog.h
|
||||
src/ui/windows/block_info_dialog.h
|
||||
@@ -1004,6 +1020,12 @@ if(BUILD_TESTING)
|
||||
src/services/wallet_security_workflow.cpp
|
||||
src/services/wallet_security_workflow_executor.cpp
|
||||
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/chat/chat_database.cpp
|
||||
src/chat/chat_outgoing.cpp
|
||||
src/wallet/lite_owned_string.cpp
|
||||
src/wallet/lite_rollout_policy.cpp
|
||||
src/wallet/lite_client_bridge.cpp
|
||||
|
||||
Binary file not shown.
@@ -110,6 +110,28 @@
|
||||
"bootstrap_warning": "Vorhandene Blockdaten (blocks, chainstate, notarizations) werden gelöscht und ersetzt. Ihre wallet.dat wird NICHT verändert oder gelöscht.",
|
||||
"cancel": "Abbrechen",
|
||||
"characters": "Zeichen",
|
||||
"chat": "Chat",
|
||||
"chat_cancel": "Abbrechen",
|
||||
"chat_contact_request": "kontaktanfrage",
|
||||
"chat_empty_hint": "Noch keine Unterhaltungen. Nachrichten, die du erhältst, erscheinen hier.",
|
||||
"chat_locked_hint": "Entsperre deine Wallet, um deine Chats zu laden.",
|
||||
"chat_new_button": "Neue Unterhaltung",
|
||||
"chat_new_message": "Nachricht",
|
||||
"chat_new_send": "Anfrage senden",
|
||||
"chat_new_title": "Neue Unterhaltung",
|
||||
"chat_new_zaddr": "z-Adresse des Empfängers",
|
||||
"chat_select_hint": "Wähle eine Unterhaltung aus, um sie anzuzeigen.",
|
||||
"chat_send": "Senden",
|
||||
"chat_send_failed": "nicht gesendet",
|
||||
"chat_toast_compose_failed": "Nachricht konnte nicht erstellt werden (zu lang?).",
|
||||
"chat_toast_lite_busy": "Es wird bereits gesendet, oder es ist keine Wallet geöffnet.",
|
||||
"chat_toast_no_zaddr": "Keine z-Adresse verfügbar, um den Chat zu senden.",
|
||||
"chat_toast_not_connected": "Nicht verbunden – Nachricht nicht gesendet.",
|
||||
"chat_toast_request_compose_failed": "Kontaktanfrage konnte nicht erstellt werden (ungültige Adresse / ungültiger Text?).",
|
||||
"chat_toast_request_queued": "Kontaktanfrage in Warteschlange.",
|
||||
"chat_toast_waiting_reply": "Warte auf die Antwort des Kontakts, bevor du ihm schreiben kannst.",
|
||||
"chat_waiting_reply": "Warte auf die Antwort dieses Kontakts – sobald er antwortet, kannst du ihm schreiben.",
|
||||
"chat_you": "Du",
|
||||
"choose_icon": "Symbol wählen",
|
||||
"clear": "Leeren",
|
||||
"clear_all_bans": "Alle Sperren aufheben",
|
||||
|
||||
@@ -110,6 +110,28 @@
|
||||
"bootstrap_warning": "Los datos de bloques existentes (blocks, chainstate, notarizations) se eliminarán y reemplazarán. Su wallet.dat NO será modificado ni eliminado.",
|
||||
"cancel": "Cancelar",
|
||||
"characters": "caracteres",
|
||||
"chat": "Chat",
|
||||
"chat_cancel": "Cancelar",
|
||||
"chat_contact_request": "solicitud de contacto",
|
||||
"chat_empty_hint": "Aún no hay conversaciones. Los mensajes que recibas aparecerán aquí.",
|
||||
"chat_locked_hint": "Desbloquea tu monedero para cargar tus chats.",
|
||||
"chat_new_button": "Nueva conversación",
|
||||
"chat_new_message": "Mensaje",
|
||||
"chat_new_send": "Enviar solicitud",
|
||||
"chat_new_title": "Nueva conversación",
|
||||
"chat_new_zaddr": "Dirección z del destinatario",
|
||||
"chat_select_hint": "Selecciona una conversación para verla.",
|
||||
"chat_send": "Enviar",
|
||||
"chat_send_failed": "no enviado",
|
||||
"chat_toast_compose_failed": "No se pudo componer el mensaje (¿demasiado largo?).",
|
||||
"chat_toast_lite_busy": "Ya hay un envío en curso, o no hay ningún monedero abierto.",
|
||||
"chat_toast_no_zaddr": "No hay ninguna dirección z disponible desde la que enviar el chat.",
|
||||
"chat_toast_not_connected": "Sin conexión: mensaje de chat no enviado.",
|
||||
"chat_toast_request_compose_failed": "No se pudo componer la solicitud de contacto (¿dirección o texto no válidos?).",
|
||||
"chat_toast_request_queued": "Solicitud de contacto en cola.",
|
||||
"chat_toast_waiting_reply": "Espera a que este contacto responda antes de poder escribirle.",
|
||||
"chat_waiting_reply": "Esperando a que este contacto responda: podrás escribirle una vez lo haga.",
|
||||
"chat_you": "Tú",
|
||||
"choose_icon": "Elegir Icono",
|
||||
"clear": "Limpiar",
|
||||
"clear_all_bans": "Limpiar Todos los Bloqueos",
|
||||
|
||||
@@ -110,6 +110,28 @@
|
||||
"bootstrap_warning": "Les données de blocs existantes (blocks, chainstate, notarizations) seront supprimées et remplacées. Votre wallet.dat ne sera PAS modifié ni supprimé.",
|
||||
"cancel": "Annuler",
|
||||
"characters": "caractères",
|
||||
"chat": "Discussion",
|
||||
"chat_cancel": "Annuler",
|
||||
"chat_contact_request": "demande de contact",
|
||||
"chat_empty_hint": "Aucune conversation pour l'instant. Les messages que vous recevez apparaîtront ici.",
|
||||
"chat_locked_hint": "Déverrouillez votre portefeuille pour charger vos discussions.",
|
||||
"chat_new_button": "Nouvelle conversation",
|
||||
"chat_new_message": "Message",
|
||||
"chat_new_send": "Envoyer la demande",
|
||||
"chat_new_title": "Nouvelle conversation",
|
||||
"chat_new_zaddr": "Adresse Z du destinataire",
|
||||
"chat_select_hint": "Sélectionnez une conversation pour l'afficher.",
|
||||
"chat_send": "Envoyer",
|
||||
"chat_send_failed": "non envoyé",
|
||||
"chat_toast_compose_failed": "Impossible de composer le message (trop long ?).",
|
||||
"chat_toast_lite_busy": "Un envoi est déjà en cours, ou aucun portefeuille n'est ouvert.",
|
||||
"chat_toast_no_zaddr": "Aucune adresse Z disponible pour envoyer le message.",
|
||||
"chat_toast_not_connected": "Non connecté — message non envoyé.",
|
||||
"chat_toast_request_compose_failed": "Impossible de composer la demande de contact (adresse / texte invalide ?).",
|
||||
"chat_toast_request_queued": "Demande de contact mise en file d'attente.",
|
||||
"chat_toast_waiting_reply": "En attente de la réponse de ce contact avant de pouvoir lui écrire.",
|
||||
"chat_waiting_reply": "En attente de la réponse de ce contact — vous pourrez lui écrire dès qu'il aura répondu.",
|
||||
"chat_you": "Vous",
|
||||
"choose_icon": "Choisir une icône",
|
||||
"clear": "Effacer",
|
||||
"clear_all_bans": "Lever tous les bannissements",
|
||||
|
||||
@@ -110,6 +110,28 @@
|
||||
"bootstrap_warning": "既存のブロックデータ(blocks、chainstate、notarizations)は削除され置き換えられます。wallet.dat は変更・削除されません。",
|
||||
"cancel": "キャンセル",
|
||||
"characters": "文字",
|
||||
"chat": "チャット",
|
||||
"chat_cancel": "キャンセル",
|
||||
"chat_contact_request": "連絡リクエスト",
|
||||
"chat_empty_hint": "まだ会話はありません。受信したメッセージはここに表示されます。",
|
||||
"chat_locked_hint": "チャットを読み込むにはウォレットのロックを解除してください。",
|
||||
"chat_new_button": "新しい会話",
|
||||
"chat_new_message": "メッセージ",
|
||||
"chat_new_send": "リクエストを送信",
|
||||
"chat_new_title": "新しい会話",
|
||||
"chat_new_zaddr": "宛先Zアドレス",
|
||||
"chat_select_hint": "表示する会話を選択してください。",
|
||||
"chat_send": "送信",
|
||||
"chat_send_failed": "未送信",
|
||||
"chat_toast_compose_failed": "メッセージを作成できませんでした(長すぎませんか?)。",
|
||||
"chat_toast_lite_busy": "すでに送信処理が進行中か、ウォレットが開かれていません。",
|
||||
"chat_toast_no_zaddr": "送信元に使えるZアドレスがありません。",
|
||||
"chat_toast_not_connected": "未接続 — チャットメッセージは送信されませんでした。",
|
||||
"chat_toast_request_compose_failed": "連絡リクエストを作成できませんでした(アドレスまたはテキストが無効?)。",
|
||||
"chat_toast_request_queued": "連絡リクエストを送信待ちに追加しました。",
|
||||
"chat_toast_waiting_reply": "メッセージを送るには、この相手からの返信を待つ必要があります。",
|
||||
"chat_waiting_reply": "この相手からの返信を待っています — 返信があればメッセージを送れます。",
|
||||
"chat_you": "自分",
|
||||
"choose_icon": "アイコンを選択",
|
||||
"clear": "クリア",
|
||||
"clear_all_bans": "すべてのブロックを解除",
|
||||
|
||||
@@ -110,6 +110,28 @@
|
||||
"bootstrap_warning": "기존 블록 데이터(blocks, chainstate, notarizations)가 삭제되고 교체됩니다. wallet.dat는 수정되거나 삭제되지 않습니다.",
|
||||
"cancel": "취소",
|
||||
"characters": "문자",
|
||||
"chat": "채팅",
|
||||
"chat_cancel": "취소",
|
||||
"chat_contact_request": "연락 요청",
|
||||
"chat_empty_hint": "아직 대화가 없습니다. 받은 메시지가 여기에 표시됩니다.",
|
||||
"chat_locked_hint": "채팅을 불러오려면 지갑 잠금을 해제하세요.",
|
||||
"chat_new_button": "새 대화",
|
||||
"chat_new_message": "메시지",
|
||||
"chat_new_send": "요청 보내기",
|
||||
"chat_new_title": "새 대화",
|
||||
"chat_new_zaddr": "받는 사람 z-주소",
|
||||
"chat_select_hint": "볼 대화를 선택하세요.",
|
||||
"chat_send": "전송",
|
||||
"chat_send_failed": "전송 안 됨",
|
||||
"chat_toast_compose_failed": "메시지를 작성할 수 없습니다 (너무 긴가요?).",
|
||||
"chat_toast_lite_busy": "이미 전송이 진행 중이거나 열린 지갑이 없습니다.",
|
||||
"chat_toast_no_zaddr": "채팅을 보낼 z-주소가 없습니다.",
|
||||
"chat_toast_not_connected": "연결되지 않음 — 채팅 메시지가 전송되지 않았습니다.",
|
||||
"chat_toast_request_compose_failed": "연락 요청을 작성할 수 없습니다 (잘못된 주소 / 텍스트?).",
|
||||
"chat_toast_request_queued": "연락 요청이 대기열에 추가되었습니다.",
|
||||
"chat_toast_waiting_reply": "상대방이 답장해야 메시지를 보낼 수 있습니다.",
|
||||
"chat_waiting_reply": "상대방의 답장을 기다리는 중입니다 — 답장하면 메시지를 보낼 수 있습니다.",
|
||||
"chat_you": "나",
|
||||
"choose_icon": "아이콘 선택",
|
||||
"clear": "지우기",
|
||||
"clear_all_bans": "모든 차단 해제",
|
||||
|
||||
@@ -110,6 +110,28 @@
|
||||
"bootstrap_warning": "Os dados de blocos existentes (blocks, chainstate, notarizations) serão excluídos e substituídos. Seu wallet.dat NÃO será modificado ou excluído.",
|
||||
"cancel": "Cancelar",
|
||||
"characters": "caracteres",
|
||||
"chat": "Chat",
|
||||
"chat_cancel": "Cancelar",
|
||||
"chat_contact_request": "solicitação de contato",
|
||||
"chat_empty_hint": "Nenhuma conversa ainda. As mensagens que você receber aparecerão aqui.",
|
||||
"chat_locked_hint": "Desbloqueie sua carteira para carregar suas conversas.",
|
||||
"chat_new_button": "Nova conversa",
|
||||
"chat_new_message": "Mensagem",
|
||||
"chat_new_send": "Enviar solicitação",
|
||||
"chat_new_title": "Nova conversa",
|
||||
"chat_new_zaddr": "Endereço-z do destinatário",
|
||||
"chat_select_hint": "Selecione uma conversa para visualizá-la.",
|
||||
"chat_send": "Enviar",
|
||||
"chat_send_failed": "não enviada",
|
||||
"chat_toast_compose_failed": "Não foi possível compor a mensagem (muito longa?).",
|
||||
"chat_toast_lite_busy": "Já há um envio em andamento, ou nenhuma carteira está aberta.",
|
||||
"chat_toast_no_zaddr": "Nenhum endereço-z disponível para enviar o chat.",
|
||||
"chat_toast_not_connected": "Não conectado — mensagem de chat não enviada.",
|
||||
"chat_toast_request_compose_failed": "Não foi possível compor a solicitação de contato (endereço / texto inválido?).",
|
||||
"chat_toast_request_queued": "Solicitação de contato na fila.",
|
||||
"chat_toast_waiting_reply": "Aguardando a resposta do contato antes que você possa enviar mensagens a ele.",
|
||||
"chat_waiting_reply": "Aguardando a resposta deste contato — você poderá enviar mensagens assim que ele responder.",
|
||||
"chat_you": "Você",
|
||||
"choose_icon": "Escolher Ícone",
|
||||
"clear": "Limpar",
|
||||
"clear_all_bans": "Remover Todos os Banimentos",
|
||||
|
||||
@@ -110,6 +110,28 @@
|
||||
"bootstrap_warning": "Существующие данные блоков (blocks, chainstate, notarizations) будут удалены и заменены. Ваш wallet.dat НЕ будет изменён или удалён.",
|
||||
"cancel": "Отмена",
|
||||
"characters": "символов",
|
||||
"chat": "Чат",
|
||||
"chat_cancel": "Отмена",
|
||||
"chat_contact_request": "запрос контакта",
|
||||
"chat_empty_hint": "Пока нет переписок. Полученные сообщения появятся здесь.",
|
||||
"chat_locked_hint": "Разблокируйте кошелёк, чтобы загрузить переписку.",
|
||||
"chat_new_button": "Новая переписка",
|
||||
"chat_new_message": "Сообщение",
|
||||
"chat_new_send": "Отправить запрос",
|
||||
"chat_new_title": "Новая переписка",
|
||||
"chat_new_zaddr": "Z-адрес получателя",
|
||||
"chat_select_hint": "Выберите переписку для просмотра.",
|
||||
"chat_send": "Отправить",
|
||||
"chat_send_failed": "не отправлено",
|
||||
"chat_toast_compose_failed": "Не удалось составить сообщение (слишком длинное?).",
|
||||
"chat_toast_lite_busy": "Отправка уже выполняется, или кошелёк не открыт.",
|
||||
"chat_toast_no_zaddr": "Нет доступного Z-адреса для отправки сообщений.",
|
||||
"chat_toast_not_connected": "Нет подключения — сообщение не отправлено.",
|
||||
"chat_toast_request_compose_failed": "Не удалось составить запрос контакта (неверный адрес / текст?).",
|
||||
"chat_toast_request_queued": "Запрос контакта поставлен в очередь.",
|
||||
"chat_toast_waiting_reply": "Ожидание ответа от контакта — вы сможете писать ему только после этого.",
|
||||
"chat_waiting_reply": "Ожидание ответа от контакта — вы сможете писать ему, как только он ответит.",
|
||||
"chat_you": "Вы",
|
||||
"choose_icon": "Выбрать иконку",
|
||||
"clear": "Очистить",
|
||||
"clear_all_bans": "Снять все блокировки",
|
||||
|
||||
@@ -110,6 +110,28 @@
|
||||
"bootstrap_warning": "现有区块数据(blocks、chainstate、notarizations)将被删除并替换。您的 wallet.dat 不会被修改或删除。",
|
||||
"cancel": "取消",
|
||||
"characters": "字符",
|
||||
"chat": "聊天",
|
||||
"chat_cancel": "取消",
|
||||
"chat_contact_request": "联系人请求",
|
||||
"chat_empty_hint": "暂无对话。您收到的消息将显示在此处。",
|
||||
"chat_locked_hint": "解锁钱包以加载您的聊天记录。",
|
||||
"chat_new_button": "新建对话",
|
||||
"chat_new_message": "消息",
|
||||
"chat_new_send": "发送请求",
|
||||
"chat_new_title": "新建对话",
|
||||
"chat_new_zaddr": "收款方 z 地址",
|
||||
"chat_select_hint": "选择一个对话以查看。",
|
||||
"chat_send": "发送",
|
||||
"chat_send_failed": "未发送",
|
||||
"chat_toast_compose_failed": "无法编写该消息(内容过长?)。",
|
||||
"chat_toast_lite_busy": "已有发送正在进行中,或未打开任何钱包。",
|
||||
"chat_toast_no_zaddr": "没有可用于发送聊天的 z 地址。",
|
||||
"chat_toast_not_connected": "未连接——聊天消息未发送。",
|
||||
"chat_toast_request_compose_failed": "无法编写联系人请求(地址或文本无效?)。",
|
||||
"chat_toast_request_queued": "联系人请求已排队。",
|
||||
"chat_toast_waiting_reply": "等待该联系人回复——对方回复后您即可向其发送消息。",
|
||||
"chat_waiting_reply": "等待该联系人回复——对方回复后您即可向其发送消息。",
|
||||
"chat_you": "我",
|
||||
"choose_icon": "选择图标",
|
||||
"clear": "清除",
|
||||
"clear_all_bans": "解除所有封禁",
|
||||
|
||||
36
src/app.cpp
36
src/app.cpp
@@ -41,7 +41,8 @@
|
||||
#include "ui/windows/transaction_details_dialog.h"
|
||||
#include "ui/windows/qr_popup_dialog.h"
|
||||
#include "ui/windows/validate_address_dialog.h"
|
||||
#include "ui/windows/address_book_dialog.h"
|
||||
#include "ui/windows/contacts_tab.h"
|
||||
#include "ui/windows/chat_tab.h"
|
||||
#include "ui/windows/shield_dialog.h"
|
||||
#include "ui/windows/request_payment_dialog.h"
|
||||
#include "ui/windows/block_info_dialog.h"
|
||||
@@ -454,6 +455,10 @@ bool App::init()
|
||||
tryConnect();
|
||||
}
|
||||
|
||||
// Populate the shared contact store from disk (per-variant addressbook.json).
|
||||
// Idempotent; a missing file is fine (returns true). Purely local — no daemon/RPC dependency.
|
||||
address_book_.load();
|
||||
|
||||
DEBUG_LOGF("Initialization complete\n");
|
||||
return true;
|
||||
}
|
||||
@@ -598,6 +603,16 @@ void App::rebuildLiteWallet(bool force)
|
||||
// controller but never reopen, leaving a permanent "disconnected" state.
|
||||
lite_autoopen_done_ = false;
|
||||
lite_open_error_.clear();
|
||||
|
||||
// A rebuilt controller may back a different wallet (server switch / re-open); drop any chat
|
||||
// identity + decrypted messages, lock the DB, and re-arm provisioning so it re-derives (and
|
||||
// reloads the right wallet's history) from the newly opened wallet's seed.
|
||||
chat_service_.clearIdentity();
|
||||
chat_service_.store().clear();
|
||||
chat_db_.lock();
|
||||
chat_identity_provisioned_ = false;
|
||||
chat_identity_fetch_in_flight_ = false;
|
||||
chat_identity_unavailable_ = false;
|
||||
}
|
||||
|
||||
void App::update()
|
||||
@@ -667,6 +682,9 @@ void App::update()
|
||||
wallet::LiteWalletAppRefreshModel liteModel;
|
||||
if (lite_wallet_->takeRefreshedModel(liteModel)) {
|
||||
wallet::applyLiteRefreshModelToWalletState(liteModel, state_);
|
||||
// HushChat (lite): harvest chat memos from the refreshed transactions and thread them
|
||||
// (no-op when the feature is off or no identity; the store dedups across refreshes).
|
||||
ingestLiteChatMemos(liteModel);
|
||||
}
|
||||
// Deliver a completed async send/shield result to the waiting send_tab callback.
|
||||
wallet::LiteBroadcastResult broadcast;
|
||||
@@ -692,6 +710,11 @@ void App::update()
|
||||
}
|
||||
async_tasks_.reapCompleted();
|
||||
|
||||
// HushChat: once the wallet seed is reachable+unlocked, derive & set the chat identity so the
|
||||
// transaction-refresh harvest can decrypt incoming memos. Cheap early-outs keep it idle until
|
||||
// it can act; compiled away when the feature is off (constexpr gate inside).
|
||||
maybeProvisionChatIdentity();
|
||||
|
||||
// Auto-lock check (only when connected + encrypted + unlocked)
|
||||
if (state_.connected && state_.isUnlocked()) {
|
||||
checkAutoLock();
|
||||
@@ -1687,6 +1710,12 @@ void App::render()
|
||||
case ui::NavPage::History:
|
||||
ui::RenderTransactionsTab(this);
|
||||
break;
|
||||
case ui::NavPage::Contacts:
|
||||
ui::RenderContactsTab(this);
|
||||
break;
|
||||
case ui::NavPage::Chat:
|
||||
ui::RenderChatTab(this);
|
||||
break;
|
||||
case ui::NavPage::Mining:
|
||||
ui::RenderMiningTab(this);
|
||||
break;
|
||||
@@ -1884,10 +1913,7 @@ void App::render()
|
||||
|
||||
// Validate address dialog (triggered from Edit menu)
|
||||
ui::ValidateAddressDialog::render(this);
|
||||
|
||||
// Address book dialog (triggered from Edit menu)
|
||||
ui::AddressBookDialog::render(this);
|
||||
|
||||
|
||||
// Shield/merge dialog (triggered from Wallet menu)
|
||||
ui::ShieldDialog::render(this);
|
||||
|
||||
|
||||
46
src/app.h
46
src/app.h
@@ -14,6 +14,7 @@
|
||||
#include <unordered_set>
|
||||
#include <nlohmann/json_fwd.hpp>
|
||||
#include "data/transaction_history_cache.h"
|
||||
#include "data/address_book.h"
|
||||
#include "data/wallet_state.h"
|
||||
#include "rpc/connection.h"
|
||||
#include "services/network_refresh_service.h"
|
||||
@@ -22,6 +23,8 @@
|
||||
#include "util/async_task_manager.h"
|
||||
#include "util/pool_stats_service.h"
|
||||
#include "wallet/wallet_capabilities.h"
|
||||
#include "chat/chat_service.h"
|
||||
#include "chat/chat_database.h"
|
||||
#include "ui/sidebar.h"
|
||||
#include "ui/windows/console_tab.h"
|
||||
#include "imgui.h"
|
||||
@@ -35,7 +38,7 @@ namespace dragonx {
|
||||
namespace config { class Settings; }
|
||||
namespace daemon { class DaemonController; class EmbeddedDaemon; class XmrigManager; }
|
||||
namespace util { class Bootstrap; class SecureVault; }
|
||||
namespace wallet { class LiteWalletController; }
|
||||
namespace wallet { class LiteWalletController; struct LiteWalletAppRefreshModel; }
|
||||
}
|
||||
|
||||
namespace dragonx {
|
||||
@@ -165,16 +168,33 @@ public:
|
||||
config::Settings* settings() { return settings_.get(); }
|
||||
// Lite wallet controller (non-null only in lite builds with a linked backend).
|
||||
wallet::LiteWalletController* liteWallet() { return lite_wallet_.get(); }
|
||||
// HushChat service (identity + in-memory message store); the Chat tab reads its store.
|
||||
chat::ChatService& chatService() { return chat_service_; }
|
||||
// HushChat composing: construct, broadcast (broadcastChatMemos), and locally echo an outgoing
|
||||
// message (to a conversation whose peer key we know) / a new-conversation contact request.
|
||||
void sendChatMessage(const std::string& conversationId, const std::string& text);
|
||||
void startChatConversation(const std::string& peerZaddr, const std::string& text);
|
||||
// Debug/sweep convenience: give the Chat tab a demo identity + a few sample conversations
|
||||
// (in-memory only, not persisted) so the screenshot sweep captures the populated UI. No-op when
|
||||
// the chat feature is off.
|
||||
void seedChatDemoData();
|
||||
// Reason the lite wallet failed to auto-open this session (empty if none / opened OK).
|
||||
const std::string& liteOpenError() const { return lite_open_error_; }
|
||||
// Show the lite send-time unlock modal (called when a spend is attempted on a locked wallet).
|
||||
void requestLiteUnlock() { lite_unlock_prompt_ = true; }
|
||||
// Lock the lite wallet AND immediately tear down the chat session (the lite backend `lock`
|
||||
// doesn't update state_.locked until the next poll, so chat secrets would otherwise linger).
|
||||
bool lockLiteWallet();
|
||||
// (Re)build the lite controller from current settings so a changed lite-server selection
|
||||
// takes effect. No-op on non-lite/unlinked builds; preserves a live wallet (see app.cpp).
|
||||
void rebuildLiteWallet(bool force = false);
|
||||
WalletState& state() { return state_; }
|
||||
const WalletState& state() const { return state_; }
|
||||
const WalletState& getWalletState() const { return state_; }
|
||||
// Shared contact store (Contacts tab / Send picker / future Chat roster). App-owned so
|
||||
// every surface reads one source of truth instead of a per-dialog singleton.
|
||||
data::AddressBook& addressBook() { return address_book_; }
|
||||
const data::AddressBook& addressBook() const { return address_book_; }
|
||||
|
||||
// Connection state (convenience wrappers)
|
||||
bool isConnected() const { return state_.connected; }
|
||||
@@ -565,6 +585,26 @@ private:
|
||||
// Reason an existing lite wallet failed to auto-open (e.g. server unreachable). Surfaced in
|
||||
// the UI so a stuck "disconnected" state isn't silent; cleared once a wallet opens.
|
||||
std::string lite_open_error_;
|
||||
|
||||
// HushChat (experimental; gated by DRAGONX_ENABLE_CHAT — inert when OFF). App owns the chat
|
||||
// service so the transaction-refresh harvest can decrypt incoming memos into threaded messages.
|
||||
// The identity is derived from the wallet's OWN SDXLite-compatible seed phrase (full-node
|
||||
// z_exportmnemonic / lite exportSeed → the same KDF), so it is portable across both variants.
|
||||
chat::ChatService chat_service_;
|
||||
chat::ChatDatabase chat_db_; // persistent backing (seed-derived encryption at rest)
|
||||
bool chat_identity_provisioned_ = false; // identity set on the service this session
|
||||
bool chat_identity_fetch_in_flight_ = false; // a z_exportmnemonic worker job is pending
|
||||
bool chat_identity_unavailable_ = false; // provisioning failed definitively (e.g. non-mnemonic wallet)
|
||||
// Provision the chat identity once the wallet seed is reachable+unlocked (per-tick, both
|
||||
// variants); derives via deriveChatIdentityFromSecret and wipes the secret. No-op when the
|
||||
// feature is off, already provisioned, in flight, or unavailable.
|
||||
void maybeProvisionChatIdentity();
|
||||
void provisionChatIdentityFromSecret(std::string secret);
|
||||
std::string chatReplyZaddr(); // a stable (persisted) wallet z-addr for chat
|
||||
std::string generateChatLocalId(const char* prefix, int numBytes) const; // unique echo id / cid
|
||||
bool broadcastChatMemos(const chat::OutgoingChatMemos& memos); // returns true if submitted
|
||||
bool broadcastChatMemosLite(const chat::OutgoingChatMemos& memos); // lite two-recipient send
|
||||
void ingestLiteChatMemos(const wallet::LiteWalletAppRefreshModel& model); // lite chat receive harvest
|
||||
// Lite first-run welcome prompt: dismissed for the session once the user picks an action.
|
||||
bool lite_firstrun_dismissed_ = false;
|
||||
// Lite send-time unlock: set to show the unlock modal when a spend is attempted while locked.
|
||||
@@ -616,8 +656,7 @@ private:
|
||||
bool show_import_key_ = false;
|
||||
bool show_export_key_ = false;
|
||||
bool show_backup_ = false;
|
||||
bool show_address_book_ = false;
|
||||
|
||||
|
||||
// Embedded daemon state
|
||||
bool use_embedded_daemon_ = wallet::supportsEmbeddedDaemon(wallet::currentWalletCapabilities());
|
||||
std::string daemon_status_;
|
||||
@@ -814,6 +853,7 @@ private:
|
||||
// PIN vault
|
||||
std::unique_ptr<util::SecureVault> vault_;
|
||||
data::TransactionHistoryCache transaction_history_cache_;
|
||||
data::AddressBook address_book_; // shared contact store; loaded once in init(), self-saves on mutation
|
||||
std::string pending_transaction_history_cache_passphrase_;
|
||||
bool transaction_history_cache_loaded_ = false;
|
||||
|
||||
|
||||
@@ -33,8 +33,12 @@
|
||||
#include "rpc/rpc_client.h"
|
||||
#include "rpc/rpc_worker.h"
|
||||
#include "rpc/connection.h"
|
||||
#include "chat/chat_identity.h" // deriveChatIdentityFromSecret for HushChat identity provisioning
|
||||
#include <sodium.h> // sodium_memzero for wiping the fetched mnemonic
|
||||
#include <cctype>
|
||||
#include "config/settings.h"
|
||||
#include "wallet/lite_wallet_controller.h" // lite send/new-address routing
|
||||
#include "wallet/lite_wallet_state_mapper.h" // LiteWalletAppRefreshModel for lite chat harvest
|
||||
#include "daemon/daemon_controller.h"
|
||||
#include "daemon/embedded_daemon.h"
|
||||
#include "daemon/xmrig_manager.h"
|
||||
@@ -100,6 +104,8 @@ const char* tracePageName(ui::NavPage page)
|
||||
case ui::NavPage::Send: return "Send tab";
|
||||
case ui::NavPage::Receive: return "Receive tab";
|
||||
case ui::NavPage::History: return "History tab";
|
||||
case ui::NavPage::Contacts: return "Contacts tab";
|
||||
case ui::NavPage::Chat: return "Chat tab";
|
||||
case ui::NavPage::Mining: return "Mining tab";
|
||||
case ui::NavPage::Market: return "Market tab";
|
||||
case ui::NavPage::Console: return "Console tab";
|
||||
@@ -756,6 +762,8 @@ static const char* sweepPageName(ui::NavPage page)
|
||||
case ui::NavPage::Send: return "send";
|
||||
case ui::NavPage::Receive: return "receive";
|
||||
case ui::NavPage::History: return "history";
|
||||
case ui::NavPage::Contacts: return "contacts";
|
||||
case ui::NavPage::Chat: return "chat";
|
||||
case ui::NavPage::Mining: return "mining";
|
||||
case ui::NavPage::Market: return "market";
|
||||
case ui::NavPage::Console: return "console";
|
||||
@@ -1474,6 +1482,15 @@ void App::refreshTransactionData()
|
||||
confirmed_cache_block_,
|
||||
last_tx_block_height_
|
||||
};
|
||||
// HushChat: decrypt & thread any harvested chat memos BEFORE `result` is moved into
|
||||
// applyTransactionRefreshResult. No-op when the feature is off or no identity is set;
|
||||
// the store dedups (txid+position) so the full + recent refresh paths ingest safely.
|
||||
if (chat::hushChatFeatureEnabledAtBuild() && chat_service_.hasIdentity() &&
|
||||
!result.hushChatMetadata.empty()) {
|
||||
std::unordered_map<std::string, std::int64_t> chatTxTimes;
|
||||
for (const auto& tx : result.transactions) chatTxTimes[tx.txid] = tx.timestamp;
|
||||
chat_service_.ingest(result.hushChatMetadata, chatTxTimes, std::time(nullptr));
|
||||
}
|
||||
NetworkRefreshService::applyTransactionRefreshResult(
|
||||
state_, cacheUpdate, std::move(result), std::time(nullptr));
|
||||
shielded_history_scan_heights_ = std::move(shieldedScanHeights);
|
||||
@@ -1524,6 +1541,14 @@ void App::refreshRecentTransactionData()
|
||||
confirmed_cache_block_,
|
||||
last_tx_block_height_
|
||||
};
|
||||
// HushChat: decrypt & thread any harvested chat memos BEFORE `result` is moved (see the
|
||||
// full-refresh path above for rationale; the store dedups across both paths).
|
||||
if (chat::hushChatFeatureEnabledAtBuild() && chat_service_.hasIdentity() &&
|
||||
!result.hushChatMetadata.empty()) {
|
||||
std::unordered_map<std::string, std::int64_t> chatTxTimes;
|
||||
for (const auto& tx : result.transactions) chatTxTimes[tx.txid] = tx.timestamp;
|
||||
chat_service_.ingest(result.hushChatMetadata, chatTxTimes, std::time(nullptr));
|
||||
}
|
||||
NetworkRefreshService::applyTransactionRefreshResult(
|
||||
state_, cacheUpdate, std::move(result), std::time(nullptr));
|
||||
shielded_history_scan_heights_ = std::move(shieldedScanHeights);
|
||||
@@ -2327,6 +2352,413 @@ void App::exportPrivateKey(const std::string& address, std::function<void(const
|
||||
});
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// HushChat identity provisioning (experimental; gated by DRAGONX_ENABLE_CHAT)
|
||||
// ============================================================================
|
||||
|
||||
// Derive the chat identity from the wallet's own seed-phrase secret and hand it to the chat
|
||||
// service. `secret` is taken by value so we own a copy to wipe; the caller must wipe its own.
|
||||
void App::provisionChatIdentityFromSecret(std::string secret)
|
||||
{
|
||||
// Defensive: strip surrounding whitespace so a stray newline can't change the identity — the
|
||||
// same wallet must derive the SAME identity on full-node and lite (both return the canonical
|
||||
// single-space phrase today, so this is belt-and-suspenders). Trim into a SEPARATE buffer so
|
||||
// both the original (kept full-size) and the trimmed copy (size == content) are fully wiped —
|
||||
// an in-place erase/pop_back would shrink size() and leave un-scrubbed seed bytes past it.
|
||||
auto isws = [](char c) { return std::isspace(static_cast<unsigned char>(c)) != 0; };
|
||||
std::size_t begin = 0, end = secret.size();
|
||||
while (begin < end && isws(secret[begin])) ++begin;
|
||||
while (end > begin && isws(secret[end - 1])) --end;
|
||||
std::string trimmed = secret.substr(begin, end - begin);
|
||||
|
||||
chat::ChatKeyPair keys;
|
||||
const auto result = chat::deriveChatIdentityFromSecret(trimmed, keys);
|
||||
|
||||
if (result.status == chat::ChatIdentityStatus::Ready) {
|
||||
chat_service_.setIdentity(keys); // copies the keypair
|
||||
chat_identity_provisioned_ = true;
|
||||
// Persistence: unlock the seed-derived chat DB with the SAME secret and rehydrate the store
|
||||
// with prior messages (decrypted at rest under a key only this seed can derive).
|
||||
chat_service_.setPersistence(&chat_db_);
|
||||
if (chat_db_.unlockWithSecret(trimmed)) {
|
||||
chat_service_.loadFromDatabase();
|
||||
}
|
||||
} else {
|
||||
chat_identity_unavailable_ = true;
|
||||
}
|
||||
if (!trimmed.empty()) sodium_memzero(&trimmed[0], trimmed.size());
|
||||
if (!secret.empty()) sodium_memzero(&secret[0], secret.size());
|
||||
chat::wipeChatKeyPair(keys);
|
||||
}
|
||||
|
||||
// Provision the chat identity once the wallet's seed phrase is reachable. Called every update()
|
||||
// tick; cheap early-outs keep it idle until it can act. Both variants derive the SAME identity
|
||||
// because they feed the SAME SDXLite-compatible mnemonic into the KDF (identity derivation is
|
||||
// local — peers only ever exchange public keys).
|
||||
void App::maybeProvisionChatIdentity()
|
||||
{
|
||||
if (!chat::hushChatFeatureEnabledAtBuild()) return; // constexpr — folds away in OFF builds
|
||||
|
||||
// Locked (encrypted && locked, mirrored for both variants) → the seed is unreadable. Drop any
|
||||
// in-memory identity and re-arm so it re-derives on the next unlock. Unencrypted wallets report
|
||||
// isLocked()==false and fall through.
|
||||
if (state_.isLocked()) {
|
||||
if (chat_identity_provisioned_ || chat_service_.hasIdentity()) {
|
||||
chat_service_.clearIdentity();
|
||||
chat_service_.store().clear(); // decrypted plaintext in RAM — drop it; DB reloads on unlock
|
||||
chat_db_.lock();
|
||||
chat_identity_provisioned_ = false;
|
||||
chat_identity_unavailable_ = false;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (chat_service_.hasIdentity() || chat_identity_provisioned_ ||
|
||||
chat_identity_fetch_in_flight_ || chat_identity_unavailable_) return;
|
||||
|
||||
if (supportsLiteBackend()) {
|
||||
// Lite: the backend's `seed` command returns the 24-word mnemonic synchronously.
|
||||
if (!lite_wallet_ || !lite_wallet_->walletOpen()) return;
|
||||
wallet::LiteSeedResult seed = lite_wallet_->exportSeed();
|
||||
if (!seed.ok || seed.seedPhrase.empty()) {
|
||||
wallet::secureWipeLiteSecret(seed.seedPhrase);
|
||||
chat_identity_unavailable_ = true;
|
||||
return;
|
||||
}
|
||||
provisionChatIdentityFromSecret(seed.seedPhrase); // copies internally
|
||||
wallet::secureWipeLiteSecret(seed.seedPhrase);
|
||||
} else if (supportsFullNodeLifecycleActions()) {
|
||||
// Full-node: fetch the identity secret off the UI thread, provision on the main thread.
|
||||
// Prefer the wallet's mnemonic (z_exportmnemonic) for a portable, SDXLite-compatible identity;
|
||||
// if the wallet has no mnemonic (a legacy random-seed wallet, or a daemon without that RPC),
|
||||
// fall back to a stable z-address's spending key (z_exportkey) — a functional, wallet-local
|
||||
// identity that works on any daemon. Both secrets are wiped after deriving.
|
||||
if (!state_.connected || !rpc_ || !worker_) return;
|
||||
if (!state_.encryption_state_known) return; // don't fetch before we know the lock state
|
||||
const std::string fallbackZaddr = chatReplyZaddr(); // main thread: stable identity source
|
||||
chat_identity_fetch_in_flight_ = true;
|
||||
worker_->post([this, fallbackZaddr]() -> rpc::RPCWorker::MainCb {
|
||||
std::string secret; // the mnemonic (portable) OR a spending key (legacy fallback)
|
||||
bool transientFail = false; // connection blip — allow a later retry
|
||||
bool unavailable = false; // no usable secret — stop retrying this session
|
||||
rpc::RPCClient::TraceScope trace("HushChat / identity");
|
||||
try {
|
||||
auto response = rpc_->call("z_exportmnemonic");
|
||||
if (response.contains("mnemonic") && response["mnemonic"].is_string()) {
|
||||
// Scrub the json's own copy of the seed after taking ours (the rest of the RPC
|
||||
// response chain is unmanaged — a fuller fix belongs in the RPC layer).
|
||||
auto& phrase = response["mnemonic"].get_ref<std::string&>();
|
||||
secret = phrase;
|
||||
if (!phrase.empty()) sodium_memzero(&phrase[0], phrase.size());
|
||||
}
|
||||
} catch (const rpc::RpcError&) {
|
||||
// No mnemonic → derive from a spending key instead (legacy wallet / old daemon).
|
||||
if (fallbackZaddr.empty()) {
|
||||
unavailable = true;
|
||||
} else {
|
||||
try {
|
||||
secret = rpc_->call("z_exportkey", {fallbackZaddr}).get<std::string>();
|
||||
} catch (const rpc::RpcError&) {
|
||||
unavailable = true; // no spending key either (view-only?) — give up
|
||||
} catch (const std::exception&) {
|
||||
transientFail = true;
|
||||
}
|
||||
}
|
||||
} catch (const std::exception&) {
|
||||
transientFail = true;
|
||||
}
|
||||
return [this, secret = std::move(secret), transientFail, unavailable]() mutable {
|
||||
chat_identity_fetch_in_flight_ = false;
|
||||
if (unavailable) chat_identity_unavailable_ = true;
|
||||
// Provision only on success AND while still unlocked: a lock (e.g. auto-lock) can
|
||||
// land during the blocking fetch, and provisioning then would unlock the chat DB +
|
||||
// load decrypted history while the wallet is locked. Leaving the flag cleared here
|
||||
// re-arms a fresh fetch on the next unlock.
|
||||
if (!transientFail && !unavailable && !secret.empty() && !state_.isLocked()) {
|
||||
provisionChatIdentityFromSecret(secret); // copies internally
|
||||
}
|
||||
if (!secret.empty()) sodium_memzero(&secret[0], secret.size());
|
||||
};
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// A STABLE wallet z-address for chat: the "z" reply field + the send-from address, and (for
|
||||
// legacy/non-mnemonic wallets) the source of the seed-derived chat identity. Persisted on first use
|
||||
// so the identity + reply address don't shift when new addresses are generated. Prefers a spendable
|
||||
// one so replies land somewhere we control and fees can be paid.
|
||||
std::string App::chatReplyZaddr()
|
||||
{
|
||||
// Reuse the previously-chosen address as long as we still own it spendably.
|
||||
if (settings_) {
|
||||
const std::string saved = settings_->getChatReplyZaddr();
|
||||
if (!saved.empty()) {
|
||||
for (const auto& addr : state_.z_addresses)
|
||||
if (addr.address == saved && addr.has_spending_key) return saved;
|
||||
}
|
||||
}
|
||||
// Otherwise pick the lexicographically-smallest spendable z-address (deterministic) and persist.
|
||||
std::string best;
|
||||
for (const auto& addr : state_.z_addresses)
|
||||
if (addr.has_spending_key && !addr.address.empty() && (best.empty() || addr.address < best))
|
||||
best = addr.address;
|
||||
if (!best.empty()) {
|
||||
if (settings_ && settings_->getChatReplyZaddr() != best) {
|
||||
settings_->setChatReplyZaddr(best);
|
||||
settings_->save();
|
||||
}
|
||||
return best;
|
||||
}
|
||||
// No spendable z-address yet — fall back to the smallest we have (don't persist a view-only one).
|
||||
for (const auto& addr : state_.z_addresses)
|
||||
if (!addr.address.empty() && (best.empty() || addr.address < best)) best = addr.address;
|
||||
return best;
|
||||
}
|
||||
|
||||
// A unique opaque id (hex), used for the local echo id (we never harvest our own sends) and for
|
||||
// new conversation ids. Random — collisions are astronomically unlikely.
|
||||
std::string App::generateChatLocalId(const char* prefix, int numBytes) const
|
||||
{
|
||||
if (numBytes < 1) numBytes = 8;
|
||||
std::vector<unsigned char> buf(static_cast<std::size_t>(numBytes));
|
||||
randombytes_buf(buf.data(), buf.size());
|
||||
static const char* kHex = "0123456789abcdef";
|
||||
std::string id = prefix ? prefix : "";
|
||||
for (unsigned char b : buf) { id.push_back(kHex[b >> 4]); id.push_back(kHex[b & 0x0F]); }
|
||||
return id;
|
||||
}
|
||||
|
||||
// Broadcast the header + payload as two 0-value memo outputs to memos.recipientZaddr (header first,
|
||||
// the lower memo position). Routes to the lite controller in lite builds, else full-node z_sendmany.
|
||||
//
|
||||
// LIVE-VERIFY (cannot be proven from source): that dragonxd returns the memo under `memoStr` verbatim
|
||||
// on receive, that recipient-array order maps to note position (the receive pairing needs the header
|
||||
// at a lower position), that a 0-value memo-only tx relays, and full SDXL<->DragonX interop.
|
||||
bool App::broadcastChatMemos(const chat::OutgoingChatMemos& memos)
|
||||
{
|
||||
if (memos.recipientZaddr.empty()) return false;
|
||||
|
||||
if (lite_wallet_) {
|
||||
return broadcastChatMemosLite(memos);
|
||||
}
|
||||
|
||||
if (!state_.connected || !rpc_ || !worker_) {
|
||||
ui::Notifications::instance().error(TR("chat_toast_not_connected"));
|
||||
return false;
|
||||
}
|
||||
const std::string from = chatReplyZaddr(); // spendable + advertised as the reply-to address
|
||||
if (from.empty()) {
|
||||
ui::Notifications::instance().error(TR("chat_toast_no_zaddr"));
|
||||
return false;
|
||||
}
|
||||
|
||||
// Full-node: each memo needs the daemon's "utf8:" prefix (raw JSON/hex is otherwise rejected).
|
||||
const auto outputs = chat::chatSendOutputs(memos, /*utf8Prefix=*/true);
|
||||
nlohmann::json recipients = nlohmann::json::array();
|
||||
for (const auto& out : outputs) {
|
||||
nlohmann::json recipient;
|
||||
recipient["address"] = out.address;
|
||||
recipient["amount"] = util::formatAmountFixed(0.0); // 0-value memo output
|
||||
recipient["memo"] = out.memo;
|
||||
recipients.push_back(std::move(recipient));
|
||||
}
|
||||
|
||||
const double fee = settings_ ? settings_->getDefaultFee() : 0.0001;
|
||||
// markFeeGapRetry=true is deliberate: it suppresses the fee-gap auto-retry, which rebuilds a
|
||||
// SINGLE-recipient tx from the scalar to/amount/memo and would drop the second (payload) output.
|
||||
submitZSendMany(from, memos.recipientZaddr, 0.0, fee, /*memo*/"", recipients,
|
||||
"HushChat / broadcast", /*markFeeGapRetry*/ true, /*callback*/{});
|
||||
return true; // submitted (async build/broadcast; a later failure surfaces via the opid poller)
|
||||
}
|
||||
|
||||
// Lite variant: two 0-value recipients to the same z-address, RAW memos (the backend does
|
||||
// Memo::from_str directly — no "utf8:" prefix). The backend accepts duplicate addresses + 0-value
|
||||
// outputs for exactly this HushChat pattern. Fire-and-forget: the result surfaces via the lite
|
||||
// broadcast log; the message is already echoed locally.
|
||||
bool App::broadcastChatMemosLite(const chat::OutgoingChatMemos& memos)
|
||||
{
|
||||
if (!lite_wallet_) return false;
|
||||
const auto outputs = chat::chatSendOutputs(memos, /*utf8Prefix=*/false);
|
||||
wallet::LiteSendRequest req;
|
||||
for (const auto& out : outputs) {
|
||||
wallet::LiteSendRecipient recipient;
|
||||
recipient.address = out.address;
|
||||
recipient.amountZatoshis = 0;
|
||||
recipient.memo = out.memo;
|
||||
req.recipients.push_back(std::move(recipient));
|
||||
}
|
||||
if (!lite_wallet_->sendTransaction(req)) {
|
||||
ui::Notifications::instance().error(TR("chat_toast_lite_busy"));
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
void App::sendChatMessage(const std::string& conversationId, const std::string& text)
|
||||
{
|
||||
if (!chat::hushChatFeatureEnabledAtBuild() || !chat_service_.hasIdentity()) return;
|
||||
if (text.empty() || conversationId.empty()) return;
|
||||
|
||||
// The peer's z-address + public key come from a message we already have in this conversation.
|
||||
std::string peerZaddr;
|
||||
std::string peerPubKey;
|
||||
for (const auto& m : chat_service_.store().conversation(conversationId)) {
|
||||
if (!m.peer_zaddr.empty()) peerZaddr = m.peer_zaddr;
|
||||
if (!m.peer_public_key_hex.empty()) peerPubKey = m.peer_public_key_hex;
|
||||
}
|
||||
if (peerPubKey.empty()) {
|
||||
ui::Notifications::instance().info(TR("chat_toast_waiting_reply"));
|
||||
return;
|
||||
}
|
||||
const std::string myReply = chatReplyZaddr();
|
||||
if (myReply.empty()) {
|
||||
ui::Notifications::instance().error(TR("chat_toast_no_zaddr"));
|
||||
return;
|
||||
}
|
||||
|
||||
chat::OutgoingChatMemos memos;
|
||||
if (chat_service_.composeMessage(myReply, peerPubKey, peerZaddr, conversationId, text, memos)
|
||||
!= chat::ChatComposeStatus::Ok) {
|
||||
ui::Notifications::instance().error(TR("chat_toast_compose_failed"));
|
||||
return;
|
||||
}
|
||||
const bool submitted = broadcastChatMemos(memos);
|
||||
|
||||
chat::ChatMessage echo;
|
||||
echo.direction = chat::ChatDirection::Outgoing;
|
||||
echo.kind = chat::ChatMessageKind::Message;
|
||||
echo.conversation_id = conversationId;
|
||||
echo.peer_zaddr = peerZaddr;
|
||||
echo.peer_public_key_hex = peerPubKey;
|
||||
echo.body = text;
|
||||
echo.timestamp = std::time(nullptr);
|
||||
echo.txid = generateChatLocalId("out:", 8);
|
||||
echo.payload_position = 0;
|
||||
echo.delivery = submitted ? chat::ChatDelivery::Sent : chat::ChatDelivery::Failed;
|
||||
chat_service_.recordOutgoing(echo);
|
||||
}
|
||||
|
||||
void App::startChatConversation(const std::string& peerZaddr, const std::string& text)
|
||||
{
|
||||
if (!chat::hushChatFeatureEnabledAtBuild() || !chat_service_.hasIdentity()) return;
|
||||
if (peerZaddr.empty() || text.empty()) return;
|
||||
const std::string myReply = chatReplyZaddr();
|
||||
if (myReply.empty()) {
|
||||
ui::Notifications::instance().error(TR("chat_toast_no_zaddr"));
|
||||
return;
|
||||
}
|
||||
|
||||
const std::string cid = generateChatLocalId("", 16); // opaque per-conversation id
|
||||
chat::OutgoingChatMemos memos;
|
||||
if (chat_service_.composeContactRequest(myReply, peerZaddr, cid, text, memos)
|
||||
!= chat::ChatComposeStatus::Ok) {
|
||||
ui::Notifications::instance().error(TR("chat_toast_request_compose_failed"));
|
||||
return;
|
||||
}
|
||||
const bool submitted = broadcastChatMemos(memos);
|
||||
|
||||
chat::ChatMessage echo;
|
||||
echo.direction = chat::ChatDirection::Outgoing;
|
||||
echo.kind = chat::ChatMessageKind::ContactRequest;
|
||||
echo.conversation_id = cid;
|
||||
echo.peer_zaddr = peerZaddr;
|
||||
echo.body = text;
|
||||
echo.timestamp = std::time(nullptr);
|
||||
echo.txid = generateChatLocalId("out:", 8);
|
||||
echo.payload_position = 0;
|
||||
echo.delivery = submitted ? chat::ChatDelivery::Sent : chat::ChatDelivery::Failed;
|
||||
chat_service_.recordOutgoing(echo);
|
||||
if (submitted) ui::Notifications::instance().success(TR("chat_toast_request_queued"));
|
||||
}
|
||||
|
||||
// HushChat (lite variant): the full-node harvest works off z_viewtransaction, but the lite wallet's
|
||||
// transactions come from the backend. The backend lists one entry per received (non-change) note —
|
||||
// same txid, distinct position + decoded-UTF-8 memo — so a chat tx yields two entries (header +
|
||||
// payload). Group the received notes by txid, run them through the SAME parser, and thread the
|
||||
// result. The store dedups (txid+position), so re-listing every refresh is harmless.
|
||||
void App::ingestLiteChatMemos(const wallet::LiteWalletAppRefreshModel& model)
|
||||
{
|
||||
if (!chat::hushChatFeatureEnabledAtBuild() || !chat_service_.hasIdentity()) return;
|
||||
|
||||
std::unordered_map<std::string, chat::HushChatTransactionInput> byTxid;
|
||||
std::unordered_map<std::string, std::int64_t> txTimestamps;
|
||||
for (const auto& tx : model.transactions) {
|
||||
if (tx.kind != wallet::LiteWalletAppTransactionKind::Receive) continue; // only incoming carry chat
|
||||
if (tx.memo.empty() || !tx.position) continue;
|
||||
auto& input = byTxid[tx.txid];
|
||||
input.txid = tx.txid;
|
||||
input.outputs.push_back({static_cast<std::size_t>(*tx.position), tx.memo});
|
||||
txTimestamps[tx.txid] = tx.timestamp;
|
||||
}
|
||||
if (byTxid.empty()) return;
|
||||
|
||||
std::vector<chat::HushChatTransactionMetadata> metadata;
|
||||
for (auto& entry : byTxid) {
|
||||
auto extracted = chat::extractHushChatTransactionMetadata(entry.second, true);
|
||||
for (auto& meta : extracted.metadata) metadata.push_back(std::move(meta));
|
||||
}
|
||||
if (!metadata.empty()) chat_service_.ingest(metadata, txTimestamps, std::time(nullptr));
|
||||
}
|
||||
|
||||
bool App::lockLiteWallet()
|
||||
{
|
||||
if (!lite_wallet_) return false;
|
||||
const bool ok = lite_wallet_->lockWallet();
|
||||
if (ok) {
|
||||
// Full-node App::lockWallet() sets state_.locked synchronously; the lite backend `lock`
|
||||
// doesn't, and state_.locked only refreshes on the next ~2s poll. Mirror the full-node
|
||||
// behavior here and tear down the chat session NOW so no decrypted store / unlocked DB key
|
||||
// lingers past an explicit lock. (These chat calls are safe no-ops when chat is off/idle.)
|
||||
state_.locked = true;
|
||||
chat_service_.clearIdentity();
|
||||
chat_service_.store().clear();
|
||||
chat_db_.lock();
|
||||
chat_identity_provisioned_ = false;
|
||||
chat_identity_unavailable_ = false;
|
||||
}
|
||||
return ok;
|
||||
}
|
||||
|
||||
void App::seedChatDemoData()
|
||||
{
|
||||
if (!chat::hushChatFeatureEnabledAtBuild()) return;
|
||||
|
||||
// Demo identity so the tab renders the populated UI (hasIdentity()==true). Injected messages go
|
||||
// straight into the in-memory store (no DB attached here → not persisted, gone on restart).
|
||||
chat::ChatKeyPair keys;
|
||||
if (chat::deriveChatIdentityFromSecret("obsidian-dragon-demo-chat", keys).status
|
||||
== chat::ChatIdentityStatus::Ready) {
|
||||
chat_service_.setIdentity(keys);
|
||||
}
|
||||
chat::wipeChatKeyPair(keys);
|
||||
|
||||
auto& store = chat_service_.store();
|
||||
const std::string zAlice = "zs1demoalice6xh2n8fchrz23thcgqqd2353v8ev2pr7p7lq4p3elsyrfkuenq";
|
||||
const std::string zBob = "zs1demobob9k4p3elsyrfkuenq4kl79j2pg7l3h2juz4t6q9wqxkha2n8fchrz2";
|
||||
auto add = [&](const std::string& cid, const std::string& z, const std::string& pk,
|
||||
chat::ChatDirection dir, chat::ChatMessageKind kind, const std::string& body,
|
||||
std::int64_t ts, const std::string& id) {
|
||||
chat::ChatMessage m;
|
||||
m.direction = dir; m.kind = kind; m.conversation_id = cid; m.peer_zaddr = z;
|
||||
m.peer_public_key_hex = pk; m.body = body; m.timestamp = ts; m.txid = id;
|
||||
m.payload_position = 0;
|
||||
store.append(m);
|
||||
};
|
||||
// A full thread (peer key known → composer enabled).
|
||||
add("demo-1", zAlice, std::string(64, 'a'), chat::ChatDirection::Incoming, chat::ChatMessageKind::Message,
|
||||
"hey! got the DragonX wallet running, chat works great", 1751284800, "demo:a1");
|
||||
add("demo-1", zAlice, std::string(64, 'a'), chat::ChatDirection::Outgoing, chat::ChatMessageKind::Message,
|
||||
"nice - this is end-to-end encrypted over shielded memos", 1751285400, "demo:a2");
|
||||
add("demo-1", zAlice, std::string(64, 'a'), chat::ChatDirection::Incoming, chat::ChatMessageKind::Message,
|
||||
"and it survives the daemon's output shuffle now", 1751286000, "demo:a3");
|
||||
// An incoming contact request (peer key known → can reply).
|
||||
add("demo-2", zBob, std::string(64, 'b'), chat::ChatDirection::Incoming, chat::ChatMessageKind::ContactRequest,
|
||||
"hi, add me? - bob", 1751200000, "demo:b1");
|
||||
// An outgoing contact request awaiting a reply (no peer key → "waiting" composer state).
|
||||
add("demo-3", "zs1demopeerawaitingtheirfirstreplybeforewecanmessagethemyet00", "",
|
||||
chat::ChatDirection::Outgoing, chat::ChatMessageKind::ContactRequest, "hey, let's chat", 1751100000, "demo:c1");
|
||||
}
|
||||
|
||||
void App::exportAllKeys(std::function<void(const std::string&, int, int)> callback)
|
||||
{
|
||||
if (!state_.connected || !rpc_) {
|
||||
|
||||
@@ -612,7 +612,12 @@ void App::checkAutoLock() {
|
||||
float elapsed = std::chrono::duration<float>(now - last_interaction_).count();
|
||||
|
||||
if (elapsed >= (float)timeout) {
|
||||
lockWallet();
|
||||
// Lite has no daemon `walletlock` — App::lockWallet() early-returns without rpc_, so route
|
||||
// lite through lockLiteWallet() (locks the backend + tears down the chat session so no
|
||||
// decrypted store / unlocked DB key survives the idle lock). In full-node builds
|
||||
// lite_wallet_ is always null, so this branch is a no-op and behaviour is unchanged.
|
||||
if (lite_wallet_) lockLiteWallet();
|
||||
else lockWallet();
|
||||
DEBUG_LOGF("[App] Auto-locked wallet after %d seconds idle\n", timeout);
|
||||
}
|
||||
}
|
||||
|
||||
173
src/chat/chat_crypto.cpp
Normal file
173
src/chat/chat_crypto.cpp
Normal file
@@ -0,0 +1,173 @@
|
||||
// DragonX Wallet - HushChat crypto primitives (implementation).
|
||||
|
||||
#include "chat_crypto.h"
|
||||
|
||||
#include <sodium.h>
|
||||
|
||||
#include <cstring>
|
||||
#include <vector>
|
||||
|
||||
namespace dragonx::chat {
|
||||
|
||||
namespace {
|
||||
|
||||
// Local constants tied to the libsodium primitive. (The dev-only chat_fixture_tooling.h
|
||||
// declares equivalents, but that header is not linked into the app.)
|
||||
constexpr std::size_t kStreamHeaderBytes = 24; // crypto_secretstream_xchacha20poly1305_HEADERBYTES
|
||||
constexpr std::size_t kStreamABytes = 17; // crypto_secretstream_xchacha20poly1305_ABYTES
|
||||
|
||||
static_assert(kChatKeyBytes == crypto_kx_PUBLICKEYBYTES, "kx public key size mismatch");
|
||||
static_assert(kChatKeyBytes == crypto_kx_SECRETKEYBYTES, "kx secret key size mismatch");
|
||||
|
||||
// Decode exactly outLen bytes from a lowercase/uppercase hex string; reject any other length.
|
||||
bool hexToFixed(const std::string& hex, unsigned char* out, std::size_t outLen) {
|
||||
if (hex.size() != outLen * 2) return false;
|
||||
std::size_t binLen = 0;
|
||||
if (sodium_hex2bin(out, outLen, hex.data(), hex.size(), nullptr, &binLen, nullptr) != 0) {
|
||||
return false;
|
||||
}
|
||||
return binLen == outLen;
|
||||
}
|
||||
|
||||
// Decode a variable-length hex string into bytes.
|
||||
bool hexToBytes(const std::string& hex, std::vector<unsigned char>& out) {
|
||||
if (hex.empty() || (hex.size() % 2) != 0) return false;
|
||||
out.resize(hex.size() / 2);
|
||||
std::size_t binLen = 0;
|
||||
if (sodium_hex2bin(out.data(), out.size(), hex.data(), hex.size(), nullptr, &binLen, nullptr) != 0) {
|
||||
return false;
|
||||
}
|
||||
out.resize(binLen);
|
||||
return true;
|
||||
}
|
||||
|
||||
std::string bytesToHex(const unsigned char* bytes, std::size_t n) {
|
||||
std::string hex(n * 2 + 1, '\0');
|
||||
sodium_bin2hex(&hex[0], hex.size(), bytes, n);
|
||||
hex.resize(n * 2); // drop the NUL sodium_bin2hex appends
|
||||
return hex;
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
const char* chatCryptoStatusName(ChatCryptoStatus status) {
|
||||
switch (status) {
|
||||
case ChatCryptoStatus::Ok: return "Ok";
|
||||
case ChatCryptoStatus::SodiumInitFailed: return "SodiumInitFailed";
|
||||
case ChatCryptoStatus::BadPeerKey: return "BadPeerKey";
|
||||
case ChatCryptoStatus::BadHeaderHex: return "BadHeaderHex";
|
||||
case ChatCryptoStatus::BadCiphertextHex: return "BadCiphertextHex";
|
||||
case ChatCryptoStatus::CiphertextTooShort: return "CiphertextTooShort";
|
||||
case ChatCryptoStatus::SessionKeyFailed: return "SessionKeyFailed";
|
||||
case ChatCryptoStatus::EncryptFailed: return "EncryptFailed";
|
||||
case ChatCryptoStatus::DecryptFailed: return "DecryptFailed";
|
||||
}
|
||||
return "Unknown";
|
||||
}
|
||||
|
||||
void wipeChatKeyPair(ChatKeyPair& keys) {
|
||||
sodium_memzero(keys.public_key.data(), keys.public_key.size());
|
||||
sodium_memzero(keys.secret_key.data(), keys.secret_key.size());
|
||||
}
|
||||
|
||||
ChatCryptoStatus encryptOutgoing(const ChatKeyPair& mine,
|
||||
const std::string& peerPublicKeyHex,
|
||||
const std::string& plaintext,
|
||||
std::string& outStreamHeaderHex,
|
||||
std::string& outCiphertextHex) {
|
||||
static_assert(kStreamHeaderBytes == crypto_secretstream_xchacha20poly1305_HEADERBYTES, "");
|
||||
static_assert(kStreamABytes == crypto_secretstream_xchacha20poly1305_ABYTES, "");
|
||||
|
||||
if (sodium_init() < 0) return ChatCryptoStatus::SodiumInitFailed;
|
||||
|
||||
unsigned char peerPk[crypto_kx_PUBLICKEYBYTES];
|
||||
if (!hexToFixed(peerPublicKeyHex, peerPk, sizeof peerPk)) return ChatCryptoStatus::BadPeerKey;
|
||||
|
||||
unsigned char rx[crypto_kx_SESSIONKEYBYTES];
|
||||
unsigned char tx[crypto_kx_SESSIONKEYBYTES];
|
||||
if (crypto_kx_server_session_keys(rx, tx, mine.public_key.data(), mine.secret_key.data(), peerPk) != 0) {
|
||||
sodium_memzero(rx, sizeof rx);
|
||||
sodium_memzero(tx, sizeof tx);
|
||||
return ChatCryptoStatus::SessionKeyFailed;
|
||||
}
|
||||
|
||||
crypto_secretstream_xchacha20poly1305_state state;
|
||||
unsigned char header[crypto_secretstream_xchacha20poly1305_HEADERBYTES];
|
||||
ChatCryptoStatus result = ChatCryptoStatus::EncryptFailed;
|
||||
|
||||
if (crypto_secretstream_xchacha20poly1305_init_push(&state, header, tx) == 0) {
|
||||
std::vector<unsigned char> ciphertext(plaintext.size() + crypto_secretstream_xchacha20poly1305_ABYTES);
|
||||
unsigned long long ctLen = 0;
|
||||
// Only report Ok if the push actually succeeded — otherwise ctLen stays 0 and we would
|
||||
// ship a valid header with an empty ciphertext.
|
||||
if (crypto_secretstream_xchacha20poly1305_push(
|
||||
&state, ciphertext.data(), &ctLen,
|
||||
reinterpret_cast<const unsigned char*>(plaintext.data()), plaintext.size(),
|
||||
nullptr, 0, crypto_secretstream_xchacha20poly1305_TAG_FINAL) == 0) {
|
||||
outStreamHeaderHex = bytesToHex(header, sizeof header);
|
||||
outCiphertextHex = bytesToHex(ciphertext.data(), static_cast<std::size_t>(ctLen));
|
||||
result = ChatCryptoStatus::Ok;
|
||||
}
|
||||
}
|
||||
|
||||
sodium_memzero(rx, sizeof rx);
|
||||
sodium_memzero(tx, sizeof tx);
|
||||
sodium_memzero(&state, sizeof state);
|
||||
return result;
|
||||
}
|
||||
|
||||
ChatCryptoStatus decryptIncoming(const ChatKeyPair& mine,
|
||||
const std::string& peerPublicKeyHex,
|
||||
const std::string& streamHeaderHex,
|
||||
const std::string& ciphertextHex,
|
||||
std::string& outPlaintext) {
|
||||
if (sodium_init() < 0) return ChatCryptoStatus::SodiumInitFailed;
|
||||
|
||||
unsigned char peerPk[crypto_kx_PUBLICKEYBYTES];
|
||||
if (!hexToFixed(peerPublicKeyHex, peerPk, sizeof peerPk)) return ChatCryptoStatus::BadPeerKey;
|
||||
|
||||
unsigned char header[crypto_secretstream_xchacha20poly1305_HEADERBYTES];
|
||||
if (!hexToFixed(streamHeaderHex, header, sizeof header)) return ChatCryptoStatus::BadHeaderHex;
|
||||
|
||||
std::vector<unsigned char> ciphertext;
|
||||
if (!hexToBytes(ciphertextHex, ciphertext)) return ChatCryptoStatus::BadCiphertextHex;
|
||||
// Guard the size_t subtraction below (a ciphertext shorter than the auth tag can't be
|
||||
// authentic). Exactly ABYTES is the valid empty-plaintext case, so it round-trips
|
||||
// symmetrically with encryptOutgoing (message-content policy belongs to the caller).
|
||||
if (ciphertext.size() < crypto_secretstream_xchacha20poly1305_ABYTES) {
|
||||
return ChatCryptoStatus::CiphertextTooShort;
|
||||
}
|
||||
|
||||
unsigned char rx[crypto_kx_SESSIONKEYBYTES];
|
||||
unsigned char tx[crypto_kx_SESSIONKEYBYTES];
|
||||
if (crypto_kx_client_session_keys(rx, tx, mine.public_key.data(), mine.secret_key.data(), peerPk) != 0) {
|
||||
sodium_memzero(rx, sizeof rx);
|
||||
sodium_memzero(tx, sizeof tx);
|
||||
return ChatCryptoStatus::SessionKeyFailed;
|
||||
}
|
||||
|
||||
crypto_secretstream_xchacha20poly1305_state state;
|
||||
ChatCryptoStatus result = ChatCryptoStatus::DecryptFailed;
|
||||
|
||||
if (crypto_secretstream_xchacha20poly1305_init_pull(&state, header, rx) == 0) {
|
||||
std::vector<unsigned char> plain(ciphertext.size() - crypto_secretstream_xchacha20poly1305_ABYTES);
|
||||
unsigned long long plainLen = 0;
|
||||
unsigned char tag = 0;
|
||||
if (crypto_secretstream_xchacha20poly1305_pull(
|
||||
&state, plain.data(), &plainLen, &tag,
|
||||
ciphertext.data(), ciphertext.size(), nullptr, 0) == 0 &&
|
||||
tag == crypto_secretstream_xchacha20poly1305_TAG_FINAL) {
|
||||
outPlaintext.assign(reinterpret_cast<const char*>(plain.data()),
|
||||
static_cast<std::size_t>(plainLen));
|
||||
result = ChatCryptoStatus::Ok;
|
||||
}
|
||||
sodium_memzero(plain.data(), plain.size()); // wipe the decrypted scratch
|
||||
}
|
||||
|
||||
sodium_memzero(rx, sizeof rx);
|
||||
sodium_memzero(tx, sizeof tx);
|
||||
sodium_memzero(&state, sizeof state);
|
||||
return result;
|
||||
}
|
||||
|
||||
} // namespace dragonx::chat
|
||||
65
src/chat/chat_crypto.h
Normal file
65
src/chat/chat_crypto.h
Normal file
@@ -0,0 +1,65 @@
|
||||
#pragma once
|
||||
|
||||
// DragonX Wallet - HushChat crypto primitives.
|
||||
//
|
||||
// crypto_kx (X25519) session-key agreement + crypto_secretstream_xchacha20poly1305
|
||||
// message encryption, byte-exact per the HushChat wire format so DragonX interoperates
|
||||
// with SilentDragonXLite. See docs/_archive/contacts-chat-phase1-detail-2026-07-05.md
|
||||
// (Appendix A.3/A.4). Pure crypto — no gating, no I/O; the feature gate lives at the
|
||||
// service layer. NEVER logs plaintext, ciphertext, keys, or session material.
|
||||
|
||||
#include <array>
|
||||
#include <cstddef>
|
||||
#include <string>
|
||||
|
||||
namespace dragonx::chat {
|
||||
|
||||
// crypto_kx key sizes (== crypto_kx_PUBLICKEYBYTES / SECRETKEYBYTES == 32).
|
||||
constexpr std::size_t kChatKeyBytes = 32;
|
||||
|
||||
using ChatPublicKey = std::array<unsigned char, kChatKeyBytes>;
|
||||
using ChatSecretKey = std::array<unsigned char, kChatKeyBytes>;
|
||||
|
||||
struct ChatKeyPair {
|
||||
ChatPublicKey public_key{};
|
||||
ChatSecretKey secret_key{};
|
||||
};
|
||||
|
||||
enum class ChatCryptoStatus {
|
||||
Ok,
|
||||
SodiumInitFailed,
|
||||
BadPeerKey, // peer public-key hex missing / wrong length / not hex
|
||||
BadHeaderHex, // secretstream header hex missing / wrong length / not hex
|
||||
BadCiphertextHex, // ciphertext hex malformed
|
||||
CiphertextTooShort, // ciphertext shorter than the auth tag
|
||||
SessionKeyFailed, // crypto_kx_*_session_keys rejected the peer key
|
||||
EncryptFailed,
|
||||
DecryptFailed // init_pull / pull / tag mismatch — the single neutral auth failure
|
||||
};
|
||||
|
||||
const char* chatCryptoStatusName(ChatCryptoStatus status);
|
||||
|
||||
// Encrypt `plaintext` addressed to peer `peerPublicKeyHex` (64 lowercase hex chars).
|
||||
// Sender takes the crypto_kx "server" role (server_tx), matching SDXL's send path.
|
||||
// Outputs the secretstream header hex (the memo "e" field) and the ciphertext hex
|
||||
// (the payload memo). Returns Ok on success.
|
||||
ChatCryptoStatus encryptOutgoing(const ChatKeyPair& mine,
|
||||
const std::string& peerPublicKeyHex,
|
||||
const std::string& plaintext,
|
||||
std::string& outStreamHeaderHex,
|
||||
std::string& outCiphertextHex);
|
||||
|
||||
// Decrypt an incoming message addressed to us from peer `peerPublicKeyHex`.
|
||||
// Receiver takes the crypto_kx "client" role (client_rx), matching SDXL's receive path.
|
||||
// Requires the memo "e" (streamHeaderHex) and the payload ciphertext hex. Enforces the
|
||||
// Poly1305 auth tag AND that the stream tag is TAG_FINAL. Returns Ok + fills outPlaintext.
|
||||
ChatCryptoStatus decryptIncoming(const ChatKeyPair& mine,
|
||||
const std::string& peerPublicKeyHex,
|
||||
const std::string& streamHeaderHex,
|
||||
const std::string& ciphertextHex,
|
||||
std::string& outPlaintext);
|
||||
|
||||
// Zero both key arrays (call when discarding an identity's keys).
|
||||
void wipeChatKeyPair(ChatKeyPair& keys);
|
||||
|
||||
} // namespace dragonx::chat
|
||||
330
src/chat/chat_database.cpp
Normal file
330
src/chat/chat_database.cpp
Normal file
@@ -0,0 +1,330 @@
|
||||
// DragonX Wallet - HushChat persistent message store (implementation).
|
||||
|
||||
#include "chat_database.h"
|
||||
|
||||
#include "../util/logger.h"
|
||||
#include "../util/platform.h"
|
||||
|
||||
#include <nlohmann/json.hpp>
|
||||
#include <sodium.h>
|
||||
#include <sqlite3.h>
|
||||
|
||||
#include <cstdint>
|
||||
#include <filesystem>
|
||||
#include <utility>
|
||||
|
||||
namespace fs = std::filesystem;
|
||||
|
||||
namespace dragonx::chat {
|
||||
|
||||
namespace {
|
||||
|
||||
// Domain-separated KDF contexts (used as the keyed-BLAKE2b key, like chat_identity). Both lengths
|
||||
// sit inside crypto_generichash's key-length bounds. Bumping a context rotates that derivation.
|
||||
constexpr char kStorageKeyContext[] = "DragonX-HushChat-Storage-v1";
|
||||
constexpr char kWalletTagContext[] = "DragonX-HushChat-WalletId-v1";
|
||||
constexpr std::size_t kStorageKeyContextLen = sizeof(kStorageKeyContext) - 1;
|
||||
constexpr std::size_t kWalletTagContextLen = sizeof(kWalletTagContext) - 1;
|
||||
|
||||
std::string toHex(const unsigned char* data, std::size_t len)
|
||||
{
|
||||
static const char* kHex = "0123456789abcdef";
|
||||
std::string out;
|
||||
out.reserve(len * 2);
|
||||
for (std::size_t i = 0; i < len; ++i) {
|
||||
out.push_back(kHex[data[i] >> 4]);
|
||||
out.push_back(kHex[data[i] & 0x0F]);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
// keyed-BLAKE2b: out = generichash(in=secret, key=context). Deterministic, so the same seed always
|
||||
// derives the same storage key + wallet tag across sessions.
|
||||
bool deriveKeyed(const std::string& secret, const char* context, std::size_t contextLen,
|
||||
unsigned char* out, std::size_t outLen)
|
||||
{
|
||||
return crypto_generichash(out, outLen,
|
||||
reinterpret_cast<const unsigned char*>(secret.data()), secret.size(),
|
||||
reinterpret_cast<const unsigned char*>(context), contextLen) == 0;
|
||||
}
|
||||
|
||||
std::string associatedData(const std::string& walletTag)
|
||||
{
|
||||
return std::string("obsidian-dragon-hushchat-v1:") + walletTag;
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
ChatDatabase::ChatDatabase() : database_path_(defaultDatabasePath()) {}
|
||||
ChatDatabase::ChatDatabase(std::string databasePath) : database_path_(std::move(databasePath)) {}
|
||||
|
||||
ChatDatabase::~ChatDatabase()
|
||||
{
|
||||
lock();
|
||||
close();
|
||||
}
|
||||
|
||||
std::string ChatDatabase::defaultDatabasePath()
|
||||
{
|
||||
return (fs::path(util::Platform::getConfigDir()) / "chat_messages.sqlite").string();
|
||||
}
|
||||
|
||||
bool ChatDatabase::unlockWithSecret(const std::string& secret)
|
||||
{
|
||||
if (sodium_init() < 0) return false;
|
||||
|
||||
if (!deriveKeyed(secret, kStorageKeyContext, kStorageKeyContextLen, key_.data(), key_.size()))
|
||||
return false;
|
||||
|
||||
unsigned char tag[32];
|
||||
if (!deriveKeyed(secret, kWalletTagContext, kWalletTagContextLen, tag, sizeof(tag))) {
|
||||
sodium_memzero(key_.data(), key_.size());
|
||||
return false;
|
||||
}
|
||||
wallet_tag_ = toHex(tag, sizeof(tag));
|
||||
sodium_memzero(tag, sizeof(tag));
|
||||
key_ready_ = true;
|
||||
|
||||
if (!ensureOpen()) {
|
||||
lock();
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
void ChatDatabase::lock()
|
||||
{
|
||||
sodium_memzero(key_.data(), key_.size());
|
||||
key_ready_ = false;
|
||||
wallet_tag_.clear();
|
||||
}
|
||||
|
||||
bool ChatDatabase::append(const ChatMessage& message)
|
||||
{
|
||||
if (!key_ready_ || !ensureOpen()) return false;
|
||||
|
||||
std::vector<unsigned char> nonce;
|
||||
std::vector<unsigned char> cipher;
|
||||
std::string plain = serialize(message); // full plaintext (decrypted body + metadata)
|
||||
const bool encrypted = encrypt(plain, nonce, cipher);
|
||||
if (!plain.empty()) sodium_memzero(&plain[0], plain.size()); // don't leave it on the heap
|
||||
if (!encrypted) return false;
|
||||
|
||||
const std::string dedup = dedupHash(message.txid, message.payload_position);
|
||||
|
||||
sqlite3_stmt* stmt = nullptr;
|
||||
if (sqlite3_prepare_v2(db_,
|
||||
"INSERT OR IGNORE INTO chat_messages (wallet_tag, dedup_hash, nonce, payload) "
|
||||
"VALUES (?, ?, ?, ?)",
|
||||
-1, &stmt, nullptr) != SQLITE_OK) {
|
||||
return false;
|
||||
}
|
||||
sqlite3_bind_text(stmt, 1, wallet_tag_.c_str(), -1, SQLITE_TRANSIENT);
|
||||
sqlite3_bind_text(stmt, 2, dedup.c_str(), -1, SQLITE_TRANSIENT);
|
||||
sqlite3_bind_blob(stmt, 3, nonce.data(), static_cast<int>(nonce.size()), SQLITE_TRANSIENT);
|
||||
sqlite3_bind_blob(stmt, 4, cipher.data(), static_cast<int>(cipher.size()), SQLITE_TRANSIENT);
|
||||
const bool done = sqlite3_step(stmt) == SQLITE_DONE;
|
||||
sqlite3_finalize(stmt);
|
||||
if (!done) return false;
|
||||
return sqlite3_changes(db_) > 0;
|
||||
}
|
||||
|
||||
std::vector<ChatMessage> ChatDatabase::load()
|
||||
{
|
||||
std::vector<ChatMessage> out;
|
||||
if (!key_ready_ || !ensureOpen()) return out;
|
||||
|
||||
sqlite3_stmt* stmt = nullptr;
|
||||
if (sqlite3_prepare_v2(db_,
|
||||
"SELECT nonce, payload FROM chat_messages WHERE wallet_tag = ? ORDER BY rowid",
|
||||
-1, &stmt, nullptr) != SQLITE_OK) {
|
||||
return out;
|
||||
}
|
||||
sqlite3_bind_text(stmt, 1, wallet_tag_.c_str(), -1, SQLITE_TRANSIENT);
|
||||
while (sqlite3_step(stmt) == SQLITE_ROW) {
|
||||
const auto* noncePtr = static_cast<const unsigned char*>(sqlite3_column_blob(stmt, 0));
|
||||
const int nonceLen = sqlite3_column_bytes(stmt, 0);
|
||||
const auto* cipherPtr = static_cast<const unsigned char*>(sqlite3_column_blob(stmt, 1));
|
||||
const int cipherLen = sqlite3_column_bytes(stmt, 1);
|
||||
if (!noncePtr || !cipherPtr) continue;
|
||||
|
||||
std::vector<unsigned char> nonce(noncePtr, noncePtr + nonceLen);
|
||||
std::vector<unsigned char> cipher(cipherPtr, cipherPtr + cipherLen);
|
||||
std::string plain;
|
||||
if (!decrypt(nonce, cipher, plain)) continue; // wrong wallet / tampered — skip
|
||||
|
||||
ChatMessage message;
|
||||
if (deserialize(plain, message)) out.push_back(std::move(message));
|
||||
sodium_memzero(&plain[0], plain.size());
|
||||
}
|
||||
sqlite3_finalize(stmt);
|
||||
return out;
|
||||
}
|
||||
|
||||
void ChatDatabase::clearWallet()
|
||||
{
|
||||
if (wallet_tag_.empty() || !ensureOpen()) return;
|
||||
sqlite3_stmt* stmt = nullptr;
|
||||
if (sqlite3_prepare_v2(db_, "DELETE FROM chat_messages WHERE wallet_tag = ?", -1, &stmt, nullptr)
|
||||
!= SQLITE_OK) {
|
||||
return;
|
||||
}
|
||||
sqlite3_bind_text(stmt, 1, wallet_tag_.c_str(), -1, SQLITE_TRANSIENT);
|
||||
sqlite3_step(stmt);
|
||||
sqlite3_finalize(stmt);
|
||||
}
|
||||
|
||||
bool ChatDatabase::ensureOpen()
|
||||
{
|
||||
if (db_) return true;
|
||||
|
||||
try {
|
||||
fs::path path(database_path_);
|
||||
if (!path.parent_path().empty()) fs::create_directories(path.parent_path());
|
||||
} catch (const std::exception& exception) {
|
||||
DEBUG_LOGF("Failed to create chat database directory: %s\n", exception.what());
|
||||
return false;
|
||||
}
|
||||
|
||||
sqlite3* openedDb = nullptr;
|
||||
if (sqlite3_open(database_path_.c_str(), &openedDb) != SQLITE_OK) {
|
||||
DEBUG_LOGF("Failed to open chat database: %s\n",
|
||||
openedDb ? sqlite3_errmsg(openedDb) : "unknown error");
|
||||
if (openedDb) sqlite3_close(openedDb);
|
||||
return false;
|
||||
}
|
||||
|
||||
db_ = openedDb;
|
||||
sqlite3_busy_timeout(db_, 2000);
|
||||
exec("PRAGMA journal_mode=WAL");
|
||||
exec("PRAGMA synchronous=NORMAL");
|
||||
|
||||
if (!createSchema()) {
|
||||
close();
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
bool ChatDatabase::exec(const char* sql)
|
||||
{
|
||||
if (!db_) return false;
|
||||
char* error = nullptr;
|
||||
if (sqlite3_exec(db_, sql, nullptr, nullptr, &error) != SQLITE_OK) {
|
||||
DEBUG_LOGF("Chat database SQL error: %s\n", error ? error : sqlite3_errmsg(db_));
|
||||
if (error) sqlite3_free(error);
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
bool ChatDatabase::createSchema()
|
||||
{
|
||||
return exec("CREATE TABLE IF NOT EXISTS chat_messages ("
|
||||
"wallet_tag TEXT NOT NULL, "
|
||||
"dedup_hash TEXT NOT NULL, "
|
||||
"nonce BLOB NOT NULL, "
|
||||
"payload BLOB NOT NULL, "
|
||||
"PRIMARY KEY (wallet_tag, dedup_hash))");
|
||||
}
|
||||
|
||||
std::string ChatDatabase::dedupHash(const std::string& txid, std::size_t position) const
|
||||
{
|
||||
const std::string input = txid + ":" + std::to_string(position);
|
||||
unsigned char hash[32];
|
||||
crypto_generichash(hash, sizeof(hash),
|
||||
reinterpret_cast<const unsigned char*>(input.data()), input.size(),
|
||||
key_.data(), key_.size()); // keyed by the storage key → txid stays private
|
||||
return toHex(hash, sizeof(hash));
|
||||
}
|
||||
|
||||
std::string ChatDatabase::serialize(const ChatMessage& message) const
|
||||
{
|
||||
nlohmann::json json;
|
||||
json["d"] = static_cast<int>(message.direction);
|
||||
json["k"] = static_cast<int>(message.kind);
|
||||
json["txid"] = message.txid;
|
||||
json["cid"] = message.conversation_id;
|
||||
json["z"] = message.peer_zaddr;
|
||||
json["p"] = message.peer_public_key_hex;
|
||||
json["b"] = message.body;
|
||||
json["ts"] = message.timestamp;
|
||||
json["pos"] = static_cast<std::uint64_t>(message.payload_position);
|
||||
json["dl"] = static_cast<int>(message.delivery);
|
||||
return json.dump();
|
||||
}
|
||||
|
||||
bool ChatDatabase::deserialize(const std::string& json, ChatMessage& out) const
|
||||
{
|
||||
try {
|
||||
const auto parsed = nlohmann::json::parse(json);
|
||||
out.direction = static_cast<ChatDirection>(parsed.value("d", 0));
|
||||
out.kind = static_cast<ChatMessageKind>(parsed.value("k", 0));
|
||||
out.txid = parsed.value("txid", std::string());
|
||||
out.conversation_id = parsed.value("cid", std::string());
|
||||
out.peer_zaddr = parsed.value("z", std::string());
|
||||
out.peer_public_key_hex = parsed.value("p", std::string());
|
||||
out.body = parsed.value("b", std::string());
|
||||
out.timestamp = parsed.value("ts", static_cast<std::int64_t>(0));
|
||||
out.payload_position = static_cast<std::size_t>(parsed.value("pos", static_cast<std::uint64_t>(0)));
|
||||
out.delivery = static_cast<ChatDelivery>(parsed.value("dl", 0)); // old rows → Sent
|
||||
return true;
|
||||
} catch (const std::exception&) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
bool ChatDatabase::encrypt(const std::string& plain,
|
||||
std::vector<unsigned char>& nonce,
|
||||
std::vector<unsigned char>& cipher) const
|
||||
{
|
||||
if (!key_ready_) return false;
|
||||
nonce.resize(crypto_aead_xchacha20poly1305_ietf_NPUBBYTES);
|
||||
randombytes_buf(nonce.data(), nonce.size());
|
||||
|
||||
const std::string ad = associatedData(wallet_tag_);
|
||||
cipher.resize(plain.size() + crypto_aead_xchacha20poly1305_ietf_ABYTES);
|
||||
unsigned long long cipherLen = 0;
|
||||
if (crypto_aead_xchacha20poly1305_ietf_encrypt(
|
||||
cipher.data(), &cipherLen,
|
||||
reinterpret_cast<const unsigned char*>(plain.data()), plain.size(),
|
||||
reinterpret_cast<const unsigned char*>(ad.data()), ad.size(),
|
||||
nullptr, nonce.data(), key_.data()) != 0) {
|
||||
return false;
|
||||
}
|
||||
cipher.resize(static_cast<std::size_t>(cipherLen));
|
||||
return true;
|
||||
}
|
||||
|
||||
bool ChatDatabase::decrypt(const std::vector<unsigned char>& nonce,
|
||||
const std::vector<unsigned char>& cipher,
|
||||
std::string& plain) const
|
||||
{
|
||||
if (!key_ready_) return false;
|
||||
if (nonce.size() != crypto_aead_xchacha20poly1305_ietf_NPUBBYTES) return false;
|
||||
if (cipher.size() < crypto_aead_xchacha20poly1305_ietf_ABYTES) return false;
|
||||
|
||||
const std::string ad = associatedData(wallet_tag_);
|
||||
std::vector<unsigned char> out(cipher.size());
|
||||
unsigned long long outLen = 0;
|
||||
if (crypto_aead_xchacha20poly1305_ietf_decrypt(
|
||||
out.data(), &outLen, nullptr,
|
||||
cipher.data(), cipher.size(),
|
||||
reinterpret_cast<const unsigned char*>(ad.data()), ad.size(),
|
||||
nonce.data(), key_.data()) != 0) {
|
||||
return false;
|
||||
}
|
||||
plain.assign(reinterpret_cast<const char*>(out.data()), static_cast<std::size_t>(outLen));
|
||||
sodium_memzero(out.data(), out.size());
|
||||
return true;
|
||||
}
|
||||
|
||||
void ChatDatabase::close()
|
||||
{
|
||||
if (db_) {
|
||||
sqlite3_close(db_);
|
||||
db_ = nullptr;
|
||||
}
|
||||
}
|
||||
|
||||
} // namespace dragonx::chat
|
||||
74
src/chat/chat_database.h
Normal file
74
src/chat/chat_database.h
Normal file
@@ -0,0 +1,74 @@
|
||||
#pragma once
|
||||
|
||||
// DragonX Wallet - HushChat persistent message store (Phase 2).
|
||||
//
|
||||
// Sqlite-backed, encrypted at rest with a SEED-DERIVED key (no wallet passphrase). Every record —
|
||||
// message bodies, peer z-addresses, threading (conversation id), and timestamps — is AEAD-encrypted
|
||||
// under a key derived from the wallet's own seed secret (the same secret used for the chat
|
||||
// identity), and even the per-message dedup key is a KEYED hash of the txid — so the database
|
||||
// reveals nothing about your conversations to disk-level access without the seed. Rows are
|
||||
// partitioned by a seed-derived wallet tag so one file can hold several wallets, each readable only
|
||||
// with its own seed. Not thread-safe — drive from the main thread. Mirrors the lifecycle of
|
||||
// data::TransactionHistoryCache.
|
||||
|
||||
#include "chat_message.h"
|
||||
|
||||
#include <array>
|
||||
#include <cstddef>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
struct sqlite3;
|
||||
|
||||
namespace dragonx::chat {
|
||||
|
||||
class ChatDatabase {
|
||||
public:
|
||||
ChatDatabase();
|
||||
explicit ChatDatabase(std::string databasePath);
|
||||
~ChatDatabase();
|
||||
|
||||
ChatDatabase(const ChatDatabase&) = delete;
|
||||
ChatDatabase& operator=(const ChatDatabase&) = delete;
|
||||
|
||||
static std::string defaultDatabasePath();
|
||||
|
||||
// Derive the storage key + wallet tag from the wallet's seed secret and open the DB. The caller
|
||||
// still owns and must wipe `secret`. Returns false on sodium/db failure (DB then stays locked).
|
||||
bool unlockWithSecret(const std::string& secret);
|
||||
void lock(); // wipe the key material (DB handle stays open); load()/append() then no-op
|
||||
bool hasKey() const { return key_ready_; }
|
||||
|
||||
// Persist one message (INSERT OR IGNORE, deduped by a keyed hash of txid+payload_position).
|
||||
// Returns true if newly inserted; false on duplicate or while locked.
|
||||
bool append(const ChatMessage& message);
|
||||
|
||||
// Decrypt and return every stored message for the unlocked wallet, in insertion order. Empty
|
||||
// while locked or if none. Rows that fail to decrypt/parse are skipped.
|
||||
std::vector<ChatMessage> load();
|
||||
|
||||
void clearWallet(); // delete the unlocked wallet's rows
|
||||
|
||||
private:
|
||||
bool ensureOpen();
|
||||
bool exec(const char* sql);
|
||||
bool createSchema();
|
||||
std::string dedupHash(const std::string& txid, std::size_t position) const;
|
||||
std::string serialize(const ChatMessage& message) const;
|
||||
bool deserialize(const std::string& json, ChatMessage& out) const;
|
||||
bool encrypt(const std::string& plain,
|
||||
std::vector<unsigned char>& nonce,
|
||||
std::vector<unsigned char>& cipher) const;
|
||||
bool decrypt(const std::vector<unsigned char>& nonce,
|
||||
const std::vector<unsigned char>& cipher,
|
||||
std::string& plain) const;
|
||||
void close();
|
||||
|
||||
sqlite3* db_ = nullptr;
|
||||
std::string database_path_;
|
||||
std::array<unsigned char, 32> key_{}; // AEAD storage key (seed-derived)
|
||||
std::string wallet_tag_; // seed-derived row partition (a keyed hash, hex)
|
||||
bool key_ready_ = false;
|
||||
};
|
||||
|
||||
} // namespace dragonx::chat
|
||||
70
src/chat/chat_identity.cpp
Normal file
70
src/chat/chat_identity.cpp
Normal file
@@ -0,0 +1,70 @@
|
||||
// DragonX Wallet - HushChat identity derivation (implementation).
|
||||
|
||||
#include "chat_identity.h"
|
||||
|
||||
#include <sodium.h>
|
||||
|
||||
namespace dragonx::chat {
|
||||
|
||||
// The KDF context is used as the BLAKE2b key, so its length must sit within the primitive's
|
||||
// key bounds.
|
||||
static_assert(kChatIdentityKdfContextLen >= crypto_generichash_KEYBYTES_MIN,
|
||||
"chat identity KDF context is shorter than BLAKE2b's minimum key length");
|
||||
static_assert(kChatIdentityKdfContextLen <= crypto_generichash_KEYBYTES_MAX,
|
||||
"chat identity KDF context is longer than BLAKE2b's maximum key length");
|
||||
|
||||
const char* chatIdentityStatusName(ChatIdentityStatus status) {
|
||||
switch (status) {
|
||||
case ChatIdentityStatus::Ready: return "Ready";
|
||||
case ChatIdentityStatus::FeatureDisabled: return "FeatureDisabled";
|
||||
case ChatIdentityStatus::SecretUnavailable: return "SecretUnavailable";
|
||||
case ChatIdentityStatus::DerivationFailed: return "DerivationFailed";
|
||||
}
|
||||
return "Unknown";
|
||||
}
|
||||
|
||||
std::string chatIdentityPublicKeyHex(const ChatKeyPair& keys) {
|
||||
char hex[crypto_kx_PUBLICKEYBYTES * 2 + 1];
|
||||
sodium_bin2hex(hex, sizeof hex, keys.public_key.data(), keys.public_key.size());
|
||||
return std::string(hex);
|
||||
}
|
||||
|
||||
ChatIdentityResult deriveChatIdentityFromSecret(const std::string& stableSecret,
|
||||
ChatKeyPair& outKeys,
|
||||
bool featureEnabled) {
|
||||
ChatIdentityResult result;
|
||||
auto finish = [&result](ChatIdentityStatus status) -> ChatIdentityResult {
|
||||
result.status = status;
|
||||
result.error_name = chatIdentityStatusName(status);
|
||||
return result;
|
||||
};
|
||||
|
||||
if (!featureEnabled) return finish(ChatIdentityStatus::FeatureDisabled);
|
||||
if (stableSecret.empty()) return finish(ChatIdentityStatus::SecretUnavailable);
|
||||
if (sodium_init() < 0) return finish(ChatIdentityStatus::DerivationFailed);
|
||||
|
||||
unsigned char kxSeed[crypto_kx_SEEDBYTES];
|
||||
static_assert(sizeof(kxSeed) == kChatKeyBytes, "kx seed size mismatch");
|
||||
|
||||
const int hashStatus = crypto_generichash(
|
||||
kxSeed, sizeof kxSeed,
|
||||
reinterpret_cast<const unsigned char*>(stableSecret.data()), stableSecret.size(),
|
||||
reinterpret_cast<const unsigned char*>(kChatIdentityKdfContext), kChatIdentityKdfContextLen);
|
||||
if (hashStatus != 0) {
|
||||
sodium_memzero(kxSeed, sizeof kxSeed);
|
||||
return finish(ChatIdentityStatus::DerivationFailed);
|
||||
}
|
||||
|
||||
const int keypairStatus =
|
||||
crypto_kx_seed_keypair(outKeys.public_key.data(), outKeys.secret_key.data(), kxSeed);
|
||||
sodium_memzero(kxSeed, sizeof kxSeed); // wipe the seed immediately, success or failure
|
||||
if (keypairStatus != 0) {
|
||||
wipeChatKeyPair(outKeys);
|
||||
return finish(ChatIdentityStatus::DerivationFailed);
|
||||
}
|
||||
|
||||
result.public_key_hex = chatIdentityPublicKeyHex(outKeys);
|
||||
return finish(ChatIdentityStatus::Ready);
|
||||
}
|
||||
|
||||
} // namespace dragonx::chat
|
||||
57
src/chat/chat_identity.h
Normal file
57
src/chat/chat_identity.h
Normal file
@@ -0,0 +1,57 @@
|
||||
#pragma once
|
||||
|
||||
// DragonX Wallet - HushChat identity derivation.
|
||||
//
|
||||
// The DragonX-native chat identity is an X25519 (crypto_kx) keypair derived from a stable
|
||||
// per-wallet secret via a domain-separated keyed BLAKE2b KDF. This deliberately does NOT
|
||||
// use SDXL's UTF-8-hex-seed quirk (that quirk is only for the Phase-4 "import an existing
|
||||
// SDXL identity" path). Interop is unaffected: identity derivation is local — peers only
|
||||
// exchange public keys. See docs/_archive/contacts-chat-tab-plan-2026-07-05.md §5.6.
|
||||
|
||||
#include "chat_crypto.h" // ChatKeyPair
|
||||
#include "chat_protocol.h" // hushChatFeatureEnabledAtBuild()
|
||||
|
||||
#include <cstddef>
|
||||
#include <string>
|
||||
|
||||
namespace dragonx::chat {
|
||||
|
||||
// Domain-separation label — used as the BLAKE2b key so a different app/version cannot
|
||||
// derive the same identity from the same wallet secret. A char[] (not const char*) so its
|
||||
// length is a compile-time constant for the KEYBYTES-bounds static_assert.
|
||||
inline constexpr char kChatIdentityKdfContext[] = "DragonX-HushChat-Identity-v1";
|
||||
inline constexpr std::size_t kChatIdentityKdfContextLen = sizeof(kChatIdentityKdfContext) - 1;
|
||||
|
||||
enum class ChatIdentityStatus {
|
||||
Ready,
|
||||
FeatureDisabled, // DRAGONX_ENABLE_CHAT off (or caller passed featureEnabled=false)
|
||||
SecretUnavailable, // no stable secret (wallet locked / not open / empty)
|
||||
DerivationFailed // libsodium init or KDF/keypair failure
|
||||
};
|
||||
|
||||
const char* chatIdentityStatusName(ChatIdentityStatus status);
|
||||
|
||||
struct ChatIdentityResult {
|
||||
ChatIdentityStatus status = ChatIdentityStatus::FeatureDisabled;
|
||||
std::string public_key_hex; // 64 lowercase hex chars when Ready
|
||||
const char* error_name = "FeatureDisabled"; // == chatIdentityStatusName(status)
|
||||
};
|
||||
|
||||
// Pure, no-I/O derivation: hashes `stableSecret` (variable-length: a mnemonic on lite, a
|
||||
// spending key on full-node) with the KDF context as the BLAKE2b key into a clean 32-byte
|
||||
// crypto_kx seed, then crypto_kx_seed_keypair() into `outKeys`. Deterministic for a given
|
||||
// secret. On any non-Ready result `outKeys` is left wiped. featureEnabled defaults to the
|
||||
// build predicate but tests pass true to exercise the crypto in an OFF build.
|
||||
//
|
||||
// OWNERSHIP: `stableSecret` is BORROWED (const&) and is NOT wiped here — the caller owns it
|
||||
// and MUST sodium_memzero its backing buffer after this returns. The per-variant provider
|
||||
// that fetches the wallet secret (mnemonic / spending key) should hold it in a wipeable
|
||||
// buffer, not a plain std::string literal, in production.
|
||||
ChatIdentityResult deriveChatIdentityFromSecret(const std::string& stableSecret,
|
||||
ChatKeyPair& outKeys,
|
||||
bool featureEnabled = hushChatFeatureEnabledAtBuild());
|
||||
|
||||
// 64-char lowercase hex of the public key.
|
||||
std::string chatIdentityPublicKeyHex(const ChatKeyPair& keys);
|
||||
|
||||
} // namespace dragonx::chat
|
||||
30
src/chat/chat_message.h
Normal file
30
src/chat/chat_message.h
Normal file
@@ -0,0 +1,30 @@
|
||||
#pragma once
|
||||
|
||||
// DragonX Wallet - HushChat decrypted message model. Held in memory by ChatStore and persisted at
|
||||
// rest (encrypted under a seed-derived key) by ChatDatabase.
|
||||
|
||||
#include <cstdint>
|
||||
#include <string>
|
||||
|
||||
namespace dragonx::chat {
|
||||
|
||||
enum class ChatDirection { Incoming, Outgoing };
|
||||
enum class ChatMessageKind { Message, ContactRequest };
|
||||
// Outgoing delivery: Sent = the broadcast was submitted; Failed = it wasn't (not connected, no
|
||||
// spendable address, a send already in progress). Always Sent for incoming.
|
||||
enum class ChatDelivery { Sent, Failed };
|
||||
|
||||
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
|
||||
ChatDelivery delivery = ChatDelivery::Sent; // outgoing only
|
||||
};
|
||||
|
||||
} // namespace dragonx::chat
|
||||
108
src/chat/chat_outgoing.cpp
Normal file
108
src/chat/chat_outgoing.cpp
Normal file
@@ -0,0 +1,108 @@
|
||||
// DragonX Wallet - HushChat outgoing memo construction (implementation).
|
||||
|
||||
#include "chat_outgoing.h"
|
||||
|
||||
#include "chat_protocol.h" // kHushChat* constants
|
||||
|
||||
#include <nlohmann/json.hpp>
|
||||
|
||||
namespace dragonx::chat {
|
||||
|
||||
namespace {
|
||||
|
||||
// Serialize the HushChat header. nlohmann emits object keys in sorted (alphabetical) order —
|
||||
// cid,e,h,p,t,v,z — which is exactly SilentDragonXLite's on-wire key order.
|
||||
std::string buildHeaderMemo(const std::string& replyZaddr,
|
||||
const std::string& conversationId,
|
||||
const char* type,
|
||||
const std::string& streamHeaderHex,
|
||||
const std::string& publicKeyHex)
|
||||
{
|
||||
nlohmann::json header;
|
||||
header["h"] = 1; // header number (>= 1)
|
||||
header["v"] = kHushChatSupportedVersion; // 0
|
||||
header["z"] = replyZaddr; // where the peer should reply (my address)
|
||||
header["cid"] = conversationId;
|
||||
header["t"] = type; // "Memo" or "Cont"
|
||||
header["e"] = streamHeaderHex; // 48-hex secretstream header (Memo) / "" (Cont)
|
||||
header["p"] = publicKeyHex; // my 64-hex crypto_kx public key
|
||||
return header.dump();
|
||||
}
|
||||
|
||||
bool present(const std::string& value) { return !value.empty(); }
|
||||
|
||||
} // namespace
|
||||
|
||||
std::array<ChatSendOutput, 2> chatSendOutputs(const OutgoingChatMemos& memos, bool utf8Prefix)
|
||||
{
|
||||
const std::string prefix = utf8Prefix ? "utf8:" : "";
|
||||
return {{
|
||||
{ memos.recipientZaddr, prefix + memos.headerMemo }, // header — the lower memo position
|
||||
{ memos.recipientZaddr, prefix + memos.payloadMemo },
|
||||
}};
|
||||
}
|
||||
|
||||
ChatComposeStatus buildOutgoingMessage(const ChatKeyPair& mine,
|
||||
const std::string& myPublicKeyHex,
|
||||
const std::string& myReplyZaddr,
|
||||
const std::string& peerPublicKeyHex,
|
||||
const std::string& peerZaddr,
|
||||
const std::string& conversationId,
|
||||
const std::string& plaintext,
|
||||
OutgoingChatMemos& out)
|
||||
{
|
||||
if (plaintext.empty()) return ChatComposeStatus::EmptyBody;
|
||||
if (!present(myPublicKeyHex) || !present(myReplyZaddr) || !present(peerZaddr) ||
|
||||
!present(conversationId)) {
|
||||
return ChatComposeStatus::MissingField;
|
||||
}
|
||||
if (peerPublicKeyHex.size() != kHushChatPublicKeyHexLength) return ChatComposeStatus::BadPeerKey;
|
||||
|
||||
std::string streamHeaderHex;
|
||||
std::string ciphertextHex;
|
||||
if (encryptOutgoing(mine, peerPublicKeyHex, plaintext, streamHeaderHex, ciphertextHex)
|
||||
!= ChatCryptoStatus::Ok) {
|
||||
return ChatComposeStatus::EncryptFailed;
|
||||
}
|
||||
|
||||
OutgoingChatMemos memos;
|
||||
memos.recipientZaddr = peerZaddr;
|
||||
memos.headerMemo = buildHeaderMemo(myReplyZaddr, conversationId, "Memo", streamHeaderHex, myPublicKeyHex);
|
||||
memos.payloadMemo = ciphertextHex;
|
||||
if (memos.headerMemo.size() > kHushChatMemoByteLimit ||
|
||||
memos.payloadMemo.size() > kHushChatMemoByteLimit) {
|
||||
return ChatComposeStatus::TooLong;
|
||||
}
|
||||
out = std::move(memos);
|
||||
return ChatComposeStatus::Ok;
|
||||
}
|
||||
|
||||
ChatComposeStatus buildOutgoingContactRequest(const std::string& myPublicKeyHex,
|
||||
const std::string& myReplyZaddr,
|
||||
const std::string& peerZaddr,
|
||||
const std::string& conversationId,
|
||||
const std::string& requestText,
|
||||
OutgoingChatMemos& out)
|
||||
{
|
||||
if (requestText.empty()) return ChatComposeStatus::EmptyBody;
|
||||
// The receive parser treats any memo starting with '{' as a header, so a request payload must
|
||||
// not start with one (see isContactPayloadCandidate in chat_protocol.cpp).
|
||||
if (requestText.front() == '{') return ChatComposeStatus::BadRequestText;
|
||||
if (!present(myPublicKeyHex) || !present(myReplyZaddr) || !present(peerZaddr) ||
|
||||
!present(conversationId)) {
|
||||
return ChatComposeStatus::MissingField;
|
||||
}
|
||||
|
||||
OutgoingChatMemos memos;
|
||||
memos.recipientZaddr = peerZaddr;
|
||||
memos.headerMemo = buildHeaderMemo(myReplyZaddr, conversationId, "Cont", "", myPublicKeyHex);
|
||||
memos.payloadMemo = requestText;
|
||||
if (memos.headerMemo.size() > kHushChatMemoByteLimit ||
|
||||
memos.payloadMemo.size() > kHushChatMemoByteLimit) {
|
||||
return ChatComposeStatus::TooLong;
|
||||
}
|
||||
out = std::move(memos);
|
||||
return ChatComposeStatus::Ok;
|
||||
}
|
||||
|
||||
} // namespace dragonx::chat
|
||||
66
src/chat/chat_outgoing.h
Normal file
66
src/chat/chat_outgoing.h
Normal file
@@ -0,0 +1,66 @@
|
||||
#pragma once
|
||||
|
||||
// DragonX Wallet - HushChat outgoing memo construction (the inverse of the receive parser).
|
||||
//
|
||||
// Given the sender's identity and the peer, produce the header memo JSON + payload memo that,
|
||||
// sent as two 0-value memo outputs to the peer's z-address (header at the LOWER memo position),
|
||||
// another HushChat client parses and decrypts. The byte format matches SilentDragonXLite: the
|
||||
// header keys serialize alphabetically (nlohmann default) to cid,e,h,p,t,v,z. Pure — no I/O, no
|
||||
// network; broadcasting the memos is the caller's job (the transport lands in a later phase).
|
||||
|
||||
#include "chat_crypto.h" // ChatKeyPair
|
||||
|
||||
#include <array>
|
||||
#include <string>
|
||||
|
||||
namespace dragonx::chat {
|
||||
|
||||
struct OutgoingChatMemos {
|
||||
std::string recipientZaddr; // the peer's z-address (recipient of both memo outputs)
|
||||
std::string headerMemo; // JSON header — MUST occupy the lower memo position on the wire
|
||||
std::string payloadMemo; // ciphertext hex (Message) or plaintext (ContactRequest)
|
||||
};
|
||||
|
||||
// One of the two 0-value memo outputs a HushChat send produces (amount is always 0).
|
||||
struct ChatSendOutput {
|
||||
std::string address; // the peer's z-address
|
||||
std::string memo; // memo encoded for the target transport (utf8:-prefixed or raw)
|
||||
};
|
||||
|
||||
// The two outputs for a HushChat send, HEADER FIRST (it must occupy the lower memo position).
|
||||
// `utf8Prefix` prepends the daemon's "utf8:" marker required by full-node z_sendmany (which then
|
||||
// UTF-8-encodes the bytes on-chain, byte-identical to SDXLite's Memo::from_str); lite backends take
|
||||
// raw UTF-8, so pass false there.
|
||||
std::array<ChatSendOutput, 2> chatSendOutputs(const OutgoingChatMemos& memos, bool utf8Prefix);
|
||||
|
||||
enum class ChatComposeStatus {
|
||||
Ok,
|
||||
EmptyBody,
|
||||
MissingField,
|
||||
BadPeerKey,
|
||||
BadRequestText, // a contact request text must not start with '{' (parser would read it as a header)
|
||||
EncryptFailed,
|
||||
TooLong // a resulting memo exceeds the HushChat 512-byte memo limit
|
||||
};
|
||||
|
||||
// Build an ENCRYPTED message to a peer whose public key you already learned from a memo they sent
|
||||
// you. `mine` is the sender's identity keypair; `myPublicKeyHex` its public half (goes in header p).
|
||||
ChatComposeStatus buildOutgoingMessage(const ChatKeyPair& mine,
|
||||
const std::string& myPublicKeyHex,
|
||||
const std::string& myReplyZaddr,
|
||||
const std::string& peerPublicKeyHex,
|
||||
const std::string& peerZaddr,
|
||||
const std::string& conversationId,
|
||||
const std::string& plaintext,
|
||||
OutgoingChatMemos& out);
|
||||
|
||||
// Build a plaintext contact request — no peer public key needed yet; this is how the peer first
|
||||
// learns your public key + reply address. The payload is the (plaintext) request text.
|
||||
ChatComposeStatus buildOutgoingContactRequest(const std::string& myPublicKeyHex,
|
||||
const std::string& myReplyZaddr,
|
||||
const std::string& peerZaddr,
|
||||
const std::string& conversationId,
|
||||
const std::string& requestText,
|
||||
OutgoingChatMemos& out);
|
||||
|
||||
} // namespace dragonx::chat
|
||||
@@ -177,11 +177,42 @@ HushChatMemoGroupingResult groupHushChatMemoOutputs(const std::vector<HushChatMe
|
||||
HushChatMemoGroupingResult result;
|
||||
std::optional<HushChatMemoOutput> pending_header_output;
|
||||
std::optional<HushChatHeader> pending_header;
|
||||
// A payload memo seen BEFORE its header. The full-node daemon shuffles the two 0-value memo
|
||||
// outputs of a chat tx (transaction_builder ShuffleOutputs), so the header is NOT guaranteed to
|
||||
// occupy the lower position. We hold an orphan payload until its header arrives and pair them
|
||||
// regardless of on-chain order (a chat tx carries exactly one header + one payload).
|
||||
std::optional<HushChatMemoOutput> pending_payload_output;
|
||||
|
||||
auto addIssue = [&](HushChatMemoGroupingIssue issue, std::size_t position, std::string detail) {
|
||||
result.issues.push_back(HushChatMemoGroupingIssueInfo{issue, position, std::move(detail)});
|
||||
};
|
||||
|
||||
auto payloadMatchesHeader = [&](const std::string& memo) {
|
||||
return pending_header->type == HushChatHeaderType::Message
|
||||
? isCiphertextPayloadCandidate(memo)
|
||||
: isContactPayloadCandidate(memo);
|
||||
};
|
||||
|
||||
auto emitPair = [&](const HushChatMemoOutput& payloadOutput) {
|
||||
HushChatMemoPair pair;
|
||||
pair.header = std::move(*pending_header);
|
||||
pair.header_position = pending_header_output->position;
|
||||
pair.payload_position = payloadOutput.position;
|
||||
pair.payload_memo = payloadOutput.memo;
|
||||
result.pairs.push_back(std::move(pair));
|
||||
pending_header_output.reset();
|
||||
pending_header.reset();
|
||||
};
|
||||
|
||||
// Pair a held (early) payload with the now-pending header, if it matches the header's type.
|
||||
auto tryPairHeldPayload = [&]() {
|
||||
if (!pending_header || !pending_payload_output) return;
|
||||
if (payloadMatchesHeader(pending_payload_output->memo)) {
|
||||
emitPair(*pending_payload_output);
|
||||
pending_payload_output.reset();
|
||||
}
|
||||
};
|
||||
|
||||
auto clearPendingAsMissing = [&]() {
|
||||
if (!pending_header_output) return;
|
||||
addIssue(HushChatMemoGroupingIssue::MissingPayload,
|
||||
@@ -216,33 +247,27 @@ HushChatMemoGroupingResult groupHushChatMemoOutputs(const std::vector<HushChatMe
|
||||
|
||||
pending_header_output = output;
|
||||
pending_header = std::move(parsed.header);
|
||||
tryPairHeldPayload(); // the payload may have arrived first (shuffled output order)
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!pending_header_output || !pending_header) {
|
||||
// Non-header memo.
|
||||
if (pending_header_output && pending_header) {
|
||||
if (payloadMatchesHeader(output.memo)) {
|
||||
emitPair(output);
|
||||
} else {
|
||||
++result.ignored_memo_count;
|
||||
}
|
||||
} else if (!pending_payload_output && !output.memo.empty()) {
|
||||
// No header yet — hold this as a candidate payload for a header still to come.
|
||||
pending_payload_output = output;
|
||||
} else {
|
||||
++result.ignored_memo_count;
|
||||
continue;
|
||||
}
|
||||
|
||||
const bool payload_matches = pending_header->type == HushChatHeaderType::Message
|
||||
? isCiphertextPayloadCandidate(output.memo)
|
||||
: isContactPayloadCandidate(output.memo);
|
||||
if (!payload_matches) {
|
||||
++result.ignored_memo_count;
|
||||
continue;
|
||||
}
|
||||
|
||||
HushChatMemoPair pair;
|
||||
pair.header = std::move(*pending_header);
|
||||
pair.header_position = pending_header_output->position;
|
||||
pair.payload_position = output.position;
|
||||
pair.payload_memo = output.memo;
|
||||
result.pairs.push_back(std::move(pair));
|
||||
pending_header_output.reset();
|
||||
pending_header.reset();
|
||||
}
|
||||
|
||||
clearPendingAsMissing();
|
||||
if (pending_payload_output) ++result.ignored_memo_count; // orphan payload, no header arrived
|
||||
return result;
|
||||
}
|
||||
|
||||
@@ -275,6 +300,9 @@ HushChatTransactionExtractionResult extractHushChatTransactionMetadata(
|
||||
metadata.header_position = pair.header_position;
|
||||
metadata.payload_position = pair.payload_position;
|
||||
metadata.payload_size = pair.payload_memo.size();
|
||||
metadata.sender_public_key_hex = pair.header.public_key_hex;
|
||||
metadata.secretstream_header_hex = pair.header.secretstream_header_hex;
|
||||
metadata.payload_memo = pair.payload_memo;
|
||||
result.metadata.push_back(std::move(metadata));
|
||||
}
|
||||
|
||||
|
||||
@@ -75,6 +75,11 @@ struct HushChatTransactionMetadata {
|
||||
std::size_t header_position = 0;
|
||||
std::size_t payload_position = 0;
|
||||
std::size_t payload_size = 0;
|
||||
// Decrypt inputs carried through from the paired header + payload memos so the chat
|
||||
// service can actually decrypt (a Message) or read the request (a ContactRequest).
|
||||
std::string sender_public_key_hex; // header "p": peer crypto_kx public key (hex)
|
||||
std::string secretstream_header_hex; // header "e": secretstream header (hex; empty for ContactRequest)
|
||||
std::string payload_memo; // ciphertext hex (Message) or plaintext request text (ContactRequest)
|
||||
};
|
||||
|
||||
struct HushChatTransactionExtractionResult {
|
||||
|
||||
108
src/chat/chat_service.cpp
Normal file
108
src/chat/chat_service.cpp
Normal file
@@ -0,0 +1,108 @@
|
||||
// DragonX Wallet - HushChat service (implementation).
|
||||
|
||||
#include "chat_service.h"
|
||||
|
||||
#include "chat_database.h"
|
||||
#include "chat_identity.h" // chatIdentityPublicKeyHex
|
||||
|
||||
#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,
|
||||
const std::unordered_map<std::string, std::int64_t>& txTimestamps,
|
||||
std::int64_t fallbackTimestamp) {
|
||||
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;
|
||||
const auto timeIt = txTimestamps.find(meta.txid);
|
||||
message.timestamp = timeIt != txTimestamps.end() ? timeIt->second : fallbackTimestamp;
|
||||
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);
|
||||
}
|
||||
|
||||
// In-memory store dedups (txid+position); only persist the genuinely new ones. On the next
|
||||
// session loadFromDatabase() repopulates the store, so re-scanning the chain re-ingests but
|
||||
// the store dedup prevents a duplicate write.
|
||||
if (store_.append(message)) {
|
||||
if (db_) db_->append(message);
|
||||
++added;
|
||||
}
|
||||
}
|
||||
return added;
|
||||
}
|
||||
|
||||
void ChatService::loadFromDatabase() {
|
||||
if (!db_) return;
|
||||
for (const auto& message : db_->load()) {
|
||||
store_.append(message);
|
||||
}
|
||||
}
|
||||
|
||||
std::string ChatService::identityPublicKeyHex() const {
|
||||
if (!has_identity_) return {};
|
||||
return chatIdentityPublicKeyHex(identity_);
|
||||
}
|
||||
|
||||
ChatComposeStatus ChatService::composeMessage(const std::string& myReplyZaddr,
|
||||
const std::string& peerPublicKeyHex,
|
||||
const std::string& peerZaddr,
|
||||
const std::string& conversationId,
|
||||
const std::string& plaintext,
|
||||
OutgoingChatMemos& out) const {
|
||||
if (!has_identity_) return ChatComposeStatus::MissingField;
|
||||
return buildOutgoingMessage(identity_, chatIdentityPublicKeyHex(identity_), myReplyZaddr,
|
||||
peerPublicKeyHex, peerZaddr, conversationId, plaintext, out);
|
||||
}
|
||||
|
||||
ChatComposeStatus ChatService::composeContactRequest(const std::string& myReplyZaddr,
|
||||
const std::string& peerZaddr,
|
||||
const std::string& conversationId,
|
||||
const std::string& requestText,
|
||||
OutgoingChatMemos& out) const {
|
||||
if (!has_identity_) return ChatComposeStatus::MissingField;
|
||||
return buildOutgoingContactRequest(chatIdentityPublicKeyHex(identity_), myReplyZaddr,
|
||||
peerZaddr, conversationId, requestText, out);
|
||||
}
|
||||
|
||||
bool ChatService::recordOutgoing(const ChatMessage& message) {
|
||||
if (store_.append(message)) {
|
||||
if (db_) db_->append(message);
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
} // namespace dragonx::chat
|
||||
85
src/chat/chat_service.h
Normal file
85
src/chat/chat_service.h
Normal file
@@ -0,0 +1,85 @@
|
||||
#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 <cstdint>
|
||||
#include <string>
|
||||
#include <unordered_map>
|
||||
#include <vector>
|
||||
|
||||
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).
|
||||
int ingest(const std::vector<HushChatTransactionMetadata>& metadata,
|
||||
const std::unordered_map<std::string, std::int64_t>& txTimestamps,
|
||||
std::int64_t fallbackTimestamp = 0);
|
||||
|
||||
// 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.
|
||||
void setPersistence(ChatDatabase* db) { db_ = 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();
|
||||
|
||||
// --- 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 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);
|
||||
|
||||
const ChatStore& store() const { return store_; }
|
||||
ChatStore& store() { return store_; }
|
||||
|
||||
private:
|
||||
ChatKeyPair identity_{};
|
||||
bool has_identity_ = false;
|
||||
ChatStore store_;
|
||||
ChatDatabase* db_ = nullptr; // optional; not owned
|
||||
};
|
||||
|
||||
} // namespace dragonx::chat
|
||||
39
src/chat/chat_store.cpp
Normal file
39
src/chat/chat_store.cpp
Normal 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
|
||||
38
src/chat/chat_store.h
Normal file
38
src/chat/chat_store.h
Normal file
@@ -0,0 +1,38 @@
|
||||
#pragma once
|
||||
|
||||
// DragonX Wallet - HushChat in-memory message store: the fast read model / dedup view. Durable
|
||||
// persistence lives in ChatDatabase; ChatService rehydrates this store from it on unlock.
|
||||
|
||||
#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
|
||||
@@ -146,6 +146,7 @@ bool Settings::load(const std::string& path)
|
||||
loadScalar(j, "address_explorer_url", address_explorer_url_);
|
||||
loadScalar(j, "language", language_);
|
||||
loadScalar(j, "skin_id", skin_id_);
|
||||
loadScalar(j, "chat_reply_zaddr", chat_reply_zaddr_);
|
||||
loadScalar(j, "acrylic_enabled", acrylic_enabled_);
|
||||
loadScalar(j, "acrylic_quality", acrylic_quality_);
|
||||
loadScalar(j, "blur_multiplier", blur_multiplier_);
|
||||
@@ -401,6 +402,7 @@ bool Settings::save(const std::string& path)
|
||||
j["address_explorer_url"] = address_explorer_url_;
|
||||
j["language"] = language_;
|
||||
j["skin_id"] = skin_id_;
|
||||
j["chat_reply_zaddr"] = chat_reply_zaddr_;
|
||||
j["acrylic_enabled"] = acrylic_enabled_;
|
||||
j["acrylic_quality"] = acrylic_quality_;
|
||||
j["blur_multiplier"] = blur_multiplier_;
|
||||
|
||||
@@ -108,6 +108,12 @@ public:
|
||||
std::string getSkinId() const { return skin_id_; }
|
||||
void setSkinId(const std::string& id) { skin_id_ = id; }
|
||||
|
||||
// Stable z-address chosen for HushChat: the reply-to address in outgoing headers, and (for
|
||||
// legacy/non-mnemonic wallets) the source of the seed-derived chat identity. Persisted so the
|
||||
// identity + reply address don't shift when new addresses are generated.
|
||||
std::string getChatReplyZaddr() const { return chat_reply_zaddr_; }
|
||||
void setChatReplyZaddr(const std::string& z) { chat_reply_zaddr_ = z; }
|
||||
|
||||
// Privacy
|
||||
bool getSaveZtxs() const { return save_ztxs_; }
|
||||
void setSaveZtxs(bool save) { save_ztxs_ = save; }
|
||||
@@ -410,6 +416,7 @@ private:
|
||||
// Settings values
|
||||
std::string theme_ = "dragonx";
|
||||
std::string skin_id_ = "dragonx";
|
||||
std::string chat_reply_zaddr_;
|
||||
bool save_ztxs_ = true;
|
||||
bool auto_shield_ = true;
|
||||
bool use_tor_ = false;
|
||||
|
||||
@@ -33,7 +33,6 @@
|
||||
#include "../material/type.h"
|
||||
#include "../material/colors.h"
|
||||
#include "../windows/validate_address_dialog.h"
|
||||
#include "../windows/address_book_dialog.h"
|
||||
#include "../windows/shield_dialog.h"
|
||||
#include "../windows/request_payment_dialog.h"
|
||||
#include "../windows/block_info_dialog.h"
|
||||
@@ -1260,7 +1259,7 @@ void RenderSettingsPage(App* app) {
|
||||
bw = std::max(minBtnW, bw);
|
||||
|
||||
if (TactileButton(TR("settings_address_book"), ImVec2(bw, 0), S.resolveFont("button")))
|
||||
AddressBookDialog::show();
|
||||
app->setCurrentPage(ui::NavPage::Contacts); // now a top-level tab
|
||||
if (ImGui::IsItemHovered()) material::Tooltip("%s", TR("tt_address_book"));
|
||||
ImGui::SameLine(0, btnSpacing);
|
||||
if (TactileButton(TR("settings_validate_address"), ImVec2(bw, 0), S.resolveFont("button")))
|
||||
@@ -1819,8 +1818,9 @@ void RenderSettingsPage(App* app) {
|
||||
} else {
|
||||
ImGui::SetCursorScreenPos(ImVec2(leftX, ImGui::GetCursorScreenPos().y));
|
||||
if (TactileButton(TrId("lite_lock_now", "LiteLock").c_str(), ImVec2(0, 0), S.resolveFont("button"))) {
|
||||
// Route through App so the chat session is torn down immediately on lock.
|
||||
s_settingsState.lite_encryption_status =
|
||||
app->liteWallet()->lockWallet() ? TR("lite_wallet_locked") : TR("lite_lock_failed");
|
||||
app->lockLiteWallet() ? TR("lite_wallet_locked") : TR("lite_lock_failed");
|
||||
}
|
||||
}
|
||||
ImGui::SetCursorScreenPos(ImVec2(leftX, ImGui::GetCursorScreenPos().y));
|
||||
@@ -2497,6 +2497,12 @@ void RenderSettingsPage(App* app) {
|
||||
ImGui::SameLine();
|
||||
if (TactileButton(TR("screenshot_open_dir"), ImVec2(0, 0), S.resolveFont("button")))
|
||||
util::Platform::openFolder(app->screenshotDir());
|
||||
if (chat::hushChatFeatureEnabledAtBuild()) {
|
||||
// Populate the Chat tab with demo conversations so the sweep captures its real UI.
|
||||
ImGui::SameLine();
|
||||
if (TactileButton("Seed demo chat", ImVec2(0, 0), S.resolveFont("button")))
|
||||
app->seedChatDemoData();
|
||||
}
|
||||
ImGui::Dummy(ImVec2(0, Layout::spacingSm()));
|
||||
ImGui::Separator();
|
||||
ImGui::Dummy(ImVec2(0, Layout::spacingSm()));
|
||||
|
||||
@@ -25,6 +25,8 @@ enum class NavPage {
|
||||
Send,
|
||||
Receive,
|
||||
History,
|
||||
Contacts,
|
||||
Chat,
|
||||
// --- separator ---
|
||||
Mining,
|
||||
Market,
|
||||
@@ -51,6 +53,8 @@ inline const NavItem kNavItems[] = {
|
||||
{ "Send", NavPage::Send, nullptr, "send", nullptr },
|
||||
{ "Receive", NavPage::Receive, nullptr, "receive", nullptr },
|
||||
{ "History", NavPage::History, nullptr, "history", nullptr },
|
||||
{ "Contacts", NavPage::Contacts, nullptr, "contacts", nullptr },
|
||||
{ "Chat", NavPage::Chat, nullptr, "chat", nullptr },
|
||||
{ "Mining", NavPage::Mining, "TOOLS", "mining", "tools" },
|
||||
{ "Market", NavPage::Market, nullptr, "market", nullptr },
|
||||
{ "Console", NavPage::Console, "ADVANCED","console", "advanced" },
|
||||
@@ -78,6 +82,8 @@ inline wallet::WalletUiSurface NavPageSurface(NavPage page)
|
||||
case NavPage::Send: return wallet::WalletUiSurface::Send;
|
||||
case NavPage::Receive: return wallet::WalletUiSurface::Receive;
|
||||
case NavPage::History: return wallet::WalletUiSurface::History;
|
||||
case NavPage::Contacts: return wallet::WalletUiSurface::Contacts;
|
||||
case NavPage::Chat: return wallet::WalletUiSurface::Chat;
|
||||
case NavPage::Mining: return wallet::WalletUiSurface::Mining;
|
||||
case NavPage::Market: return wallet::WalletUiSurface::Market;
|
||||
case NavPage::Console: return wallet::WalletUiSurface::Console;
|
||||
@@ -103,6 +109,8 @@ inline const char* GetNavIconMD(NavPage page)
|
||||
case NavPage::Send: return ICON_MD_CALL_MADE;
|
||||
case NavPage::Receive: return ICON_MD_CALL_RECEIVED;
|
||||
case NavPage::History: return ICON_MD_HISTORY;
|
||||
case NavPage::Contacts: return ICON_MD_CONTACTS;
|
||||
case NavPage::Chat: return ICON_MD_CHAT;
|
||||
case NavPage::Mining: return ICON_MD_CONSTRUCTION;
|
||||
case NavPage::Market: return ICON_MD_TRENDING_UP;
|
||||
case NavPage::Console: return ICON_MD_TERMINAL;
|
||||
|
||||
@@ -1,293 +0,0 @@
|
||||
// DragonX Wallet - ImGui Edition
|
||||
// Copyright 2024-2026 The Hush Developers
|
||||
// Released under the GPLv3
|
||||
|
||||
#include "address_book_dialog.h"
|
||||
#include "../../app.h"
|
||||
#include "../../data/address_book.h"
|
||||
#include "../../util/i18n.h"
|
||||
#include "../../util/text_format.h"
|
||||
#include "../notifications.h"
|
||||
#include "../schema/ui_schema.h"
|
||||
#include "../material/draw_helpers.h"
|
||||
#include "imgui.h"
|
||||
#include <cstring>
|
||||
#include <memory>
|
||||
|
||||
namespace dragonx {
|
||||
namespace ui {
|
||||
|
||||
// Static member initialization
|
||||
bool AddressBookDialog::s_open = false;
|
||||
int AddressBookDialog::s_selected_index = -1;
|
||||
bool AddressBookDialog::s_show_add_dialog = false;
|
||||
bool AddressBookDialog::s_show_edit_dialog = false;
|
||||
char AddressBookDialog::s_edit_label[128] = "";
|
||||
char AddressBookDialog::s_edit_address[512] = "";
|
||||
char AddressBookDialog::s_edit_notes[512] = "";
|
||||
|
||||
// Shared address book instance
|
||||
static std::unique_ptr<data::AddressBook> s_address_book;
|
||||
|
||||
static data::AddressBook& getAddressBook() {
|
||||
if (!s_address_book) {
|
||||
s_address_book = std::make_unique<data::AddressBook>();
|
||||
s_address_book->load();
|
||||
}
|
||||
return *s_address_book;
|
||||
}
|
||||
|
||||
static void copyEditField(char* dest, size_t destSize, const std::string& source) {
|
||||
if (destSize == 0) return;
|
||||
std::strncpy(dest, source.c_str(), destSize - 1);
|
||||
dest[destSize - 1] = '\0';
|
||||
}
|
||||
|
||||
void AddressBookDialog::show()
|
||||
{
|
||||
s_open = true;
|
||||
s_selected_index = -1;
|
||||
s_show_add_dialog = false;
|
||||
s_show_edit_dialog = false;
|
||||
|
||||
// Reload address book
|
||||
getAddressBook().load();
|
||||
}
|
||||
|
||||
bool AddressBookDialog::isOpen()
|
||||
{
|
||||
return s_open;
|
||||
}
|
||||
|
||||
void AddressBookDialog::render(App* app)
|
||||
{
|
||||
(void)app; // May use for send-to feature later
|
||||
|
||||
if (!s_open) return;
|
||||
|
||||
auto& S = schema::UI();
|
||||
auto win = S.window("dialogs.address-book");
|
||||
auto addrTable = S.table("dialogs.address-book", "address-table");
|
||||
auto addrFrontLbl = S.label("dialogs.address-book", "address-front-label");
|
||||
auto addrBackLbl = S.label("dialogs.address-book", "address-back-label");
|
||||
auto addrInput = S.input("dialogs.address-book", "address-input");
|
||||
auto notesInput = S.input("dialogs.address-book", "notes-input");
|
||||
auto actionBtn = S.button("dialogs.address-book", "action-button");
|
||||
|
||||
auto clearEditFields = []() {
|
||||
s_edit_label[0] = '\0';
|
||||
s_edit_address[0] = '\0';
|
||||
s_edit_notes[0] = '\0';
|
||||
};
|
||||
|
||||
auto loadEditFields = [](const data::AddressBookEntry& entry) {
|
||||
copyEditField(s_edit_label, sizeof(s_edit_label), entry.label);
|
||||
copyEditField(s_edit_address, sizeof(s_edit_address), entry.address);
|
||||
copyEditField(s_edit_notes, sizeof(s_edit_notes), entry.notes);
|
||||
};
|
||||
|
||||
auto renderEntryDialog = [&]() {
|
||||
bool isEdit = s_show_edit_dialog;
|
||||
bool* open = isEdit ? &s_show_edit_dialog : &s_show_add_dialog;
|
||||
if (!*open) return;
|
||||
|
||||
const char* title = isEdit ? TR("address_book_edit") : TR("address_book_add");
|
||||
const char* id = isEdit ? "AddressBookEdit" : "AddressBookAdd";
|
||||
float dialogW = std::max(Layout::kDialogMinWidth(), Layout::kDialogDefaultWidth());
|
||||
float formW = addrInput.width > 0 ? addrInput.width : Layout::kDialogFormWidth();
|
||||
float actionW = actionBtn.width > 0 ? actionBtn.width : Layout::kDialogActionWidth();
|
||||
float actionGap = actionBtn.gap > 0 ? actionBtn.gap : Layout::kDialogActionGap();
|
||||
float notesH = notesInput.height > 0 ? notesInput.height : 60.0f;
|
||||
|
||||
if (material::BeginOverlayDialog(title, open, dialogW, 0.94f,
|
||||
Layout::kDialogCompactBottomRatio(), id)) {
|
||||
material::LabeledInput(TR("label"), isEdit ? "##EditLabel" : "##AddLabel",
|
||||
s_edit_label, sizeof(s_edit_label), formW);
|
||||
|
||||
ImGui::Spacing();
|
||||
|
||||
material::LabeledInput(TR("address_label"), isEdit ? "##EditAddress" : "##AddAddress",
|
||||
s_edit_address, sizeof(s_edit_address), formW);
|
||||
if (!isEdit) {
|
||||
ImGui::SameLine();
|
||||
if (material::StyledButton(TR("paste"), ImVec2(0,0), S.resolveFont(actionBtn.font))) {
|
||||
const char* clipboard = ImGui::GetClipboardText();
|
||||
if (clipboard) copyEditField(s_edit_address, sizeof(s_edit_address), clipboard);
|
||||
}
|
||||
}
|
||||
|
||||
ImGui::Spacing();
|
||||
|
||||
material::LabeledInputMultiline(TR("notes_optional"), isEdit ? "##EditNotes" : "##AddNotes",
|
||||
s_edit_notes, sizeof(s_edit_notes), ImVec2(formW, notesH));
|
||||
|
||||
bool canSubmit = std::strlen(s_edit_label) > 0 && std::strlen(s_edit_address) > 0;
|
||||
float totalActionsW = actionW * 2.0f + actionGap;
|
||||
material::BeginOverlayDialogFooter(totalActionsW);
|
||||
|
||||
if (!canSubmit) ImGui::BeginDisabled();
|
||||
|
||||
const char* primaryLabel = isEdit ? TR("save") : TR("add");
|
||||
if (material::StyledButton(primaryLabel, ImVec2(actionW, 0), S.resolveFont(actionBtn.font))) {
|
||||
// Trim the label/address (a pasted address often carries a trailing newline); keep notes as-is.
|
||||
auto trimAB = [](std::string s) {
|
||||
while (!s.empty() && (s.front()==' '||s.front()=='\t'||s.front()=='\n'||s.front()=='\r')) s.erase(s.begin());
|
||||
while (!s.empty() && (s.back()==' '||s.back()=='\t'||s.back()=='\n'||s.back()=='\r')) s.pop_back();
|
||||
return s;
|
||||
};
|
||||
data::AddressBookEntry entry(trimAB(s_edit_label), trimAB(s_edit_address), s_edit_notes);
|
||||
if (isEdit) {
|
||||
if (getAddressBook().updateEntry(s_selected_index, entry)) {
|
||||
Notifications::instance().success(TR("address_book_updated"));
|
||||
s_show_edit_dialog = false;
|
||||
} else {
|
||||
Notifications::instance().error(TR("address_book_update_failed"));
|
||||
}
|
||||
} else {
|
||||
if (getAddressBook().addEntry(entry)) {
|
||||
Notifications::instance().success(TR("address_book_added"));
|
||||
s_show_add_dialog = false;
|
||||
} else {
|
||||
Notifications::instance().error(TR("address_book_exists"));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!canSubmit) ImGui::EndDisabled();
|
||||
|
||||
ImGui::SameLine(0, actionGap);
|
||||
if (material::StyledButton(TR("cancel"), ImVec2(actionW, 0), S.resolveFont(actionBtn.font))) {
|
||||
*open = false;
|
||||
}
|
||||
|
||||
material::EndOverlayDialog();
|
||||
}
|
||||
};
|
||||
|
||||
if (material::BeginOverlayDialog(TR("address_book_title"), &s_open, win.width, 0.94f)) {
|
||||
auto& book = getAddressBook();
|
||||
|
||||
// Toolbar
|
||||
if (material::StyledButton(TR("address_book_add_new"), ImVec2(0,0), S.resolveFont(actionBtn.font))) {
|
||||
s_show_add_dialog = true;
|
||||
clearEditFields();
|
||||
}
|
||||
|
||||
ImGui::SameLine();
|
||||
|
||||
bool has_selection = s_selected_index >= 0 && s_selected_index < static_cast<int>(book.size());
|
||||
|
||||
if (!has_selection) ImGui::BeginDisabled();
|
||||
|
||||
if (material::StyledButton(TR("edit"), ImVec2(0,0), S.resolveFont(actionBtn.font))) {
|
||||
if (has_selection) {
|
||||
const auto& entry = book.entries()[s_selected_index];
|
||||
loadEditFields(entry);
|
||||
s_show_edit_dialog = true;
|
||||
}
|
||||
}
|
||||
|
||||
ImGui::SameLine();
|
||||
|
||||
static int s_confirmDeleteIdx = -1;
|
||||
if (material::StyledButton(TR("delete"), ImVec2(0,0), S.resolveFont(actionBtn.font))) {
|
||||
if (has_selection) {
|
||||
if (s_confirmDeleteIdx == s_selected_index) {
|
||||
book.removeEntry(s_selected_index);
|
||||
s_selected_index = -1;
|
||||
s_confirmDeleteIdx = -1;
|
||||
Notifications::instance().success(TR("address_book_deleted"));
|
||||
} else {
|
||||
// Require a second click to confirm (no undo for a removed contact).
|
||||
s_confirmDeleteIdx = s_selected_index;
|
||||
Notifications::instance().warning("Click Delete again to remove this entry.");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
ImGui::SameLine();
|
||||
|
||||
if (material::StyledButton(TR("copy_address"), ImVec2(0,0), S.resolveFont(actionBtn.font))) {
|
||||
if (has_selection) {
|
||||
ImGui::SetClipboardText(book.entries()[s_selected_index].address.c_str());
|
||||
Notifications::instance().info(TR("address_copied"));
|
||||
}
|
||||
}
|
||||
|
||||
if (!has_selection) ImGui::EndDisabled();
|
||||
|
||||
ImGui::Spacing();
|
||||
ImGui::Separator();
|
||||
ImGui::Spacing();
|
||||
|
||||
// Address list
|
||||
if (ImGui::BeginTable("AddressBookTable", 3,
|
||||
ImGuiTableFlags_Borders | ImGuiTableFlags_RowBg |
|
||||
ImGuiTableFlags_Resizable | ImGuiTableFlags_ScrollY,
|
||||
ImVec2(0, addrTable.bottomReserve > 0 ? -addrTable.bottomReserve : -35)))
|
||||
{
|
||||
float labelColW = (addrTable.columns.count("label") && addrTable.columns.at("label").width > 0) ? addrTable.columns.at("label").width : 150;
|
||||
float notesColW = (addrTable.columns.count("notes") && addrTable.columns.at("notes").width > 0) ? addrTable.columns.at("notes").width : 150;
|
||||
ImGui::TableSetupColumn(TR("label"), ImGuiTableColumnFlags_WidthFixed, labelColW);
|
||||
ImGui::TableSetupColumn(TR("address_label"), ImGuiTableColumnFlags_WidthStretch);
|
||||
ImGui::TableSetupColumn(TR("notes"), ImGuiTableColumnFlags_WidthFixed, notesColW);
|
||||
ImGui::TableSetupScrollFreeze(0, 1);
|
||||
ImGui::TableHeadersRow();
|
||||
|
||||
if (book.empty()) {
|
||||
ImGui::TableNextRow();
|
||||
ImGui::TableNextColumn();
|
||||
ImGui::TextDisabled("%s", TR("address_book_empty"));
|
||||
} else {
|
||||
for (size_t i = 0; i < book.size(); i++) {
|
||||
const auto& entry = book.entries()[i];
|
||||
|
||||
ImGui::TableNextRow();
|
||||
ImGui::PushID(static_cast<int>(i));
|
||||
|
||||
ImGui::TableNextColumn();
|
||||
bool is_selected = (s_selected_index == static_cast<int>(i));
|
||||
if (ImGui::Selectable(entry.label.c_str(), is_selected,
|
||||
ImGuiSelectableFlags_SpanAllColumns)) {
|
||||
s_selected_index = static_cast<int>(i);
|
||||
}
|
||||
|
||||
// Double-click to edit
|
||||
if (ImGui::IsItemHovered() && ImGui::IsMouseDoubleClicked(0)) {
|
||||
s_selected_index = static_cast<int>(i);
|
||||
loadEditFields(entry);
|
||||
s_show_edit_dialog = true;
|
||||
}
|
||||
|
||||
ImGui::TableNextColumn();
|
||||
// Truncate long addresses
|
||||
std::string addr_display = entry.address;
|
||||
int addrTruncLen = (addrTable.columns.count("address") && addrTable.columns.at("address").truncate > 0) ? addrTable.columns.at("address").truncate : 40;
|
||||
if (addr_display.length() > static_cast<size_t>(addrTruncLen)) {
|
||||
addr_display = util::truncateMiddle(addr_display, addrFrontLbl.truncate, addrBackLbl.truncate);
|
||||
}
|
||||
ImGui::TextDisabled("%s", addr_display.c_str());
|
||||
if (ImGui::IsItemHovered()) {
|
||||
material::Tooltip("%s", entry.address.c_str());
|
||||
}
|
||||
|
||||
ImGui::TableNextColumn();
|
||||
ImGui::TextDisabled("%s", entry.notes.c_str());
|
||||
|
||||
ImGui::PopID();
|
||||
}
|
||||
}
|
||||
|
||||
ImGui::EndTable();
|
||||
}
|
||||
|
||||
// Status line
|
||||
ImGui::TextDisabled(TR("address_book_count"), book.size());
|
||||
material::EndOverlayDialog();
|
||||
}
|
||||
|
||||
renderEntryDialog();
|
||||
}
|
||||
|
||||
} // namespace ui
|
||||
} // namespace dragonx
|
||||
@@ -1,45 +0,0 @@
|
||||
// DragonX Wallet - ImGui Edition
|
||||
// Copyright 2024-2026 The Hush Developers
|
||||
// Released under the GPLv3
|
||||
|
||||
#pragma once
|
||||
|
||||
namespace dragonx {
|
||||
|
||||
class App;
|
||||
|
||||
namespace ui {
|
||||
|
||||
/**
|
||||
* @brief Address book dialog for managing saved addresses
|
||||
*/
|
||||
class AddressBookDialog {
|
||||
public:
|
||||
/**
|
||||
* @brief Show the address book dialog
|
||||
*/
|
||||
static void show();
|
||||
|
||||
/**
|
||||
* @brief Render the dialog (call every frame)
|
||||
* @param app Pointer to app instance
|
||||
*/
|
||||
static void render(App* app);
|
||||
|
||||
/**
|
||||
* @brief Check if dialog is currently open
|
||||
*/
|
||||
static bool isOpen();
|
||||
|
||||
private:
|
||||
static bool s_open;
|
||||
static int s_selected_index;
|
||||
static bool s_show_add_dialog;
|
||||
static bool s_show_edit_dialog;
|
||||
static char s_edit_label[128];
|
||||
static char s_edit_address[512];
|
||||
static char s_edit_notes[512];
|
||||
};
|
||||
|
||||
} // namespace ui
|
||||
} // namespace dragonx
|
||||
318
src/ui/windows/chat_tab.cpp
Normal file
318
src/ui/windows/chat_tab.cpp
Normal file
@@ -0,0 +1,318 @@
|
||||
// DragonX Wallet - ImGui Edition
|
||||
// Copyright 2024-2026 The Hush Developers
|
||||
// Released under the GPLv3
|
||||
//
|
||||
// chat_tab.cpp — read-only HushChat view (Phase 3): a conversation list + the
|
||||
// selected thread, both read from the App-owned ChatService store.
|
||||
|
||||
#include "chat_tab.h"
|
||||
#include "../../app.h"
|
||||
#include "../../data/address_book.h"
|
||||
#include "../../chat/chat_service.h"
|
||||
#include "../../util/i18n.h"
|
||||
#include "../material/colors.h"
|
||||
#include "../material/type.h"
|
||||
#include "imgui.h"
|
||||
|
||||
#include <algorithm>
|
||||
#include <cfloat>
|
||||
#include <ctime>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
namespace dragonx {
|
||||
namespace ui {
|
||||
|
||||
namespace {
|
||||
|
||||
// Selected conversation id (cid). Empty => none selected / auto-select first.
|
||||
std::string s_selected_cid;
|
||||
std::string s_scroll_to_cid; // when set, scroll the thread to the bottom next frame
|
||||
|
||||
// Composer + new-conversation UI state.
|
||||
char s_compose[512] = "";
|
||||
bool s_show_new_convo = false;
|
||||
char s_new_zaddr[128] = "";
|
||||
char s_new_msg[256] = "";
|
||||
|
||||
// Effective draw-list font size for a material font (mirrors sidebar's ScaledFontSize).
|
||||
float scaledSize(ImFont* f) { return f->LegacySize * ImGui::GetStyle().FontScaleMain; }
|
||||
|
||||
std::string shorten(const std::string& s, std::size_t head = 12, std::size_t tail = 6) {
|
||||
if (s.size() <= head + tail + 3) return s;
|
||||
return s.substr(0, head) + "..." + s.substr(s.size() - tail);
|
||||
}
|
||||
|
||||
std::string formatTime(std::int64_t ts) {
|
||||
if (ts <= 0) return "";
|
||||
std::time_t t = static_cast<std::time_t>(ts);
|
||||
std::tm* tm = std::localtime(&t); // UI thread only
|
||||
if (!tm) return "";
|
||||
char buf[32];
|
||||
std::strftime(buf, sizeof(buf), "%Y-%m-%d %H:%M", tm);
|
||||
return buf;
|
||||
}
|
||||
|
||||
// One line of collapsed body text for the list preview (newlines flattened).
|
||||
std::string previewOf(const std::string& body) {
|
||||
std::string out = body;
|
||||
std::replace(out.begin(), out.end(), '\n', ' ');
|
||||
std::replace(out.begin(), out.end(), '\r', ' ');
|
||||
return out;
|
||||
}
|
||||
|
||||
struct ConvSummary {
|
||||
std::string cid;
|
||||
std::string peerZaddr;
|
||||
std::string peerPubKey; // peer crypto_kx key (from a received memo); empty => can't reply yet
|
||||
std::string peerName; // contact label if known, else a shortened z-addr / cid
|
||||
std::string lastBody;
|
||||
std::int64_t lastTs = 0;
|
||||
int count = 0;
|
||||
};
|
||||
|
||||
// Centered, muted, wrapped hint for the empty states.
|
||||
void centeredHint(const char* text) {
|
||||
ImVec2 avail = ImGui::GetContentRegionAvail();
|
||||
ImGui::PushFont(material::Type().body2());
|
||||
const float wrap = std::min(avail.x - 40.0f, 420.0f);
|
||||
const ImVec2 sz = ImGui::CalcTextSize(text, nullptr, false, wrap);
|
||||
ImGui::SetCursorPos(ImVec2(ImGui::GetCursorPos().x + std::max(0.0f, (avail.x - sz.x) * 0.5f),
|
||||
ImGui::GetCursorPos().y + std::max(0.0f, (avail.y - sz.y) * 0.5f)));
|
||||
ImGui::PushStyleColor(ImGuiCol_Text, material::OnSurfaceMedium());
|
||||
ImGui::PushTextWrapPos(ImGui::GetCursorPos().x + wrap);
|
||||
ImGui::TextUnformatted(text);
|
||||
ImGui::PopTextWrapPos();
|
||||
ImGui::PopStyleColor();
|
||||
ImGui::PopFont();
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
void RenderChatTab(App* app)
|
||||
{
|
||||
auto& service = app->chatService();
|
||||
const auto& store = service.store();
|
||||
auto& book = app->addressBook();
|
||||
|
||||
// Not unlocked / identity not derived yet → nothing to show.
|
||||
if (!service.hasIdentity()) {
|
||||
centeredHint(TR("chat_locked_hint"));
|
||||
return;
|
||||
}
|
||||
|
||||
// Build conversation summaries (single scan per conversation), sorted by most-recent activity.
|
||||
std::vector<ConvSummary> convs;
|
||||
for (const auto& cid : store.conversationIds()) {
|
||||
const auto messages = store.conversation(cid);
|
||||
if (messages.empty()) continue;
|
||||
ConvSummary c;
|
||||
c.cid = cid;
|
||||
c.count = static_cast<int>(messages.size());
|
||||
for (const auto& m : messages) { // last non-empty peer z-addr / key across the thread
|
||||
if (!m.peer_zaddr.empty()) c.peerZaddr = m.peer_zaddr;
|
||||
if (!m.peer_public_key_hex.empty()) c.peerPubKey = m.peer_public_key_hex;
|
||||
}
|
||||
const auto& last = messages.back();
|
||||
c.lastBody = last.body;
|
||||
c.lastTs = last.timestamp;
|
||||
const int idx = c.peerZaddr.empty() ? -1 : book.findByAddress(c.peerZaddr);
|
||||
c.peerName = (idx >= 0) ? book.entries()[idx].label
|
||||
: shorten(!c.peerZaddr.empty() ? c.peerZaddr : cid);
|
||||
convs.push_back(std::move(c));
|
||||
}
|
||||
std::sort(convs.begin(), convs.end(),
|
||||
[](const ConvSummary& a, const ConvSummary& b) { return a.lastTs > b.lastTs; });
|
||||
|
||||
// Keep the selection valid (only when there is something to select).
|
||||
if (!convs.empty() &&
|
||||
std::none_of(convs.begin(), convs.end(),
|
||||
[](const ConvSummary& c) { return c.cid == s_selected_cid; })) {
|
||||
s_selected_cid = convs.front().cid;
|
||||
s_scroll_to_cid = s_selected_cid;
|
||||
}
|
||||
|
||||
const ImVec2 avail = ImGui::GetContentRegionAvail();
|
||||
const float listW = std::clamp(avail.x * 0.32f, 220.0f, 360.0f);
|
||||
const float pad = 10.0f;
|
||||
const float rowH = 52.0f;
|
||||
|
||||
ImFont* nameFont = material::Type().body2();
|
||||
ImFont* metaFont = material::Type().caption();
|
||||
const float nameSz = scaledSize(nameFont);
|
||||
const float metaSz = scaledSize(metaFont);
|
||||
|
||||
// ---- Left: new-conversation button + conversation list ----
|
||||
ImGui::BeginChild("##ChatList", ImVec2(listW, avail.y), true);
|
||||
{
|
||||
if (ImGui::Button(TR("chat_new_button"), ImVec2(-FLT_MIN, 0.0f))) {
|
||||
s_show_new_convo = true;
|
||||
s_new_zaddr[0] = '\0';
|
||||
s_new_msg[0] = '\0';
|
||||
}
|
||||
ImGui::Separator();
|
||||
if (convs.empty()) {
|
||||
ImGui::PushFont(metaFont);
|
||||
ImGui::PushStyleColor(ImGuiCol_Text, material::OnSurfaceMedium());
|
||||
ImGui::PushTextWrapPos(0.0f);
|
||||
ImGui::TextUnformatted(TR("chat_empty_hint"));
|
||||
ImGui::PopTextWrapPos();
|
||||
ImGui::PopStyleColor();
|
||||
ImGui::PopFont();
|
||||
}
|
||||
|
||||
ImDrawList* dl = ImGui::GetWindowDrawList(); // the list child's draw list (correct clip/z-order)
|
||||
const ImU32 selBg = ImGui::GetColorU32(ImGuiCol_Header);
|
||||
const ImU32 hoverBg = ImGui::GetColorU32(ImGuiCol_HeaderHovered);
|
||||
for (std::size_t i = 0; i < convs.size(); ++i) {
|
||||
const ConvSummary& c = convs[i];
|
||||
ImGui::PushID(static_cast<int>(i));
|
||||
const ImVec2 p = ImGui::GetCursorScreenPos();
|
||||
const float rowW = ImGui::GetContentRegionAvail().x;
|
||||
const bool clicked = ImGui::InvisibleButton("##row", ImVec2(rowW, rowH));
|
||||
const bool hovered = ImGui::IsItemHovered();
|
||||
const bool selected = (c.cid == s_selected_cid);
|
||||
if (clicked) { s_selected_cid = c.cid; s_scroll_to_cid = c.cid; }
|
||||
if (selected || hovered)
|
||||
dl->AddRectFilled(p, ImVec2(p.x + rowW, p.y + rowH), selected ? selBg : hoverBg, 6.0f);
|
||||
|
||||
// Name (top-left).
|
||||
dl->AddText(nameFont, nameSz, ImVec2(p.x + pad, p.y + pad), material::OnSurface(),
|
||||
c.peerName.c_str());
|
||||
// Time (top-right, muted).
|
||||
const std::string when = formatTime(c.lastTs);
|
||||
if (!when.empty()) {
|
||||
const ImVec2 wsz = metaFont->CalcTextSizeA(metaSz, FLT_MAX, 0.0f, when.c_str());
|
||||
dl->AddText(metaFont, metaSz, ImVec2(p.x + rowW - pad - wsz.x, p.y + pad + 1.0f),
|
||||
material::OnSurfaceMedium(), when.c_str());
|
||||
}
|
||||
// Preview (bottom, clipped, muted).
|
||||
const std::string preview = previewOf(c.lastBody);
|
||||
dl->PushClipRect(p, ImVec2(p.x + rowW - pad, p.y + rowH), true);
|
||||
dl->AddText(metaFont, metaSz, ImVec2(p.x + pad, p.y + rowH - pad - metaSz),
|
||||
material::OnSurfaceMedium(), preview.c_str());
|
||||
dl->PopClipRect();
|
||||
ImGui::PopID();
|
||||
}
|
||||
}
|
||||
ImGui::EndChild();
|
||||
|
||||
ImGui::SameLine();
|
||||
|
||||
// ---- Right: selected conversation thread ----
|
||||
ImGui::BeginChild("##ChatThread", ImVec2(0, avail.y), true);
|
||||
{
|
||||
const ConvSummary* sel = nullptr;
|
||||
for (const auto& c : convs) if (c.cid == s_selected_cid) { sel = &c; break; }
|
||||
|
||||
if (sel) {
|
||||
// Header: peer name + z-address.
|
||||
ImGui::PushFont(material::Type().subtitle1());
|
||||
ImGui::TextUnformatted(sel->peerName.c_str());
|
||||
ImGui::PopFont();
|
||||
if (!sel->peerZaddr.empty()) {
|
||||
ImGui::PushFont(metaFont);
|
||||
ImGui::PushStyleColor(ImGuiCol_Text, material::OnSurfaceMedium());
|
||||
ImGui::TextUnformatted(shorten(sel->peerZaddr, 20, 12).c_str());
|
||||
ImGui::PopStyleColor();
|
||||
ImGui::PopFont();
|
||||
}
|
||||
ImGui::Separator();
|
||||
|
||||
const float footerH = ImGui::GetTextLineHeightWithSpacing() + 12.0f;
|
||||
ImGui::BeginChild("##ChatMessages", ImVec2(0, ImGui::GetContentRegionAvail().y - footerH), false);
|
||||
{
|
||||
const auto messages = store.conversation(s_selected_cid);
|
||||
for (const auto& m : messages) {
|
||||
const bool outgoing = (m.direction == chat::ChatDirection::Outgoing);
|
||||
const bool request = (m.kind == chat::ChatMessageKind::ContactRequest);
|
||||
const bool failed = outgoing && m.delivery == chat::ChatDelivery::Failed;
|
||||
// Meta line: who + time (+ request tag / failed marker).
|
||||
std::string who = outgoing ? std::string(TR("chat_you")) : sel->peerName;
|
||||
const std::string when = formatTime(m.timestamp);
|
||||
if (!when.empty()) who += " " + when;
|
||||
if (request) who += " [" + std::string(TR("chat_contact_request")) + "]";
|
||||
if (failed) who += " · " + std::string(TR("chat_send_failed"));
|
||||
ImGui::PushFont(metaFont);
|
||||
ImGui::PushStyleColor(ImGuiCol_Text,
|
||||
failed ? material::Error() : (outgoing ? material::Primary() : material::OnSurfaceMedium()));
|
||||
ImGui::TextUnformatted(who.c_str());
|
||||
ImGui::PopStyleColor();
|
||||
ImGui::PopFont();
|
||||
// Body (wrapped).
|
||||
ImGui::PushFont(nameFont);
|
||||
ImGui::PushTextWrapPos(0.0f);
|
||||
ImGui::TextWrapped("%s", m.body.c_str());
|
||||
ImGui::PopTextWrapPos();
|
||||
ImGui::PopFont();
|
||||
ImGui::Dummy(ImVec2(0.0f, 6.0f));
|
||||
}
|
||||
if (s_scroll_to_cid == s_selected_cid) {
|
||||
ImGui::SetScrollHereY(1.0f);
|
||||
s_scroll_to_cid.clear();
|
||||
}
|
||||
}
|
||||
ImGui::EndChild();
|
||||
|
||||
// Composer footer: message input + send (only once we know the peer's key), else a hint.
|
||||
ImGui::Separator();
|
||||
if (sel->peerPubKey.empty()) {
|
||||
ImGui::PushFont(metaFont);
|
||||
ImGui::PushStyleColor(ImGuiCol_Text, material::OnSurfaceMedium());
|
||||
ImGui::PushTextWrapPos(0.0f);
|
||||
ImGui::TextUnformatted(TR("chat_waiting_reply"));
|
||||
ImGui::PopTextWrapPos();
|
||||
ImGui::PopStyleColor();
|
||||
ImGui::PopFont();
|
||||
} else {
|
||||
const float sendW = 74.0f;
|
||||
ImGui::SetNextItemWidth(ImGui::GetContentRegionAvail().x - sendW - ImGui::GetStyle().ItemSpacing.x);
|
||||
bool submit = ImGui::InputText("##compose", s_compose, sizeof(s_compose),
|
||||
ImGuiInputTextFlags_EnterReturnsTrue);
|
||||
ImGui::SameLine();
|
||||
if (ImGui::Button(TR("chat_send"), ImVec2(sendW, 0.0f))) submit = true;
|
||||
if (submit && s_compose[0] != '\0') {
|
||||
app->sendChatMessage(sel->cid, s_compose);
|
||||
s_compose[0] = '\0';
|
||||
s_scroll_to_cid = sel->cid;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
centeredHint(convs.empty() ? TR("chat_empty_hint") : TR("chat_select_hint"));
|
||||
}
|
||||
}
|
||||
ImGui::EndChild();
|
||||
|
||||
// ---- New-conversation popup (send a contact request to a z-address) ----
|
||||
if (s_show_new_convo) {
|
||||
ImGui::OpenPopup("##NewConversation");
|
||||
s_show_new_convo = false;
|
||||
}
|
||||
ImGui::SetNextWindowPos(ImGui::GetMainViewport()->GetCenter(), ImGuiCond_Appearing, ImVec2(0.5f, 0.5f));
|
||||
if (ImGui::BeginPopupModal("##NewConversation", nullptr, ImGuiWindowFlags_AlwaysAutoResize)) {
|
||||
ImGui::PushFont(material::Type().subtitle1());
|
||||
ImGui::TextUnformatted(TR("chat_new_title"));
|
||||
ImGui::PopFont();
|
||||
ImGui::Spacing();
|
||||
ImGui::TextUnformatted(TR("chat_new_zaddr"));
|
||||
ImGui::SetNextItemWidth(440.0f);
|
||||
ImGui::InputText("##newz", s_new_zaddr, sizeof(s_new_zaddr));
|
||||
ImGui::TextUnformatted(TR("chat_new_message"));
|
||||
ImGui::SetNextItemWidth(440.0f);
|
||||
ImGui::InputText("##newm", s_new_msg, sizeof(s_new_msg));
|
||||
ImGui::Spacing();
|
||||
const bool canSend = s_new_zaddr[0] != '\0' && s_new_msg[0] != '\0';
|
||||
if (ImGui::Button(TR("chat_new_send"), ImVec2(150.0f, 0.0f)) && canSend) {
|
||||
app->startChatConversation(s_new_zaddr, s_new_msg);
|
||||
s_new_zaddr[0] = '\0';
|
||||
s_new_msg[0] = '\0';
|
||||
ImGui::CloseCurrentPopup();
|
||||
}
|
||||
ImGui::SameLine();
|
||||
if (ImGui::Button(TR("chat_cancel"), ImVec2(100.0f, 0.0f))) ImGui::CloseCurrentPopup();
|
||||
ImGui::EndPopup();
|
||||
}
|
||||
}
|
||||
|
||||
} // namespace ui
|
||||
} // namespace dragonx
|
||||
27
src/ui/windows/chat_tab.h
Normal file
27
src/ui/windows/chat_tab.h
Normal file
@@ -0,0 +1,27 @@
|
||||
// DragonX Wallet - ImGui Edition
|
||||
// Copyright 2024-2026 The Hush Developers
|
||||
// Released under the GPLv3
|
||||
|
||||
#pragma once
|
||||
|
||||
namespace dragonx {
|
||||
|
||||
class App;
|
||||
|
||||
namespace ui {
|
||||
|
||||
/**
|
||||
* @brief Render the Chat tab (read-only HushChat conversation view — Phase 3).
|
||||
*
|
||||
* A two-pane view: a conversation list on the left and the selected thread on
|
||||
* the right, both read from the App-owned ChatService store (App::chatService()).
|
||||
* Peer z-addresses are resolved to contact names via the address book when known.
|
||||
* Read-only for now — composing/sending arrives in a later phase. The tab is only
|
||||
* reachable when built with DRAGONX_ENABLE_CHAT (gated via WalletUiSurface::Chat).
|
||||
*
|
||||
* @param app Pointer to the app instance.
|
||||
*/
|
||||
void RenderChatTab(App* app);
|
||||
|
||||
} // namespace ui
|
||||
} // namespace dragonx
|
||||
65
src/ui/windows/contact_picker.h
Normal file
65
src/ui/windows/contact_picker.h
Normal file
@@ -0,0 +1,65 @@
|
||||
// DragonX Wallet - ImGui Edition
|
||||
// Copyright 2024-2026 The Hush Developers
|
||||
// Released under the GPLv3
|
||||
|
||||
#pragma once
|
||||
|
||||
#include "imgui.h"
|
||||
#include "../../data/address_book.h"
|
||||
#include "../../util/i18n.h"
|
||||
#include "../../util/text_format.h"
|
||||
#include <cstring>
|
||||
#include <string>
|
||||
|
||||
namespace dragonx {
|
||||
namespace ui {
|
||||
|
||||
/**
|
||||
* @brief Shared address-book contact picker popup.
|
||||
*
|
||||
* Header-only so Send (and later Chat "new conversation") can reuse it without
|
||||
* pulling the address book into the material layer. Trigger with
|
||||
* ImGui::OpenPopup(id) from a button, then call this every frame. On selection
|
||||
* it copies the chosen contact's address into outBuf (NUL-terminated) and
|
||||
* returns true.
|
||||
*
|
||||
* @param id Popup id (must match the ImGui::OpenPopup(id) call).
|
||||
* @param book The App-owned address book (app->addressBook()).
|
||||
* @param outBuf Destination buffer for the chosen address.
|
||||
* @param outSz Size of outBuf.
|
||||
* @return true on the frame a contact is picked.
|
||||
*/
|
||||
inline bool ContactPickerPopup(const char* id, const data::AddressBook& book,
|
||||
char* outBuf, size_t outSz)
|
||||
{
|
||||
bool picked = false;
|
||||
if (ImGui::BeginPopup(id)) {
|
||||
if (book.empty()) {
|
||||
ImGui::TextDisabled("%s", TR("address_book_empty"));
|
||||
} else {
|
||||
const auto& entries = book.entries();
|
||||
for (size_t i = 0; i < entries.size(); ++i) {
|
||||
const auto& e = entries[i];
|
||||
ImGui::PushID(static_cast<int>(i));
|
||||
// [Z]/[T] type marker (derived from the address) + label + short address.
|
||||
const char* tag = (!e.address.empty() && e.address[0] == 'z') ? "[Z] " : "[T] ";
|
||||
std::string shortAddr = util::truncateMiddle(e.address, 24);
|
||||
std::string row = std::string(tag) + e.label + " " + shortAddr;
|
||||
if (ImGui::Selectable(row.c_str())) {
|
||||
if (outSz > 0) {
|
||||
std::strncpy(outBuf, e.address.c_str(), outSz - 1);
|
||||
outBuf[outSz - 1] = '\0';
|
||||
}
|
||||
picked = true;
|
||||
ImGui::CloseCurrentPopup();
|
||||
}
|
||||
ImGui::PopID();
|
||||
}
|
||||
}
|
||||
ImGui::EndPopup();
|
||||
}
|
||||
return picked;
|
||||
}
|
||||
|
||||
} // namespace ui
|
||||
} // namespace dragonx
|
||||
376
src/ui/windows/contacts_tab.cpp
Normal file
376
src/ui/windows/contacts_tab.cpp
Normal file
@@ -0,0 +1,376 @@
|
||||
// DragonX Wallet - ImGui Edition
|
||||
// Copyright 2024-2026 The Hush Developers
|
||||
// Released under the GPLv3
|
||||
|
||||
#include "contacts_tab.h"
|
||||
#include "../../app.h"
|
||||
#include "../../data/address_book.h"
|
||||
#include "../../util/i18n.h"
|
||||
#include "../../util/text_format.h"
|
||||
#include "../notifications.h"
|
||||
#include "../schema/ui_schema.h"
|
||||
#include "../material/draw_helpers.h"
|
||||
#include "imgui.h"
|
||||
#include <algorithm>
|
||||
#include <cctype>
|
||||
#include <cstring>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
namespace dragonx {
|
||||
namespace ui {
|
||||
|
||||
// Tab state (formerly AddressBookDialog's class statics). s_selected_index is a
|
||||
// STORAGE index into book.entries() — decoupled from the visible row order so
|
||||
// search + sort can't corrupt edit/delete/copy targets.
|
||||
static int s_selected_index = -1;
|
||||
static bool s_show_add_dialog = false;
|
||||
static bool s_show_edit_dialog = false;
|
||||
static bool s_focus_edit_field = false; // focus the first field the frame the add/edit dialog opens
|
||||
static int s_confirm_delete_idx = -1; // armed storage index; a 2nd Delete confirms
|
||||
static char s_edit_label[128] = "";
|
||||
static char s_edit_address[512] = "";
|
||||
static char s_edit_notes[512] = "";
|
||||
static char s_search[128] = "";
|
||||
|
||||
static void copyEditField(char* dest, size_t destSize, const std::string& source) {
|
||||
if (destSize == 0) return;
|
||||
std::strncpy(dest, source.c_str(), destSize - 1);
|
||||
dest[destSize - 1] = '\0';
|
||||
}
|
||||
|
||||
static std::string toLower(std::string s) {
|
||||
std::transform(s.begin(), s.end(), s.begin(),
|
||||
[](unsigned char c) { return static_cast<char>(std::tolower(c)); });
|
||||
return s;
|
||||
}
|
||||
|
||||
static bool matchesSearch(const data::AddressBookEntry& e, const std::string& needleLower) {
|
||||
if (needleLower.empty()) return true;
|
||||
return toLower(e.label).find(needleLower) != std::string::npos
|
||||
|| toLower(e.address).find(needleLower) != std::string::npos
|
||||
|| toLower(e.notes).find(needleLower) != std::string::npos;
|
||||
}
|
||||
|
||||
static bool isShieldedAddr(const std::string& a) {
|
||||
return !a.empty() && a[0] == 'z';
|
||||
}
|
||||
|
||||
void RenderContactsTab(App* app)
|
||||
{
|
||||
auto& S = schema::UI();
|
||||
// Reuse the existing address-book schema/column config for the table + add/edit form.
|
||||
auto addrTable = S.table("dialogs.address-book", "address-table");
|
||||
auto addrFrontLbl = S.label("dialogs.address-book", "address-front-label");
|
||||
auto addrBackLbl = S.label("dialogs.address-book", "address-back-label");
|
||||
auto addrInput = S.input("dialogs.address-book", "address-input");
|
||||
auto notesInput = S.input("dialogs.address-book", "notes-input");
|
||||
auto actionBtn = S.button("dialogs.address-book", "action-button");
|
||||
|
||||
auto& book = app->addressBook();
|
||||
|
||||
auto clearEditFields = []() {
|
||||
s_edit_label[0] = '\0';
|
||||
s_edit_address[0] = '\0';
|
||||
s_edit_notes[0] = '\0';
|
||||
};
|
||||
|
||||
auto loadEditFields = [](const data::AddressBookEntry& entry) {
|
||||
copyEditField(s_edit_label, sizeof(s_edit_label), entry.label);
|
||||
copyEditField(s_edit_address, sizeof(s_edit_address), entry.address);
|
||||
copyEditField(s_edit_notes, sizeof(s_edit_notes), entry.notes);
|
||||
};
|
||||
|
||||
bool has_selection = s_selected_index >= 0 && s_selected_index < static_cast<int>(book.size());
|
||||
|
||||
// Shared delete/copy actions (used by both the toolbar buttons and keyboard shortcuts).
|
||||
auto doDelete = [&]() {
|
||||
if (!has_selection) return;
|
||||
if (s_confirm_delete_idx == s_selected_index) {
|
||||
book.removeEntry(s_selected_index);
|
||||
s_selected_index = -1;
|
||||
s_confirm_delete_idx = -1;
|
||||
Notifications::instance().success(TR("address_book_deleted"));
|
||||
} else {
|
||||
// Require a second, deliberate confirm (no undo for a removed contact).
|
||||
s_confirm_delete_idx = s_selected_index;
|
||||
}
|
||||
};
|
||||
auto doCopy = [&]() {
|
||||
if (!has_selection) return;
|
||||
ImGui::SetClipboardText(book.entries()[s_selected_index].address.c_str());
|
||||
Notifications::instance().info(TR("address_copied"));
|
||||
};
|
||||
auto openEdit = [&]() {
|
||||
if (!has_selection) return;
|
||||
loadEditFields(book.entries()[s_selected_index]);
|
||||
s_show_edit_dialog = true;
|
||||
s_focus_edit_field = true;
|
||||
};
|
||||
|
||||
// Add/edit form — a modal popup layered over the tab.
|
||||
auto renderEntryDialog = [&]() {
|
||||
bool isEdit = s_show_edit_dialog;
|
||||
bool* open = isEdit ? &s_show_edit_dialog : &s_show_add_dialog;
|
||||
if (!*open) return;
|
||||
|
||||
const char* title = isEdit ? TR("address_book_edit") : TR("address_book_add");
|
||||
const char* id = isEdit ? "AddressBookEdit" : "AddressBookAdd";
|
||||
float dialogW = std::max(Layout::kDialogMinWidth(), Layout::kDialogDefaultWidth());
|
||||
float formW = addrInput.width > 0 ? addrInput.width : Layout::kDialogFormWidth();
|
||||
float actionW = actionBtn.width > 0 ? actionBtn.width : Layout::kDialogActionWidth();
|
||||
float actionGap = actionBtn.gap > 0 ? actionBtn.gap : Layout::kDialogActionGap();
|
||||
float notesH = notesInput.height > 0 ? notesInput.height : 60.0f;
|
||||
|
||||
if (material::BeginOverlayDialog(title, open, dialogW, 0.94f,
|
||||
Layout::kDialogCompactBottomRatio(), id)) {
|
||||
// Focus the first field the frame the dialog opens so it's keyboard-ready.
|
||||
if (s_focus_edit_field) {
|
||||
ImGui::SetKeyboardFocusHere();
|
||||
s_focus_edit_field = false;
|
||||
}
|
||||
material::LabeledInput(TR("label"), isEdit ? "##EditLabel" : "##AddLabel",
|
||||
s_edit_label, sizeof(s_edit_label), formW);
|
||||
|
||||
ImGui::Spacing();
|
||||
|
||||
material::LabeledInput(TR("address_label"), isEdit ? "##EditAddress" : "##AddAddress",
|
||||
s_edit_address, sizeof(s_edit_address), formW);
|
||||
if (!isEdit) {
|
||||
ImGui::SameLine();
|
||||
if (material::StyledButton(TR("paste"), ImVec2(0,0), S.resolveFont(actionBtn.font))) {
|
||||
const char* clipboard = ImGui::GetClipboardText();
|
||||
if (clipboard) copyEditField(s_edit_address, sizeof(s_edit_address), clipboard);
|
||||
}
|
||||
}
|
||||
|
||||
ImGui::Spacing();
|
||||
|
||||
material::LabeledInputMultiline(TR("notes_optional"), isEdit ? "##EditNotes" : "##AddNotes",
|
||||
s_edit_notes, sizeof(s_edit_notes), ImVec2(formW, notesH));
|
||||
|
||||
bool canSubmit = std::strlen(s_edit_label) > 0 && std::strlen(s_edit_address) > 0;
|
||||
float totalActionsW = actionW * 2.0f + actionGap;
|
||||
material::BeginOverlayDialogFooter(totalActionsW);
|
||||
|
||||
if (!canSubmit) ImGui::BeginDisabled();
|
||||
|
||||
const char* primaryLabel = isEdit ? TR("save") : TR("add");
|
||||
if (material::StyledButton(primaryLabel, ImVec2(actionW, 0), S.resolveFont(actionBtn.font))) {
|
||||
// Trim the label/address (a pasted address often carries a trailing newline); keep notes as-is.
|
||||
auto trimAB = [](std::string s) {
|
||||
while (!s.empty() && (s.front()==' '||s.front()=='\t'||s.front()=='\n'||s.front()=='\r')) s.erase(s.begin());
|
||||
while (!s.empty() && (s.back()==' '||s.back()=='\t'||s.back()=='\n'||s.back()=='\r')) s.pop_back();
|
||||
return s;
|
||||
};
|
||||
data::AddressBookEntry entry(trimAB(s_edit_label), trimAB(s_edit_address), s_edit_notes);
|
||||
if (isEdit) {
|
||||
if (app->addressBook().updateEntry(s_selected_index, entry)) {
|
||||
Notifications::instance().success(TR("address_book_updated"));
|
||||
s_show_edit_dialog = false;
|
||||
} else {
|
||||
Notifications::instance().error(TR("address_book_update_failed"));
|
||||
}
|
||||
} else {
|
||||
if (app->addressBook().addEntry(entry)) {
|
||||
Notifications::instance().success(TR("address_book_added"));
|
||||
s_show_add_dialog = false;
|
||||
} else {
|
||||
Notifications::instance().error(TR("address_book_exists"));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!canSubmit) ImGui::EndDisabled();
|
||||
|
||||
ImGui::SameLine(0, actionGap);
|
||||
if (material::StyledButton(TR("cancel"), ImVec2(actionW, 0), S.resolveFont(actionBtn.font))) {
|
||||
*open = false;
|
||||
}
|
||||
|
||||
material::EndOverlayDialog();
|
||||
}
|
||||
};
|
||||
|
||||
// Inline tab content lives in a scroll child (mirrors peers_tab / explorer_tab).
|
||||
ImVec2 avail = ImGui::GetContentRegionAvail();
|
||||
ImGui::BeginChild("##ContactsScroll", avail, false,
|
||||
ImGuiWindowFlags_NoBackground | ImGuiWindowFlags_NoScrollbar);
|
||||
|
||||
// Toolbar
|
||||
if (material::StyledButton(TR("address_book_add_new"), ImVec2(0,0), S.resolveFont(actionBtn.font))) {
|
||||
s_show_add_dialog = true;
|
||||
s_focus_edit_field = true;
|
||||
clearEditFields();
|
||||
}
|
||||
|
||||
ImGui::SameLine();
|
||||
|
||||
if (!has_selection) ImGui::BeginDisabled();
|
||||
|
||||
if (material::StyledButton(TR("edit"), ImVec2(0,0), S.resolveFont(actionBtn.font)))
|
||||
openEdit();
|
||||
|
||||
ImGui::SameLine();
|
||||
|
||||
// Delete — relabel to a visible confirm prompt while armed (not just a toast).
|
||||
bool armed = has_selection && s_confirm_delete_idx == s_selected_index;
|
||||
const char* delLabel = armed ? TR("address_book_confirm_delete") : TR("delete");
|
||||
if (material::StyledButton(delLabel, ImVec2(0,0), S.resolveFont(actionBtn.font)))
|
||||
doDelete();
|
||||
|
||||
ImGui::SameLine();
|
||||
|
||||
if (material::StyledButton(TR("copy_address"), ImVec2(0,0), S.resolveFont(actionBtn.font)))
|
||||
doCopy();
|
||||
|
||||
if (!has_selection) ImGui::EndDisabled();
|
||||
|
||||
// Search / filter
|
||||
ImGui::Spacing();
|
||||
ImGui::SetNextItemWidth(ImGui::GetContentRegionAvail().x);
|
||||
ImGui::InputTextWithHint("##ContactSearch", TR("contacts_search_placeholder"),
|
||||
s_search, sizeof(s_search));
|
||||
bool searchActive = ImGui::IsItemActive();
|
||||
std::string needle = toLower(s_search);
|
||||
|
||||
ImGui::Spacing();
|
||||
ImGui::Separator();
|
||||
ImGui::Spacing();
|
||||
|
||||
// Filtered, sorted view — storage indices only (keeps s_selected_index meaningful).
|
||||
std::vector<size_t> visibleRows;
|
||||
visibleRows.reserve(book.size());
|
||||
for (size_t i = 0; i < book.size(); ++i) {
|
||||
if (matchesSearch(book.entries()[i], needle)) visibleRows.push_back(i);
|
||||
}
|
||||
|
||||
// Address list — size the table to fill the tab, leaving room for the count footer.
|
||||
float footerH = ImGui::GetTextLineHeightWithSpacing() + ImGui::GetStyle().ItemSpacing.y;
|
||||
float tableH = ImGui::GetContentRegionAvail().y - footerH;
|
||||
if (tableH < 120.0f) tableH = 120.0f;
|
||||
if (ImGui::BeginTable("AddressBookTable", 3,
|
||||
ImGuiTableFlags_Borders | ImGuiTableFlags_RowBg | ImGuiTableFlags_Sortable |
|
||||
ImGuiTableFlags_Resizable | ImGuiTableFlags_ScrollY,
|
||||
ImVec2(0, tableH)))
|
||||
{
|
||||
float labelColW = (addrTable.columns.count("label") && addrTable.columns.at("label").width > 0) ? addrTable.columns.at("label").width : 150;
|
||||
float notesColW = (addrTable.columns.count("notes") && addrTable.columns.at("notes").width > 0) ? addrTable.columns.at("notes").width : 150;
|
||||
ImGui::TableSetupColumn(TR("label"), ImGuiTableColumnFlags_WidthFixed | ImGuiTableColumnFlags_DefaultSort, labelColW);
|
||||
ImGui::TableSetupColumn(TR("address_label"), ImGuiTableColumnFlags_WidthStretch);
|
||||
ImGui::TableSetupColumn(TR("notes"), ImGuiTableColumnFlags_WidthFixed, notesColW);
|
||||
ImGui::TableSetupScrollFreeze(0, 1);
|
||||
ImGui::TableHeadersRow();
|
||||
|
||||
// Sort the visible view by the active column (indices stay storage indices).
|
||||
if (ImGuiTableSortSpecs* specs = ImGui::TableGetSortSpecs()) {
|
||||
if (specs->SpecsCount > 0) {
|
||||
const ImGuiTableColumnSortSpecs& sc = specs->Specs[0];
|
||||
bool asc = sc.SortDirection != ImGuiSortDirection_Descending;
|
||||
const auto& entries = book.entries();
|
||||
std::stable_sort(visibleRows.begin(), visibleRows.end(),
|
||||
[&](size_t a, size_t b) {
|
||||
const std::string* ka; const std::string* kb;
|
||||
switch (sc.ColumnIndex) {
|
||||
case 1: ka = &entries[a].address; kb = &entries[b].address; break;
|
||||
case 2: ka = &entries[a].notes; kb = &entries[b].notes; break;
|
||||
default: ka = &entries[a].label; kb = &entries[b].label; break;
|
||||
}
|
||||
int cmp = toLower(*ka).compare(toLower(*kb));
|
||||
return asc ? (cmp < 0) : (cmp > 0);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (book.empty()) {
|
||||
ImGui::TableNextRow();
|
||||
ImGui::TableNextColumn();
|
||||
ImGui::TextDisabled("%s", TR("address_book_empty"));
|
||||
} else if (visibleRows.empty()) {
|
||||
ImGui::TableNextRow();
|
||||
ImGui::TableNextColumn();
|
||||
ImGui::TextDisabled("%s", TR("contacts_search_no_match"));
|
||||
} else {
|
||||
for (size_t vi = 0; vi < visibleRows.size(); ++vi) {
|
||||
size_t i = visibleRows[vi];
|
||||
const auto& entry = book.entries()[i];
|
||||
|
||||
ImGui::TableNextRow();
|
||||
ImGui::PushID(static_cast<int>(i));
|
||||
|
||||
ImGui::TableNextColumn();
|
||||
bool is_selected = (s_selected_index == static_cast<int>(i));
|
||||
if (ImGui::Selectable(entry.label.c_str(), is_selected,
|
||||
ImGuiSelectableFlags_SpanAllColumns)) {
|
||||
if (s_selected_index != static_cast<int>(i)) s_confirm_delete_idx = -1;
|
||||
s_selected_index = static_cast<int>(i);
|
||||
}
|
||||
if (ImGui::IsItemHovered() && ImGui::IsMouseDoubleClicked(0)) {
|
||||
s_selected_index = static_cast<int>(i);
|
||||
openEdit();
|
||||
}
|
||||
|
||||
ImGui::TableNextColumn();
|
||||
// Z/T type badge + address in normal (legible) text — not muted.
|
||||
// Darker/more-saturated variants on light skins so the badge doesn't wash out.
|
||||
bool shielded = isShieldedAddr(entry.address);
|
||||
bool lightTheme = material::IsLightTheme();
|
||||
ImVec4 zCol = lightTheme ? ImVec4(0.10f, 0.55f, 0.38f, 1.0f) : ImVec4(0.35f, 0.80f, 0.60f, 1.0f);
|
||||
ImVec4 tCol = lightTheme ? ImVec4(0.72f, 0.48f, 0.05f, 1.0f) : ImVec4(0.95f, 0.72f, 0.30f, 1.0f);
|
||||
ImGui::TextColored(shielded ? zCol : tCol, "%s", shielded ? "Z" : "T");
|
||||
ImGui::SameLine(0.0f, 6.0f);
|
||||
std::string addr_display = entry.address;
|
||||
int addrTruncLen = (addrTable.columns.count("address") && addrTable.columns.at("address").truncate > 0) ? addrTable.columns.at("address").truncate : 40;
|
||||
if (addr_display.length() > static_cast<size_t>(addrTruncLen)) {
|
||||
addr_display = util::truncateMiddle(addr_display, addrFrontLbl.truncate, addrBackLbl.truncate);
|
||||
}
|
||||
ImGui::TextUnformatted(addr_display.c_str());
|
||||
if (ImGui::IsItemHovered()) {
|
||||
material::Tooltip("%s", entry.address.c_str());
|
||||
}
|
||||
|
||||
ImGui::TableNextColumn();
|
||||
ImGui::TextUnformatted(entry.notes.c_str());
|
||||
|
||||
ImGui::PopID();
|
||||
}
|
||||
}
|
||||
|
||||
ImGui::EndTable();
|
||||
}
|
||||
|
||||
// Status line
|
||||
ImGui::TextDisabled(TR("address_book_count"), book.size());
|
||||
|
||||
// Keyboard shortcuts — only when the tab owns focus (no field being edited, no modal up).
|
||||
if (!searchActive && !ImGui::IsAnyItemActive() &&
|
||||
!s_show_add_dialog && !s_show_edit_dialog && !visibleRows.empty()) {
|
||||
// Current selection's position within the visible view.
|
||||
int curPos = -1;
|
||||
for (size_t k = 0; k < visibleRows.size(); ++k)
|
||||
if (static_cast<int>(visibleRows[k]) == s_selected_index) { curPos = static_cast<int>(k); break; }
|
||||
|
||||
if (ImGui::IsKeyPressed(ImGuiKey_DownArrow)) {
|
||||
curPos = (curPos < 0) ? 0 : std::min(curPos + 1, static_cast<int>(visibleRows.size()) - 1);
|
||||
s_selected_index = static_cast<int>(visibleRows[curPos]);
|
||||
s_confirm_delete_idx = -1;
|
||||
} else if (ImGui::IsKeyPressed(ImGuiKey_UpArrow)) {
|
||||
curPos = (curPos < 0) ? 0 : std::max(curPos - 1, 0);
|
||||
s_selected_index = static_cast<int>(visibleRows[curPos]);
|
||||
s_confirm_delete_idx = -1;
|
||||
} else if (ImGui::IsKeyPressed(ImGuiKey_Enter, false) || ImGui::IsKeyPressed(ImGuiKey_KeypadEnter, false)) {
|
||||
openEdit();
|
||||
} else if (ImGui::IsKeyPressed(ImGuiKey_Delete, false)) {
|
||||
doDelete();
|
||||
} else if (ImGui::GetIO().KeyCtrl && ImGui::IsKeyPressed(ImGuiKey_C, false)) {
|
||||
doCopy();
|
||||
}
|
||||
}
|
||||
|
||||
ImGui::EndChild();
|
||||
|
||||
renderEntryDialog();
|
||||
}
|
||||
|
||||
} // namespace ui
|
||||
} // namespace dragonx
|
||||
25
src/ui/windows/contacts_tab.h
Normal file
25
src/ui/windows/contacts_tab.h
Normal file
@@ -0,0 +1,25 @@
|
||||
// DragonX Wallet - ImGui Edition
|
||||
// Copyright 2024-2026 The Hush Developers
|
||||
// Released under the GPLv3
|
||||
|
||||
#pragma once
|
||||
|
||||
namespace dragonx {
|
||||
|
||||
class App;
|
||||
|
||||
namespace ui {
|
||||
|
||||
/**
|
||||
* @brief Render the Contacts tab (the address book, promoted out of Settings).
|
||||
*
|
||||
* Doubles as the future chat roster. Reads/writes the App-owned AddressBook
|
||||
* (App::addressBook()). Renders inline in the main content area; the add/edit
|
||||
* form is a modal popup layered over the tab.
|
||||
*
|
||||
* @param app Pointer to the app instance.
|
||||
*/
|
||||
void RenderContactsTab(App* app);
|
||||
|
||||
} // namespace ui
|
||||
} // namespace dragonx
|
||||
@@ -16,6 +16,7 @@
|
||||
#include "../../util/text_format.h"
|
||||
#include "../notifications.h"
|
||||
#include "../layout.h"
|
||||
#include "contact_picker.h"
|
||||
#include "../schema/ui_schema.h"
|
||||
#include "../material/type.h"
|
||||
#include "../material/draw_helpers.h"
|
||||
@@ -1272,7 +1273,8 @@ void RenderSendTab(App* app)
|
||||
ImGui::Dummy(ImVec2(0, Layout::spacingSm()));
|
||||
|
||||
float pasteW = std::max(schema::UI().drawElement("tabs.send", "paste-btn-min-width").size, colW * schema::UI().drawElement("tabs.send", "paste-btn-width-ratio").size);
|
||||
ImGui::PushItemWidth(colW - pasteW - Layout::spacingSm());
|
||||
float contactsW = ImGui::GetFrameHeight(); // compact square icon button for the contact picker
|
||||
ImGui::PushItemWidth(colW - pasteW - contactsW - Layout::spacingSm() * 2.0f);
|
||||
|
||||
// Show clipboard preview as transparent overlay when paste button is hovered
|
||||
bool paste_hovered = false;
|
||||
@@ -1331,6 +1333,15 @@ void RenderSendTab(App* app)
|
||||
}
|
||||
}
|
||||
|
||||
// Contact picker — pick a saved contact's address as the recipient.
|
||||
ImGui::SameLine();
|
||||
if (material::TactileButton(ICON_MD_CONTACTS "##pickContact", ImVec2(contactsW, 0),
|
||||
material::Type().iconMed()))
|
||||
ImGui::OpenPopup("##ContactPickerPopup");
|
||||
if (ImGui::IsItemHovered()) material::Tooltip("%s", TR("send_contacts_button"));
|
||||
ContactPickerPopup("##ContactPickerPopup", app->addressBook(),
|
||||
s_to_address, sizeof(s_to_address));
|
||||
|
||||
// Recently sent-to suggestions
|
||||
RenderAddressSuggestions(state, colW, "##AddrSugForm");
|
||||
}
|
||||
|
||||
@@ -210,6 +210,32 @@ void I18n::loadBuiltinEnglish()
|
||||
strings_["receive"] = "Receive";
|
||||
strings_["transactions"] = "Transactions";
|
||||
strings_["history"] = "History";
|
||||
strings_["contacts"] = "Contacts";
|
||||
strings_["chat"] = "Chat";
|
||||
strings_["chat_locked_hint"] = "Unlock your wallet to load your chats.";
|
||||
strings_["chat_empty_hint"] = "No conversations yet. Messages you receive will appear here.";
|
||||
strings_["chat_you"] = "You";
|
||||
strings_["chat_contact_request"] = "contact request";
|
||||
strings_["chat_send_failed"] = "not sent";
|
||||
strings_["chat_new_button"] = "New conversation";
|
||||
strings_["chat_select_hint"] = "Select a conversation to view it.";
|
||||
strings_["chat_waiting_reply"] = "Waiting for this contact to reply — you can message them once they do.";
|
||||
strings_["chat_send"] = "Send";
|
||||
strings_["chat_new_title"] = "New conversation";
|
||||
strings_["chat_new_zaddr"] = "Recipient z-address";
|
||||
strings_["chat_new_message"] = "Message";
|
||||
strings_["chat_new_send"] = "Send request";
|
||||
strings_["chat_cancel"] = "Cancel";
|
||||
strings_["chat_toast_not_connected"] = "Not connected — chat message not sent.";
|
||||
strings_["chat_toast_no_zaddr"] = "No z-address available to send chat from.";
|
||||
strings_["chat_toast_lite_busy"] = "A send is already in progress, or no wallet is open.";
|
||||
strings_["chat_toast_waiting_reply"] = "Waiting for the contact to reply before you can message them.";
|
||||
strings_["chat_toast_compose_failed"] = "Could not compose the message (too long?).";
|
||||
strings_["chat_toast_request_compose_failed"] = "Could not compose the contact request (invalid address / text?).";
|
||||
strings_["chat_toast_request_queued"] = "Contact request queued.";
|
||||
strings_["contacts_search_placeholder"] = "Search contacts...";
|
||||
strings_["contacts_search_no_match"] = "No matching contacts";
|
||||
strings_["address_book_confirm_delete"] = "Confirm delete?";
|
||||
strings_["mining"] = "Mining";
|
||||
strings_["peers"] = "Peers";
|
||||
strings_["market"] = "Market";
|
||||
@@ -750,6 +776,7 @@ void I18n::loadBuiltinEnglish()
|
||||
// Send Tab
|
||||
strings_["pay_from"] = "Pay From";
|
||||
strings_["send_to"] = "Send To";
|
||||
strings_["send_contacts_button"] = "Pick from contacts";
|
||||
strings_["amount"] = "Amount";
|
||||
strings_["memo"] = "Memo (optional, encrypted)";
|
||||
strings_["miner_fee"] = "Miner Fee";
|
||||
|
||||
@@ -16,6 +16,10 @@
|
||||
#define DRAGONX_ENABLE_LITE_BACKEND 0
|
||||
#endif
|
||||
|
||||
#ifndef DRAGONX_ENABLE_CHAT
|
||||
#define DRAGONX_ENABLE_CHAT 0
|
||||
#endif
|
||||
|
||||
namespace dragonx {
|
||||
namespace wallet {
|
||||
|
||||
@@ -34,6 +38,8 @@ enum class WalletUiSurface {
|
||||
Send,
|
||||
Receive,
|
||||
History,
|
||||
Contacts, // address book / chat roster; available in both variants (default: return true)
|
||||
Chat, // HushChat conversations; gated on DRAGONX_ENABLE_CHAT (default OFF)
|
||||
Mining,
|
||||
Market,
|
||||
Console,
|
||||
@@ -158,6 +164,8 @@ constexpr bool isUiSurfaceAvailable(const WalletCapabilities& capabilities,
|
||||
case WalletUiSurface::LiteNetwork:
|
||||
case WalletUiSurface::LiteConsole:
|
||||
return !capabilities.fullNodePagesAvailable; // lite builds only
|
||||
case WalletUiSurface::Chat:
|
||||
return DRAGONX_ENABLE_CHAT != 0; // experimental; compiled-in only when the feature is on
|
||||
case WalletUiSurface::BootstrapDownload:
|
||||
case WalletUiSurface::SetupWizard:
|
||||
case WalletUiSurface::NodeSettings:
|
||||
|
||||
@@ -1,3 +1,7 @@
|
||||
#include "chat/chat_crypto.h"
|
||||
#include "chat/chat_identity.h"
|
||||
#include "chat/chat_service.h"
|
||||
#include "chat/chat_database.h"
|
||||
#include "daemon/daemon_controller.h"
|
||||
#include "data/transaction_history_cache.h"
|
||||
#include "daemon/lifecycle_adapters.h"
|
||||
@@ -5552,6 +5556,477 @@ void testPoolWeightedSelection()
|
||||
EXPECT_TRUE(stay > 1000); // >50% thanks to kIncumbentStayBias
|
||||
}
|
||||
|
||||
// HushChat crypto core: seed-derived identity + secretstream encrypt/decrypt round-trip.
|
||||
// Pass featureEnabled=true so the crypto is exercised even in the default (chat-OFF) build.
|
||||
void testHushChatCrypto()
|
||||
{
|
||||
using namespace dragonx::chat;
|
||||
|
||||
ChatKeyPair alice, bob;
|
||||
ChatIdentityResult ra = deriveChatIdentityFromSecret("alice-wallet-secret", alice, /*featureEnabled=*/true);
|
||||
ChatIdentityResult rb = deriveChatIdentityFromSecret("bob-wallet-secret", bob, /*featureEnabled=*/true);
|
||||
EXPECT_TRUE(ra.status == ChatIdentityStatus::Ready);
|
||||
EXPECT_TRUE(rb.status == ChatIdentityStatus::Ready);
|
||||
EXPECT_EQ((int)ra.public_key_hex.size(), 64);
|
||||
EXPECT_TRUE(ra.public_key_hex != rb.public_key_hex);
|
||||
|
||||
// Deterministic: the same secret always yields the same identity.
|
||||
ChatKeyPair alice2;
|
||||
ChatIdentityResult ra2 = deriveChatIdentityFromSecret("alice-wallet-secret", alice2, true);
|
||||
EXPECT_EQ(ra.public_key_hex, ra2.public_key_hex);
|
||||
|
||||
// Feature gate: featureEnabled=false yields FeatureDisabled (and error_name matches).
|
||||
ChatKeyPair off;
|
||||
ChatIdentityResult roff = deriveChatIdentityFromSecret("x", off, false);
|
||||
EXPECT_TRUE(roff.status == ChatIdentityStatus::FeatureDisabled);
|
||||
EXPECT_EQ(std::string(roff.error_name), std::string("FeatureDisabled"));
|
||||
// Empty secret is rejected.
|
||||
ChatKeyPair none;
|
||||
EXPECT_TRUE(deriveChatIdentityFromSecret("", none, true).status == ChatIdentityStatus::SecretUnavailable);
|
||||
|
||||
// Round-trip: Alice (server role) encrypts to Bob; Bob (client role) decrypts from Alice.
|
||||
std::string headerHex, cipherHex;
|
||||
ChatCryptoStatus es = encryptOutgoing(alice, rb.public_key_hex, "hello bob \xF0\x9F\x90\x89", headerHex, cipherHex);
|
||||
EXPECT_TRUE(es == ChatCryptoStatus::Ok);
|
||||
EXPECT_EQ((int)headerHex.size(), 48); // 24-byte secretstream header as hex
|
||||
|
||||
std::string plain;
|
||||
ChatCryptoStatus ds = decryptIncoming(bob, ra.public_key_hex, headerHex, cipherHex, plain);
|
||||
EXPECT_TRUE(ds == ChatCryptoStatus::Ok);
|
||||
EXPECT_EQ(plain, std::string("hello bob \xF0\x9F\x90\x89"));
|
||||
|
||||
// Empty plaintext round-trips symmetrically (the crypto layer imposes no content policy).
|
||||
std::string emptyHeader, emptyCipher, emptyPlain;
|
||||
EXPECT_TRUE(encryptOutgoing(alice, rb.public_key_hex, "", emptyHeader, emptyCipher) == ChatCryptoStatus::Ok);
|
||||
EXPECT_TRUE(decryptIncoming(bob, ra.public_key_hex, emptyHeader, emptyCipher, emptyPlain) == ChatCryptoStatus::Ok);
|
||||
EXPECT_EQ(emptyPlain, std::string(""));
|
||||
|
||||
// Tampered ciphertext fails the auth tag (neutral DecryptFailed, not a crash).
|
||||
std::string tampered = cipherHex;
|
||||
tampered.back() = (tampered.back() == '0') ? '1' : '0';
|
||||
std::string plain2;
|
||||
EXPECT_TRUE(decryptIncoming(bob, ra.public_key_hex, headerHex, tampered, plain2) != ChatCryptoStatus::Ok);
|
||||
|
||||
// Wrong recipient key can't derive the session and/or fails auth.
|
||||
std::string plain3;
|
||||
ChatKeyPair mallory;
|
||||
deriveChatIdentityFromSecret("mallory-secret", mallory, true);
|
||||
EXPECT_TRUE(decryptIncoming(mallory, ra.public_key_hex, headerHex, cipherHex, plain3) != ChatCryptoStatus::Ok);
|
||||
|
||||
// Malformed inputs are rejected without touching the tag path.
|
||||
std::string plain4;
|
||||
EXPECT_TRUE(decryptIncoming(bob, "not-hex", headerHex, cipherHex, plain4) == ChatCryptoStatus::BadPeerKey);
|
||||
EXPECT_TRUE(decryptIncoming(bob, ra.public_key_hex, headerHex, "00", plain4) == ChatCryptoStatus::CiphertextTooShort);
|
||||
}
|
||||
|
||||
// End-to-end receive path: identity -> encrypt -> HushChat memo pair -> parser harvest
|
||||
// (extractHushChatTransactionMetadata now carries e/p/ciphertext, Phase 1 Step 1) -> decrypt.
|
||||
void testHushChatReceivePath()
|
||||
{
|
||||
using namespace dragonx::chat;
|
||||
|
||||
ChatKeyPair alice, bob;
|
||||
ChatIdentityResult ra = deriveChatIdentityFromSecret("alice-recv", alice, true);
|
||||
ChatIdentityResult rb = deriveChatIdentityFromSecret("bob-recv", bob, true);
|
||||
EXPECT_TRUE(ra.status == ChatIdentityStatus::Ready);
|
||||
EXPECT_TRUE(rb.status == ChatIdentityStatus::Ready);
|
||||
|
||||
// Alice encrypts to Bob, wrapped as a HushChat header memo + payload memo.
|
||||
std::string e, ct;
|
||||
EXPECT_TRUE(encryptOutgoing(alice, rb.public_key_hex, "gm bob", e, ct) == ChatCryptoStatus::Ok);
|
||||
std::string headerMemo = std::string("{\"cid\":\"conv-42\",\"e\":\"") + e +
|
||||
"\",\"h\":1,\"p\":\"" + ra.public_key_hex +
|
||||
"\",\"t\":\"Memo\",\"v\":0,\"z\":\"zs-alice-reply\"}";
|
||||
|
||||
HushChatTransactionInput tx;
|
||||
tx.txid = "txid-recv-1";
|
||||
tx.outputs.push_back(HushChatMemoOutput{0, headerMemo});
|
||||
tx.outputs.push_back(HushChatMemoOutput{1, ct});
|
||||
|
||||
HushChatTransactionExtractionResult ext = extractHushChatTransactionMetadata(tx, true);
|
||||
EXPECT_TRUE(ext.feature_enabled);
|
||||
EXPECT_EQ((int)ext.metadata.size(), 1);
|
||||
const HushChatTransactionMetadata& m = ext.metadata[0];
|
||||
EXPECT_TRUE(m.type == HushChatHeaderType::Message);
|
||||
EXPECT_EQ(m.sender_public_key_hex, ra.public_key_hex);
|
||||
EXPECT_EQ(m.secretstream_header_hex, e);
|
||||
EXPECT_EQ(m.payload_memo, ct);
|
||||
|
||||
// Bob decrypts straight from the carried metadata — the whole receive path.
|
||||
std::string plain;
|
||||
EXPECT_TRUE(decryptIncoming(bob, m.sender_public_key_hex, m.secretstream_header_hex,
|
||||
m.payload_memo, plain) == ChatCryptoStatus::Ok);
|
||||
EXPECT_EQ(plain, std::string("gm bob"));
|
||||
|
||||
// Feature disabled -> the harvest yields nothing.
|
||||
HushChatTransactionExtractionResult off = extractHushChatTransactionMetadata(tx, false);
|
||||
EXPECT_TRUE(!off.feature_enabled);
|
||||
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());
|
||||
}
|
||||
|
||||
// Phase 2: persistent, seed-encrypted store — round-trip, dedup, per-wallet isolation, and
|
||||
// ChatService write-through + reload (reload needs only the storage key, not the chat identity).
|
||||
void testHushChatDatabase()
|
||||
{
|
||||
using namespace dragonx::chat;
|
||||
namespace fs = std::filesystem;
|
||||
|
||||
const std::string dbPath = (fs::temp_directory_path() / "drgx_chat_db_test.sqlite").string();
|
||||
const std::string dbPath2 = (fs::temp_directory_path() / "drgx_chat_db_test2.sqlite").string();
|
||||
auto scrub = [](const std::string& p) {
|
||||
fs::remove(p); fs::remove(p + "-wal"); fs::remove(p + "-shm");
|
||||
};
|
||||
scrub(dbPath); scrub(dbPath2);
|
||||
|
||||
const std::string seedA = "wallet A seed phrase words here";
|
||||
const std::string seedB = "a completely different wallet B seed";
|
||||
|
||||
auto makeMsg = [](const std::string& txid, std::size_t pos, const std::string& cid,
|
||||
const std::string& body, ChatMessageKind kind, std::int64_t ts) {
|
||||
ChatMessage m;
|
||||
m.direction = ChatDirection::Incoming;
|
||||
m.kind = kind;
|
||||
m.txid = txid;
|
||||
m.conversation_id = cid;
|
||||
m.peer_zaddr = "zs-peer";
|
||||
m.peer_public_key_hex = "deadbeef";
|
||||
m.body = body;
|
||||
m.timestamp = ts;
|
||||
m.payload_position = pos;
|
||||
return m;
|
||||
};
|
||||
|
||||
// Locked DB is inert; unlock, then write three (with a duplicate that is ignored).
|
||||
{
|
||||
ChatDatabase db(dbPath);
|
||||
EXPECT_TRUE(!db.hasKey());
|
||||
EXPECT_TRUE(db.append(makeMsg("t1", 1, "c", "hello", ChatMessageKind::Message, 111)) == false);
|
||||
EXPECT_TRUE(db.unlockWithSecret(seedA));
|
||||
EXPECT_TRUE(db.hasKey());
|
||||
EXPECT_TRUE(db.append(makeMsg("t1", 1, "c", "hello", ChatMessageKind::Message, 111)));
|
||||
EXPECT_TRUE(db.append(makeMsg("t2", 1, "c", "world", ChatMessageKind::Message, 222)));
|
||||
EXPECT_TRUE(db.append(makeMsg("t3", 0, "c", "add me", ChatMessageKind::ContactRequest, 333)));
|
||||
EXPECT_TRUE(db.append(makeMsg("t1", 1, "c", "hello", ChatMessageKind::Message, 111)) == false); // dup
|
||||
}
|
||||
|
||||
// Reopen with the SAME seed → messages persist, fields + order intact.
|
||||
{
|
||||
ChatDatabase db(dbPath);
|
||||
EXPECT_TRUE(db.unlockWithSecret(seedA));
|
||||
std::vector<ChatMessage> all = db.load();
|
||||
EXPECT_EQ((int)all.size(), 3);
|
||||
EXPECT_EQ(all[0].body, std::string("hello"));
|
||||
EXPECT_EQ(all[0].txid, std::string("t1"));
|
||||
EXPECT_EQ(all[0].timestamp, (std::int64_t)111);
|
||||
EXPECT_EQ(all[1].body, std::string("world"));
|
||||
EXPECT_TRUE(all[2].kind == ChatMessageKind::ContactRequest);
|
||||
EXPECT_EQ(all[2].body, std::string("add me"));
|
||||
EXPECT_EQ(all[2].peer_public_key_hex, std::string("deadbeef"));
|
||||
}
|
||||
|
||||
// A DIFFERENT seed is partitioned + can't decrypt → sees nothing, writes in isolation.
|
||||
{
|
||||
ChatDatabase db(dbPath);
|
||||
EXPECT_TRUE(db.unlockWithSecret(seedB));
|
||||
EXPECT_TRUE(db.load().empty());
|
||||
EXPECT_TRUE(db.append(makeMsg("t1", 1, "c", "B-secret", ChatMessageKind::Message, 999)));
|
||||
EXPECT_EQ((int)db.load().size(), 1);
|
||||
}
|
||||
// ...and wallet A still sees exactly its three.
|
||||
{
|
||||
ChatDatabase db(dbPath);
|
||||
EXPECT_TRUE(db.unlockWithSecret(seedA));
|
||||
EXPECT_EQ((int)db.load().size(), 3);
|
||||
db.lock();
|
||||
EXPECT_TRUE(!db.hasKey());
|
||||
EXPECT_TRUE(db.load().empty()); // inert once locked
|
||||
}
|
||||
|
||||
// End-to-end: ChatService write-through on ingest, then reload into a fresh service WITHOUT an
|
||||
// identity (proves stored messages are decryptable with the storage key alone).
|
||||
{
|
||||
ChatKeyPair alice, bob;
|
||||
ChatIdentityResult ra = deriveChatIdentityFromSecret("db-alice", alice, true);
|
||||
ChatIdentityResult rb = deriveChatIdentityFromSecret("db-bob", bob, true);
|
||||
std::string e, ct;
|
||||
EXPECT_TRUE(encryptOutgoing(alice, rb.public_key_hex, "persisted!", e, ct) == ChatCryptoStatus::Ok);
|
||||
HushChatTransactionMetadata m;
|
||||
m.txid = "txp"; m.type = HushChatHeaderType::Message; m.conversation_id = "cp";
|
||||
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;
|
||||
std::vector<HushChatTransactionMetadata> batch{m};
|
||||
std::unordered_map<std::string, std::int64_t> times{{"txp", 4242}};
|
||||
|
||||
{
|
||||
ChatDatabase db(dbPath2);
|
||||
EXPECT_TRUE(db.unlockWithSecret("e2e-seed"));
|
||||
ChatService svc;
|
||||
svc.setPersistence(&db);
|
||||
svc.setIdentity(bob);
|
||||
EXPECT_EQ(svc.ingest(batch, times), 1);
|
||||
}
|
||||
{
|
||||
ChatDatabase db(dbPath2);
|
||||
EXPECT_TRUE(db.unlockWithSecret("e2e-seed"));
|
||||
ChatService svc;
|
||||
svc.setPersistence(&db);
|
||||
svc.loadFromDatabase(); // no setIdentity() — reload doesn't need it
|
||||
std::vector<ChatMessage> conv = svc.store().conversation("cp");
|
||||
EXPECT_EQ((int)conv.size(), 1);
|
||||
EXPECT_EQ(conv[0].body, std::string("persisted!"));
|
||||
EXPECT_EQ(conv[0].timestamp, (std::int64_t)4242);
|
||||
}
|
||||
}
|
||||
|
||||
// Delivery status persists (a failed outgoing echo round-trips as Failed).
|
||||
{
|
||||
ChatDatabase db(dbPath);
|
||||
EXPECT_TRUE(db.unlockWithSecret("delivery-seed"));
|
||||
ChatMessage m = makeMsg("tD", 1, "cD", "not-sent one", ChatMessageKind::Message, 555);
|
||||
m.direction = ChatDirection::Outgoing;
|
||||
m.delivery = ChatDelivery::Failed;
|
||||
EXPECT_TRUE(db.append(m));
|
||||
std::vector<ChatMessage> loaded = db.load();
|
||||
EXPECT_EQ((int)loaded.size(), 1);
|
||||
EXPECT_TRUE(loaded[0].delivery == ChatDelivery::Failed);
|
||||
}
|
||||
|
||||
scrub(dbPath); scrub(dbPath2);
|
||||
}
|
||||
|
||||
// Phase 4: outgoing memo construction round-trips through the receive parser + decrypt, and
|
||||
// ChatService compose/recordOutgoing echoes into the store.
|
||||
void testHushChatOutgoing()
|
||||
{
|
||||
using namespace dragonx::chat;
|
||||
|
||||
ChatKeyPair alice, bob;
|
||||
ChatIdentityResult ra = deriveChatIdentityFromSecret("out-alice", alice, true);
|
||||
ChatIdentityResult rb = deriveChatIdentityFromSecret("out-bob", bob, true);
|
||||
const std::string aliceZ = "zs-alice-reply";
|
||||
const std::string bobZ = "zs-bob";
|
||||
const std::string cid = "conv-out";
|
||||
|
||||
// Encrypted message Alice -> Bob, then round-trip through the receive path.
|
||||
OutgoingChatMemos memos;
|
||||
EXPECT_TRUE(buildOutgoingMessage(alice, ra.public_key_hex, aliceZ, rb.public_key_hex, bobZ, cid,
|
||||
"hello bob", memos) == ChatComposeStatus::Ok);
|
||||
EXPECT_EQ(memos.recipientZaddr, bobZ);
|
||||
EXPECT_TRUE(!memos.headerMemo.empty() && memos.headerMemo.front() == '{');
|
||||
// Header keys serialize alphabetically (cid before e).
|
||||
EXPECT_TRUE(memos.headerMemo.find("\"cid\"") < memos.headerMemo.find("\"e\""));
|
||||
|
||||
HushChatTransactionInput tx;
|
||||
tx.txid = "txout1";
|
||||
tx.outputs.push_back({0, memos.headerMemo}); // header at the lower position
|
||||
tx.outputs.push_back({1, memos.payloadMemo});
|
||||
auto extracted = extractHushChatTransactionMetadata(tx, true);
|
||||
EXPECT_EQ((int)extracted.metadata.size(), 1);
|
||||
|
||||
ChatService bobSvc;
|
||||
bobSvc.setIdentity(bob);
|
||||
EXPECT_EQ(bobSvc.ingest(extracted.metadata, {}, 5), 1);
|
||||
std::vector<ChatMessage> conv = bobSvc.store().conversation(cid);
|
||||
EXPECT_EQ((int)conv.size(), 1);
|
||||
EXPECT_EQ(conv[0].body, std::string("hello bob"));
|
||||
EXPECT_EQ(conv[0].peer_public_key_hex, ra.public_key_hex); // Bob learns Alice's key
|
||||
EXPECT_EQ(conv[0].peer_zaddr, aliceZ);
|
||||
|
||||
// Plaintext contact request Alice -> Bob.
|
||||
OutgoingChatMemos creq;
|
||||
EXPECT_TRUE(buildOutgoingContactRequest(ra.public_key_hex, aliceZ, bobZ, "conv-cr", "add me?",
|
||||
creq) == ChatComposeStatus::Ok);
|
||||
HushChatTransactionInput tx2;
|
||||
tx2.txid = "txout2";
|
||||
tx2.outputs.push_back({0, creq.headerMemo});
|
||||
tx2.outputs.push_back({1, creq.payloadMemo});
|
||||
auto ex2 = extractHushChatTransactionMetadata(tx2, true);
|
||||
EXPECT_EQ((int)ex2.metadata.size(), 1);
|
||||
EXPECT_TRUE(ex2.metadata[0].type == HushChatHeaderType::ContactRequest);
|
||||
bobSvc.ingest(ex2.metadata, {}, 6);
|
||||
EXPECT_EQ((int)bobSvc.store().conversation("conv-cr").size(), 1);
|
||||
EXPECT_EQ(bobSvc.store().conversation("conv-cr")[0].body, std::string("add me?"));
|
||||
|
||||
// Validation guards.
|
||||
OutgoingChatMemos dummy;
|
||||
EXPECT_TRUE(buildOutgoingMessage(alice, ra.public_key_hex, aliceZ, "tooshort", bobZ, cid, "x", dummy)
|
||||
== ChatComposeStatus::BadPeerKey);
|
||||
EXPECT_TRUE(buildOutgoingMessage(alice, ra.public_key_hex, aliceZ, rb.public_key_hex, bobZ, cid, "", dummy)
|
||||
== ChatComposeStatus::EmptyBody);
|
||||
EXPECT_TRUE(buildOutgoingContactRequest(ra.public_key_hex, aliceZ, bobZ, "c", "{bad", dummy)
|
||||
== ChatComposeStatus::BadRequestText);
|
||||
|
||||
// ChatService compose + recordOutgoing echo.
|
||||
OutgoingChatMemos svcMemos;
|
||||
EXPECT_TRUE(bobSvc.composeMessage(bobZ, ra.public_key_hex, aliceZ, cid, "reply!", svcMemos)
|
||||
== ChatComposeStatus::Ok);
|
||||
ChatMessage echo;
|
||||
echo.direction = ChatDirection::Outgoing;
|
||||
echo.kind = ChatMessageKind::Message;
|
||||
echo.conversation_id = cid;
|
||||
echo.peer_zaddr = aliceZ;
|
||||
echo.peer_public_key_hex = ra.public_key_hex;
|
||||
echo.body = "reply!";
|
||||
echo.timestamp = 7;
|
||||
echo.txid = "out:local1";
|
||||
echo.payload_position = 0;
|
||||
EXPECT_TRUE(bobSvc.recordOutgoing(echo));
|
||||
std::vector<ChatMessage> conv2 = bobSvc.store().conversation(cid);
|
||||
EXPECT_EQ((int)conv2.size(), 2);
|
||||
EXPECT_TRUE(conv2.back().direction == ChatDirection::Outgoing);
|
||||
EXPECT_EQ(conv2.back().body, std::string("reply!"));
|
||||
}
|
||||
|
||||
// Phase 5: the transport encoding (chatSendOutputs) — header first, "utf8:" for full-node / raw for
|
||||
// lite — and that the on-chain form (after the daemon strips "utf8:") round-trips through receive.
|
||||
void testHushChatTransport()
|
||||
{
|
||||
using namespace dragonx::chat;
|
||||
|
||||
ChatKeyPair alice, bob;
|
||||
ChatIdentityResult ra = deriveChatIdentityFromSecret("tx-alice", alice, true);
|
||||
ChatIdentityResult rb = deriveChatIdentityFromSecret("tx-bob", bob, true);
|
||||
OutgoingChatMemos memos;
|
||||
EXPECT_TRUE(buildOutgoingMessage(alice, ra.public_key_hex, "zs-alice", rb.public_key_hex,
|
||||
"zs-bob", "cid-tx", "over the wire", memos) == ChatComposeStatus::Ok);
|
||||
|
||||
// Full-node: header at index 0, both to the peer, each "utf8:"-prefixed.
|
||||
std::array<ChatSendOutput, 2> fn = chatSendOutputs(memos, /*utf8Prefix=*/true);
|
||||
EXPECT_EQ(fn[0].address, std::string("zs-bob"));
|
||||
EXPECT_EQ(fn[1].address, std::string("zs-bob"));
|
||||
EXPECT_TRUE(fn[0].memo.rfind("utf8:", 0) == 0);
|
||||
EXPECT_TRUE(fn[1].memo.rfind("utf8:", 0) == 0);
|
||||
EXPECT_EQ(fn[0].memo, std::string("utf8:") + memos.headerMemo);
|
||||
EXPECT_EQ(fn[1].memo, std::string("utf8:") + memos.payloadMemo);
|
||||
|
||||
// Lite: raw, no prefix.
|
||||
std::array<ChatSendOutput, 2> lt = chatSendOutputs(memos, /*utf8Prefix=*/false);
|
||||
EXPECT_EQ(lt[0].memo, memos.headerMemo);
|
||||
EXPECT_EQ(lt[1].memo, memos.payloadMemo);
|
||||
|
||||
// On-chain form round-trips: the daemon stores the UTF-8 text (the string after "utf8:"), which
|
||||
// is exactly what the harvest parser reads back (header first / lower position).
|
||||
auto strip = [](const std::string& m) { return m.rfind("utf8:", 0) == 0 ? m.substr(5) : m; };
|
||||
HushChatTransactionInput tx;
|
||||
tx.txid = "txwire";
|
||||
tx.outputs.push_back({0, strip(fn[0].memo)});
|
||||
tx.outputs.push_back({1, strip(fn[1].memo)});
|
||||
auto extracted = extractHushChatTransactionMetadata(tx, true);
|
||||
EXPECT_EQ((int)extracted.metadata.size(), 1);
|
||||
ChatService bobSvc;
|
||||
bobSvc.setIdentity(bob);
|
||||
EXPECT_EQ(bobSvc.ingest(extracted.metadata, {}, 9), 1);
|
||||
EXPECT_EQ(bobSvc.store().conversation("cid-tx")[0].body, std::string("over the wire"));
|
||||
}
|
||||
|
||||
// Phase 5: the full-node daemon SHUFFLES the two memo outputs, so the header may land at a HIGHER
|
||||
// note position than its payload. The receive parser must pair them regardless of on-chain order.
|
||||
void testHushChatShuffledReceive()
|
||||
{
|
||||
using namespace dragonx::chat;
|
||||
|
||||
ChatKeyPair alice, bob;
|
||||
ChatIdentityResult ra = deriveChatIdentityFromSecret("shuf-alice", alice, true);
|
||||
ChatIdentityResult rb = deriveChatIdentityFromSecret("shuf-bob", bob, true);
|
||||
ChatService bobSvc;
|
||||
bobSvc.setIdentity(bob);
|
||||
|
||||
// Payload BEFORE header (positions swapped, as a shuffle may produce).
|
||||
OutgoingChatMemos memos;
|
||||
EXPECT_TRUE(buildOutgoingMessage(alice, ra.public_key_hex, "zs-alice", rb.public_key_hex,
|
||||
"zs-bob", "cid-shuf", "shuffled hi", memos) == ChatComposeStatus::Ok);
|
||||
HushChatTransactionInput tx;
|
||||
tx.txid = "txshuf";
|
||||
tx.outputs.push_back({0, memos.payloadMemo}); // payload at the LOWER position
|
||||
tx.outputs.push_back({1, memos.headerMemo}); // header at the HIGHER position
|
||||
auto extracted = extractHushChatTransactionMetadata(tx, true);
|
||||
EXPECT_EQ((int)extracted.metadata.size(), 1);
|
||||
EXPECT_EQ(bobSvc.ingest(extracted.metadata, {}, 3), 1);
|
||||
EXPECT_EQ(bobSvc.store().conversation("cid-shuf")[0].body, std::string("shuffled hi"));
|
||||
|
||||
// With a change output (empty memo) interspersed and the header last, still pairs.
|
||||
OutgoingChatMemos memos2;
|
||||
EXPECT_TRUE(buildOutgoingMessage(alice, ra.public_key_hex, "zs-alice", rb.public_key_hex,
|
||||
"zs-bob", "cid-shuf2", "with change", memos2) == ChatComposeStatus::Ok);
|
||||
HushChatTransactionInput tx2;
|
||||
tx2.txid = "txshuf2";
|
||||
tx2.outputs.push_back({0, std::string()}); // change output: empty memo (ignored)
|
||||
tx2.outputs.push_back({1, memos2.payloadMemo}); // payload
|
||||
tx2.outputs.push_back({2, memos2.headerMemo}); // header last
|
||||
auto ex2 = extractHushChatTransactionMetadata(tx2, true);
|
||||
EXPECT_EQ((int)ex2.metadata.size(), 1);
|
||||
EXPECT_EQ(bobSvc.ingest(ex2.metadata, {}, 4), 1);
|
||||
EXPECT_EQ(bobSvc.store().conversation("cid-shuf2")[0].body, std::string("with change"));
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
int main()
|
||||
@@ -5642,6 +6117,13 @@ int main()
|
||||
testPoolHashrateParsing();
|
||||
testPoolWeightedSelection();
|
||||
testAtomicFileWrite();
|
||||
testHushChatCrypto();
|
||||
testHushChatReceivePath();
|
||||
testHushChatService();
|
||||
testHushChatDatabase();
|
||||
testHushChatOutgoing();
|
||||
testHushChatTransport();
|
||||
testHushChatShuffledReceive();
|
||||
testAddressChecksumValidation();
|
||||
testLiteServerProbeLive();
|
||||
testXmrigLiveInstall();
|
||||
|
||||
Reference in New Issue
Block a user