22 Commits

Author SHA1 Message Date
b75801f5c4 i18n(chat): translate the chat send/receive toasts into all 8 languages
The HushChat UI labels were already translated, but the send-path
notifications (not-connected, no-z-address, waiting-for-reply, compose
failures, contact-request queued, lite busy) were still hardcoded English.
Route all eight call sites in app_network.cpp through TR() with seven new
keys (the three "no z-address" variants unified into chat_toast_no_zaddr),
add the English fallbacks in i18n.cpp, and translate the keys into de/es/fr/
ja/ko/pt/ru/zh — reusing each file's existing chat_* terminology (z-address,
contact request, message) for consistency. Additive only (950 -> 957 keys
per language). CJK subset font rebuilt to cover the new zh/ja/ko glyphs.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-06 23:36:56 -05:00
6ddca6aed0 fix(security): auto-lock the lite wallet on idle
checkAutoLock() called App::lockWallet(), which early-returns without an
rpc_ handle — so lite wallets, which have no daemon, never idle-locked and
stayed unlocked indefinitely. Route lite through lockLiteWallet() instead,
which locks the backend and tears down the chat session so no decrypted
store / unlocked DB key survives the idle lock. Full-node builds keep
lite_wallet_ null, so the branch is a no-op there and behaviour is unchanged.

The idle timer (last_interaction_, reset on ImGui input at the top of
App::update()) is already build-agnostic, so no other wiring is needed.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-06 23:30:38 -05:00
ca1ac22b17 i18n(chat): translate the Chat UI strings into all 8 languages
Add the 15 HushChat UI strings (nav label, locked/empty/select/waiting hints,
composer + new-conversation dialog, You / contact request / not-sent tags) to
res/lang/{de,es,fr,ja,ko,pt,ru,zh}.json. Previously they fell back to English.

Added directly to the JSON files (935 -> 950 keys, additive only): the
scripts/gen_<lang>.py generators are stale — they emit ~285 fewer keys than the
committed JSONs, so regenerating from them would silently drop translations.
(Pre-existing issue; noted for follow-up. The generators are left untouched.)

Rebuilt the usage-based CJK subset font (res/fonts/NotoSansCJK-Subset.ttf) from
the full JSONs so the new chat glyphs (聊天 / 对话 / 会話 / 답장, etc.) render —
verified every CJK code point in the full JSONs is covered (no glyph regression).

Verified: Linux + Windows build (embedded lang headers regenerated via xxd, font
re-embedded via INCBIN), hygiene clean; per-file diff is additive (15 keys, 0
deletions).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-06 23:23:01 -05:00
6bfb56aeb9 feat(chat): mark outgoing messages that fail to send
Outgoing echoes were recorded optimistically regardless of whether the broadcast
was actually submitted. Now broadcastChatMemos / broadcastChatMemosLite return
whether the send was submitted (false on immediate failures: not connected, no
spendable z-address, or a lite send already in progress), and the echo records a
ChatDelivery status (Sent / Failed). The Chat tab shows a "not sent" marker in
the error color on failed messages. The status persists in the DB (backward-
compatible: old rows deserialize as Sent).

(A later async failure — an opid that fails after submission — still surfaces via
the existing send-progress notification; only the submit gate is reflected here.)

Also removes the now-unused chat_readonly_note string (the composer replaced the
read-only footer). Test: delivery status round-trips through the DB. Verified:
Linux + Windows build with chat ON, ctest 100%, hygiene clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-06 23:11:50 -05:00
6c08a08127 feat(chat): enable HushChat by default + legacy (non-mnemonic) wallet support
Flip DRAGONX_ENABLE_CHAT to default ON (a fresh configure now builds chat in),
and make full-node chat work for every wallet + any daemon.

