Carry the sender's compose time as an optional "ts" (Unix seconds) in the
plaintext header JSON that rides outside the AEAD, and prefer it as the
displayed message time so both ends show the same send time regardless of when
the tx confirms. Parse "ts" leniently. On ingest, clamp: reject a "ts"
implausibly in the future vs the receive/block time (1h skew tolerated) so a
wrong/ahead peer clock can't pin messages to the bottom of a thread; a past
compose time is fine (the note buffer may broadcast a queued message later, and
a confirmed tx's block time is always >= compose time). Tests cover the
round-trip and the future-clock clamp.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Two causes of the "every message I send shows up again as a reply from the peer":
1. Harvest bug — the full-refresh path fed a SENT tx's outgoing memos (from
z_viewtransaction outgoing outputs) into the chat metadata extractor, and
ChatService::ingest marks everything Incoming. So each send was re-ingested as
a phantom "from peer" message. The recent-refresh path already omitted this, so
it was accidental. Drop the outgoing chat-harvest (keep the tx-history harvest);
genuine incoming still comes from z_listreceivedbyaddress, and our sends are
recorded by the local echo.
2. Own-identity ingest filter — a memo whose sender public key equals our own
identity is by definition something we sent (only we hold our key); it must
never be ingested as incoming. Skip those in ingest. This also collapses
same-seed self-chat (running the SAME wallet in the full node and Lite makes
them one chat identity, so sends land on an address we also own and loop back).
For a real two-party chat use two DIFFERENT wallets/seeds — same-seed wallets are
one identity and can't be distinct peers.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Four confirmed findings from the review of ef247c9:
1. Persistence regression — the deferred-persist echo (in-memory Sending, written
only when the async callback resolved) meant a message broadcast on-chain but
whose callback hadn't fired yet was LOST from history if the app quit/crashed
in that window. Persist the echo immediately as Sending and UPSERT the final
status on resolve (new ChatDatabase::upsert with ON CONFLICT DO UPDATE, since
append is INSERT-OR-IGNORE). A stray persisted Sending still loads as Sent.
2. Fee ceiling — dragonxd REJECTS a 0-value tx whose fee exceeds the default
miners fee (0.0001), and max(getDefaultFee(), 0.0001) can only raise it, so a
default_fee > 0.0001 broke every chat send. Pin chat to exactly kChatMinFeeDrgx,
dropping getDefaultFee() from this path (chat always moves 0 value).
3. Lifetime — the resolve callback had no generation guard, so a wallet lock (which
doesn't disconnect) between submit and callback could resolve against a cleared
store. Capture chat_session_generation_ and bail on mismatch (both the full-node
callback and the lite optimistic resolve), matching the identity-fetch pattern.
4. Retry misdirect — Retry on a failed CONTACT REQUEST called sendChatMessage,
which (no peer key yet) just showed "waiting for reply". Route it to
sendContactRequestForCid() (refactored out of startChatConversation) so it
re-sends the request into the SAME conversation.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Chat sends move 0 value, so the network fee is structurally load-bearing (it's
the only thing that forces a real shielded input; 0-value + 0-fee builds a
degenerate, unrelayable tx). Three gaps addressed:
1. Fee floor — broadcastChatMemos now uses max(getDefaultFee(), kChatMinFeeDrgx),
so a 0 / too-low global default-fee setting can't silently break chat.
2. Real delivery status — the echo was marked Sent on SUBMIT regardless of the
on-chain outcome (the z_sendmany callback was empty), so failures were
invisible and the Retry affordance never fired for async failures. Add a third
ChatDelivery::Sending state (appended so persisted 0=Sent stays valid); record
the echo in-memory as Sending, and resolve it to Sent/Failed from the
z_sendmany completion callback — persisting only the final status (so a restart
never shows a stuck spinner; a stray persisted Sending loads as Sent). A subtle
"sending…" label shows while in flight.
3. Pay-from-funded + pre-check — z_sendmany spends from one z-address, and the
identity reply address may be unfunded while funds sit elsewhere. chatPayFromZaddr
picks a spendable z-address that can cover the fee (preferring the identity
address); the memo still advertises the identity address as reply-to, so paying
from a different note is transport-transparent. If nothing can cover the fee, a
clear "need a small shielded balance" toast replaces the cryptic failure.
Full node only for the callback path; lite resolves optimistically on queue.
8-language strings + CJK subset (+1 glyph 賄).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Four confirmed findings from the review pass:
SECURITY — B7 was incomplete:
- The single-key Export dialog (key_export_dialog) still used plain call() for
z_exportkey/dumpprivkey/z_exportviewingkey — a live spending-key leak on the
most common per-address export path, missed by the B7 commit.
- callSecret() zeros the raw body but the parsed json holds its OWN heap copy of
the secret; several callers did .get<string>() on a temporary json and freed
that copy un-wiped.
Fix: add RPCClient::callSecretString() — returns the bare-string result with
BOTH the raw body AND the json node zeroed, so callers can't forget. Route
key_export_dialog (×2), exportPrivateKey, and export_all_keys (×2) through it;
scrub the z_exportmnemonic json node in seed_wallet_creator (object result);
also wipe the transient key copies, the displayed s_key on reset, and the
aggregated export-all `keys` buffer.
CHAT:
- Jump-to-latest pill: SetCursorScreenPos moved the parent cursor and never
restored it, so the composer footer rendered ~8px too high while scrolled up.
Save + restore the cursor around the pill.
- New-message toast: gating on a chatUnreadCount() watermark delta could be
swallowed when an outgoing echo (wall-clock) pushed the seen-watermark past a
later reply's block time. ingest() now reports the cids it appended; the toast
fires when any is a non-muted conversation — skew-proof, still mute-aware.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
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>
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>
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>