Three next-batch UX-audit fixes:
- Chat delivery: the sending/sent glyph has higher contrast and a tooltip
("Sent to the network — arrives after it confirms", so a check isn't mistaken
for "delivered"); a failed send now carries the real reason (ChatMessage::
fail_reason, set from onChatBroadcastResult via friendlyRpcError, in-memory
only) in the "not sent" hover tooltip.
- Explorer: a block height beyond the tip says so ("Block N is beyond the chain
tip (M)") instead of silently snapping to the top; the empty block list shows
Not connected / Syncing (N behind) / Loading instead of an endless "Loading".
- Settings: the misleading "Reset to Defaults" button (which only reloads the
last-saved values) is relabeled "Discard unsaved changes" with accurate copy.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Ingesting a scan's worth of chat messages persisted each new message
with its own append() call, so a batch of fresh messages opened and
committed one SQLite transaction per row.
- A new appendBatch persists many messages in a single transaction,
using the same INSERT OR IGNORE dedup key as append() and preserving
input order so load()'s rowid ordering is unchanged.
- Every message is serialized and encrypted before the transaction
opens, so a message with a non-UTF-8 body is skipped on its own
without leaving a transaction open; the valid siblings still persist.
- Ingest collects the newly stored messages and writes them once after
the loop instead of per message. It is inert (returns 0) while locked
or on empty input.
Tests cover a batch insert, dedup on re-insert, empty input, bad-UTF-8
isolation with a valid sibling and a following append(), insert order,
and the locked no-op.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Loading chat history did more work than it needed to. Seeding the unread
watermark walked every conversation and copied each one's full message
list out of the store just to read the last timestamp, and the database
reload copied every message into the store one at a time with no
reservation.
- The watermark now comes from one pass over the stored messages that
returns the latest timestamp per conversation, with no per-
conversation copy or sort.
- The store gained a move-append and a reserve, so the reload pre-sizes
its vector and dedup set once and moves each row in instead of copying
it. The dedup key is computed before the move.
Behaviour is unchanged: the watermark values and the blocked-conversation
filter on reload are the same as before.
Tests cover the per-conversation latest-timestamp pass and move-append.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The History tab copies every chat txid into a fresh set each frame to
badge and filter chat transactions: M string allocations for M chat
messages. ChatStore gains hasChatTxid(), an O(1) lookup in a txid index
that is rebuilt only after the store changed (append, delivery update,
erase, clear) and never built for an empty store. clear() also drops
the index, so no chat txids linger after a lock. The History tab moves
to it in the next commit.
The chat note-buffer pump, which runs every frame while chat is idle,
built the whole conversation-id list just to see whether the store was
empty; it now asks store().empty(), O(1) instead of O(M).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A z_sendmany submit set off a burst of wallet scans before anything had
changed. Ops now re-read the wallet when they finish, by who started them:
the user's sends and dialog ops get a re-read plus one forced balance once
none of their ops is pending; client auto-shield re-reads unforced; chat
messages and splits refresh only the chat note view. A pending send no
longer forces scans, and chat txids no longer enter the tracked-send set.
Chat ingest skips stored messages before decrypting and caches
undecryptable ones per identity. The inbox scan runs when getwalletinfo's
txcount changed (a cheap probe, at most 2% duty) or a cost-scaled safety
net is due; the note-buffer scan runs on need. Both are admitted by the
scan budget, on a steady clock. The chat payer comes from a per-address
view of verified notes, with debits re-applied across in-flight scans;
splits are sized to one payer and only when they can refill the buffer.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Adds a per-conversation "delete" with two modes, and removes the per-frame
rescans of the chat history that shared these files.
Delete conversation (header trash icon → confirm dialog):
- Delete (revive-on-new-message): clears local history and tombstones the
messages (new chat_deleted table, keyed dedup hashes) so the every-few-
seconds memo re-scan can't re-import them; a genuinely NEW message (new
txid) revives the thread.
- Delete & block: removes history WITHOUT a tombstone and records the cid as
blocked (settings); ChatService::ingest drops that conversation's messages
— old and future — until unblocked from the "Blocked" manager, which then
re-imports the conversation from chain.
- Local-only (messages remain on-chain; the peer keeps their copy).
deleteConversation() deletes the DB rows FIRST and only then mutates the
store, so a failed write can't leave the two diverged.
Unit-tested (revive / tombstone-survives-reload / block / unblock) and
adversarially reviewed (store/DB divergence, half-open DB, revive-unread).
Performance (chat + badge hot paths, from the perf audit):
- ChatStore gains revision(); the Chat unread badge is now a single O(N)
no-alloc pass cached on it (was O(conversations x messages) copy+sort every
frame). The conversation list and open thread are memoized on revision()
(+ show-hidden and AddressBook::revision() for peer-name resolution).
- AddressBook gains revision() so an in-place contact rename invalidates the
chat memo (an edit keeps entries().size() constant).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Dev-branch audit (docs/_archive/security-audit-dev-2026-07-22.md) re-found the
master issues (absent on dev) plus new ones in dev-only code. Applied here;
full-node build + test suite green; the subtle fixes were adversarially re-verified.
Ported from the master remediation (adapted to dev's code):
- bootstrap: reject zip-slip / path-traversal archive members (isSafeArchivePath)
before writing (S2-1). (dev already fail-closes on a missing checksum.)
- xmrig updater: fail closed when a signature is required but no key is pinned (F1-1).
- http_download.httpGetString: 16 MiB hard cap + MAXFILESIZE on the shared
metadata/price fetch (F1-2 / caps the updater + exchange paths at one site).
- rpc_client: explicit SSL_VERIFYPEER/VERIFYHOST (F4-1) and a 256 MiB response
cap in WriteCallback (F4-3).
- lite_connection_service: reject remote http:// lite servers, loopback only (L1-1).
Loopback is matched by a strict dotted-decimal 127.0.0.0/8 check (not a
startsWith("127.") prefix, which would wrongly accept 127.0.0.1.evil.com), with
userinfo/fragment stripping.
- lite controller: propagate encrypt/decrypt save() failure instead of reporting
success (F7-1).
- xmrig_manager: chmod(0600) the pool config before writing secrets (F5-2).
- app: clear the copied secret from the OS clipboard on shutdown (F3b-1).
- export_transactions: neutralize CSV/spreadsheet formula injection (F13-1).
- build pipeline: build-from-source lite backend + remove the self-attested
CMake signature gate (F15-1); pinned+verified appimagetool (F15-3/4);
verified Sapling params in setup.sh (F15-6); build.sh exits 0 on success.
(F14-1 empty-quoted-arg and F8-2 NUL-termination were already fixed on dev.)
Dev-only findings:
- rpc_client.callRaw: scrub the raw buffer + parsed tree (templated scrubJsonSecrets
for ordered_json) so console dumpprivkey/z_exportkey keys don't linger in freed
heap (N1-1).
- seed_wallet_creator: wipe the exported mnemonic on the failure path so a discarded
failed result never carries a live seed (W1-2).
- export_all_keys: write the plaintext key dump 0600 + atomically via
writeFileAtomically (U1-2).
- chat_database: restrict chat_messages.sqlite and its WAL/SHM sidecars to owner-only (C3-1).
Not done (need a decision, documented in the report):
- Chat header metadata (cid/z/p) rides outside the AEAD (C1/C2) — binding it is a
wire-protocol change requiring SilentDragonXLite interop review.
- Bootstrap lacks an offline-rooted signature (S2-2 residual) — needs signing infra.
- Console scrollback retains console-typed key-export output in plaintext (N1-1
residual) — inherent to an echoing console; would need output redaction.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Transactions that carried a chat message (sent or received, messages + contact
requests) now show a "Message" badge in the History tab, and a new "Chat" option
in the type-filter combo shows only those transactions.
Detection reuses what the chat store already tracks: ChatStore gains an inline
chatTxids() returning the set of on-chain txids that carried a chat message; the
tab builds it once per frame (cheap — O(chat messages)) and tests txid membership
for the badge and the filter. Empty when chat is disabled or the wallet has no
chat identity. Both variants populate the chat store before this tab renders, so
the same code serves full-node and lite with no separate handling.
The "Message" badge reuses the stacked top-pill slot (chat txs are send/receive,
not the autoshield "shield" type, so it and the "Shielded" badge are mutually
exclusive; chat takes precedence) in a distinct Secondary() colour. Guards the
summary-card accent idx_map so the new filter value (4) can't index it OOB.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
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>
B4: the legacy chat-identity fallback copied the z_exportkey spending key out of
the RPC response and let the temporary json destruct un-zeroed. Mirror the
mnemonic path — take our copy, then sodium_memzero the json's own buffer.
B2: the memo header's peer z-address / cid ride OUTSIDE the secretstream AEAD, so
trusting the newest message's header let a later message redirect our replies or
splice threads. Pin the reply target (and displayed peer) to the EARLIEST
(establishing) message instead of the latest, at both the send and display sites.
Because ChatStore returned filtered INSERTION order (a scan harvests txids in
set/hash order — not chronological), "earliest" wasn't reliable; ChatStore::
conversation now returns messages sorted by (timestamp, txid, payload_position),
which also fixes out-of-order thread rendering and the last-message preview.
A complete fix binds z+cid into the AEAD additional-data, but that's a coordinated
HushChat/SDXLite wire-format change; this pin hardens the reply target without it.
Adversarially verified; the store-ordering gap it surfaced is fixed here.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
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>
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>
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>
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>
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>
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>
Two dead-code removals surfaced by the audit; no runtime behavior change.
1. Delete the lite-lifecycle "readiness" scaffold (~1,586 lines). The pure
dry-run evaluateLiteWalletServerLifecycleReadiness (whose own status text
says "wallet lifecycle execution is still disabled in this scaffold") had
zero callers; executeLiteWalletServerSelectionUi had zero callers; and
executeLiteWalletLifecycleUiRequest's only caller was a fallback in
settings_page reached only when app->liteWallet() is null. Deleted
lite_wallet_server_lifecycle_readiness.{h,cpp} and the readiness /
UI-execution machinery + enums + translation tables from both adapters,
keeping the genuinely-live helpers (liteConnectionSettingsFromAppSettings,
applyLiteConnectionSettingsToAppSettings, redactLiteServerSelectionValue,
and the LiteWalletLifecycleUiExecutionInput DTO the live create/open/
restore path still uses). The null-backend fallback now sets a plain
"Lite wallet backend unavailable" status. This is exactly the
readiness/scaffold pattern CLAUDE.md forbids regrowing.
2. Split the HushChat fixture/capture-manifest/seed-projection tooling
(~2,050 lines, incl. the libsodium seed-projection) out of
chat_protocol.cpp (1854 -> 284) and chat_protocol.h (586 -> 105) into a
new chat_fixture_tooling.{h,cpp} compiled ONLY into the HushChatFixtureCheck
dev tool — never into the app, lite, or test binaries (verified via nm).
The app keeps only the runtime slice (memo parsing + tx metadata
extraction) that network_refresh_service actually calls. Also moved the
decrypt-preflight/hex helpers, which likewise had no runtime caller.
Note: the HushChat split is on gated-off experimental code and should be
coordinated with the pending dormant-HushChat-content handling (commit
af06b8b) before the lite PR — done here as a pure mechanical split, no redesign.
Full-node + Lite + HushChatFixtureCheck build clean; ctest 1/1; hygiene clean.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Preserve the previously-uncommitted lite wallet implementation and related dev WIP
under version control:
- src/wallet/ lite services: client bridge, bridge runtime, connection, lifecycle,
sync, gateway, result parsers, state mapper, artifact contract/resolver, refresh
services, UI adapters, wallet_backend/capabilities. (Includes two small M1 fixes:
lifecycle walletReady now parses the response; default chain name -> "main".)
- src/chat/ chat protocol; tests/fixtures/ (lite + hushchat); tools/hushchat_fixture_check.cpp;
scripts/build-lite-backend-artifact.sh.
- Pre-existing modified app_network/security/wizard, network_refresh_service, sidebar,
mining_tab, bootstrap dialog, and version headers captured as-is.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>