Legacy compat: chat identity is derived from the wallet's mnemonic
(z_exportmnemonic) for a portable, SDXLite-compatible identity — but a legacy
random-seed wallet has no mnemonic, and released daemons don't have that RPC at
all. Provisioning now falls back to a stable z-address's spending key
(z_exportkey) in those cases: a functional, wallet-local identity that works on
any daemon. Since a chat identity is local (peers learn your public key from
your message headers, not your derivation), interop is unaffected; only
cross-client portability needs the mnemonic. The spending key is an in-memory
KDF input over a key the wallet already holds, wiped after use — no new exposure.

Stability: the chosen chat z-address (the reply-to in headers AND the legacy
identity source) is now persisted in settings (chat_reply_zaddr), so the
identity + reply address don't shift when new addresses are generated.
chatReplyZaddr() picks the smallest spendable z-addr once and reuses it.

CLAUDE.md updated to reflect the default flip. Verified: Linux + Windows build
with chat ON, fresh-configure default confirmed ON, ctest 100%, hygiene clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-06 22:16:35 -05:00
f5bde67f64 fix(chat): tear down the chat session immediately on lite wallet lock
Security-review finding (low): on the lite variant, "Lock now" ran the backend
`lock` but never updated state_.locked — that only refreshes on the next ~2s
poll. Since chat teardown is driven by state_.isLocked() in maybeProvision
ChatIdentity, the decrypted in-memory store, the chat identity secret key, and
the seed-derived AEAD DB key all lingered in RAM for up to ~2s past an explicit
lock (the full-node path closes this immediately, since App::lockWallet sets
state_.locked synchronously).

New App::lockLiteWallet() mirrors the full-node behavior: on a successful lite
lock it sets state_.locked and tears down the chat session now (clearIdentity +
store().clear() + chat_db_.lock() + re-arm). The lite "Lock now" button routes
through it. (Note: the lite variant has no auto-lock at all — checkAutoLock ->
App::lockWallet early-returns for lite — which is a separate, general lite gap.)

Completes the end-to-end feature security review (1 confirmed finding of 3 raw,
now fixed). Verified: Linux + Windows build with chat ON, ctest 100%, hygiene clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-06 19:51:44 -05:00
516e5ec688 feat(chat): demo-chat seed for the screenshot sweep (debug)
Add App::seedChatDemoData() + a "Seed demo chat" button in the DEBUG OPTIONS
card (shown only in chat-enabled builds, next to the screenshot sweep). It gives
the Chat tab a demo identity and injects a few sample conversations into the
in-memory store (NOT persisted — no DB attached, gone on restart) so the
screenshot sweep captures the tab's real UI (conversation list, thread bubbles,
composer, and the "waiting for reply" contact-request state) instead of the
empty "unlock" hint. No-op when the chat feature is off.

Verified: Linux + Windows build with chat ON, ctest 100%, hygiene clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-06 19:47:41 -05:00
008cc82ee5 feat(chat): harvest chat memos on the lite variant (lite receive)
The full-node receive harvest works off z_viewtransaction; the lite wallet's
transactions come from the backend instead, so lite chat receive was unwired.

App::ingestLiteChatMemos runs after each lite refresh: 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
Receive-kind notes by txid, feed them through the SAME extractHushChatTransaction
Metadata parser (now order-tolerant, so the daemon's output shuffle is a non-issue),
and thread the result into ChatService. The store dedups (txid+position), so
re-listing every refresh is harmless.

This closes the lite variant's chat loop end-to-end: identity (exportSeed), DB
unlock/load, receive (here), UI, and send (broadcastChatMemosLite). Gated by
DRAGONX_ENABLE_CHAT (default OFF). Verified: Linux + Windows build with chat ON,
ctest 100%, hygiene clean; caches restored to OFF.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-06 17:05:20 -05:00
2e6188a36d fix(chat): order-tolerant memo pairing (survives the daemon output shuffle)
The full-node daemon unconditionally shuffles a transaction's shielded outputs
(transaction_builder.cpp ShuffleOutputs, to hide the change position), so the
two 0-value HushChat memo outputs land at random note positions — the header is
NOT guaranteed to precede its payload. The receive parser paired a header only
with the NEXT payload in position order, so a shuffled send failed to pair (the
message was silently lost) ~50%+ of the time.

groupHushChatMemoOutputs now holds an orphan payload (one seen before its header)
and pairs it with the header when it arrives, so header↔payload pair regardless
of on-chain order. A chat tx carries exactly one header + one payload (the harvest
already skips change outputs), so this is unambiguous.

This is the complete fix for the ObsidianDragon ecosystem: full-node and lite both
receive through this one C++ parser, so full-node→full-node and full-node→lite now
work despite the shuffle, with no daemon fork and no lite-backend change. Context:
SilentDragonXLite (which hardcodes payload position==1 and forced the daemon/backend
question) is being replaced by ObsidianDragonLite. Lite send never shuffles.

Tests: shuffled receive (payload-before-header, and change-interspersed) pairs +
decrypts; all existing ordered-input tests unchanged. Verified: Linux + Windows
build, ctest 100%, hygiene clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-06 16:46:57 -05:00
e191680782 feat(chat): wire the two-variant send transport (Phase 5)
Make composed messages actually reach the wire: broadcastChatMemos sends the
header + payload as two 0-value memo outputs to the peer's z-address (header
first, the lower memo position), on both variants.

