testAddressBookScope hardcoded ".config/ObsidianDragon" for its
pre-scoping addressbook.json, but AddressBook::load() reads the
per-variant config dir (Lite -> ObsidianDragonLite/). In a --lite build
the write and read diverged, so load() found nothing, size()==0, and the
following entries()[0] read out of bounds -> SIGSEGV, taking the whole
suite down.
Write to dragonx::util::Platform::getConfigDir() (the same path load()
uses, resolved under the test's temp HOME) and guard the entries()[0]
access so a non-aborting EXPECT can never SIGSEGV the suite. Verified:
ctest now passes 100% in BOTH the full-node and --lite builds.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Give the lite console the parity analog of the full-node RPC command
reference: the same searchable two-pane modal (browse/search, detail
pane, examples, Insert / Insert & run, destructive confirm), driven by
the lite backend's own command set instead of daemon RPC.
- ConsoleCommandExecutor::commandReference() returns the category table
(full node = consoleCommandCategories(); lite = new
liteConsoleCommandCategories() -- 25 backend verbs in 5 categories).
The shared renderCommandsPopup reads the table from the executor.
- The Commands button now shows for both variants (gated on
commandReference()!=nullptr); title/tooltip/arg-quoting branch on
hasRpcReference() (clarified to mean "speaks JSON-RPC / full node").
- Lite args are bare tokens (the backend takes one unsplit arg string
and does not strip quotes), so the param-builder's JSON string
auto-quoting is disabled for lite -- Insert & run emits runnable bare
commands. send uses the JSON-array form (its positional form is
unreachable via the single-arg transport); new uses zs/R; import
takes just the key (the backend hardcodes birthday=0).
Adds 7 i18n keys across all 8 languages (additive). Full-node behavior
is unchanged. Adversarially reviewed (data vs the backend registry,
plumbing/regression, i18n) with all findings fixed.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The lite console tab lagged the full-node one on i18n: its toolbar status,
status lines, help, and error/warning strings were hard-coded English or
worded for a full node (a "daemon" the lite wallet doesn't have).
- toolbarStatus()/statusLines(): route through TR() (reusing the existing
lite_net_* keys where they already map)
- printHelp(): enumerate the 25 pass-through backend verbs for discoverability,
since the C++ tab intercepts `help` before the backend's own HelpCommand runs
- gate the destructive 'stop' confirmation to the full node (hasRpcReference);
lite has no node, so it lets 'stop' fall through to the backend instead of
showing a phantom "shut down the node" warning behind a dead gate
- word the not-connected error per variant (daemon vs "no wallet open")
- TR() the "(no output)" command-result fallback
Adds 7 i18n keys across all 8 languages (additive); rebuilds the CJK subset
for the one new glyph (U+C5D4 for the Korean "backend"). Build + ctest +
source-hygiene green; changes adversarially verified (no logic/i18n defects).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The status indicator's left inset (+2) and dot-to-text reservation (+6) were raw pixels while the dot radius is DPI-scaled, so the dot shifted a couple native px at HiDPI. Multiply both by Layout::dpiScale() per the project's hand-drawn-geometry rule.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
std::localtime shares a process-wide static tm; both sites are reachable from background threads (the RPC price-parse on the worker thread, and Logger::write from worker/monitor threads), so a concurrent localtime call could clobber the struct between the call and the read. Copy into a local std::tm via localtime_r / localtime_s, matching the codebase's existing convention (console_tab rpcTraceTimestamp, app_network, etc.). From the console-tab audit; benign (garbled/stale timestamp only), no crash or state impact.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The accent + text-colour toggle buttons pushed/popped ImGuiCol_Text guarded by a flag the TactileButton flips in between, so a click left the colour stack unbalanced for that frame — and ImGui's error recovery drew a red rectangle around the console window (imgui.cpp:11727). Capture the flag into a local before the button and guard both the push and the pop with it.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Render the portfolio trend sparkline in every row style, each with a layout that features it:
- Table: a dedicated header-labelled TREND column (48dp rows), aligned across rows.
- Cards: a left-aligned info stack (icon + label / value+delta chip / DRGX) with a full-height sparkline filling the rest of the width (content-driven, so short groups give the chart more room).
- Spotlight: a big-value-over-a-full-width-chart-band hero tile (92dp).
Default sparkline interval is now MONTH (a real curve from the daily series, not the young minute buffer). Removed the per-row container accent line (redundant with the icon/sparkline colour) and the per-group shielded/transparent bar (private-by-default makes it near-always 100%, and dropped its now-dead SumPortfolioSplit helper). DPI-scale the sparkline stroke. Adds the "Trend" column string across 9 languages.
Relocated the Market controls out of a removed settings modal + gear: the chart line/candle picker now sits top-right of the chart (left of the trade button, candle-capable ranges only), and the Table/Cards/Spotlight picker sits left of Manage. Dropped the market fetch-prices toggle (still in the general Settings page).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Two coupled Market-tab changes (same file):
- Sparkline in every style: render the per-entry trend sparkline in Table (a reserved trend column, aligned under the column header, reserved only when at least one group opts in so the dense grid isn't padded otherwise) and Cards (a filled strip on the right, above the full-width Z/T bar), not just Spotlight — so the per-entry 'show sparkline' setting is honored in all three views. DPI-scale the sparkline stroke (1.2f/1.4f * dp), from the sparkline audit's L1.
- Fix the candlestick toggle intermittently missing: the Market tab never triggered the per-exchange OHLC fetch on its own (only a pair-chip / refresh click did), so candlesticks were unavailable on a fresh tab open. Call refreshExchangeChart() every frame (self-throttled + in-flight-guarded — its own doc comment already says 'safe to call every frame'), which also fixes the new pair's candles never loading if you switch pairs mid-fetch.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Market-tab audit follow-up (all 9 confirmed findings):
- Scale the unscaled corner radii on the Cards/Spotlight row rects, the address-preview glass panel, the chart Y-axis labels, and both chart hover tooltips by dpiScale() — they mismatched the coincident already-scaled rects on HiDPI (the CLAUDE.md hand-drawn-geometry rule).
- Share the portfolio row-height/gap formula (pfRowHeight/pfRowGapFor) and the TABLE column widths (kPfValColW/kPfDrgxColW) between the draw loop and the scroll-region height budget so they can't drift and clip.
- Derive the portfolio total from the shielded/transparent split (one address scan instead of two in Cards).
- Build the address-picker selected-set once (unordered_set) instead of a linear PortfolioEntryContains per sort-compare and per row.
No correctness/security/threading defects were found in the audit.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Widen the chat settings modal and split it into a live message preview (canned bubbles rendered with the real style/accent/density/font/emoji/timestamp code) beside the controls; size the Done button to its text.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Add a settings-gear modal with a segmented avatar-shape picker (circle / rounded square / full-row left tab), a list-scale slider, and a live two-row preview. Smooth-scroll the address list and fix a latent wheel double-scroll on the outer scroll child. (settings.h also refreshes the portfolio-style doc comment.)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Add contacts_shape_tab, the market settings-modal + column-header keys; rename the portfolio-style labels to Table / Cards / Spotlight (same keys, new values). Rebuild the CJK subset for the new glyphs. All 8 languages, additive.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
material::SegmentedControl drew each label centred with no clip, so long non-English labels (Russian/German, etc.) overflowed their cell and overlapped neighbours. Left-align on overflow and clip per cell (mirroring the chat modal's segmented helper); the fits-case is visually unchanged. Benefits every caller (contacts, receive, wallets, market).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The inner scroll child (##PeersList / ConsoleOutput) uses NoScrollWithMouse + ApplySmoothScroll; ImGui forwards its wheel up to the outer scroll container, so the wheel scrolled both. Add NoScrollWithMouse to the outer containers (safe: both fill the remaining height and never overflow), matching the contacts/explorer fix.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Chat tab overhaul:
- Header/layout: single-row header (name + key-lock + compact click-to-copy address +
right-aligned icon toolbar with a settings "notch"), composer moved below the message
box (emoji toggle left of a bottom-flush input), tightened list-pane controls.
- Message list: per-day date separators (Today/Yesterday/date), time-only group headers,
tight same-sender grouping with iMessage-style merged corners, per-run peer avatar,
delivery status (clock -> check), hover-reveal per-message time. Message base ~18px.
- Customization: a settings "notch" gear opens a modal (also under Settings ->
Chat & Contacts) with segmented controls for emoji style / bubble style / density /
timestamps, sliders for poll rate + text size, a bubble-accent dropdown, Enter-to-send.
Bubble style/accent/density/text-size/timestamps all applied live in the message loop.
- Settings: app-wide clock-format control lives in Settings -> General; chat keeps a
per-tab override. Both chat panes now use the app's smooth wheel-scroll.
- i18n: new strings across all 8 languages; CJK subset rebuilt for the added glyphs.
Inline contact rename, hide/mute, 0-conf fast-scan and the export path are carried through.
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>
Add util::formatClockDateTime/formatClockTime driven by a process-wide flag (setClock12h,
synced from the time_format setting each frame). Switch the primary user-facing timestamp
displays to it — the transaction list, wallet-state tx + banned-peer times, the explorer
block time, and the block-info dialog — so one preference drives every clock.
Log files, export filenames/content, console line prefixes, the market chart axis, and the
worker-thread "last updated" string intentionally stay fixed 24h.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Persisted (additive JSON, clamped on load) preferences backing the new chat settings
surface and the global clock:
- chat: emoji style (color default), poll rate, bubble style + accent, message density,
text scale, per-tab timestamp override, Enter-to-send.
- app-wide time_format (0=24h, 1=12h) that the Chat tab falls back to and every other
user-facing timestamp now follows.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The chat "Emoji style: Color" option renders the merged emoji in color (COLR/CPAL
Twemoji) instead of the monochrome NotoEmoji subset. This needs FreeType, which the
default stb_truetype rasterizer can't do for color glyphs.
- Vendor imgui_freetype (matches the bundled 1.92 ImFontLoader API) and embed a 1.4 MB
COLRv0 Twemoji font (no libpng/harfbuzz needed).
- CMake gains an optional FreeType path: native Linux/macOS use the system FreeType via
find_package; the mingw-w64 cross-compile has none, so build.sh --win-release now
cross-builds a minimal static FreeType (scripts/build-freetype-mingw.sh) and passes it
in. Absent FreeType => graceful monochrome fallback, so no build breaks.
- Typography selects the FreeType loader + the color font (LoadColor) when color emoji is
on, else the stb loader + mono subset; toggling reloads the atlas. Both the DX11 and
OpenGL backends already support the 1.92 RGBA dynamic atlas, so color glyphs render.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Tier A + B chat-tab visual pass:
- Widen the list<->thread gutter (1px -> 10px) for breathing room.
- Redesign the thread header: avatar + name on the left with a
right-aligned frameless icon toolbar (add-contact/export/mute/hide,
each with a tooltip); the name is clipped to the toolbar's left edge
so a long contact label can't overrun the icons.
- Replace the "Copy Full Address" button with a click-to-copy shortened
address (copies the full z-addr), preceded by a key-verify lock whose
tooltip shows a comparable identity-key fingerprint.
- Show a "Waiting for reply" chip in the header when the peer's identity
key isn't known yet.
- Emoji picker: frameless grid with tight cells (glyphs fill the cell)
and leftover width spread into the column gaps so it stays edge-to-edge.
- Larger 30px composer emoji toggle that lights up while the picker is
open; footer height + byte-counter centering adjusted to match.
New i18n keys (chat_copy_address_tip / chat_verify_key / chat_awaiting_key)
added additively to all 8 languages; CJK subset font rebuilt for the new
zh glyph.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Reworked the emoji picker from a floating popup (which overlapped the message
area) into an overlay that takes over the conversation-list pane while open:
a Cancel button + a keyword search box at the top, then a responsive grid below
that wraps to the pane width.
Each emoji now carries search keywords (grin/heart/fire/…) so the search box
filters the ~150-emoji set. The 🙂 composer button toggles the overlay;
selecting an emoji appends it to the composer (byte-cap respected) and the
overlay stays open for multiple picks.
+1 CJK glyph (絵) for the ja "Search emoji" string.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- Emoji picker: a 🙂 button on the composer row opens a scrollable grid of ~150
common single-codepoint emoji (all verified present in the bundled NotoEmoji
subset); clicking appends to the composer, respecting the byte cap. ImGui does
no shaping, so the set is single-codepoint only (ZWJ sequences / flags omitted).
- Show hidden: when there are hidden conversations, a "Show hidden (N)" toggle
appears in the list; toggling it reveals them (dimmed) and the thread header's
Hide button becomes Unhide. Off by default, and resets when nothing is hidden.
8-language strings; no new CJK glyphs.
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>
Review of 30bd0d9 found the 0-conf fast-scan MainCb was the one async chat
callback missing the chat_session_generation_ guard the broadcast + identity-fetch
callbacks use. worker_ survives a wallet switch/lock, so a fast-scan posted under
wallet A could drain after resetChatSession() and ingest A's metadata (or toast)
against wallet B's freshly-provisioned store.
- Capture scanGen at post time and drop the result if it changed by drain time.
- Clear chat_fast_scan_in_flight_ in resetChatSession() so a switch immediately
re-enables the fast path; the stale callback returns WITHOUT clearing the flag so
it can't clobber the new session's own in-flight scan (generation is bumped only
in resetChatSession, which already reset the flag).
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>
Review of fc414ee found two issues:
- HIGH: the lite variant harvests chat via ingestLiteChatMemos, which called
ingest() without the newIncomingCids out-param — so the un-hide never ran and a
hidden conversation stayed hidden PERMANENTLY on new messages (chat is default-ON
and fully supported in Lite), breaking the "a new message brings it back"
invariant. Wire the same un-hide + off-tab toast into the lite path.
- LOW: the new-conversation contact picker listed every z-address contact,
ignoring the per-wallet scope the Contacts tab enforces — leaking another
wallet's scoped contact into this wallet's picker. Apply the same scope test
(global + legacy fail open; "w:" scopes match the active wallet).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Hide conversations:
- A "Hide" action in the thread header drops a conversation from the list. The
messages stay in the seed-encrypted store (on-chain history can't be deleted);
a new INCOMING message un-hides it (you can't un-receive), so nothing is lost.
- Hidden cids persist in settings (mirrors the mute list) and are skipped by both
the conversation list and the unread badge.
New-conversation address picker:
- A "Choose from contacts" dropdown lists the address book's shielded (z-address)
contacts and fills the recipient field on selection; manual paste still works.
8-language strings + CJK subset (+1 glyph 届).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Root cause of the reported "my own messages come back as replies": seedChatDemoData
(the Settings "Seed demo chat" debug button) set the chat identity to a FIXED
secret ("obsidian-dragon-demo-chat") and, because maybeProvisionChatIdentity
no-ops once any identity exists (app_network.cpp:2771), that demo identity stuck
and overrode the wallet's real seed-derived one. Clicking it on two different-seed
wallets gave BOTH the same constant identity — so they were cryptographically the
same person, and a wallet's own outgoing memo (harvested) decrypted back as an
incoming message.
Two rules now:
- Only fabricate a demo identity when there is NO real one (never clobber a
provisioned wallet identity).
- Derive it from a RANDOM per-run secret, so it can never be a constant shared
across installs/wallets.
Complements 7351d8a (which removed the self-harvest path); together they close the
loopback both at the harvest and at the identity source.
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>
DRAGONX_LITE_BUILD is ALWAYS defined (0 for the full node, 1 for Lite) via
$<BOOL:...>, so the previous #ifdef was true for BOTH variants — the full node
took the Lite branch and grabbed the "obsidiandragonlite" lock, so launching Lite
next still collided ("ObsidianDragonLite already running"). Switch to
#if DRAGONX_LITE_BUILD (value check), matching how the rest of the codebase
guards this macro.
Verified: full-node binary now contains only "obsidiandragon"; preprocessor check
confirms LITE=1 selects "obsidiandragonlite". (wallet_capabilities.h's #ifndef is
a separate, correct default-definition idiom — not affected.)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The single-instance lock hardcoded the name "obsidiandragon" for both variants,
so launching ObsidianDragonLite while ObsidianDragon was running (or vice-versa)
was refused with "Another instance is already running". Nothing else actually
required them to be exclusive — DRAGONX_APP_NAME already gives each variant its
own config dir (settings / wallets index / address book / chat db), the full
node's daemon lives under ~/.hush/DRAGONX with no Lite counterpart, and the lock
guards no cross-instance IPC (the payment URI is handled locally).
Key the lock per variant (obsidiandragon / obsidiandragonlite) — the Windows
named mutex already derives from the same name, so it's fixed on both platforms.
Each variant still enforces a single instance of itself. Also make the
already-running message report the actual variant (DRAGONX_APP_NAME) and use
MessageBoxA so it can. mingw-verified.
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>
Two verified medium leaks in the B7 scrub chain:
- parseRpcResult parses the body into a local `response` tree and returns a COPY
of response["result"] (operator[] yields an lvalue ref, so the by-value return
copy-constructs). The local tree — holding its own heap copy of the secret — was
then freed without zeroing, so callSecret/callSecretString still left one
un-scrubbed copy. Add scrubJsonSecrets() (recursive string zero) and a
scrubSource flag; the secret paths opt in, wiping the tree before it frees. The
secret export chain is now fully covered: raw body → parse tree → result copy →
caller-owned string.
- key_export_dialog cleared s_key with plain std::string::clear() on the Close
button, the scrim/Esc dismiss path, and the QR cache (s_qr_cached) — leaving the
displayed private/spending key in freed heap on the ordinary close paths. Route
all three through wallet::secureWipeLiteSecret (zero-then-clear), matching
show()/hide().
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Merge a monochrome Noto Emoji subset into the text fonts so chat messages, the
composer, contact names, and memos render emoji (😀🎉 ❤ 🔥👍 …) instead of
tofu.
- Enable IMGUI_USE_WCHAR32 (imconfig.h): emoji live above the BMP (U+1F300+), so
16-bit ImWchar literally can't address them. This widens ImWchar build-wide;
the only ImWchar uses in-tree are glyph-range arrays and one BMP private-use
codepoint, so nothing else is affected. Tests + full build pass.
- Bundle res/fonts/NotoEmoji-Subset.ttf — the OFL monochrome Noto Emoji (color
CBDT/COLR fonts can't be rasterized by ImGui's stb_truetype) pinned to wght=400
and subset to the emoji planes (1411 glyphs, 747 KB). Reproducible via
scripts/build_emoji_subset.py. Embedded via INCBIN like the CJK subset.
- Typography::loadFont merges it (MergeMode) only into the small text fonts
(Body/Subtitle/Caption/Button) — not headers, which don't need 1400 emoji.
The base font keeps precedence for U+2600–26FF, so text-style symbols stay.
Limits: ImGui does no shaping, so single-codepoint emoji render but ZWJ sequences
(family/profession) and regional-indicator flags won't compose; emoji are
monochrome (the OS emoji picker still inputs them fine, and the composer byte
counter already counts their 4-byte UTF-8 cost against the on-chain cap).
Verified headless: sizeof(ImWchar)==4 and every probed emoji is in-font and bakes
into the atlas.
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>
Translate the 14 chat strings added by the backlog work (relative time, retry,
jump-to-latest, empty states, search, export, message-too-long, mute/unmute) into
de/es/fr/ja/ko/pt/ru/zh, added additively (no existing key overwritten).
Also fix a key collision the English pass introduced: the new empty-state
sub-hint reused "chat_empty_hint", which already meant the standalone list hint —
so English and the translations disagreed. Split it into a distinct
"chat_empty_start" for the "Start one with New conversation" sub-line, leaving
the original chat_empty_hint intact.
Rebuilt the CJK subset font: +3 glyphs (刚静音) for zh "刚刚"/"静音", 0 removed.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Per-conversation mute toggle in the thread header. Muted conversations (tracked
by cid in settings, so it persists) are skipped by chatUnreadCount(), so they
neither raise the nav-item unread badge nor the new-message toast — the toast now
gates on a chatUnreadCount() delta across ingest, which already skips muted cids,
so mute is respected for free. "Block" (rejecting a peer's inbound memos) is a
larger ingest-filter change and is intentionally left out of this pass.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The secret exports (z_exportmnemonic / z_exportkey / dumpprivkey) already scrub
the parsed value at the call site, but the raw HTTP response string those RPCs
build — the curl write buffer, which holds the same secret in the clear — was
freed without zeroing. That's the "fuller fix belongs in the RPC layer" the
identity-fetch comment flagged.
Add RPCClient::callSecret(), a call() variant that sodium_memzeros the raw
response body after parsing (on success and on throw). NRVO makes the returned
string the very buffer curl wrote into, so one wipe covers it. Route every
secret-bearing export through it: chat identity (mnemonic + z_exportkey
fallback), Settings seed-phrase + single-key export, Export-all-keys, and the
migrate-to-seed isolated-node mnemonic export. Purely additive — the parsed
result is byte-identical, so no behavior change (safe for the fund-critical
migrate path).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Chat-tab backlog from the audit:
- V3/V7/Q7/Q9 — thread overhaul: direction-aligned rounded bubbles with
sender/minute grouping and a grouped meta line; peer avatar in the header;
hover shows the full timestamp; a right-aligned "not sent · Retry" affordance
re-sends failed outgoing messages; a floating "Latest" pill appears when the
thread is scrolled up.
- V4 — centered empty states (icon + title + hint) for the locked, no-conversations,
and no-selection panes.
- V6 — faint sidebar tint on the conversation list + a tight single-line seam.
- Q5 — compact relative time ("now"/"5m"/"3h"/"2d"/"Mon DD") in the list preview.
- Q6 — multi-line composer (Enter sends, Ctrl+Enter newline) with a live byte
counter against the on-chain body cap (= (512−len"utf8:")/2 − ABYTES = 236),
Send disabled + counter reddened when over.
- Q8 — case-insensitive conversation search over name + last body (thread stays
open even when filtered out); "no matches" hint.
- Q11 — export a decrypted conversation to a plaintext file in the config dir
(restricted perms, plaintext-warning tooltip), toasting the path.
English strings added to i18n.cpp; per-language JSONs follow.
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>
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>
- Q3: copy the peer z-address from the thread header (SmallButton), and right-click
any message to copy its body.
- Q2: an "Add contact" action in the header when the peer isn't already known —
one click saves them to the address book (rename later in Contacts).
- Q4: capture ChatService::ingest's new-message count (previously discarded) and
fire an in-app toast when new encrypted chat arrives while the user isn't on the
Chat tab (main-thread MainCb sites only).
i18n (EN + 8 languages, additive; no new CJK glyphs).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Audit B3/V1/V2/V5/B6 — bring the Chat tab up to the contacts_tab bar:
- New-Conversation modal rebuilt on the house BlurFloat OverlayDialog with
LabeledInput fields and an accented TactileButton footer (Send disabled until
both fields are set); wipes the sent plaintext. Replaces the raw ImGui popup and
its hardcoded widths (V1).
- Conversation rows: leading letter-avatars (deterministic palette color + the
peer's UTF-8 initial), Primary-tinted selected fill + border, OnSurface hover —
matching contacts_tab's tactile card rows (V2). Stable PushID(cid) instead of the
re-sorted loop index (B6). Taller rows.
- Send / New are accented TactileButtons with press feedback (V5).
- All hardcoded px (Send width, modal) go through Layout::dpiScale() so nothing
clips at 150% (B3).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Audit B1/B5/B8:
- The chat composer / new-conversation buffers are file-static char[]; on a wallet
switch or lock the plaintext a user typed for wallet A (a private message, or a
recipient z-address) resurfaced verbatim in wallet B's composer and lingered
unwiped in RAM. Add ui::ResetChatTab() (sodium_memzero the buffers + clear the
selection ids) and call it from App::resetChatSession().
- Wipe the single composer draft when the active conversation changes, so text
typed for one contact can't be sent to another (B5).
- Refresh the stale "read-only / Phase 3" docs — composing/sending is wired (B8).
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>
render() computes visible_indices_ (indices into model_) once per frame at the
top, before the toolbar. The toolbar's Clear button called clear() → model_.clear(),
emptying model_ mid-frame (the "cleared" marker is only queued, drained next
frame). renderOutput() then indexed model_[visible_indices_[vi]] with the stale
indices → out-of-bounds → crash.
clear() now also drops visible_indices_ and the selection (both hold line indices
into model_), so this frame's renderOutput iterates zero lines and computeVisibleLines
rebuilds them next frame. The right-click "Clear console" menu item now routes
through clear() instead of a bare model_.clear() so it's covered too.
Adversarially verified: no other same-frame path indexes the emptied model
(fold-toggle, ingest, and selectAll are bounds-guarded).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Two issues the latest live log exposed:
- The switch's start-retry spawned a SECOND dragonxd while the first was still
shutting down, so the two held wallet.dat against each other (BDB "Failed to
rename wallet-savings.dat … Error"), and it then span on a stale "Daemon
already running". Revert to a single start — the stopDaemonForWalletSwitch()
wait already ensures the old process is gone, so a valid wallet opens cleanly
and a bad one exits during init and reverts, without overlapping spawns.
- findProcessByName() used the non-suffixed PROCESSENTRY32/Process32First with an
ANSI _stricmp; if UNICODE is defined those map to the wide variants, so the
compare comparing garbage would NEVER match — silently making the process-gone
wait a no-op. Rewritten with the explicit wide Toolhelp API + lstrcmpiW so it's
correct either way (verified to compile under mingw with and without -DUNICODE).
Note: a corrupt wallet.dat (BDB recovery failing) still can't be opened by any
node — that's a data issue needing a clean reset, not a switch-flow bug.
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>
Live logs showed the switch's new node starting on the correct wallet, reaching
"Verifying wallet…", then aborting with "Binding RPC on ::1 port 21769 failed" +
"Failed to rename wallet-savings.dat" — the OLD node's RPC port (on ::1/IPv6) and
Berkeley DB environment weren't fully released yet, so the wallet-verify DB
recovery couldn't rename the file. The app then reverted, and the connect loop
brought a node up on the DEFAULT wallet.
Two causes fixed:
- isPortInUse() only probed 127.0.0.1 (IPv4). The daemon also binds ::1 (IPv6),
which lingers after IPv4 releases — so the readiness wait returned "free"
prematurely. Now probe BOTH families (Windows: IPv4 + ::1 via in6addr_loopback;
Linux: /proc/net/tcp + tcp6). mingw-verified.
- The datadir/DB-env can still be briefly held right after the old node exits, so
the first start can abort. Retry the start (up to 6×, 2s backoff) with the
CORRECT -wallet — active_wallet_file isn't reverted until we give up — resetting
the crash count and re-arming -rescan each attempt, until one survives.
Also: the "Wallet switch failed" modal no longer repeats its title in the warning
header — it now shows the actual reason there.
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>
After the stop, the switch started the replacement dragonxd too early — while the
old (direct-connected, unowned) node was still doing a slow graceful shutdown
(~70s; its network threads block on peer TLS timeouts) and holding the DATADIR
LOCK. The replacement couldn't acquire the lock, failed repeatedly, and the
crash-wedge left it stuck "starting". Root cause: the readiness poll used
isRpcPortInUse(), which on Windows is a connect() probe that reads "free" the
moment the daemon stops ACCEPTING RPC — early in shutdown, long before the
process exits and releases the datadir.
- EmbeddedDaemon::isDaemonProcessRunning(): true while any dragonxd process is
alive (Windows findProcessByName; Linux /proc/<pid>/comm scan; macOS port
fallback) — reflects the PROCESS, not just RPC acceptance.
- stopDaemonForWalletSwitch: for an UNOWNED node (no handle), after the RPC stop
wait until BOTH the port is free AND isDaemonProcessRunning() is false, bounded
~120s (or ~5s if the stop couldn't be sent). Owned nodes are unchanged
(stopEmbeddedDaemon() blocks for exit via the handle).
- Switch notification reworded to set the up-to-a-minute expectation (60s toast).
daemon_restarting_ stays set across the wait so the connect loop can't spawn a
competing daemon. Adversarially verified 6/6; fixes the seed-adopt path too.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Live logs showed the app usually just DIRECT-CONNECTS to an already-running
dragonxd (config found → connect; EmbeddedDaemon::start() never called). Two
consequences broke the switch: no process handle, and externalDaemonDetected()
stays false (it's only latched inside start()). So a direct-connected node was
treated as "owned" → stopEmbeddedDaemon() → an autoDetectConfig() temp RPC stop
that never reached the daemon → the node never stopped → the ~40s port poll timed
out → "the running node didn't release its connection in time" revert.
Gate on the real ownership signal — whether we hold a live process handle
(isEmbeddedDaemonRunning()) — instead of the unreliable externalDaemonDetected():
- stopDaemonForWalletSwitch: owned (we spawned it) → stopEmbeddedDaemon() with
SIGTERM/SIGKILL; NOT owned (adopted or direct-connect, no handle) → RPC "stop"
over the exact creds we're connected with (saved_config_), which is guaranteed
to reach our node. Then the unchanged port-free poll.
- switchToWallet confirm gate: show "Stop the running node?" when connected to a
node this session didn't spawn (state_.connected && !isEmbeddedDaemonRunning()).
- Fixes beginAdoptSeedWallet's direct-connect case identically (shared helper).
Adversarially verified (6/6): ownership signal, saved_config_ delivery incl.
cookie auth, foreign-daemon safety, confirm gate, seed-adopt, re-ownership/revert.
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>
beginAdoptSeedWallet had the identical adopted-external-daemon bug the wallet
switch fix (3808b3e) fixed: it gated the wallet.dat swap on isEmbeddedDaemonRunning(),
which is process-handle-only and reads false immediately for an adopted daemon —
so the swap could run while a live daemon still held wallet.dat, and the restart
fast-failed on the held RPC port ("wallet swapped but daemon didn't restart").
Route the stop through stopDaemonForWalletSwitch() (RPC-stop the adopted daemon,
wait for the RPC port to actually free) and gate the swap on that port_free
signal instead. Clear the external latch before relaunch only when we actually
stopped it (port_free), so a still-running foreign process is never marked owned.
Owned daemons are unchanged in effect: stopEmbeddedDaemon() already blocks for
full process exit, so wallet.dat is closed before the swap.
The funded new wallet is never at risk (read-only copy source; its isolated
creator daemon was already stopped). Two rounds of adversarial review (fund/swap
safety + control-flow) cleared it. Still pending per policy: a live mainnet run.
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>
When a row is probed as an HD wallet (has hdseed/hdchain records) but the
mnemonic flag can't be read — e.g. the rare tier-1 byte-scan fallback on an
unusual BDB variant or a >256 MB file — it now shows a neutral "HD wallet"
badge (ICON_MD_ACCOUNT_TREE) rather than a bare "?", which read as alarming.
"Unknown" (?) is reserved for a scan that couldn't even establish it's HD
(incomplete, no HD marker seen). Both still yield to the Lock badge when
encrypted. Seed/legacy/hd/unknown remain mutually exclusive.
Adds wallets_badge_hd / _hd_short strings (EN source + all 8 languages,
additive) and rebuilds the CJK subset font for the new glyphs.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The wallet switcher couldn't tell a BIP39 seed-phrase wallet from a legacy /
raw-entropy HD wallet without loading it, so non-active rows fell back to bare
HD-record presence — which mislabeled every HD wallet as "Seed phrase".
The distinction is actually on disk: the daemon serializes CHDChain with an
fMnemonicSeed bool (VERSION_HD_MNEMONIC=3, byte offset 52), and the hdchain
record stays plaintext even in an encrypted wallet. Read it directly:
- wallet_file_probe.h: hdChainMnemonicFlag() decodes the flag from the hdchain
value (1 mnemonic / 2 no-phrase / 0 undecidable); WalletBtreeStats.mnemonicSeed
surfaces it from the tier-2 btree walk.
- wallets_dialog.h: the badge prefers runtime z_exportmnemonic for the active
wallet, then the on-disk flag, then HD-record presence — so every row is
classified correctly (or honestly shows "?" when the flag can't be read, e.g.
the tier-1 byte-scan fallback).
- tests: decode-level cases (v3 set/clear, v1/v2, truncated, garbage version)
plus an end-to-end btree walk asserting mnemonicSeed.
Offset math + badge logic adversarially verified against the daemon source.
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 audit: while a switch/first-load is in flight the wallet identity hash
is empty, so contact/portfolio scope-writes silently fell back to "global"
(leaking a wallet-specific entry into every wallet) and scope-filters showed
every wallet's scoped entries.
- Contacts: a NEW wallet-scoped contact created while the identity is unknown is
now refused with a clear message (tick global or wait); editing an existing
scoped contact preserves its scope instead of demoting it to global.
- Portfolio: the "Add group" button is disabled while the identity is unknown
(with a tooltip), so a new group can't get an empty/global scope.
- Both views' visibility filters now show ONLY global entries when the identity
is unknown — never another wallet's scoped contacts/groups — instead of
showing everything.
- +2 i18n strings (8 langs; reworded one zh string to stay within the CJK subset).
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>
Adversarial review of the off-thread decode found the one real defect: a
detached decode worker decremented the static s_animInFlight counter, which at
process exit could run after static destruction begins (shutdown UB). Make the
counter a heap std::shared_ptr<atomic> the worker co-owns, so it safely outlives
teardown; the worker now touches only heap objects it holds a share of. Also
noted s_animatingThisFrame is intentionally main-thread-only.
(Review confirmed the rest: worker never touches the thumb map, done
release/acquire publishes the frames, GL upload stays on the UI thread, stb is
thread_local + libwebp per-instance so concurrent decodes are safe, the
in-flight slot is never leaked, and growth is bounded/cleared on navigate.)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Hovering an animated GIF/WebP previously decoded ALL frames + uploaded every
texture synchronously on the UI thread — a big/long animation froze the UI for
that first hover (stb's GIF decode is monolithic).
Now the decode runs on a detached background worker (bounded to 2 concurrent),
and frames are uploaded to GPU textures a few per UI frame; the still thumbnail
keeps showing until the sequence is ready, then it animates. A shared_ptr job
keeps the worker's result alive if the thumbnail is destroyed mid-decode (e.g.
navigating away), so nothing blocks. Only images the cheap badge-probe already
flagged as animated ever spawn a worker.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Animated thumbnails now show a small play-arrow badge (bottom-right) so users can
spot which images move before hovering; it's hidden while the image plays on
hover.
Detection is cheap — a new util::IsAnimatedImageFile probes without a full
decode: animated WebP via WebPGetFeatures.has_animation, and a multi-frame GIF
via a lightweight image-descriptor block walk (stops at the 2nd frame). The
picker only probes .gif/.webp thumbnails and caches the result on the Thumb.
Verified: animated GIF + animated WebP report animated; still GIF/WebP/PNG do not.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Adversarial review found that the contact list/table loops (no clipper) call
drawContactAvatar for every row, so an animated avatar scrolled out of the
list/table viewport still flagged the render loop as animating — pinning the app
at vsync-rate redraw instead of idling (power drain).
currentAvatarFrame now takes an onScreen flag: off-screen it shows frame 0 and
does NOT set the keep-redrawing flag. The list and table pass ImGui::IsRectVisible
for the row/avatar; the preview passes true; the library grid already yields a
null texture for culled cells. (Two other findings — stb GIF peak host RAM and
the session texture cache — were reviewed and judged bounded/local-only.)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Hovering a GIF/WebP thumbnail in the picker now previews its animation:
- The picker's Thumb gains a lazily-loaded frame sequence (via LoadAnimatedRGBA)
fetched the first time an animatable (.gif/.webp) thumbnail is hovered; still
images and other formats keep their single static thumbnail (no re-decode).
- On hover the current frame is drawn on the ImGui clock; leaving the thumbnail
returns it to the still frame-0 preview.
- A clear-on-read flag (ImagePicker::consumeAnimationActive) is OR'd into
ConsumeContactsAvatarAnimation so the render loop keeps drawing while a hover
preview plays and idles otherwise. clearThumbs frees all frame textures too.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Animate contact avatars end to end:
- texture_loader gains LoadAnimatedRGBA: decodes an image into a downscaled
RGBA frame sequence + per-frame durations — animated GIF via stb
(stbi_load_gif_from_memory) and animated WebP via libwebp's WebPAnimDecoder;
stills (and APNG, which stb reads as one image) return a single frame. Frames
are box-downscaled (smaller cap for animations) to bound VRAM, capped at 300.
- The contacts avatar cache now holds a frame sequence; currentAvatarFrame()
advances animated avatars by the ImGui clock and is used everywhere avatars
draw (list, cards, table, grid, preview). When a live animated frame is drawn
it flags the render loop (ConsumeContactsAvatarAnimation, clear-on-read) so
main.cpp keeps producing frames while animation plays and idles when it stops
or the contacts view is hidden.
- New animate_avatars setting (default on) + a Settings appearance toggle
("Animate avatars"); off shows the first frame only. currentAvatarFrame
honors it. +i18n (8 langs, CJK subset rebuilt for 帧/播/첫).
Verified: a 3-frame GIF and a 3-frame animated WebP both decode to 3 frames
with correct 120ms delays through the exact libwebp/stb calls used here.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Broaden avatar/image support:
- Add libwebp (FetchContent, static, decode-only) so WebP loads. Built from
source for Linux / mingw-Windows / macOS-osxcross identically — the cross
sysroots have no webp, so vendoring from source is the one portable path;
encode/tool builds are disabled to avoid pulling in libpng/zlib. Linked as
webp + webpdemux (the latter for animated WebP, wired next).
- texture_loader routes all decode through DecodeImageRGBA: sniffs the RIFF/
WEBP header and uses libwebp (WebPDecodeRGBAInto into a free()-able buffer),
else stb — so every existing caller (avatars, QR, thumbnails) gains WebP for
free with no allocator mismatch.
- Enable stb's TGA / PSD / PNM / PIC decoders and add .webp/.tga/.psd/.pnm/
.ppm/.pgm/.pic to the image-picker + avatar-library extension lists.
Verified: libwebp builds static and a real .webp decodes to correct RGBA.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
From the adversarial review of the image-library grid:
- MED: the delete badge was an InvisibleButton positioned via SetCursorScreenPos,
which left CursorPosPrevLine at the badge corner — the next cell's SameLine
reads that, so a hovered/selected row's trailing cells jittered ~3px and their
hit-rects overlapped. Hit-test the badge MANUALLY (no layout item, no cursor
moves); it still takes click priority over selecting the thumbnail.
- MED: avatar textures were uploaded at full native resolution and cached for the
session with no cap — a library of large photos could cost GBs of VRAM, and the
contact list decoded every image avatar at once on tab open. Box-downscale to
<=256px (an avatar renders at most ~112px) and budget decodes to a few per
frame, so large libraries fill in progressively instead of stalling.
(A third finding — a symlink edge case in the delete guard — was reviewed and
judged below the bar; the weakly_canonical + parent-path guard already holds.)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Redesign the Image avatar tab from a single-image chooser into a grid over a
persistent image library (mirrors the Icon tab):
- Images the user adds live in <config>/contact-avatars/ and PERSIST as a
reusable, portable set — they travel with the wallet data dir, so avatars
survive moving to another machine without remembering source paths.
- The grid's first cell is always the "+ add image" button (opens the picker,
decode-verifies, copies into the library, auto-selects the new image).
- Each library image is a selectable thumbnail (Primary ring when selected)
with a delete badge (top-right, red on hover) to remove it from the library;
the delete is deferred past the grid loop and clears the selection if it
pointed at the removed file. Contacts still referencing a deleted image fall
back to their Z/T badge.
- Removes the previous auto-prune of "unused" images on edit/delete — images
are only removed by the explicit delete badge now.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Compare the file's parent path to the managed dir instead of a string prefix,
so a sibling dir like contact-avatars-x can't false-match. Our avatar copies
always live directly in the dir, so this is both correct and safer.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- Table view now shows the same avatar (image / icon / Z-T badge) before each
contact's label, drawn after the row Selectable so its highlight doesn't
paint over it — visual parity with Cards/List.
- When a contact's custom image avatar is replaced (edit) or the contact is
deleted, its now-unused file in <config>/contact-avatars/ is removed
(pruneOrphanAvatar) — but only if no other contact still references it and
the path is inside our managed dir, and its cached texture is dropped too.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
From an adversarial review of the avatar edit-dialog + image picker:
- HIGH: the per-row Delete icon called doDelete() (which erases from
book.entries()) INSIDE the loop iterating that same vector — out-of-bounds
reads / wrong rows on the confirming click. Defer it until after the loop.
- HIGH: the decode stack was compiled PNG-only (STBI_ONLY_PNG) while the image
picker accepts .jpg/.jpeg/.bmp/.gif, so picking a JPEG (the common photo
case) silently produced a non-loading avatar + an orphaned copy on disk.
Enable JPEG/BMP/GIF decoders, and guard the pick: verify the source decodes
before copying/committing (new contact_avatar_bad_image string, 8 langs).
- MED: the fixed, non-scrolling edit card floored bodyH at 260*dp, which could
push Save/Cancel below the card on short windows — floor lowered so the
footer always stays inside.
- LOW: the live-preview panel rounding is now dp-scaled (10*dp) to match the
real list card it mirrors.
- LOW: re-picking the same source path after its contents changed showed a
stale cached texture — evict the avatar texture cache entry on re-pick.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Fix the thumbnail grid to a constant 6 columns whose square cells scale to the
available width, instead of fixed 96px cells that left dead space on the right.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- Smooth (lerped) wheel scrolling via ApplySmoothScroll on the list, matching
the app's other modal lists.
- The list is now a bordered/rounded outer frame whose 6px padding insets the
scrollbar so it clears the card's rounded corners; the inner scroll child is
transparent (the frame draws the single background — no more box-in-a-box
from the ChildBg being left on the stack across both BeginChild calls).
- Folders render as a 2-column grid of thin rounded rectangles (folder icon +
name) instead of full-width rows, so more folders are visible at a glance.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- Wrap the picker body in a padded inner child so the filled directory list and
thumbnail grid keep a clear margin from the card's rounded corners instead of
running edge-to-edge past them.
- Center the Use image / Cancel buttons and drop the stray separator line above
them (the faint artifact at the footer's left edge).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Address five reported issues:
- Address field is now full width with a centered Paste button beneath it
(was a narrow field with Paste crammed alongside).
- The pinned "Show in every wallet" checkbox no longer clips at the column
bottom — the Notes fill reserves a clear margin for it.
- Image mode: more spacing between the preview circle and the Choose/Remove
row so the button isn't crowding the avatar.
- Contact list: hovering a row now un-collapses the address to its full form
inline (clipped to the text column) instead of popping a tooltip.
- Per-row copy/edit/delete icons fire on the first click on an unselected row:
the action lambdas validate s_selected_index freshly (via selValid())
instead of the frame-top has_selection bool, which was stale in the same
frame the icon set the selection.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The dialog was a squat auto-height box with a big dead zone below and an icon
grid that clipped at ~3 rows. Make it a fixed, tall card (up to 84% of the
viewport) whose body flexes to fill the height:
- The icon grid grows into the space — ~8–9 rows visible instead of 3.
- Notes expands to fill the left column above the now bottom-pinned Global
checkbox, so the left side uses the height too.
- The image-mode preview circle is bigger (r44 → r56) and vertically centered,
with glyphs scaled to it.
- Footer pins to the bottom; Save/Add is now accent-colored so the primary
action reads above Cancel.
Notes/actionButton schema lookups that only fed the old fixed heights are gone.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Four refinements now that the two-column layout has room:
1. Image mode shows a large circular preview of the chosen picture (or a
placeholder circle with an add-photo glyph, or a broken-image glyph if the
file went missing), the filename, and centered Choose/Remove buttons —
instead of a bare button.
2. The live-preview avatar is larger (r20 → r26) and the address is now
middle-truncated (head + tail) so both ends read, like the real list row.
3. Badge mode shows the two actual chips — Z (shielded) and T (transparent) —
with labels, making clear the badge is auto-picked from the address type,
rather than a line of text.
4. The Badge/Icon/Image segmented control gains glyphs (badge / palette /
image) beside its labels via a small two-font inline control (the shared
SegmentedControl helper is single-font).
Adds contact_avatar_shielded / _transparent keys and rewords the badge hint
(the chips now carry the Z/T meaning); +8-language translations, all within
the existing CJK subset.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The edit dialog was a tall, narrow single column that wasted ~half the
horizontal space and cramped the icon grid into ~2.5 clipped rows crowding
the footer. Rework it into the portfolio-editor two-column shape:
- Card widened 560 → 880 logical.
- Full-width live preview stays on top (now shows more of the address).
- Body is two fixed-height columns: form (label / address+paste / notes /
global) on the left, avatar picker (segmented + icon grid / image / badge
hint) on the right, each filling its column so the grid gets ~6 columns and
~4 rows instead of clipping mid-row.
- Overall modal is much shorter, so Save/Cancel no longer crowd the grid.
- Badge-mode hint re-centered relative to the current cursor (it now sits
below the segmented control inside the shared column, not a fresh child).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The revamped edit dialog derives its input widths from the card content
width, so the address-input schema config is no longer read.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Rebuild the add/edit contact dialog on the portfolio-editor design language:
- A live preview card at the top shows the contact exactly as it renders in the
list (avatar + name + address), updating as you type and pick an avatar.
- An avatar picker (Badge / Icon / Image segmented control) lets you keep the
default Z/T type badge, choose a Material wallet icon from a searchable grid,
or set a custom image. The picker area is fixed-height so the modal doesn't
jump when switching modes.
- Custom images go through a new in-app ImagePicker (image_picker.h): a
Material overlay that browses the filesystem starting at the user's Pictures
folder, shows a thumbnail grid (decoded to raw pixels, box-downscaled to a
small texture, cached per directory and freed on navigate/close, budgeted a
few decodes per frame so large folders don't hitch), and returns the chosen
path. The chosen image is copied into <config>/contact-avatars/ (named by an
FNV hash of the source path, so re-picking is idempotent) and stored as
"img:<path>". Like FolderPicker, it takes over the modal surface while open.
- Paste is now available on both add and edit; buttons are content-sized.
Adds three sweep surfaces (contacts-edit-{icon,badge,image}) that open the
dialog on a seeded contact in each avatar mode, plus i18n keys (+ 8-language
translations; reworded two zh/ja strings to stay within the existing CJK
subset, so no font rebuild).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Add an `avatar` field to AddressBookEntry ("" = default Z/T type badge,
"icon:<name>" = a Material wallet-icon, "img:<path>" = a custom image),
serialized additively in addressbook.json (only written when non-empty, so
existing books are untouched).
Render it in the Cards/List views via a new drawContactAvatar helper: custom
images are loaded once through a path-keyed texture cache and drawn
circular-cropped (centre-cropped UVs + a thin border ring); icons reuse the
project-icon set (incl. the special pickaxe font path) in a tinted circle;
everything else falls back to the existing Z/T badge (also the fallback when
an image fails to load or an icon name is unknown).
Seed one sweep contact with an icon avatar to exercise the icon-badge path.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Three interaction refinements to the address list:
- The globe badge now stays pinned far-right; the per-row copy/edit/delete actions
appear to its LEFT on hover/selection instead of replacing it.
- A left-click on empty space in the Cards/List area clears the current selection
(no row/action hovered -> deselect).
- Right-clicking a row (any view) selects it and opens a shared context menu
(Copy address / Edit / Delete), rendered once after the list.
Build + hygiene clean.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Two follow-ups from the contacts audit:
- Per-row actions: in the Cards/List views, copy/edit/delete icon buttons now appear
on the right of a row on hover or selection (the globe badge shows otherwise). The
row Selectable uses SetNextItemAllowOverlap so the action InvisibleButtons take
click priority; whole-row hover (IsMouseHoveringRect) drives the highlight so it
survives hovering an icon; the delete icon turns red while armed (two-click confirm);
each has a tooltip. Trailing space is reserved so the text never reflows on hover,
and the cursor is restored after the manual action layout.
- Add/edit modal HiDPI: the Layout::kDialog* helpers fold dpiScale() (physical px)
while raw schema widths are logical, so the schema-path formW/actionW/actionGap/
notesH were unscaled vs their scaled fallbacks. Scale the schema-path values; and
since BeginOverlayDialog re-applies dpiScale to cardWidth, divide the already-scaled
dialogW back out (it was double-scaled). No visible change at 100%; correct at 150%.
Build + ctest + hygiene clean.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Add three sweep surfaces (contacts-cards / -list / -table) that force each Contacts
address-list view mode and seed a few demo contacts (Z + T types, some global) so
the modes render with data instead of the empty state. Teardown restores the real
book and resets the mode.
To avoid touching the user's persisted address book (the Windows sweep can run on
the real HOME, and addEntry/removeEntry call save()), add AddressBook::sweepSetEntries
— a no-save in-memory setter used only here: the surface snapshots the real entries,
swaps in the demo set, and restores the snapshot on teardown, so nothing is written
to disk even if the sweep is interrupted.
Build + hygiene clean.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Add a persisted view toggle so the contacts address list can be rendered three
ways, moving it away from a bare data-grid toward a Material look:
- Cards: tactile rounded cards with a circular Z/T type-avatar, label over a muted
truncated address, trailing globe badge, primary-tint + outline selection, hover
fill, and a centred Material empty state.
- List: borderless two-line rows (same avatar + label/address) with hover tint and a
thin per-row divider — denser than cards.
- Table: the previous 3-column table, material-ized (grid borders dropped, row
backgrounds + interactive sort kept).
The toggle is an icon SegmentedControl (view-agenda / view-list / table-rows)
right-aligned on the toolbar; the choice persists via a new contacts_view_mode_
setting (0 cards / 1 list / 2 table, mirroring portfolio_style_). Cards/List sort by
label; Table keeps its sortable headers. All geometry is dpiScale()-aware; the globe
badge is drawn in the icon font in every mode. Default is Cards.
Build + ctest + hygiene clean.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Address the confirmed findings from a workflow audit of the Contacts tab body:
- Label clip: the Label column was WidthFixed 150px and clipped long labels
mid-word ("drgx pool payout a…"). Make all three columns WidthStretch (label 1.5,
address 2.6, notes 1.0) so the label grows with the tab and the notes column no
longer reserves a fixed empty block on the right (notes = low weight, per request).
- Globe badge tofu: the global-contact ICON_MD_PUBLIC badge was drawn with the text
font (no Material glyphs) → rendered as "?". Push Type().iconSmall() around it.
- Plural: "1 addresses saved" -> add address_book_count_one ("%zu address saved",
8 langs, %zu kept so the format signature matches) and branch on count == 1.
- DPI: the SameLine badge gaps (6/8px) and the table-height floor (120px) are now
* Layout::dpiScale(); the WidthStretch columns are relative so need no scaling.
- Toolbar: give the four actions leading Material icons (person-add / edit / delete /
content-copy) and accent the primary "Add New" — via a small local icon+label
tactile-button helper (ImGui has no two-font button), icons inherit the
disabled-aware text alpha so they gray out with BeginDisabled().
Build + ctest + hygiene clean.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The QR popup used the same window-relative card width the Export-Key dialog just
moved away from (its comment even said "Match the key-export modal: 85% of the
window width"). On a large monitor that left the centered AddressCopyField floating
far from its left-aligned "Address:" label and inflated the QR's empty margins.
Derive the card width from the address field's own natural (chunked) box width
(mirroring the key-export fix, incl. the overlay card's 28px content inset so the
address stays on one line), bounded to 85% of the window as an upper limit. Also
scale the responsive-QR size literals (280 fallback / 420 cap) by dpiScale() so the
QR renders at the intended size on HiDPI/font-scale. No change to the QR texture /
clipboard logic.
Found via a workflow audit of all 27 redesigned modals for the same anti-pattern —
the QR popup was the only other occurrence (0 false positives).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The Export-Key dialog sized its card to a fixed 85% of the window width. On a large
monitor that's ~1570px, but the content is ~900px, and AddressCopyField centers its
text-clamped box within the available width — so the address/key fields floated far
from their left-aligned "Address:"/"Viewing Key:" labels and read as off-center
(more visible after the warning box became a left-aligned DialogWarningHeader).
Derive the card width from the address field's own natural (chunked) box width —
the widest deterministic element before the key is revealed — mirroring
AddressCopyField's boxW math, plus the overlay card's 28px-per-side content inset so
the address sits on one line under its label. Adapts to address type (z vs t) and
font scale with no magic width; bounded to 85% of the window as an upper safety
limit; the revealed key+QR layout adapts to whatever width results. Width-only
change — the key fetch/wipe/mask/close logic is untouched.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Register sweep surfaces for the redesigned Wave-2 modals so they get captured for
visual review: modal-shield + modal-merge (ShieldDialog's two modes),
modal-transfer (a z->t transfer so the converted deshielding DialogWarningHeader
renders), and modal-key-export (KeyExportDialog — named distinctly from the
settings modal-export-key). Each setup opens the dialog through its public show()
without clicking any button, so no async RPC fires; teardown closes it.
Adds a static hide() to ShieldDialog and KeyExportDialog for clean sweep teardown
(AddressTransferDialog already had close()). KeyExportDialog::hide() also clears the
revealed key + frees its QR, mirroring the dialog's own close/dismiss secret-wipe.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Presentation-only migration of KeyExportDialog::render (reveals a private or
viewing key) — a secret-handling modal:
- struct-form BeginOverlayDialog(OverlayDialogSpec{BlurFloat, cardWidth cardW,
idSuffix keyexport}) replacing the legacy positional overload
- replace the bespoke red-bordered "WARNING" box with material::DialogWarningHeader
(user-approved) so the key-export warning matches the import-key reference; passes
the correct per-key-type body key (private vs viewing)
- remove the two dividers (after the warning, above the Close footer)
- 4 StyledButton -> TactileButton (retry / reveal / show-hide / close)
The secret path is byte-for-byte unchanged: the z_exportkey/dumpprivkey/
z_exportviewingkey (and lite exportPrivateKeys) fetch + their secureWipeLiteSecret
calls, the "*"-masking when hidden, the Show/Hide toggle, copySecretToClipboard
(auto-clearing), the QR generate/cache/releaseQr, and the secret-clear on BOTH exit
paths (Close button + the post-EndOverlayDialog dismiss teardown).
Verified: build + ctest + hygiene clean; a 2-lens adversarial review
(secret-hygiene / presentation-fund) returned zero findings, proving every
secret-handling path diffs byte-for-byte identical to HEAD.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Presentation-only migration of AddressTransferDialog::render (drag-one-address-
onto-another transfer) — a fund-moving modal:
- struct-form BeginOverlayDialog(OverlayDialogSpec{BlurFloat, cardWidth 620,
idSuffix addrtransfer}) replacing the legacy positional overload
- the top-of-dialog deshielding warning -> material::DialogWarningHeader; the green
shielding notice stays colored text (it's a positive notice, not a warning)
- remove the footer divider and drop its "+1.0f" footerH reserve term (keep the
form->result-preview divider)
- reuse the existing shield_operation_id key for the "Operation ID: %s" label (no
new i18n key)
The fund path is byte-for-byte unchanged: the amountValid guard, totalDeduct/
newFromBal/newToBal math, maxSendableAmount, the Max button, s_app->sendTransaction
+ its ok/fail callback, and the BeginDisabled(!amountValid || s_sending) gate.
Verified: build + ctest + hygiene clean; a 2-lens adversarial review returned zero
findings, proving the fund path diffs byte-for-byte identical to HEAD.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Presentation-only migration of ShieldDialog::render (Shield coinbase / Merge funds)
— a fund-moving modal:
- struct-form BeginOverlayDialog(OverlayDialogSpec{BlurFloat, cardWidth win.width,
idSuffix shielddialog}) replacing the legacy positional overload
- drop the description divider and the form->footer divider (keep the post-submit
operation-id section divider)
- 3 StyledButton -> TactileButton (submit / cancel / check-status)
- TR() the hardcoded "No shielded (z) address yet …" note -> new shield_no_zaddr_hint
key, 8 langs (em-dash preserved); the "DRGX" ticker stays hardcoded per convention
The fund path is byte-for-byte unchanged: the can_submit guard, fee/utxo clamps, the
"ANY_TADDR" setup, the z_shieldcoinbase / z_mergetoaddress worker->post calls + their
opid/status/Notifications/trackOperation callbacks, and the BeginDisabled/tooltip logic.
Verified: build + ctest + hygiene clean; a 2-lens adversarial review
(fund-path-untouched / presentation-i18n) returned zero findings, proving the fund
path diffs byte-for-byte identical to HEAD via isolation diffs.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Presentation-only migration of RenderSendConfirmPopup (the confirm-before-broadcast
popup) — a fund-moving modal:
- struct-form BeginOverlayDialog(OverlayDialogSpec{BlurFloat, cardWidth popupW,
idSuffix confirmsend}) replacing the legacy positional overload; p_open stays
nullptr (the popup keeps its own Escape + Cancel handling)
- TR() the one hardcoded status string in the synchronous live-revalidation guard
("Cannot send now …") -> new send_cannot_send_now key, 8 langs (em-dash preserved)
The fund path is byte-for-byte unchanged: the live re-validation guard, the
app->sendTransaction call + its success/failure callback, the lite-locked
requestLiteUnlock branch, the s_sending/s_show_confirm state, the Escape handler,
and the FROM/TO cards + fee-tier/summary rendering.
Verified: build + ctest + hygiene clean; a 2-lens adversarial review
(fund-path-untouched / ui-i18n) returned zero findings, with the fund-path lens
proving the send path diffs byte-for-byte identical to HEAD.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>