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>
The custom DragonX chat emoji now recolors to the theme like the logo — body =
accent, detail = white on dark skins / on-surface (dark) on light skins — and
re-rasterizes on a theme/dark-light change (moved out of the one-time fixed-color
load into ensureLogoTexture's re-render block). The detail highlights keep it
legible even on accent-tinted outgoing bubbles.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Add a DragonX custom emoji to chat. The emoji picker gains a DragonX tile (first
cell) that inserts the text ":drgx:"; message bodies containing it render the mark
inline via layoutChatBodyRich, which flows text words + the emoji image with word
wrap (plain bodies keep the tighter layoutChatBody path, so normal messages are
unaffected). Other clients simply show the literal ":drgx:" text — a portable
encoding with graceful degradation.
The emoji is the DragonX SVG rasterized once at fixed brand colors (crimson body,
white detail) via LoadTextureFromSvg — theme-independent so it looks identical for
sender and receiver — exposed as App::getDrgxEmojiTexture(). Emoji insertion is
factored into one insertToken() helper (space-prepend + on-chain byte cap), shared
by the DragonX tile and the Unicode emojis. Drag-to-select text is skipped on
":drgx:" messages (their inline layout doesn't match the plain-text hit-test
geometry); right-click "copy" still copies the whole message.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Replace the shared app logo (Overview header, first-run wizard, lock screen) with
the DragonX mark rendered from res/img/logos/logo_dragonx.svg, recolored to each
theme: the dragon body takes the theme accent (Primary()), the inner detail stays
light. Vendor nanosvg (memononen, zlib/public-domain) in libs/nanosvg/ and add
util::LoadTextureFromSvg (parse a copy, recolor shapes by luminance, rasterize to
RGBA, upload via the existing CreateRawTexture — GL + DX11). The SVG is embedded as
a string (src/embedded/logo_dragonx_svg.h) so it's available in every build.
The load moves into App::ensureLogoTexture(), called at the top of render() BEFORE
the wizard/lock early-returns (so those screens get the logo too, which they never
did before), and re-rasterizes when the accent or the dark/light variant changes.
The per-skin PNG remains a fallback if rasterization ever fails; logo-texture
lifetime is now freed on replace + on theme switch (was leaked).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Each chat message is a shielded tx that spends a verified note, so a burst of
messages hit "insufficient verified funds" once the single note is spent. Add a
per-frame note-buffer coordinator (both variants) that keeps ~10 verified
spendable notes: it serializes sends through the one broadcast channel, counts
verified notes by block depth (lite) or a rate-limited z_listunspent scan
(full node), self-splits in the background to refill toward the target, and
queues overflow to drain as change matures — with honest Sent/Failed status
instead of the prior optimistic "Sent". Guardrails: single split in flight with
a watchdog, cooldown, and session-generation guards so a wallet switch can't
drain another wallet's queue. Surface a "Chat buffer: N/10 ready" indicator in
the status bar while the Chat tab is active, and route chat diagnostics through
the console on both variants (the full-node console now drains the shared ring).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- requestFontRebuild() + preFrame handling so toggling color emoji swaps the atlas live;
request a rebuild at startup when the saved setting wants color.
- preFrame syncs Typography's color-emoji flag and the util clock flag from settings.
- Chat 0-conf fast-scan cadence now reads the user's poll-rate setting (was a hardcoded 2.5s).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The fast-scan was hung on the page's Transactions timer, which is 10–15s on most
pages (15s on Chat), so incoming messages still took up to ~15s to appear. Give it
its own ~2.5s accumulator (delta-time based, independent of the page cadence) so
messages land in a few seconds — network propagation then dominates, which is as
fast as 0-conf gets. Still gated behind the warmup/rescan block and self-gated in
fastScanChatMemos; the in-flight guard prevents overlapping RPCs.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The receive harvest gates each z-address behind scannedAtTip — it only re-scans
when a new block advances the tip — so incoming chat waited ~1 confirmation even
though the daemon already exposes mempool notes (FindMySaplingNotes runs on
mempool txs; z_listreceivedbyaddress(addr,0) returns them).
Add App::fastScanChatMemos(): every transaction-refresh cycle, re-scan JUST the
chat reply address (where peers send) at minconf=0, extract chat metadata, and
ingest — so messages surface at mempool speed (a few seconds) instead of waiting
for a block. Full-node only (lite has its own harvest). An in-flight guard avoids
stacking RPCs; the store dedups on txid+position, so the confirmed harvest never
double-inserts.
Hidden conversations are deliberately skipped by the fast path — they don't get
the mempool speed-up and still come back through the normal confirmed harvest
(which un-hides on a new message). New non-muted messages toast off-tab as usual.
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>
Contacts were scoped by activeWalletIdentityHash() — a hash of the wallet's
ENTIRE address set. Creating a new receive address grows the set, changing the
hash, so every contact stamped with the old hash falls out of the scope filter
(contacts_tab.cpp:915) while still being counted — the "3 saved, 1 showing"
symptom, where only the one set to global (which bypasses the scope) survives.
It also hid scoped contacts on every startup before the daemon connected (hash
empty until addresses load).
Introduce a stable per-wallet scope id: WalletIndexEntry.scopeId ("w:"+random
hex), generated once and persisted in the wallet index (keyed by wallet file),
never recomputed from the mutable address set — so creating addresses, locking,
or disconnecting never changes it. App::activeWalletScopeId() establishes it on
first use. Contacts now scope + filter on this instead of the drifting hash. The
tx-history-cache identity (the hash's real purpose) is untouched.
Recovery for already-orphaned contacts:
- AddressBook::reattachLegacyScopes() re-attaches non-global, non-"w:" contacts
to the active wallet's stable id; run once when there's a single known wallet
(unambiguous attribution). Idempotent.
- The scope filter fails OPEN for legacy scopes (multi-wallet case where recovery
can't attribute them) so no contact is ever hidden; stable "w:" scopes still
match strictly.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Track a per-conversation "last seen" watermark (message timestamp) on App:
- chatUnreadCount() sums incoming messages newer than each conversation's
watermark; surfaced as SidebarStatus.chatUnreadCount → a badge on the Chat nav
item (mirrors the History/Peers badges).
- Viewing a thread marks it seen (markChatConversationSeen while displayed).
- Baseline on load: existing stored messages are marked seen, so only messages
that arrive while the app is open badge as unread.
- Wiped in resetChatSession so unread state never leaks across a wallet switch.
In-memory only (resets on app restart); persistence is a later refinement.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
When a switch fails, the failure modal now distinguishes a CORRUPT target wallet
from other failures and offers a one-click repair:
- The switch worker watermarks the node's captured console output before the
start and, if the node dies in init, scans this start's output for a corruption
signature ("Failed to rename … .bak" / "salvage failed" / "wallet.dat corrupt" /
"Error loading wallet") → sets switch_wallet_corrupt_.
- The Failed modal then shows an accurate "this wallet appears corrupt" message
(instead of the generic "Couldn't open that wallet") plus a "Try to repair
(salvage)" button that retries the switch with the target node started under
-salvagewallet (recovers readable keypairs; implies -rescan).
- EmbeddedDaemon::setSalvageOnNextStart (one-shot, precedence salvage > zap >
rescan) + controller forwarder; switchToWallet gains a salvage arg.
Salvage operates only on the corrupt target (never the good wallet, no fund
movement). Adversarially verified: output isolation, one-shot lifecycle, state
handling, re-entrancy, no success-path regression.
i18n (EN + 8 languages) + 2 new CJK glyphs baked into the subset font.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The switch modal now shows more than a bare phase line:
- The title carries the target wallet ("Switching wallet — savings") and a
"from <previous>" caption for context (names prettified: wallet.dat → "Default
wallet", wallet-<name>.dat → "<name>", in-place links → "External wallet").
- During the Reconnecting phase — the ~30-60s where the node loads the block
index, verifies, and rescans — it surfaces the node's LIVE init stage
(state_.warmup_status/description via the existing translateWarmup mapping:
"Loading blockchain data…", "Verifying blockchain…", "Scanning for
transactions…") instead of a static "Reconnecting…".
- An elapsed timer (m:ss) so the wait visibly progresses.
i18n: from/elapsed/default-wallet/external-wallet labels (EN + 8 languages,
additive; no new CJK glyphs). No switch-flow logic change.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The "Stop the running node?" confirm modal now stays open through the entire
switch — stop → wait-for-exit → start → reconnect — showing a live phase, and
auto-closes the moment the new node connects. This turns the up-to-a-minute
graceful-shutdown wait from an apparent freeze into visible progress.
- WalletSwitchPhase (Stopping/Starting/Reconnecting/Failed) + atomic phase and
dialog-open flags; the worker advances the phase, onConnected closes the modal,
and a failed switch shows the accurate reason with a Close button.
- Owned switches (no confirm) also show the progress modal directly.
- "Continue in background" escape hatch so a long rescan / a hung startup never
traps the user (the switch keeps running; a toast reports the result).
- isWalletSwitchInProgress() keeps the frame loop redrawing so the phase text and
spinner animate while otherwise idle.
- i18n (EN + 8 languages, additive) + a modal-switch-progress sweep surface.
State machine adversarially verified 6/6 (thread-safety, no stuck modal,
confirm→progress transition, owned/unowned, no phase leak, redraw scoping).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
When switching wallets, the node the app would restart may be one this session
ADOPTED — a dragonxd already running at launch (left up by "keep node running",
or started by the user). Rather than stopping it silently (or the old dead-code
"stop any external dragonxd first" refusal), show a "Stop the running node?"
confirmation first; switching then stops it (RPC stop + wait for the port to
fully free) and relaunches on the selected wallet.
- switchToWallet(walletFile, stopDaemonConfirmed=false): when the node is adopted
(externalDaemonDetected) and not yet confirmed, defer to the dialog and return.
- renderSwitchStopDaemonDialog(): BlurFloat overlay (house style) with a warning
header; confirm re-enters switchToWallet(w, true); cancel/X aborts, node keeps
running. Owned nodes (started this session) still restart silently.
- i18n (EN + 8 languages, additive) + CJK subset rebuild; modal-switch-stopnode
sweep surface for visual review.
Gate/re-entrancy adversarially verified (no state leak; owned switches ungated;
prompt reappears on a reverted switch; dead-daemon-before-confirm handled).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Switching to a named wallet failed every time when the app had connected to a
pre-existing dragonxd at startup: the daemon is flagged externalDaemonDetected,
so stopEmbeddedDaemon()'s policy is DisconnectOnly ("not ours to stop") and the
switch skips the stop entirely. The old daemon keeps the RPC port, and the
relaunch fast-fails on EmbeddedDaemon::start()'s isPortInUse check — misread as a
bad wallet, reverting after a ~40s hang on the process-handle-only run-wait
(which reads false immediately for an adopted daemon).
A switch legitimately needs to restart the node, so:
- App::stopDaemonForWalletSwitch(): for an adopted daemon, send a graceful RPC
"stop" using the creds we actually connected with (saved_config_) — only our
own daemon obeys it, so a foreign dragonxd is a safe no-op — then wait (bounded
~40s) for the RPC port to actually free (isRpcPortInUse, the same gate start()
uses). Owned daemons take the normal stopEmbeddedDaemon() path. No PID/name kill
is ever issued at an adopted daemon.
- switchToWallet: gate start() on the port actually freeing; if it doesn't,
abort with a distinct switch_stop_failed_ reason instead of starting into a busy
port. Clear the external latch before relaunch so the fresh process is owned.
- EmbeddedDaemon::clearExternalDaemonDetected() (+ controller forwarder).
- Accurate revert message: "the running node didn't release its connection in
time" vs. the bad-wallet message.
Root-caused from live Windows logs; design + implementation adversarially
verified. Note: beginAdoptSeedWallet has the identical pattern and is left for a
separate fund-critical review.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The Wallets modal's "Seed phrase" vs "Legacy" badge came from the offline
wallet.dat probe, which flags hdseed/chdseed/hdchain. But hdchain is present in
BOTH a BIP39-mnemonic wallet AND a legacy HD wallet (DragonX has no separate
mnemonic DB record — the mnemonic is derived from the HD seed), so a legacy HD
wallet was shown as "Seed phrase".
For the ACTIVE wallet the app already knows the truth at runtime via
z_exportmnemonic (wallet_seed_status_). The active row's badge now uses that
authoritative status (activeWalletSeedBadge: seed-phrase / legacy / undecided)
and only falls back to the probe for non-active or not-yet-decided wallets; the
"unknown" seed badge is suppressed once the runtime status is authoritative.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The two deferred audit MEDs + the per-wallet PIN follow-up:
- Late-init failure revert (MED): the 1.5s start grace only catches a wallet that
fails IMMEDIATELY. A wallet that fails LATE in dragonxd init (past the grace)
used to persist as a broken active_wallet_file. Now a switch stays
"pending confirm" until the daemon actually connects (onConnected clears it);
if the connect loop instead hits the crash-wedge (crashCount >= 3) while a
switch is pending, it flags the main-thread revert (which also resets the crash
count so the restored wallet can start).
- Shutdown freeze (MED): the switch worker's 30s daemon-stop wait now breaks
promptly when shutdown starts, so beginShutdown's join of the switch task can't
freeze the UI for the full 30s (shutdown stops the daemon itself). The seed-
adopt task is intentionally left to finish (fund-safety), as before.
- Per-wallet PIN (follow-up): App::hasPinVault() (and the lock-screen path) now
gate on the per-wallet vault presence alone, not the GLOBAL getPinEnabled flag —
so disabling PIN on one wallet no longer suppresses another wallet's PIN
quick-unlock after a switch.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Adversarial verification of the audit fixes found real gaps in them:
- HIGH throw-safety: the switch worker and the encryption-restart worker set
daemon_restarting_=true but reset it only on their normal/early-return paths —
a throw from stop/startEmbeddedDaemon left the flag stuck true, wedging
reconnect and every future switch/rescan/encryption. Both now reset it on all
paths (try/catch); a throw during a switch is treated as a failed switch and
triggers the revert.
- HIGH residual chat leak: resetChatSession() cleared the flags but an already-
posted z_exportmnemonic worker job still held wallet A's secret, and its
completion callback (guarded only by isLocked(), false for an unencrypted
wallet) would provision A's identity under B. Add a chat_session_generation_
epoch bumped on every wallet change; the fetch captures it and its callback
discards the (previous-wallet) secret if the epoch no longer matches.
- LOW vault-scope collision: the per-wallet vault tag was a lossy char-substitution
(two distinct files could map to one vault). Append an 8-hex FNV-1a of the raw
filename so distinct wallets never share a vault. +unit test.
- LOW seed-adopt: removeVault() on adopt so the legacy wallet's PIN passphrase
isn't left associated with the new seed wallet (same file name).
- LOW: clear lock_unlock_in_progress_ on switch too.
Deferred (documented): the 1.5s start grace can't catch a wallet that fails LATE
in daemon init (the existing crash-wedge detection still applies); beginShutdown's
join of the switch task can briefly freeze the UI during quit (necessary to avoid
orphaning the daemon).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
From the wallet-switching audit:
- Revert on failure (HIGH): switchToWallet persisted active_wallet_file before
confirming the new daemon started, so a missing/corrupt wallet wedged across
restarts. The switch worker now confirms dragonxd survives a grace period; on
failure it flags the main thread (processWalletSwitchRevert), which restores
the previous wallet file + re-scopes the vault + re-arms reconnect (settings
writes stay on the main thread).
- Cross-guard concurrent lifecycle ops (HIGH): switchToWallet now refuses during
a rescan/repair (state_.sync.rescanning) or seed migration; rescan/repair now
refuse while daemon_restarting_; and restartDaemonAfterEncryption now sets
daemon_restarting_ (which also fixes a latent reconnect-to-a-stopped-daemon
race during the encryption restart). So the switch/adopt/restart/rescan/repair/
encryption ops are mutually exclusive.
- Quit during switch (MED): beginShutdown now joins the "Switch wallet" task (like
the adopt task) so quitting can't orphan a freshly-started dragonxd.
- Reset the daemon crash count on switch (LOW) so a prior crash-wedge can't block
reconnecting to the new wallet.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
From the wallet-switching audit (HIGH-severity cross-wallet leaks):
- Chat identity leak: the full-node switch (switchToWallet) and seed-migration
adopt reset state_ but NOT the HushChat identity, so wallet A's decrypted
conversations surfaced under wallet B and outgoing chat was signed with A's
keypair. Factor the existing teardown into App::resetChatSession() and call it
on both wallet-change paths (the lite path already reset it via
rebuildLiteWallet, which now uses the helper too).
- Global PIN vault: the PIN quick-unlock vault was a single vault.dat, so after
a switch wallet A's stored passphrase was offered/applied to encrypted wallet
B. SecureVault is now scoped per wallet (vault-<walletfile>.dat); the default
wallet keeps the legacy vault.dat for back-compat. vault_ is constructed for
the active wallet and re-scoped on switch, so B has its own (empty) vault.
- Lock-screen state: switching now clears the carried-over failed-attempt
counter + lockout timer and secure-zeroes the passphrase/PIN entry buffers so
the previous wallet's unlock state can't apply to the new one.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Phase 2 (first security modal), presentation-only + the review's one finding.
Migration: struct-form BlurFloat overlay (was legacy positional); full i18n
(reusing export_private_key / key_export_private_warning / key_export_private_key /
select_address / key_export_fetching / export / copy / close, plus one new
key_export_failed); DialogWarningHeader; centered DialogActionFooter (Export +
Close) + a Copy TactileButton; async UX (export_in_progress_ spinner, export_error_
line). The exported key stays in the char[256] buffer shown via a read-only
InputTextMultiline (deliberately NOT AddressCopyField, which would copy the secret
into a lingering std::string). exportPrivateKey is untouched.
Security fix (found by the adversarial review): the 0a "outside-click/Esc wipe" via
the Begin-returns-false early-return was UNREACHABLE — BeginOverlayDialog clears
show_export_key_ mid-frame but still returns true, so an outside-click/Esc dismiss
left the exported spending key un-wiped in export_result_. Add the in-body
`if (!show_export_key_)` teardown before EndOverlayDialog (the proven import-dialog
pattern), which is the real wipe point for every dismiss path.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The Export Private Key dialog held the exported spending key in a plain
std::string cleared only with .clear() — never sodium_memzero'd — and on
outside-click/Esc the overlay's early return skipped even that, so the secret
lingered in freed heap. Hold it in a fixed char[256] and sodium_memzero on every
close path (Close button, outside-click/Esc early return, address change). Guard
the async export callback so it can't populate the buffer after the dialog closed.
Standalone security fix ahead of the settings-modal redesign (Phase 0a); the
dialog's full restyle is Phase 2.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Add a "Sweep to my wallet (don't keep the key)" option to the Import Private
Key dialog (spending keys only). Rather than keeping the key, it imports the
key, then sends ALL its funds to a destination you control — a freshly
generated shielded address by default, or an existing wallet address you pick.
Flow (App::sweepPrivateKey): import the key (rescan; the stock node has no
address index, so its UTXOs/notes can't be enumerated otherwise) → confirm it
holds spendable funds → resolve the destination → z_sendmany balance-minus-fee
from the key's address to the destination, via the existing tested submitZSendMany
wrapper + async-operation tracker. The imported key is left in the wallet with an
empty balance (there is no remove-key RPC) — stated plainly in the UI caveat.
Design notes forced by this daemon (verified against external/dragonx):
- z_importkey returns null here, so the shielded source address is found by
diffing z_listaddresses before/after import (aborts if the pre-snapshot fails,
so a wrong address can never be picked). Transparent uses importprivkey's return.
- z_sendmany, NOT z_mergetoaddress: moves a single-UTXO transparent source (the
common paper-wallet case) and fails loudly instead of silently leaving a
remainder. Amount computed in integer satoshis for an exact fixed-decimal string.
- Clear errors for no-funds / unconfirmed / dust-below-fee / already-imported.
- The mode + destination are locked during a running sweep; a sweep that finishes
while the dialog is closed still shows its Done/Error+txid on reopen; sweep UI
can't bleed into the watch-only viewing dialog.
i18n: 8 new sweep_* keys (en + 8 langs); dynamic status strings are English,
matching the beginSweepToSeedWallet precedent.
Reviewed across three adversarial multi-agent rounds (fund-safety focus): round 1
caught the feature was broken for shielded keys (z_importkey null) and rejected
single-UTXO transparent sources; round 2 caught reopen/double-submit/precision
issues; round 3 verified the fixes. NOT yet exercised against a live daemon —
needs a small-amount mainnet test before it's trusted, like migrate-to-seed.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Follow-up to the key/viewing-key button split:
- Rename the "Import Key…" button to "Import Private Key…" (settings_import_key
value in en + all 8 langs, reusing the existing import_key_title wording) so it
reads as the natural counterpart to "Import Viewing Key…". The dialog title was
already "Import Private Key". The viewing-key wrong-type redirect hint now names
the renamed button.
- Show the "scan from block height" field in the private-key (spending) dialog too,
not just the viewing-key one. z_importkey accepts a start height, so a shielded
spend import of a recent key needn't rescan the whole chain. Transparent WIF
(importprivkey) has no such param, so once a transparent key is recognized the
field is disabled, its buffer cleared, and a "Transparent keys always rescan
fully" note replaces the hint. importPrivateKey already gates the start-height
append on (viewing || shielded), so transparent imports never receive it.
- i18n: shared scan keys renamed import_viewkey_scan_{label,hint} ->
import_scan_{label,hint}; added import_scan_transparent (en + 8 langs).
Reviewed via an adversarial multi-agent pass (rpc-routing / i18n / ui-logic /
consistency); only two nits surfaced (stale disabled-field value + a stale
comment), both fixed here.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Backup & Data now has two distinct import actions instead of one
auto-detecting dialog:
- "Import Key" imports a spending key (transparent WIF or shielded
z-spending key) with the strong "grants access to funds" warning.
- "Import Viewing Key" imports a watch-only shielded viewing key
(zxviews…) with a milder eye/watch-only note and an optional
"scan from block height" field — z_importviewingkey accepts a start
height, so watching a recent address needn't rescan the whole chain.
The shared Material dialog (renderImportKeyDialog) branches on
import_view_mode_: title, warning tone, field label, the live type
indicator, and the recognition guard. A key valid for the *other*
button (e.g. a spending key pasted into the viewing-key dialog) now
shows a redirect hint rather than a generic "unrecognized" error.
importPrivateKey gains a startHeight arg, appended to the shielded
import RPCs (z_importviewingkey / z_importkey) and ignored for
transparent WIF (importprivkey has no start-height param). The
scan-height buffer is wiped on open/close alongside the key buffer.
i18n: 9 new keys (button label + tooltip, viewing title/note/field,
scan label/hint, two wrong-type redirect hints) with translations for
all 8 languages; CJK subset rebuilt (+1 glyph). A modal-import-viewkey
sweep surface is added for the viewing-mode dialog.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Rework the full-node Import Key dialog into a safer, Material-consistent, more
capable flow:
- Security: the key is masked by default (Password) with a reveal (eye) toggle,
a DialogWarningHeader "only import a key you own" note, and the buffer is
wiped with sodium_memzero on close and on successful import.
- Progress: import triggers a blocking full-chain rescan; the dialog now shows
"Importing & rescanning — this can take several minutes" with LoadingDots and
disables Import while it runs (no double-submit). Offline shows a "connect a
running node" note and disables Import up front.
- Viewing keys: the classifier gains isViewingKey / isRecognizedImportKey;
importPrivateKey auto-detects a shielded viewing key (zxviews…) and routes to
z_importviewingkey (watch-only) vs z_importkey / importprivkey. The live type
indicator shows Transparent / Shielded spending / Shielded viewing (watch-only).
- Result: on success the imported address is captured from the RPC result and
shown via AddressCopyField.
- Material restyle (OverlayDialogSpec/BlurFloat + TactileButton + Esc) and full
i18n (12 new keys x 8 languages; CJK subset rebuilt).
Verified: rendered on dark + light skins; a seeded zxviews… key shows the masked
field + "Shielded viewing key (watch-only)" indicator.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The per-exchange chart used a single buffer keyed by the loaded pair, so
bouncing between venues (Ourbit <-> NonKYC) re-fetched every switch (loading
spinner each time). Add a per-pair cache: each venue's fetched candles (OHLC +
derived close series) are kept keyed by "<identifier>:<BASE>/<QUOTE>". Switching
back to a recently-viewed pair loads from the cache instantly — no fetch, no
spinner — while entries older than 30 min re-fetch to stay fresh. Bounded to 16
entries (irrelevant for DRGX's 2 venues, but safe if it lists more). Cache is
main-thread-only (populated by the worker's completion MainCb), so no races.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Switching exchange pairs (or first load) briefly leaves the chart with no series
while the venue's candles fetch, which showed the bare "No price history
available" empty state. When a chart fetch is in flight — App::isMarketChartLoading()
(the CoinGecko aggregate OR the per-exchange fetch) — draw a spinner + animated
"Loading price history…" in the plot area instead; the "no history" text only
shows when genuinely empty and idle. New i18n key market_chart_loading across all
8 languages. Verified via a forced-state render (spinner + label centered).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The market chart was hardwired to CoinGecko's cross-exchange USD aggregate
(vs_currency=usd), so selecting a trading pair only changed the "Trade" link —
never the chart or price. Now the SELECTED exchange drives both.
CoinGecko gives us which venues list DRGX (the ticker `market.identifier`) but
not their APIs, so add data/exchange_candles.h: a hand-maintained map from that
identifier to each venue's public candle endpoint, with two adapters verified
against the live APIs — Ourbit (MEXC-style /api/v3/klines, array format) and
NonKYC (TradingView-UDF /market/candles, `bars`). Unmapped venues return no URL,
so the chart falls back to the CoinGecko aggregate and nothing regresses.
- App::refreshExchangeChart() resolves the selected pair, fetches its intraday
(5-min) + daily candles via the TLS-verified httpGetString on the worker, and
stores them on MarketInfo (exchange_chart_intraday/daily + exchange_chart_active).
Re-fetches on pair change; self-throttled to ~30 min otherwise; falls back to
aggregate on any failure.
- chartSeries() draws the per-exchange series when active (portfolio sparklines
stay on the aggregate). The hero shows the selected venue's real price
(converted_last) — Ourbit vs NonKYC differ ~7%. parseCoinGeckoTickers now
captures the identifier + per-exchange USD price (previously discarded).
Adapters unit-tested against real captured responses (URL builder + both parsers
+ the chartSeries switch) and validated against the full live feeds (Ourbit 20d,
NonKYC 370d, ascending, correct closes). Build + ctest + hygiene green.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Revealing the DEBUG OPTIONS dropdown now opens a confirmation modal with a
warning instead of expanding directly. If the wallet is secured, it also requires
re-authentication before the options appear:
- a quick-unlock PIN → verified against the vault (Argon2id derive, off-thread,
no wallet side effects);
- otherwise an encrypted wallet → the passphrase, verified via walletpassphrase.
Unsecured wallets just confirm. Collapsing needs no gate; each expand re-gates.
App gains debugGateRequiresAuth() + verifyDebugCredential() (cb on the main
thread; secrets wiped). Adds a modal-debug-gate sweep surface. Verified 100% +
150%, dark + light.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
A pre-seed-phrase (legacy, non-mnemonic) wallet is exactly the one that benefits
from migrating — so nudge the user by glowing the Settings "Migrate to seed"
button with a soft pulsing accent halo.
Adds a cached wallet mnemonic status (probeWalletSeedStatus), classified once per
connect via z_exportmnemonic (the same signal the migration Intro pre-flight
uses): NoMnemonic = legacy → glow; HasMnemonic / Incapable (old daemon) / while
locked or on lite = no glow. Reset on disconnect so it re-probes after a wallet
switch or a post-migration adopt, with a small attempt cap to avoid re-probing on
a persistent transient error. The sweep forces the status so the glow is
captured (restored after). Verified dark + light.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Replace the drag/resize dashboard grid on the Market tab's portfolio with
three full-width row styles — compact (single-line), detailed (two-line),
and value-hero — cycled with Left/Right arrows like the Overview layouts.
Rows live in an internal scroll region bounded to the portfolio's height
budget, so the section stays put as groups are added.
- pfDrawRow renders each group in the selected style, reusing the icon /
value / DRGX / 24h / sparkline pieces; per-entry field toggles honored.
- portfolio_style setting (0/1/2) persisted; arrow-key switcher + toast.
- Portfolio groups gain a per-wallet scope: new groups default to the
active wallet's identity hash; legacy groups (empty scope) stay visible
in every wallet. Scope filter drives both the height budget and the rows.
- Remove the now-dead grid machinery (mktDrawCard, pfComputeGridLayout,
PfCell, PfGridDrag, MktCardCtx + MktCtx grid fields) — net code drop.
- Full UI sweep: seed demo portfolio groups + wavy price history and add
market-rows-{compact,detailed,featured} surfaces so the styles are
captured per skin (snapshot/restore the real portfolio + market data).
- i18n: portfolio_style_{label,compact,detailed,featured} + 8 langs; CJK
subset rebuilt.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Second phase of multi-wallet: list wallet files and switch the active one.
- Daemon plumbing: EmbeddedDaemon::setWalletFile -> start() passes -wallet=<name>
(non-default only; skipped during the isolated seed-migration start), and
DaemonController::syncSettings pushes active_wallet_file on each start.
- App::switchToWallet: persist the new active wallet, then stop + restart the
node on -wallet=<name> (rescan only if it was never synced in this datadir).
Reuses the seed-adopt restart coordination; WalletState clears on disconnect
and the P1 identity-scoped caches re-key, so no previous-wallet data leaks.
Guarded: full-node, embedded daemon, not mid-restart, no pending send.
- Wallets dialog (Settings -> Backup & Data -> "Wallets…"): a table of wallet
files (datadir wallet*.dat + user-added folders' *.dat) with size (disk) and
cached balance / address count / last-opened (wallet index), a current/Active
badge, Open (switch), and Add folder. Out-of-datadir wallets show "import to
open" (P3). Added as a sweep surface.
- Mining payout safety: non-destructive warning if the pool-mode worker looks
like a DragonX address not in the current wallet (stale after a switch).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
First phase of multi-wallet support: keep each wallet's data separate so loading
a different wallet no longer shows the previous one's data, and lay the metadata
groundwork for the wallet-files list.
- Address book scoped per wallet: AddressBookEntry gains a "scope" ("global" or a
wallet-identity hash); the Contacts tab shows global + current-wallet contacts
(App::activeWalletIdentityHash) with a "show in every wallet" toggle + globe
badge. Legacy entries migrate to "global". Scope-aware de-dup allows the same
address across different wallets.
- Wallet metadata index (data/wallet_index -> wallets.json): file-keyed cache of
balance / address count / identity / size / last-opened / synced-here, since
those can't be read off a wallet.dat without loading it. Populated on connect +
address refresh (change-detecting upsert). Plus an active_wallet_file setting
for the -wallet=<name> switch coming in P2.
Unit-tested (testAddressBookScope, testWalletIndex): migration, visibility filter,
scope-aware de-dup, upsert change-detection, save/reload round-trip.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Fixes the Windows migrate-to-seed failure (the old bundled daemon lacked
z_exportmnemonic) and rounds out the flow:
- seed_wallet_creator: actionable "daemon too old" message instead of the
raw "Method not found" RPC error.
- Startup daemon-update prompt: if the wallet bundles a newer node than the
installed one, offer to update it (once per version, keyed on a new
daemon_update_prompted_size setting; never silently clobbers a custom
node). "Install bundled" now also works when attached to an external /
leftover daemon (RPC-stops it, then swaps + restarts).
- Migrate-to-seed Intro pre-flight: detect an already-mnemonic wallet
(offer backup instead), an old daemon, or a locked/disconnected wallet
before offering to create anything.
- No-funds path: a zero-balance wallet can adopt the new seed wallet
directly (behind an acknowledgement checkbox) instead of a dead sweep.
- Loading spinners on the Working/Sweeping/Adopting steps; sweep coverage
for the new intro / no-funds surfaces.
- Translations for all 18 new strings across 8 languages (+ rebuilt CJK
subset font).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The existing sweep only captures tabs. This adds a "Full UI sweep" (DEBUG
OPTIONS) that also drives every normally-hidden surface into view and captures
it under every skin, so all the UI a normal sweep misses can be reviewed at
once.
- The capture unit becomes a "surface" (SweepTarget: a base tab, optionally
with a setup()/teardown() that forces a modal / multi-step flow / state
overlay on top). Tabs are surfaces with a null setup. The existing tab sweep
is folded into the same state machine (one code path). All sweep logic +
the catalog + demo data move into a new src/app_sweep.cpp.
- Runs OFFLINE against injected demo data (installDemoWalletData snapshots then
restores the real state_ — WalletState isn't copy-assignable, so a targeted
field snapshot is used) and fires NO live op: capture_mode_ guards the
seed-backup RPC, auto-lock, and the migration pumps; the async-firing dialogs
are neutralized by pre-setting their state (fetch_started_ + demo phrase;
seed_migration_step_ set directly). Verified: 0 RPC/secret/send calls.
- Catalog (v1): tabs + the bool-flag modals (import/export key, backup,
seed-backup, about, settings) + the 6 migrate-to-seed steps + 4 wizard steps
+ lock/warmup/not-ready overlays + the send-confirm popup (via a new
ui::SweepShowSendConfirm debug hook, since its state is send_tab-static).
- Overlay/blur surfaces settle 8 frames (blur backdrop freeze); setup re-runs
each frame (OpenPopup popups must re-fire). updateScreenshotSweep now runs
before the first-run-wizard early-return in render() so the sweep advances
while a wizard step is shown. main.cpp drops vsync during a sweep so it isn't
throttled to the compositor's unfocused rate. Output:
<config>/screenshots-full/<surface>/<skin>.png + index.md.
- DRAGONX_FULL_SWEEP=1 runs the sweep headlessly then quits (CI / verification).
Verified end-to-end under WSLg: 288 PNGs (9 skins x 32 surfaces), all 1200x720,
[Sweep] done, clean quit, no daemon. Byte-identity across runs holds only for
static skins on data-free surfaces — the rest vary by animated theme effects /
live price / relative timestamps (expected for a review tool, not a bug).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Second half of "migrate a legacy wallet to a seed wallet": sweep all funds
into the new seed wallet, then adopt it as the primary wallet.
- Sweep (beginSweepToSeedWallet): z_mergetoaddress ["ANY_TADDR","ANY_ZADDR"]
-> the new wallet's z-address, at the default fee, limits 0,0. The opid is
tracked via the existing send-callback pipeline; the txid is persisted.
- Confirming gate (fund-safety): after the sweep, pollSweepStatus() polls the
tx confirmations + legacy balance. "Make this my wallet" only unlocks once
the sweep is MINED (>= 1 conf) AND the old wallet is empty — so wallet.dat is
never swapped while the funds could still bounce back or a remainder is left.
A too-big-for-one-tx remainder offers a "Sweep remaining" re-sweep.
- Adopt (beginAdoptSeedWallet, background): stop daemon -> move legacy
wallet.dat to a timestamped .bak (never deleted; restored on failure) ->
install the new wallet -> restart with -rescan. Exception-safe;
daemon_restarting_ gates tryConnect + is always cleared; skips the restart
while shutting down; beginShutdown joins the task first.
- Resume: a pending migration reopens at Sweep, or at Confirming if the sweep
txid is already recorded (re-derived from the chain) — never re-sweeps blind.
- Secret hygiene: the seed is wiped leaving ShowSeed, on any modal dismiss
(detected after BeginOverlayDialog), and in App::~App() (same fix applied to
the seed-backup dialog).
Built both variants; tests + hygiene pass. Two rounds of parallel adversarial
review (12 findings fixed, then a clean re-verify) gate this fund-moving code.
The migration modal uses English literals (as renderBackupDialog does); it gets
translated once the flow is finalized. Live sweep test against a funded wallet
is the next step.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
First, fund-safe step of "migrate a legacy wallet to a seed wallet": mint a
brand-new BIP39 mnemonic wallet in an ISOLATED throwaway datadir so the user
can later sweep funds into it. No funds move and the real wallet.dat / main
daemon are never touched — the isolated node runs concurrently on its own port.
- EmbeddedDaemon: one-shot isolated-datadir override (setNextStartOverride)
consumed on the next start(); a skip-default-port-check for an isolated
instance beside the main daemon; and a tcpPortInUse() probe. The datadir's
basename must be the assetchain name (DRAGONX) and the daemon reads
<datadir>/DRAGONX.conf itself, so no -conf is passed (both learned from
live testing against the hd-transparent-keys daemon).
- SeedWalletCreator (src/daemon): picks a free port, writes a throwaway conf,
starts an isolated dragonxd with -usemnemonic=1 -connect=0, waits for RPC,
calls z_exportmnemonic + z_getnewaddress (the sweep target), stops it, and
keeps/scrubs the temp datadir. Off-thread; the seed is wiped after use.
- App: beginCreateSeedWallet (background) -> pumpSeedMigration (main-thread
handoff) -> renderSeedMigrationDialog (Intro -> Working -> Show seed ->
Error), reusing the seed_display grid + clipboard auto-clear.
- Settings: "Migrate to seed…" button (full-node gated) + persisted pending
migration state (dest address + temp datadir) for Phase 2 to adopt.
Validated end-to-end against the hd-transparent-keys daemon: the isolated
node comes up in ~4s and returns a 24-word mnemonic + a shielded z-address.
Requires that daemon (the bundled Jun-28 build lacks z_exportmnemonic). The
migration modal uses English literals for now (as renderBackupDialog does);
the whole flow gets translated once Phase 2 finalizes it. Phase 2 (sweep +
adopt) is next.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
New full-node wallets now get a real 24-word recovery phrase, and users can
reveal/back it up from Settings.
- Pass -usemnemonic=1 to dragonxd. The daemon reads it only inside
GenerateNewSeed() when a wallet has no seed yet, so it is inert on existing
wallets (safe to pass unconditionally) and makes every freshly-created
wallet mnemonic-backed — its phrase is then exportable via z_exportmnemonic
and portable to SDXLite/ObsidianDragonLite. (Existing/legacy wallets are
unaffected and keep using the chat identity's z_exportkey fallback.)
- App::exportSeedPhrase() wraps z_exportmnemonic on the RPC worker with
sodium_memzero wiping on every path; it flags the daemon's "not derived
from a mnemonic" error as a legacy wallet rather than a failure.
- New "Back up seed phrase" modal (Settings -> Backup & Data, full-node
gated): reveals the phrase in a numbered word grid, copy (45s clipboard
auto-clear) + save-to-file (0600), wipes the secret on every close path
incl. dismiss-mid-fetch. Shared ui/windows/seed_display.h grid helper.
- One-time nudge (settings flag seed_backup_reminded) toasts mnemonic-wallet
users to back up their phrase; legacy wallets are not nagged.
- 15 new strings translated into all 8 languages (additive, 957->972 keys);
CJK subset font rebuilt for the new glyphs.
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>
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>
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>
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>
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>
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>
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>
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>