- chat_outgoing: chatSendOutputs(memos, utf8Prefix) — the pure, testable memo
  encoder. Full-node memos get a "utf8:" prefix (dragonxd rejects raw JSON and
  then UTF-8-encodes on-chain, byte-identical to SilentDragonXLite's
  Memo::from_str); the payload is NOT double-hex. Lite memos are raw UTF-8 (the
  backend does Memo::from_str directly). Header is always output 0.
- App::broadcastChatMemos: full-node builds a two-recipient z_sendmany array
  (amount 0, from the spendable reply z-address, default fee) via submitZSendMany
  with markFeeGapRetry=true — deliberately, to suppress the fee-gap auto-retry,
  which rebuilds a single-recipient tx and would drop the payload output. Lite
  routes to broadcastChatMemosLite (two 0-value LiteSendRecipients, raw memos;
  the backend accepts duplicate addresses + 0-value for exactly this pattern).
- Encoding + design established by a four-codebase mapping (wallet, daemon, SDXL,
  lite backend) and an adversarial review of the wiring (0 confirmed findings).
- Tests: chatSendOutputs (utf8:-prefix + header-first for full-node, raw for
  lite) + on-chain round-trip (strip "utf8:" -> the harvest parser re-pairs and
  decrypts).

Remaining as a LIVE test (cannot be proven from source): that dragonxd returns
the memo under memoStr verbatim, that recipient-array order maps to note
position, that a 0-value memo-only tx relays, and full SDXL<->DragonX interop.

Gated by DRAGONX_ENABLE_CHAT (default OFF). Verified: Linux + Windows(mingw)
build with chat ON, ctest 100%, hygiene clean; caches restored to the OFF default.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-06 16:12:35 -05:00
4db609fb52 feat(chat): composer + contact requests — outgoing construction (Phase 4)
Add the outgoing side of HushChat: compose messages and start conversations.
The wire construction is the byte-exact inverse of the receive parser and is
proven by a self-consistent round-trip (build → parse → decrypt).

- src/chat/chat_outgoing.{h,cpp} (pure): buildOutgoingMessage encrypts via
  encryptOutgoing and buildOutgoingContactRequest carries plaintext; both emit
  the header memo JSON ({h,v,z,cid,t,e,p}, which nlohmann serializes
  alphabetically to match SilentDragonXLite) + the payload memo, validating the
  512-byte memo limit, the 64-hex peer key, and the "no leading '{'" rule for
  request text. My public key goes in header "p"; the peer's key is the
  encryption recipient.
- ChatService: composeMessage/composeContactRequest (encrypt with the held
  identity) + recordOutgoing (echo an Outgoing ChatMessage into the store + DB —
  we never harvest our own sends, so this is the only local record).
- App: sendChatMessage(cid,text) sources the peer z-addr + public key from the
  conversation, composes, and records a random-id echo; startChatConversation
  (zaddr,text) mints a random cid + composes a plaintext contact request;
  chatReplyZaddr() picks a spendable z-addr. broadcastChatMemos() is the Phase-5
  transport seam (network delivery + real-SDXL interop verification land there).
- Chat tab: a message composer (shown once the peer's key is known, else a
  "waiting for reply" hint) + a "New conversation" modal that sends a contact
  request to a z-address.
- Tests: outgoing round-trip through the receive parser + decrypt, contact-request
  passthrough, validation guards, and ChatService compose + recordOutgoing echo.

Adversarially reviewed (crypto/interop, secret hygiene, logic, UI) — 0 findings.
Gated by DRAGONX_ENABLE_CHAT (default OFF). Verified: Linux + Windows(mingw)
build with chat ON, ctest 100%, hygiene clean; caches restored to the OFF default.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-06 15:30:41 -05:00
980a100edd feat(chat): read-only Chat sidebar tab (Phase 3)
Add the first user-visible HushChat surface: a Chat tab in the sidebar with a
two-pane, read-only conversation view — a conversation list on the left and the
selected thread on the right — reading from the App-owned ChatService store.

- New src/ui/windows/chat_tab.{h,cpp} (RenderChatTab(App*), mirroring the
  Contacts tab). Conversations are sorted by most-recent activity; peer
  z-addresses resolve to contact names via the address book when known; each
  message shows sender + timestamp + wrapped body, with a contact-request tag
  and a read-only footer (composing arrives in a later phase). Empty states
  cover "wallet locked / identity not ready" and "no conversations yet".
- Sidebar wiring: NavPage::Chat + registry entry (static_assert stays balanced),
  NavPageSurface + GetNavIconMD (ICON_MD_CHAT), dispatch in app.cpp, trace/sweep
  page names, and App::chatService() accessor. The tab is gated on the new
  WalletUiSurface::Chat, which returns DRAGONX_ENABLE_CHAT != 0 — so it only
  appears in chat-enabled builds and is hidden (and unreachable) by default.
- i18n: English defaults for the chat nav label + hint strings.

Verified: Linux + Windows(mingw) build with chat ON, ctest 100%, hygiene clean;
caches restored to the OFF default. The screenshot sweep now includes the Chat
tab (sweepPageName "chat") for per-theme visual review.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-06 15:05:40 -05:00
46eec37013 feat(chat): persistent, seed-encrypted message store (Phase 2)
Swap the in-memory-only chat store for durable sqlite persistence with real
per-transaction timestamps, encrypted at rest under a key derived from the
wallet's own seed (no passphrase, works on encrypted and unencrypted wallets).

- ChatDatabase (src/chat/chat_database.{h,cpp}): sqlite store mirroring
  data::TransactionHistoryCache. unlockWithSecret(seed) derives a 32-byte AEAD
  storage key and a wallet-partition tag via domain-separated keyed BLAKE2b
  (generichash) contexts. Every record — bodies, peer z-addrs, threading,
  timestamps — is crypto_aead_xchacha20poly1305_ietf-encrypted with a random
  nonce and the wallet tag as associated data; even the dedup key is a keyed
  hash of txid+position, so nothing about your conversations is in cleartext on
  disk. Rows are partitioned per-wallet; a different seed sees nothing. Messages
  are decrypted once at ingest then re-encrypted under the storage key, so
  load() needs only the storage key, not the chat identity.
- ChatService: ingest() now stamps each message with its own transaction time
  (txid->time map + fallback) and writes new (store-deduped) messages through to
  the database; loadFromDatabase() rehydrates the in-memory read model on unlock.
- App: unlock the chat DB with the same seed in provisionChatIdentityFromSecret
  and load prior history; lock the DB + clear the decrypted in-memory store on
  relock and on lite-controller rebuild.

Adversarial review (4 confirmed findings, all fixed): don't provision if the
wallet locks mid-fetch (re-check isLocked at completion); wipe the serialized
plaintext temporary in append(); trim the seed into a separate fully-wiped
buffer (no residue past a shrunk size()); scrub the mnemonic copy in the RPC
json result.

Tests: ChatDatabase round-trip (persist/reload, field + order fidelity), dedup,
per-wallet isolation, lock inertness, and ChatService write-through + reload
without an identity. Gated by DRAGONX_ENABLE_CHAT (default OFF). Verified:
Linux + Windows(mingw) build with chat ON, ctest 100%, hygiene clean; caches
restored to the OFF default.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-06 14:49:33 -05:00
1738468f8c feat(chat): provision seed-derived identity + wire the receive sink
Complete the Phase-1 App integration for HushChat, now that the dragonx
`hd-transparent-keys` daemon exposes a BIP39 mnemonic (z_exportmnemonic)
that is byte-compatible with SilentDragonXLite. Both variants derive the
chat identity from the wallet's OWN seed phrase, so it is portable across
full-node and lite (same words -> same KDF input -> same identity) and
recoverable from the single wallet backup.

- App owns a dragonx::chat::ChatService; maybeProvisionChatIdentity() runs
  each update() tick and, once the wallet seed is reachable and unlocked,
  derives the identity via deriveChatIdentityFromSecret and sets it on the
  service. Full-node fetches z_exportmnemonic off the UI thread via the RPC
  worker; lite reads it synchronously through LiteWalletController::exportSeed.
  Secrets are wiped on every path.
- Wire the previously-dead TransactionRefreshResult.hushChatMetadata sink:
  both the full and recent transaction-refresh completions now ChatService
  ::ingest the harvested memos (before the result is moved) so incoming
  messages are decrypted and threaded. The store dedups across both paths.
- Robust provisioning gates: skip + re-arm on relock (isLocked, mirrored for
  both variants), don't fetch before the encryption state is known, and treat
  a non-mnemonic wallet (RpcError) as identity-unavailable rather than
  retrying every tick. rebuildLiteWallet re-arms so a server switch / re-open
  re-derives from the newly opened wallet.

Gated by DRAGONX_ENABLE_CHAT (default OFF) via the constexpr
hushChatFeatureEnabledAtBuild() gate, so the wiring folds away in shipping
builds. Verified: Linux + Windows(mingw) build with chat ON, ctest 100%,
hygiene clean; caches restored to the OFF default.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-06 03:33:00 -05:00
ba03de938e feat(chat): message model, in-memory store, and receive service
Phase 1 Steps 4-6 (the receive pipeline, minus the App/sync wiring which
lands next).

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

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

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-05 20:11:05 -05:00
d043538e2f feat(chat): carry decrypt inputs through the memo harvest
Phase 1 Step 1 (the linchpin). The receive path had nothing to decrypt:
HushChatTransactionMetadata carried only payload_position/size, and the
extractor discarded the paired payload memo + the header's "e"/"p". Widen the
metadata with sender_public_key_hex ("p"), secretstream_header_hex ("e"), and
payload_memo (ciphertext hex for a Message; request text for a ContactRequest),
populated from the already-parsed header/payload pair.

Add an end-to-end receive-path test: derive two identities, encrypt Alice->Bob,
wrap it as a HushChat header+payload memo pair, run it through
extractHushChatTransactionMetadata, and decrypt straight from the carried
metadata. Also asserts the harvest yields nothing when the feature is disabled.

Fields are only populated when featureEnabled (the extractor already returns
early otherwise), so an OFF build produces no decrypt material.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-05 20:05:26 -05:00
2502487e44 feat(chat): seed-derived identity + secretstream crypto core
Phase 1 crypto foundation (gated by DRAGONX_ENABLE_CHAT; the pure primitives
are always compiled + unit-tested, the feature gate lives at the future
service layer).

- chat_identity: derive the DragonX-native X25519 (crypto_kx) identity from a
  stable per-wallet secret via a domain-separated keyed BLAKE2b KDF
  (crypto_generichash, context "DragonX-HushChat-Identity-v1") into a clean
  32-byte crypto_kx seed. Deterministic; skips SDXL's UTF-8-hex-seed quirk
  (that's the Phase-4 import path). Per §5.6.
- chat_crypto: encryptOutgoing (server_tx) / decryptIncoming (client_rx) via
  crypto_secretstream_xchacha20poly1305, byte-exact per Appendix A.3/A.4 so
  DragonX interoperates with SilentDragonXLite. Single chunk, TAG_FINAL;
  decrypt enforces both the Poly1305 tag and TAG_FINAL (stricter than SDXL).
  Every session key / seed / stream state / plaintext scratch is sodium_memzero'd
  on all paths; no secret is ever logged.
- Tests: encrypt->decrypt round-trip (incl. empty + UTF-8), identity
  determinism, feature-gate + empty-secret handling, and malformed/tampered/
  wrong-key inputs all fail safely.

Adversarially security-reviewed (3 lenses: secret hygiene, crypto correctness,
SDXL wire-interop) — confirmed byte-compatible with the SDXL reference in both
directions. Fixes from review: check the secretstream_push return before
reporting Ok; allow the empty-plaintext ciphertext (== ABYTES) so encrypt/decrypt
round-trip symmetrically; document the caller-owns-and-wipes secret contract.

Interop caveat: round-trip proves self-consistency; a real captured SDXL
message is still needed to prove wire-compat end to end.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-05 19:54:47 -05:00
cec330ee47 fix(contacts): name the Contacts sweep dir + light-skin badge contrast
Screenshot-sweep review follow-ups:
- sweepPageName() had its own page-name switch that lacked a Contacts case,
  so the tab's screenshots landed under the fallback "page/" dir. Add the
  case so they capture under "contacts/".
- The Z/T type badge (green/amber) washed out on light skins. Use darker,
  more-saturated variants when IsLightTheme(); keep the brighter ones on dark.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-05 18:09:59 -05:00
0ff56bf5a4 feat(contacts): search, sort, keyboard nav, contrast + Z/T badges
Phase 0d — the accessibility pass on the Contacts tab.

- Search box filters by label/address/notes (case-insensitive); a distinct
  "no matching contacts" empty state.
- Sortable columns (ImGuiTableFlags_Sortable) — click a header to sort the
  view by label/address/notes, ascending or descending.
- Keyboard nav (when the tab owns focus, no field/modal active): Up/Down move
  the selection through the *visible* order, Enter edits, Delete deletes
  (feeding the two-click confirm), Ctrl+C copies.
- Contrast: the address is no longer rendered as muted TextDisabled (it's the
  row's key data) — normal legible text, with a coloured Z/T type badge.
  Notes are legible too.
- The add/edit form focuses its first field on open (SetKeyboardFocusHere).
- The delete confirm is now visible: the Delete button relabels to
  "Confirm delete?" while armed, instead of a transient toast.

Correctness: selection is tracked by STORAGE index, decoupled from the
filtered/sorted visible order, so edit/delete/copy always target the right
entry. (ImGui renders to a canvas with no OS accessibility tree, so this is a
keyboard/contrast/findability pass, not screen-reader support.)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-05 17:10:24 -05:00
ddd53dc006 feat(send): add a shared contact picker to the recipient field
Phase 0c. The Send recipient field only offered Paste and a tx-history
suggestion list — no way to pick a saved contact. Add a compact contacts
icon button next to Paste that opens a picker popup over the App-owned
address book; selecting a contact fills the recipient (re-validated next
frame).

- New header-only src/ui/windows/contact_picker.h: ContactPickerPopup(id,
  book, outBuf, outSz) — a reusable popup listing contacts ([Z]/[T] tag +
  label + short address) that copies the chosen address into the caller's
  buffer. Kept out of the material layer so the design-system headers stay
  free of data/ deps; reserved for Chat "new conversation" later.
- send_tab: shrink the recipient input to make room for the icon button
  (ICON_MD_CONTACTS), wire the popup to app->addressBook(). Coexists with the
  existing Paste + suggestion affordances.
- i18n: send_contacts_button tooltip.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-05 17:04:58 -05:00
ee59a9e2cd feat(ui): promote the address book to a Contacts sidebar tab
Phase 0b. The address book was buried in Settings behind a modal dialog.
Make it a first-class "Contacts" tab (which will also become the chat
roster), rendered inline in the main content area.

- New NavPage::Contacts (after History; ICON_MD_CONTACTS) +
  WalletUiSurface::Contacts. isUiSurfaceAvailable's `default: return true`
  shows it in BOTH variants; uiSurfaceNeedsWalletData's default keeps it
  usable before wallet data loads. All the touchpoints wired: NavPageSurface,
  GetNavIconMD, the app.cpp dispatch case, the app_network.cpp tracePageName
  case, and the `contacts` i18n label.
- New src/ui/windows/contacts_tab.{h,cpp}: RenderContactsTab lifts the
  toolbar + table + add/edit modal out of address_book_dialog, rendered in a
  BeginChild scroll region (peers_tab pattern) instead of an overlay; the
  add/edit form stays a modal layered over the tab. Reuses the existing
  address_book_* i18n keys and the dialogs.address-book schema.
- Delete address_book_dialog.{h,cpp}; remove its app.cpp render pump and the
  dead App::show_address_book_. The Settings "Address Book…" button now
  navigates to the tab (setCurrentPage) instead of opening the modal, so the
  Tools & Actions grid layout is untouched.
- CMake: swap the dialog sources for contacts_tab.

Behavior-preserving move; search/sort/keyboard/contrast land in 0d. Visual
check pending the per-theme screenshot sweep.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-05 16:59:34 -05:00
62998179ec refactor(data): make AddressBook an App-owned shared instance
Phase 0a of the Contacts/Chat plan. The address book was a file-scoped
singleton (`s_address_book` + `getAddressBook()`) trapped inside
address_book_dialog.cpp, reachable only from that TU. Promote it to an
App-owned member so the upcoming Contacts tab, the Send contact picker,
and a future Chat roster all read one source of truth.

- app.h: add `data::AddressBook address_book_;` + `App::addressBook()`
  accessors, next to the other owned data models.
- app.cpp: load it once in App::init() (idempotent; missing file is fine;
  purely local, no daemon dependency).
- address_book_dialog.cpp: delete the singleton + getAddressBook(); read
  through the App* the dialog already carries (dropping the dead
  `(void)app;`). show() is static so it can't reach the App — drop its
  per-open reload (the book is authoritative for the app lifetime and
  self-saves on mutation). Drop the now-unused <memory> include.

No behavior change; groundwork for the Contacts tab.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-05 16:48:04 -05:00
45 changed files with 3375 additions and 374 deletions

View File

@@ -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

View File

@@ -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.

View File

@@ -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",

View File

@@ -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",

View File

@@ -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",

View File

@@ -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": "すべてのブロックを解除",

View File

@@ -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": "모든 차단 해제",

View File

@@ -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",

View File

@@ -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": "Снять все блокировки",

View File

@@ -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": "解除所有封禁",

View File

@@ -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;
@@ -1885,9 +1914,6 @@ 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);

View File

@@ -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,7 +656,6 @@ 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());
@@ -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;

View File

@@ -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_) {

View File

@@ -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
View 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
View 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
View 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
View 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

View 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
View 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
View 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
View 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
View 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

View File

@@ -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;
continue;
}
const bool payload_matches = pending_header->type == HushChatHeaderType::Message
? isCiphertextPayloadCandidate(output.memo)
: isContactPayloadCandidate(output.memo);
if (!payload_matches) {
} 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;
}
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));
}

View File

@@ -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
View 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
View 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
View File

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

38
src/chat/chat_store.h Normal file
View 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

View File

@@ -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_;

View File

@@ -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;

View File

@@ -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()));

View File

@@ -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;

View File

@@ -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

View File

@@ -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
View 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
View 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

View 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

View 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

View 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

View File

@@ -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");
}

View File

@@ -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";

View File

@@ -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:

View File

@@ -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();