42 Commits

Author SHA1 Message Date
3465acc57b fix(chat): session-guard the fast-scan callback (stale-wallet ingest)
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>
2026-07-17 00:50:58 -05:00
30bd0d99ff feat(chat): 0-conf fast-scan so incoming messages show before a block
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>
2026-07-17 00:38:41 -05:00
29fbe46cce fix(chat): un-hide on lite receives + scope the contact picker per wallet
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>
2026-07-16 23:36:14 -05:00
fc414eeed4 feat(chat): hide conversations + contact address picker for new conversation
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>
2026-07-16 23:26:04 -05:00
fce873936e fix(chat): demo identity must not clobber a real one or be a shared constant
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>
2026-07-16 22:41:43 -05:00
7351d8a06d fix(chat): stop our own sent messages appearing as incoming duplicates
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>
2026-07-16 21:21:41 -05:00
76093fe82d fix(chat): address adversarial review of the send-path change
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>
2026-07-16 20:42:59 -05:00
ef247c95ff feat(chat): fee floor, real delivery status, pay-from-funded, funds pre-check
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>
2026-07-16 20:27:43 -05:00
c291e8a587 fix(app): use #if not #ifdef for the per-variant instance lock
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>
2026-07-16 19:08:41 -05:00
06e55d6394 feat(app): per-variant single-instance lock so full node + Lite can run together
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>
2026-07-16 18:42:10 -05:00
2072f70a60 fix(contacts): stable per-wallet scope so contacts don't vanish (address-hash drift)
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>
2026-07-16 18:27:23 -05:00
ee9ea15233 fix(rpc,ui): close the last secret residues found by the final-gate review
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>
2026-07-16 17:41:24 -05:00
11de117331 feat(chat): render emoji in messages and user text (Q12)
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>
2026-07-16 17:28:50 -05:00
fac0245297 fix(chat,rpc): address adversarial review of the chat backlog
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>
2026-07-16 17:11:31 -05:00
9cb91415d9 i18n(chat): translate the new chat-backlog strings (8 languages)
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>
2026-07-16 16:53:48 -05:00
24c661f743 feat(chat): mute conversations (Q10)
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>
2026-07-16 16:48:57 -05:00
fecc9015fb feat(rpc): scrub the raw response body for secret-bearing calls (B7)
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>
2026-07-16 16:45:41 -05:00
bce69362eb feat(chat): message bubbles, empty states, search, export, richer composer
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>
2026-07-16 16:41:00 -05:00
7f46d9e2d5 fix(chat): harden reply-target + scrub legacy secret (B2/B4)
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>
2026-07-16 16:07:12 -05:00
100ed01469 feat(chat): unread count + sidebar badge (Q1)
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>
2026-07-16 15:56:58 -05:00
055685a4c4 feat(chat): copy, add-to-contacts, and new-message toast (Q2/Q3/Q4)
- 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>
2026-07-16 15:52:12 -05:00
46f93e380b feat(chat): house-style polish — modal, tactile rows + avatars, DPI
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>
2026-07-16 15:46:42 -05:00
6f6ad89c9f fix(chat): wipe typed plaintext on a wallet switch + per-thread draft
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>
2026-07-16 15:40:19 -05:00
d9911a9bc9 feat(wallet): detect a corrupt target wallet on switch + offer -salvagewallet repair
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>
2026-07-15 23:40:33 -05:00
faf77de9ce fix(console): stop the Clear button crashing the tab
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>
2026-07-15 23:09:04 -05:00
7f414b3dcf fix(wallet): reliable process detection + drop the harmful start-retry
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>
2026-07-15 22:53:05 -05:00
994ddea6cd feat(wallet): richer detail in the wallet-switch progress modal
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>
2026-07-15 22:11:53 -05:00
41e4e73e63 fix(wallet): wait for IPv6 port + retry start so a switch survives the DB-env race
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>
2026-07-15 21:45:20 -05:00
88091bd77c feat(wallet): live progress modal for wallet switching
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>
2026-07-15 20:56:53 -05:00
02839dc415 fix(wallet): wait for the old node to fully exit before restarting on switch
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>
2026-07-15 20:27:46 -05:00
17b70a1388 fix(wallet): route switch stop by process-handle ownership, not the external latch
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>
2026-07-15 19:46:11 -05:00
42d59709f9 feat(wallet): confirm before stopping an adopted node to switch wallets
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>
2026-07-15 19:17:08 -05:00
30bc80b2f2 fix(seed): stop an adopted daemon before the migrate-to-seed swap
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>
2026-07-15 19:00:21 -05:00
3808b3ee82 fix(wallet): let a switch restart an adopted (external) daemon
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>
2026-07-15 18:20:50 -05:00
bb5124c1ae feat(wallets): show "HD wallet" instead of "?" when the seed flag is unreadable
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>
2026-07-15 16:47:17 -05:00
e6c78da062 fix(wallets): detect seed-phrase wallets offline via hdchain fMnemonicSeed
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>
2026-07-15 16:24:06 -05:00
5874142186 fix(wallets): don't mislabel a legacy HD wallet as "Seed phrase"
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>
2026-07-15 16:02:50 -05:00
928335dd6b fix(wallet-switch): late-fail revert, shutdown-freeze, per-wallet PIN gate
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>
2026-07-15 15:41:03 -05:00
63dcda0ad1 fix(wallet-switch): close gaps found verifying clusters A/B/C
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>
2026-07-15 13:06:56 -05:00
122db9d903 fix(wallet-switch): cluster C — no mis-scoping in the unknown-identity window
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>
2026-07-15 12:45:18 -05:00
fc509a9340 fix(wallet-switch): cluster B — daemon-switch robustness
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>
2026-07-15 12:37:11 -05:00
f546d3e2b1 fix(wallet-switch): cluster A — identity/secret teardown on wallet switch
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>
2026-07-15 12:29:45 -05:00
56 changed files with 2542 additions and 300 deletions

View File

@@ -67,7 +67,8 @@
//#define IMGUI_USE_LEGACY_CRC32_ADLER
//---- Use 32-bit for ImWchar (default is 16-bit) to support Unicode planes 1-16. (e.g. point beyond 0xFFFF like emoticons, dingbats, symbols, shapes, ancient languages, etc...)
//#define IMGUI_USE_WCHAR32
//---- Enabled so chat can render emoji (U+1F300+, above the BMP) — see Typography::loadFont emoji merge (Q12).
#define IMGUI_USE_WCHAR32
//---- Avoid multiple STB libraries implementations, or redefine path/filenames to prioritize another version
// By default the embedded implementations are declared static and not available outside of Dear ImGui sources files.

Binary file not shown.

Binary file not shown.

View File

@@ -135,25 +135,48 @@
"change_pass_title": "Passphrase ändern",
"characters": "Zeichen",
"chat": "Chat",
"chat_add_contact": "Kontakt hinzufügen",
"chat_cancel": "Abbrechen",
"chat_contact_added": "Kontakt hinzugefügt benenne ihn in Kontakte um",
"chat_contact_request": "kontaktanfrage",
"chat_empty_hint": "Noch keine Unterhaltungen. Nachrichten, die du erhältst, erscheinen hier.",
"chat_empty_start": "Starte eine mit \"Neue Unterhaltung\".",
"chat_empty_title": "Noch keine Unterhaltungen",
"chat_export": "Chat exportieren…",
"chat_export_done": "Unterhaltung exportiert",
"chat_export_failed": "Exportdatei konnte nicht geschrieben werden.",
"chat_export_warn": "Speichert die entschlüsselten Nachrichten als Klartext. Bewahre die Datei sicher auf.",
"chat_hidden_toast": "Unterhaltung ausgeblendet eine neue Nachricht holt sie zurück",
"chat_hide": "Ausblenden",
"chat_jump_latest": "Neueste",
"chat_len_over": "Nachricht zu lang",
"chat_locked_hint": "Entsperre deine Wallet, um deine Chats zu laden.",
"chat_mute": "Stummschalten",
"chat_new_button": "Neue Unterhaltung",
"chat_new_message": "Nachricht",
"chat_new_message_toast": "Neue verschlüsselte Chat-Nachricht",
"chat_new_send": "Anfrage senden",
"chat_new_title": "Neue Unterhaltung",
"chat_new_zaddr": "z-Adresse des Empfängers",
"chat_no_matches": "Keine Unterhaltungen entsprechen deiner Suche.",
"chat_no_z_contacts": "Noch keine Kontakte mit geschützter Adresse",
"chat_pick_contact": "Aus Kontakten wählen…",
"chat_retry": "Wiederholen",
"chat_search": "Unterhaltungen durchsuchen",
"chat_select_hint": "Wähle eine Unterhaltung aus, um sie anzuzeigen.",
"chat_send": "Senden",
"chat_send_failed": "nicht gesendet",
"chat_sending": "senden…",
"chat_time_now": "jetzt",
"chat_toast_compose_failed": "Nachricht konnte nicht erstellt werden (zu lang?).",
"chat_toast_lite_busy": "Es wird bereits gesendet, oder es ist keine Wallet geöffnet.",
"chat_toast_need_funds": "Ein kleines geschütztes Guthaben ist nötig, um Chats zu senden (zur Deckung der Gebühr).",
"chat_toast_no_zaddr": "Keine z-Adresse verfügbar, um den Chat zu senden.",
"chat_toast_not_connected": "Nicht verbunden Nachricht nicht gesendet.",
"chat_toast_request_compose_failed": "Kontaktanfrage konnte nicht erstellt werden (ungültige Adresse / ungültiger Text?).",
"chat_toast_request_queued": "Kontaktanfrage in Warteschlange.",
"chat_toast_waiting_reply": "Warte auf die Antwort des Kontakts, bevor du ihm schreiben kannst.",
"chat_unmute": "Stummschaltung aufheben",
"chat_waiting_reply": "Warte auf die Antwort dieses Kontakts sobald er antwortet, kannst du ihm schreiben.",
"chat_you": "Du",
"choose_icon": "Symbol wählen",
@@ -305,6 +328,7 @@
"contact_global_tt": "Ein: Dieser Kontakt bleibt sichtbar, egal welche Wallet Sie laden. Aus: Er gehört nur zur aktuellen Wallet.",
"contact_preview_addr": "Adresse erscheint hier",
"contact_preview_name": "Kontaktname",
"contact_wallet_loading": "Die Wallet lädt noch — aktiviere „In jeder Wallet anzeigen“ oder versuche es gleich erneut.",
"contacts": "Kontakte",
"contacts_search_no_match": "Keine passenden Kontakte",
"contacts_search_placeholder": "Kontakte durchsuchen...",
@@ -977,6 +1001,7 @@
"portfolio_style_featured": "Hervorgehobene Zeilen",
"portfolio_style_label": "Portfolio-Stil",
"portfolio_untitled": "Ohne Titel",
"portfolio_wallet_loading": "Warte, bis die Wallet fertig geladen ist, um eine Gruppe hinzuzufügen.",
"price_chart": "Preisdiagramm",
"privacy_great": "Großartige Privatsphäre!",
"privacy_low": "Geringe Privatsphäre — Gelder abschirmen",
@@ -1292,6 +1317,23 @@
"sweep_to": "Gefegt an:",
"sweep_toggle": "In meine Wallet fegen (Schlüssel nicht behalten)",
"sweep_tx": "Transaktion:",
"switch_corrupt_body": "Diese Wallet scheint beschädigt zu sein der Knoten konnte sie nicht öffnen. Aus einem Backup wiederherstellen, neu erstellen oder eine Reparatur versuchen.",
"switch_corrupt_repair": "Reparatur versuchen (Salvage)",
"switch_progress_background": "Im Hintergrund fortsetzen",
"switch_progress_default_wallet": "Standard-Wallet",
"switch_progress_elapsed": "Vergangen",
"switch_progress_external_wallet": "Externe Wallet",
"switch_progress_failed_title": "Wallet-Wechsel fehlgeschlagen",
"switch_progress_from_label": "von",
"switch_progress_hint": "Ein sauberes Herunterfahren kann bis zu einer Minute dauern.",
"switch_progress_reconnecting": "Neu verbinden",
"switch_progress_starting": "Knoten wird mit der neuen Wallet gestartet",
"switch_progress_stopping": "Aktueller Knoten wird gestoppt",
"switch_progress_title": "Wallet wird gewechselt",
"switch_stopnode_body": "Beim Wallet-Wechsel wird der Knoten mit der ausgewählten Wallet neu gestartet. Der laufende Knoten wird gestoppt und mit der neuen Wallet neu gestartet wenn Sie ihn absichtlich laufen ließen, startet er automatisch wieder.",
"switch_stopnode_confirm": "Knoten stoppen & wechseln",
"switch_stopnode_title": "Laufenden Knoten stoppen?",
"switch_stopnode_warn": "Es läuft bereits ein Knoten, den diese Wallet nicht gestartet hat.",
"syncing": "Synchronisiere...",
"t_address": "T-Adresse",
"t_addresses": "T-Adressen",
@@ -1472,6 +1514,8 @@
"wallets_add_folder_toggle": "+ Weiteren Ordner nach Wallets durchsuchen…",
"wallets_badge_encrypted": "Verschlüsselt (passphrasengeschützt)",
"wallets_badge_encrypted_short": "Verschlüsselt",
"wallets_badge_hd": "HD-Wallet Seed-Phrase ohne Öffnen nicht bestätigbar",
"wallets_badge_hd_short": "HD-Wallet",
"wallets_badge_legacy": "Legacy-Wallet (keine Seed-Phrase)",
"wallets_badge_legacy_short": "Legacy",
"wallets_badge_seed": "Seed-Phrase-Wallet (HD)",

View File

@@ -135,25 +135,48 @@
"change_pass_title": "Cambiar frase de contraseña",
"characters": "caracteres",
"chat": "Chat",
"chat_add_contact": "Añadir contacto",
"chat_cancel": "Cancelar",
"chat_contact_added": "Contacto añadido: renómbralo en Contactos",
"chat_contact_request": "solicitud de contacto",
"chat_empty_hint": "Aún no hay conversaciones. Los mensajes que recibas aparecerán aquí.",
"chat_empty_start": "Inicia una con \"Nueva conversación\".",
"chat_empty_title": "Aún no hay conversaciones",
"chat_export": "Exportar chat…",
"chat_export_done": "Conversación exportada",
"chat_export_failed": "No se pudo escribir el archivo de exportación.",
"chat_export_warn": "Guarda los mensajes descifrados como texto sin cifrar. Guarda el archivo de forma segura.",
"chat_hidden_toast": "Conversación oculta: un mensaje nuevo la recupera",
"chat_hide": "Ocultar",
"chat_jump_latest": "Recientes",
"chat_len_over": "Mensaje demasiado largo",
"chat_locked_hint": "Desbloquea tu monedero para cargar tus chats.",
"chat_mute": "Silenciar",
"chat_new_button": "Nueva conversación",
"chat_new_message": "Mensaje",
"chat_new_message_toast": "Nuevo mensaje de chat cifrado",
"chat_new_send": "Enviar solicitud",
"chat_new_title": "Nueva conversación",
"chat_new_zaddr": "Dirección z del destinatario",
"chat_no_matches": "Ninguna conversación coincide con tu búsqueda.",
"chat_no_z_contacts": "Aún no hay contactos con dirección blindada",
"chat_pick_contact": "Elegir de contactos…",
"chat_retry": "Reintentar",
"chat_search": "Buscar conversaciones",
"chat_select_hint": "Selecciona una conversación para verla.",
"chat_send": "Enviar",
"chat_send_failed": "no enviado",
"chat_sending": "enviando…",
"chat_time_now": "ahora",
"chat_toast_compose_failed": "No se pudo componer el mensaje (¿demasiado largo?).",
"chat_toast_lite_busy": "Ya hay un envío en curso, o no hay ningún monedero abierto.",
"chat_toast_need_funds": "Necesitas un pequeño saldo blindado para enviar chats (para cubrir la comisión).",
"chat_toast_no_zaddr": "No hay ninguna dirección z disponible desde la que enviar el chat.",
"chat_toast_not_connected": "Sin conexión: mensaje de chat no enviado.",
"chat_toast_request_compose_failed": "No se pudo componer la solicitud de contacto (¿dirección o texto no válidos?).",
"chat_toast_request_queued": "Solicitud de contacto en cola.",
"chat_toast_waiting_reply": "Espera a que este contacto responda antes de poder escribirle.",
"chat_unmute": "Reactivar",
"chat_waiting_reply": "Esperando a que este contacto responda: podrás escribirle una vez lo haga.",
"chat_you": "Tú",
"choose_icon": "Elegir Icono",
@@ -305,6 +328,7 @@
"contact_global_tt": "Activado: este contacto permanece visible sin importar qué cartera cargues. Desactivado: pertenece solo a la cartera actual.",
"contact_preview_addr": "La dirección aparecerá aquí",
"contact_preview_name": "Nombre del contacto",
"contact_wallet_loading": "La cartera aún se está cargando: marca «Mostrar en todas las carteras» o inténtalo de nuevo en un momento.",
"contacts": "Contactos",
"contacts_search_no_match": "No hay contactos coincidentes",
"contacts_search_placeholder": "Buscar contactos...",
@@ -977,6 +1001,7 @@
"portfolio_style_featured": "Filas destacadas",
"portfolio_style_label": "Estilo de cartera",
"portfolio_untitled": "Sin título",
"portfolio_wallet_loading": "Espera a que la cartera termine de cargar para añadir un grupo.",
"price_chart": "Gráfico de Precios",
"privacy_great": "¡Excelente privacidad!",
"privacy_low": "Privacidad baja: protege los fondos",
@@ -1292,6 +1317,23 @@
"sweep_to": "Barrido a:",
"sweep_toggle": "Barrer a mi monedero (no conservar la clave)",
"sweep_tx": "Transacción:",
"switch_corrupt_body": "Esta cartera parece dañada: el nodo no pudo abrirla. Restáurala desde una copia de seguridad, vuelve a crearla o intenta repararla.",
"switch_corrupt_repair": "Intentar reparar (salvage)",
"switch_progress_background": "Continuar en segundo plano",
"switch_progress_default_wallet": "Cartera predeterminada",
"switch_progress_elapsed": "Transcurrido",
"switch_progress_external_wallet": "Cartera externa",
"switch_progress_failed_title": "Error al cambiar de cartera",
"switch_progress_from_label": "desde",
"switch_progress_hint": "Un apagado ordenado puede tardar hasta un minuto.",
"switch_progress_reconnecting": "Reconectando",
"switch_progress_starting": "Iniciando el nodo con la nueva cartera",
"switch_progress_stopping": "Deteniendo el nodo actual",
"switch_progress_title": "Cambiando de cartera",
"switch_stopnode_body": "Cambiar de cartera reinicia el nodo con la cartera seleccionada. El nodo en ejecución se detendrá y se reiniciará con la nueva cartera; si lo dejaste en marcha a propósito, volverá a iniciarse automáticamente.",
"switch_stopnode_confirm": "Detener nodo y cambiar",
"switch_stopnode_title": "¿Detener el nodo en ejecución?",
"switch_stopnode_warn": "Ya hay un nodo en ejecución que esta cartera no inició.",
"syncing": "Sincronizando...",
"t_address": "Dirección T",
"t_addresses": "Direcciones T",
@@ -1472,6 +1514,8 @@
"wallets_add_folder_toggle": "+ Buscar wallets en otra carpeta…",
"wallets_badge_encrypted": "Cifrada (protegida con contraseña)",
"wallets_badge_encrypted_short": "Cifrada",
"wallets_badge_hd": "Cartera HD: no se puede confirmar la frase semilla sin abrirla",
"wallets_badge_hd_short": "Cartera HD",
"wallets_badge_legacy": "Billetera heredada (sin frase semilla)",
"wallets_badge_legacy_short": "Heredada",
"wallets_badge_seed": "Billetera con frase semilla (HD)",

View File

@@ -135,25 +135,48 @@
"change_pass_title": "Changer la phrase secrète",
"characters": "caractères",
"chat": "Discussion",
"chat_add_contact": "Ajouter un contact",
"chat_cancel": "Annuler",
"chat_contact_added": "Contact ajouté — renommez-le dans Contacts",
"chat_contact_request": "demande de contact",
"chat_empty_hint": "Aucune conversation pour l'instant. Les messages que vous recevez apparaîtront ici.",
"chat_empty_start": "Commencez-en une avec « Nouvelle conversation ».",
"chat_empty_title": "Aucune conversation pour l'instant",
"chat_export": "Exporter le chat…",
"chat_export_done": "Conversation exportée",
"chat_export_failed": "Impossible d'écrire le fichier d'exportation.",
"chat_export_warn": "Enregistre les messages déchiffrés en texte clair. Conservez le fichier en lieu sûr.",
"chat_hidden_toast": "Conversation masquée — un nouveau message la fait réapparaître",
"chat_hide": "Masquer",
"chat_jump_latest": "Récents",
"chat_len_over": "Message trop long",
"chat_locked_hint": "Déverrouillez votre portefeuille pour charger vos discussions.",
"chat_mute": "Muet",
"chat_new_button": "Nouvelle conversation",
"chat_new_message": "Message",
"chat_new_message_toast": "Nouveau message chiffré",
"chat_new_send": "Envoyer la demande",
"chat_new_title": "Nouvelle conversation",
"chat_new_zaddr": "Adresse Z du destinataire",
"chat_no_matches": "Aucune conversation ne correspond à votre recherche.",
"chat_no_z_contacts": "Aucun contact avec adresse blindée pour l'instant",
"chat_pick_contact": "Choisir dans les contacts…",
"chat_retry": "Réessayer",
"chat_search": "Rechercher des conversations",
"chat_select_hint": "Sélectionnez une conversation pour l'afficher.",
"chat_send": "Envoyer",
"chat_send_failed": "non envoyé",
"chat_sending": "envoi…",
"chat_time_now": "à l'instant",
"chat_toast_compose_failed": "Impossible de composer le message (trop long ?).",
"chat_toast_lite_busy": "Un envoi est déjà en cours, ou aucun portefeuille n'est ouvert.",
"chat_toast_need_funds": "Un petit solde blindé est nécessaire pour envoyer des messages (pour couvrir les frais).",
"chat_toast_no_zaddr": "Aucune adresse Z disponible pour envoyer le message.",
"chat_toast_not_connected": "Non connecté — message non envoyé.",
"chat_toast_request_compose_failed": "Impossible de composer la demande de contact (adresse / texte invalide ?).",
"chat_toast_request_queued": "Demande de contact mise en file d'attente.",
"chat_toast_waiting_reply": "En attente de la réponse de ce contact avant de pouvoir lui écrire.",
"chat_unmute": "Réactiver",
"chat_waiting_reply": "En attente de la réponse de ce contact — vous pourrez lui écrire dès qu'il aura répondu.",
"chat_you": "Vous",
"choose_icon": "Choisir une icône",
@@ -305,6 +328,7 @@
"contact_global_tt": "Activé : ce contact reste visible quel que soit le portefeuille chargé. Désactivé : il appartient au portefeuille actuel uniquement.",
"contact_preview_addr": "L'adresse apparaîtra ici",
"contact_preview_name": "Nom du contact",
"contact_wallet_loading": "Le portefeuille se charge encore — cochez « Afficher dans chaque portefeuille » ou réessayez dans un instant.",
"contacts": "Contacts",
"contacts_search_no_match": "Aucun contact correspondant",
"contacts_search_placeholder": "Rechercher des contacts...",
@@ -977,6 +1001,7 @@
"portfolio_style_featured": "Lignes en vedette",
"portfolio_style_label": "Style du portefeuille",
"portfolio_untitled": "Sans titre",
"portfolio_wallet_loading": "Attendez la fin du chargement du portefeuille pour ajouter un groupe.",
"price_chart": "Graphique des prix",
"privacy_great": "Excellente confidentialité !",
"privacy_low": "Faible confidentialité — blindez vos fonds",
@@ -1292,6 +1317,23 @@
"sweep_to": "Balayé vers :",
"sweep_toggle": "Balayer vers mon portefeuille (ne pas conserver la clé)",
"sweep_tx": "Transaction :",
"switch_corrupt_body": "Ce portefeuille semble corrompu — le nœud n'a pas pu l'ouvrir. Restaurez-le depuis une sauvegarde, recréez-le ou tentez de le réparer.",
"switch_corrupt_repair": "Tenter une réparation (salvage)",
"switch_progress_background": "Continuer en arrière-plan",
"switch_progress_default_wallet": "Portefeuille par défaut",
"switch_progress_elapsed": "Écoulé",
"switch_progress_external_wallet": "Portefeuille externe",
"switch_progress_failed_title": "Échec du changement de portefeuille",
"switch_progress_from_label": "depuis",
"switch_progress_hint": "Un arrêt propre peut prendre jusqu'à une minute.",
"switch_progress_reconnecting": "Reconnexion",
"switch_progress_starting": "Démarrage du nœud sur le nouveau portefeuille",
"switch_progress_stopping": "Arrêt du nœud actuel",
"switch_progress_title": "Changement de portefeuille",
"switch_stopnode_body": "Changer de portefeuille redémarre le nœud sur le portefeuille sélectionné. Le nœud en cours sera arrêté puis relancé sur le nouveau portefeuille — si vous l'avez laissé tourner exprès, il redémarre automatiquement.",
"switch_stopnode_confirm": "Arrêter le nœud et changer",
"switch_stopnode_title": "Arrêter le nœud en cours d'exécution ?",
"switch_stopnode_warn": "Un nœud que ce portefeuille n'a pas démarré est déjà en cours d'exécution.",
"syncing": "Synchronisation...",
"t_address": "Adresse T",
"t_addresses": "Adresses T",
@@ -1472,6 +1514,8 @@
"wallets_add_folder_toggle": "+ Analyser un autre dossier pour les portefeuilles…",
"wallets_badge_encrypted": "Chiffré (protégé par phrase secrète)",
"wallets_badge_encrypted_short": "Chiffré",
"wallets_badge_hd": "Portefeuille HD — impossible de confirmer une phrase de récupération sans l'ouvrir",
"wallets_badge_hd_short": "Portefeuille HD",
"wallets_badge_legacy": "Portefeuille hérité (sans phrase de récupération)",
"wallets_badge_legacy_short": "Hérité",
"wallets_badge_seed": "Portefeuille à phrase de récupération (HD)",

View File

@@ -135,25 +135,48 @@
"change_pass_title": "パスフレーズを変更",
"characters": "文字",
"chat": "チャット",
"chat_add_contact": "連絡先に追加",
"chat_cancel": "キャンセル",
"chat_contact_added": "連絡先を追加しました — 連絡先で名前を変更できます",
"chat_contact_request": "連絡リクエスト",
"chat_empty_hint": "まだ会話はありません。受信したメッセージはここに表示されます。",
"chat_empty_start": "「新しい会話」から始めましょう。",
"chat_empty_title": "会話はまだありません",
"chat_export": "チャットをエクスポート…",
"chat_export_done": "会話をエクスポートしました",
"chat_export_failed": "エクスポートファイルを書き込めませんでした。",
"chat_export_warn": "復号したメッセージを平文で保存します。ファイルは安全に保管してください。",
"chat_hidden_toast": "会話を非表示にしました — 新しいメッセージが届くと再表示されます",
"chat_hide": "非表示",
"chat_jump_latest": "最新",
"chat_len_over": "メッセージが長すぎます",
"chat_locked_hint": "チャットを読み込むにはウォレットのロックを解除してください。",
"chat_mute": "ミュート",
"chat_new_button": "新しい会話",
"chat_new_message": "メッセージ",
"chat_new_message_toast": "新しい暗号化チャットメッセージ",
"chat_new_send": "リクエストを送信",
"chat_new_title": "新しい会話",
"chat_new_zaddr": "宛先Zアドレス",
"chat_no_matches": "検索に一致する会話がありません。",
"chat_no_z_contacts": "シールドアドレスの連絡先はまだありません",
"chat_pick_contact": "連絡先から選択…",
"chat_retry": "再送信",
"chat_search": "会話を検索",
"chat_select_hint": "表示する会話を選択してください。",
"chat_send": "送信",
"chat_send_failed": "未送信",
"chat_sending": "送信中…",
"chat_time_now": "たった今",
"chat_toast_compose_failed": "メッセージを作成できませんでした(長すぎませんか?)。",
"chat_toast_lite_busy": "すでに送信処理が進行中か、ウォレットが開かれていません。",
"chat_toast_need_funds": "チャットを送信するには、手数料を賄うための少額のシールド残高が必要です。",
"chat_toast_no_zaddr": "送信元に使えるZアドレスがありません。",
"chat_toast_not_connected": "未接続 — チャットメッセージは送信されませんでした。",
"chat_toast_request_compose_failed": "連絡リクエストを作成できませんでした(アドレスまたはテキストが無効?)。",
"chat_toast_request_queued": "連絡リクエストを送信待ちに追加しました。",
"chat_toast_waiting_reply": "メッセージを送るには、この相手からの返信を待つ必要があります。",
"chat_unmute": "ミュート解除",
"chat_waiting_reply": "この相手からの返信を待っています — 返信があればメッセージを送れます。",
"chat_you": "自分",
"choose_icon": "アイコンを選択",
@@ -305,6 +328,7 @@
"contact_global_tt": "オン:この連絡先はどのウォレットを読み込んでも表示されます。オフ:現在のウォレットにのみ属します。",
"contact_preview_addr": "ここにアドレスが表示されます",
"contact_preview_name": "連絡先名",
"contact_wallet_loading": "ウォレットを読み込み中です。「すべてのウォレットに表示」にチェックするか、少し待ってから再試行してください。",
"contacts": "連絡先",
"contacts_search_no_match": "一致する連絡先がありません",
"contacts_search_placeholder": "連絡先を検索...",
@@ -977,6 +1001,7 @@
"portfolio_style_featured": "注目行",
"portfolio_style_label": "ポートフォリオスタイル",
"portfolio_untitled": "無題",
"portfolio_wallet_loading": "ウォレットの読み込みが終わってからグループを追加してください。",
"price_chart": "価格チャート",
"privacy_great": "優れたプライバシーです!",
"privacy_low": "プライバシーが低い — 資金をシールドしてください",
@@ -1292,6 +1317,23 @@
"sweep_to": "集約先:",
"sweep_toggle": "ウォレットに集約(鍵は保持しない)",
"sweep_tx": "取引:",
"switch_corrupt_body": "このウォレットは破損しているようです。ノードが開けませんでした。バックアップから復元するか、作り直すか、修復を試してください。",
"switch_corrupt_repair": "修復を試すsalvage",
"switch_progress_background": "バックグラウンドで続行",
"switch_progress_default_wallet": "既定のウォレット",
"switch_progress_elapsed": "経過",
"switch_progress_external_wallet": "外部ウォレット",
"switch_progress_failed_title": "ウォレットの切り替えに失敗しました",
"switch_progress_from_label": "元:",
"switch_progress_hint": "正常なシャットダウンには最大1分かかることがあります。",
"switch_progress_reconnecting": "再接続しています",
"switch_progress_starting": "新しいウォレットでノードを起動しています",
"switch_progress_stopping": "現在のノードを停止しています",
"switch_progress_title": "ウォレットを切り替え中",
"switch_stopnode_body": "ウォレットを切り替えると、選択したウォレットでノードが再起動します。実行中のノードは停止され、新しいウォレットで再起動されます。意図的に起動したままにしていた場合は、自動的に復帰します。",
"switch_stopnode_confirm": "ノードを停止して切り替え",
"switch_stopnode_title": "実行中のノードを停止しますか?",
"switch_stopnode_warn": "このウォレットが起動していないノードがすでに実行中です。",
"syncing": "同期中...",
"t_address": "Tアドレス",
"t_addresses": "Tアドレス",
@@ -1472,6 +1514,8 @@
"wallets_add_folder_toggle": "+ 別のフォルダーをウォレット検索…",
"wallets_badge_encrypted": "暗号化済み(パスフレーズ保護)",
"wallets_badge_encrypted_short": "暗号化",
"wallets_badge_hd": "HDウォレット — 開かないとシードフレーズを確認できません",
"wallets_badge_hd_short": "HDウォレット",
"wallets_badge_legacy": "レガシーウォレット(シードフレーズなし)",
"wallets_badge_legacy_short": "レガシー",
"wallets_badge_seed": "シードフレーズウォレット (HD)",

View File

@@ -135,25 +135,48 @@
"change_pass_title": "암호 변경",
"characters": "문자",
"chat": "채팅",
"chat_add_contact": "연락처 추가",
"chat_cancel": "취소",
"chat_contact_added": "연락처 추가됨 — 연락처에서 이름을 변경하세요",
"chat_contact_request": "연락 요청",
"chat_empty_hint": "아직 대화가 없습니다. 받은 메시지가 여기에 표시됩니다.",
"chat_empty_start": "\"새 대화\"로 시작하세요.",
"chat_empty_title": "아직 대화가 없습니다",
"chat_export": "채팅 내보내기…",
"chat_export_done": "대화를 내보냈습니다",
"chat_export_failed": "내보내기 파일을 쓸 수 없습니다.",
"chat_export_warn": "복호화된 메시지를 일반 텍스트로 저장합니다. 파일을 안전하게 보관하세요.",
"chat_hidden_toast": "대화를 숨겼습니다 — 새 메시지가 오면 다시 표시됩니다",
"chat_hide": "숨기기",
"chat_jump_latest": "최신",
"chat_len_over": "메시지가 너무 깁니다",
"chat_locked_hint": "채팅을 불러오려면 지갑 잠금을 해제하세요.",
"chat_mute": "음소거",
"chat_new_button": "새 대화",
"chat_new_message": "메시지",
"chat_new_message_toast": "새 암호화 채팅 메시지",
"chat_new_send": "요청 보내기",
"chat_new_title": "새 대화",
"chat_new_zaddr": "받는 사람 z-주소",
"chat_no_matches": "검색과 일치하는 대화가 없습니다.",
"chat_no_z_contacts": "보호 주소 연락처가 아직 없습니다",
"chat_pick_contact": "연락처에서 선택…",
"chat_retry": "다시 시도",
"chat_search": "대화 검색",
"chat_select_hint": "볼 대화를 선택하세요.",
"chat_send": "전송",
"chat_send_failed": "전송 안 됨",
"chat_sending": "전송 중…",
"chat_time_now": "방금",
"chat_toast_compose_failed": "메시지를 작성할 수 없습니다 (너무 긴가요?).",
"chat_toast_lite_busy": "이미 전송이 진행 중이거나 열린 지갑이 없습니다.",
"chat_toast_need_funds": "채팅을 보내려면 수수료를 낼 소액의 보호 잔액이 필요합니다.",
"chat_toast_no_zaddr": "채팅을 보낼 z-주소가 없습니다.",
"chat_toast_not_connected": "연결되지 않음 — 채팅 메시지가 전송되지 않았습니다.",
"chat_toast_request_compose_failed": "연락 요청을 작성할 수 없습니다 (잘못된 주소 / 텍스트?).",
"chat_toast_request_queued": "연락 요청이 대기열에 추가되었습니다.",
"chat_toast_waiting_reply": "상대방이 답장해야 메시지를 보낼 수 있습니다.",
"chat_unmute": "음소거 해제",
"chat_waiting_reply": "상대방의 답장을 기다리는 중입니다 — 답장하면 메시지를 보낼 수 있습니다.",
"chat_you": "나",
"choose_icon": "아이콘 선택",
@@ -305,6 +328,7 @@
"contact_global_tt": "켜짐: 어떤 지갑을 불러오든 이 연락처가 계속 표시됩니다. 꺼짐: 현재 지갑에만 속합니다.",
"contact_preview_addr": "여기에 주소가 표시됩니다",
"contact_preview_name": "연락처 이름",
"contact_wallet_loading": "지갑을 아직 불러오는 중입니다 — “모든 지갑에 표시”를 선택하거나 잠시 후 다시 시도하세요.",
"contacts": "연락처",
"contacts_search_no_match": "일치하는 연락처 없음",
"contacts_search_placeholder": "연락처 검색...",
@@ -977,6 +1001,7 @@
"portfolio_style_featured": "강조 행",
"portfolio_style_label": "포트폴리오 스타일",
"portfolio_untitled": "제목 없음",
"portfolio_wallet_loading": "지갑 로딩이 끝난 후 그룹을 추가하세요.",
"price_chart": "가격 차트",
"privacy_great": "프라이버시가 우수합니다!",
"privacy_low": "낮은 프라이버시 — 자금을 차폐하세요",
@@ -1292,6 +1317,23 @@
"sweep_to": "쓸어담은 주소:",
"sweep_toggle": "내 지갑으로 쓸어담기 (키 보관 안 함)",
"sweep_tx": "거래:",
"switch_corrupt_body": "이 지갑이 손상된 것 같습니다. 노드가 열 수 없습니다. 백업에서 복원하거나 다시 만들거나 복구를 시도하세요.",
"switch_corrupt_repair": "복구 시도(salvage)",
"switch_progress_background": "백그라운드에서 계속",
"switch_progress_default_wallet": "기본 지갑",
"switch_progress_elapsed": "경과",
"switch_progress_external_wallet": "외부 지갑",
"switch_progress_failed_title": "지갑 전환 실패",
"switch_progress_from_label": "이전:",
"switch_progress_hint": "정상 종료에는 최대 1분이 걸릴 수 있습니다.",
"switch_progress_reconnecting": "다시 연결하는 중",
"switch_progress_starting": "새 지갑으로 노드를 시작하는 중",
"switch_progress_stopping": "현재 노드를 중지하는 중",
"switch_progress_title": "지갑 전환 중",
"switch_stopnode_body": "지갑을 전환하면 선택한 지갑으로 노드가 다시 시작됩니다. 실행 중인 노드가 중지되고 새 지갑으로 다시 시작됩니다. 일부러 계속 실행해 두었다면 자동으로 다시 켜집니다.",
"switch_stopnode_confirm": "노드 중지 후 전환",
"switch_stopnode_title": "실행 중인 노드를 중지할까요?",
"switch_stopnode_warn": "이 지갑이 시작하지 않은 노드가 이미 실행 중입니다.",
"syncing": "동기화 중...",
"t_address": "T 주소",
"t_addresses": "T 주소",
@@ -1472,6 +1514,8 @@
"wallets_add_folder_toggle": "+ 다른 폴더에서 지갑 검색…",
"wallets_badge_encrypted": "암호화됨 (암호로 보호됨)",
"wallets_badge_encrypted_short": "암호화됨",
"wallets_badge_hd": "HD 지갑 — 열지 않으면 시드 문구를 확인할 수 없습니다",
"wallets_badge_hd_short": "HD 지갑",
"wallets_badge_legacy": "레거시 지갑 (시드 문구 없음)",
"wallets_badge_legacy_short": "레거시",
"wallets_badge_seed": "시드 문구 지갑 (HD)",

View File

@@ -135,25 +135,48 @@
"change_pass_title": "Alterar senha",
"characters": "caracteres",
"chat": "Chat",
"chat_add_contact": "Adicionar contato",
"chat_cancel": "Cancelar",
"chat_contact_added": "Contato adicionado — renomeie em Contatos",
"chat_contact_request": "solicitação de contato",
"chat_empty_hint": "Nenhuma conversa ainda. As mensagens que você receber aparecerão aqui.",
"chat_empty_start": "Inicie uma com \"Nova conversa\".",
"chat_empty_title": "Ainda não há conversas",
"chat_export": "Exportar conversa…",
"chat_export_done": "Conversa exportada",
"chat_export_failed": "Não foi possível gravar o arquivo de exportação.",
"chat_export_warn": "Salva as mensagens descriptografadas como texto simples. Guarde o arquivo com segurança.",
"chat_hidden_toast": "Conversa ocultada — uma nova mensagem a traz de volta",
"chat_hide": "Ocultar",
"chat_jump_latest": "Recentes",
"chat_len_over": "Mensagem muito longa",
"chat_locked_hint": "Desbloqueie sua carteira para carregar suas conversas.",
"chat_mute": "Silenciar",
"chat_new_button": "Nova conversa",
"chat_new_message": "Mensagem",
"chat_new_message_toast": "Nova mensagem de chat criptografada",
"chat_new_send": "Enviar solicitação",
"chat_new_title": "Nova conversa",
"chat_new_zaddr": "Endereço-z do destinatário",
"chat_no_matches": "Nenhuma conversa corresponde à sua pesquisa.",
"chat_no_z_contacts": "Ainda não há contatos com endereço blindado",
"chat_pick_contact": "Escolher dos contatos…",
"chat_retry": "Tentar novamente",
"chat_search": "Pesquisar conversas",
"chat_select_hint": "Selecione uma conversa para visualizá-la.",
"chat_send": "Enviar",
"chat_send_failed": "não enviada",
"chat_sending": "enviando…",
"chat_time_now": "agora",
"chat_toast_compose_failed": "Não foi possível compor a mensagem (muito longa?).",
"chat_toast_lite_busy": "Já há um envio em andamento, ou nenhuma carteira está aberta.",
"chat_toast_need_funds": "É necessário um pequeno saldo blindado para enviar chats (para cobrir a taxa).",
"chat_toast_no_zaddr": "Nenhum endereço-z disponível para enviar o chat.",
"chat_toast_not_connected": "Não conectado — mensagem de chat não enviada.",
"chat_toast_request_compose_failed": "Não foi possível compor a solicitação de contato (endereço / texto inválido?).",
"chat_toast_request_queued": "Solicitação de contato na fila.",
"chat_toast_waiting_reply": "Aguardando a resposta do contato antes que você possa enviar mensagens a ele.",
"chat_unmute": "Reativar som",
"chat_waiting_reply": "Aguardando a resposta deste contato — você poderá enviar mensagens assim que ele responder.",
"chat_you": "Você",
"choose_icon": "Escolher Ícone",
@@ -305,6 +328,7 @@
"contact_global_tt": "Ativado: este contato permanece visível em qualquer carteira que você carregar. Desativado: ele pertence apenas à carteira atual.",
"contact_preview_addr": "O endereço aparecerá aqui",
"contact_preview_name": "Nome do contato",
"contact_wallet_loading": "A carteira ainda está carregando — marque “Mostrar em todas as carteiras” ou tente novamente em um momento.",
"contacts": "Contatos",
"contacts_search_no_match": "Nenhum contato correspondente",
"contacts_search_placeholder": "Pesquisar contatos...",
@@ -977,6 +1001,7 @@
"portfolio_style_featured": "Linhas em destaque",
"portfolio_style_label": "Estilo do portfólio",
"portfolio_untitled": "Sem título",
"portfolio_wallet_loading": "Aguarde a carteira terminar de carregar para adicionar um grupo.",
"price_chart": "Gráfico de Preços",
"privacy_great": "Ótima privacidade!",
"privacy_low": "Baixa privacidade — blinde os fundos",
@@ -1292,6 +1317,23 @@
"sweep_to": "Varrido para:",
"sweep_toggle": "Varrer para minha carteira (não manter a chave)",
"sweep_tx": "Transação:",
"switch_corrupt_body": "Esta carteira parece corrompida — o nó não conseguiu abri-la. Restaure de um backup, recrie-a ou tente repará-la.",
"switch_corrupt_repair": "Tentar reparar (salvage)",
"switch_progress_background": "Continuar em segundo plano",
"switch_progress_default_wallet": "Carteira padrão",
"switch_progress_elapsed": "Decorrido",
"switch_progress_external_wallet": "Carteira externa",
"switch_progress_failed_title": "Falha ao trocar de carteira",
"switch_progress_from_label": "de",
"switch_progress_hint": "Um desligamento normal pode levar até um minuto.",
"switch_progress_reconnecting": "Reconectando",
"switch_progress_starting": "Iniciando o nó com a nova carteira",
"switch_progress_stopping": "Parando o nó atual",
"switch_progress_title": "Trocando de carteira",
"switch_stopnode_body": "Trocar de carteira reinicia o nó com a carteira selecionada. O nó em execução será parado e reiniciado com a nova carteira — se você o deixou em execução de propósito, ele volta automaticamente.",
"switch_stopnode_confirm": "Parar nó e trocar",
"switch_stopnode_title": "Parar o nó em execução?",
"switch_stopnode_warn": "Já há um nó em execução que esta carteira não iniciou.",
"syncing": "Sincronizando...",
"t_address": "Endereço T",
"t_addresses": "Endereços T",
@@ -1472,6 +1514,8 @@
"wallets_add_folder_toggle": "+ Procurar carteiras noutra pasta…",
"wallets_badge_encrypted": "Encriptada (protegida por senha)",
"wallets_badge_encrypted_short": "Encriptada",
"wallets_badge_hd": "Carteira HD — não é possível confirmar a frase-semente sem abri-la",
"wallets_badge_hd_short": "Carteira HD",
"wallets_badge_legacy": "Carteira legada (sem frase semente)",
"wallets_badge_legacy_short": "Legada",
"wallets_badge_seed": "Carteira com frase semente (HD)",

View File

@@ -135,25 +135,48 @@
"change_pass_title": "Сменить пароль",
"characters": "символов",
"chat": "Чат",
"chat_add_contact": "Добавить контакт",
"chat_cancel": "Отмена",
"chat_contact_added": "Контакт добавлен — переименуйте его в Контактах",
"chat_contact_request": "запрос контакта",
"chat_empty_hint": "Пока нет переписок. Полученные сообщения появятся здесь.",
"chat_empty_start": "Начните новый с помощью «Новый разговор».",
"chat_empty_title": "Пока нет разговоров",
"chat_export": "Экспорт чата…",
"chat_export_done": "Разговор экспортирован",
"chat_export_failed": "Не удалось записать файл экспорта.",
"chat_export_warn": "Сохраняет расшифрованные сообщения в виде обычного текста. Храните файл в надёжном месте.",
"chat_hidden_toast": "Разговор скрыт — новое сообщение вернёт его",
"chat_hide": "Скрыть",
"chat_jump_latest": "Новые",
"chat_len_over": "Сообщение слишком длинное",
"chat_locked_hint": "Разблокируйте кошелёк, чтобы загрузить переписку.",
"chat_mute": "Отключить уведомления",
"chat_new_button": "Новая переписка",
"chat_new_message": "Сообщение",
"chat_new_message_toast": "Новое зашифрованное сообщение",
"chat_new_send": "Отправить запрос",
"chat_new_title": "Новая переписка",
"chat_new_zaddr": "Z-адрес получателя",
"chat_no_matches": "Нет разговоров, соответствующих запросу.",
"chat_no_z_contacts": "Пока нет контактов с защищённым адресом",
"chat_pick_contact": "Выбрать из контактов…",
"chat_retry": "Повторить",
"chat_search": "Поиск разговоров",
"chat_select_hint": "Выберите переписку для просмотра.",
"chat_send": "Отправить",
"chat_send_failed": "не отправлено",
"chat_sending": "отправка…",
"chat_time_now": "сейчас",
"chat_toast_compose_failed": "Не удалось составить сообщение (слишком длинное?).",
"chat_toast_lite_busy": "Отправка уже выполняется, или кошелёк не открыт.",
"chat_toast_need_funds": "Для отправки сообщений нужен небольшой экранированный баланс (для оплаты комиссии).",
"chat_toast_no_zaddr": "Нет доступного Z-адреса для отправки сообщений.",
"chat_toast_not_connected": "Нет подключения — сообщение не отправлено.",
"chat_toast_request_compose_failed": "Не удалось составить запрос контакта (неверный адрес / текст?).",
"chat_toast_request_queued": "Запрос контакта поставлен в очередь.",
"chat_toast_waiting_reply": "Ожидание ответа от контакта — вы сможете писать ему только после этого.",
"chat_unmute": "Включить уведомления",
"chat_waiting_reply": "Ожидание ответа от контакта — вы сможете писать ему, как только он ответит.",
"chat_you": "Вы",
"choose_icon": "Выбрать иконку",
@@ -305,6 +328,7 @@
"contact_global_tt": "Вкл.: этот контакт остаётся видимым, какой бы кошелёк вы ни загрузили. Выкл.: он принадлежит только текущему кошельку.",
"contact_preview_addr": "Здесь появится адрес",
"contact_preview_name": "Имя контакта",
"contact_wallet_loading": "Кошелёк ещё загружается — отметьте «Показывать во всех кошельках» или повторите чуть позже.",
"contacts": "Контакты",
"contacts_search_no_match": "Совпадающих контактов нет",
"contacts_search_placeholder": "Поиск контактов...",
@@ -977,6 +1001,7 @@
"portfolio_style_featured": "Избранные строки",
"portfolio_style_label": "Стиль портфеля",
"portfolio_untitled": "Без названия",
"portfolio_wallet_loading": "Дождитесь загрузки кошелька, чтобы добавить группу.",
"price_chart": "График цен",
"privacy_great": "Отличная конфиденциальность!",
"privacy_low": "Низкая конфиденциальность — экранируйте средства",
@@ -1292,6 +1317,23 @@
"sweep_to": "Переведено на:",
"sweep_toggle": "Перевести в мой кошелёк (не сохранять ключ)",
"sweep_tx": "Транзакция:",
"switch_corrupt_body": "Похоже, этот кошелёк повреждён — узел не смог его открыть. Восстановите из резервной копии, создайте заново или попробуйте восстановить.",
"switch_corrupt_repair": "Попробовать восстановить (salvage)",
"switch_progress_background": "Продолжить в фоне",
"switch_progress_default_wallet": "Кошелёк по умолчанию",
"switch_progress_elapsed": "Прошло",
"switch_progress_external_wallet": "Внешний кошелёк",
"switch_progress_failed_title": "Не удалось переключить кошелёк",
"switch_progress_from_label": "из",
"switch_progress_hint": "Корректное завершение работы может занять до минуты.",
"switch_progress_reconnecting": "Переподключение",
"switch_progress_starting": "Запуск узла с новым кошельком",
"switch_progress_stopping": "Остановка текущего узла",
"switch_progress_title": "Переключение кошелька",
"switch_stopnode_body": "Смена кошелька перезапускает узел с выбранным кошельком. Запущенный узел будет остановлен и перезапущен с новым кошельком — если вы намеренно оставили его работать, он запустится снова автоматически.",
"switch_stopnode_confirm": "Остановить узел и переключить",
"switch_stopnode_title": "Остановить запущенный узел?",
"switch_stopnode_warn": "Уже запущен узел, который не был запущен этим кошельком.",
"syncing": "Синхронизация...",
"t_address": "T-адрес",
"t_addresses": "T-адреса",
@@ -1472,6 +1514,8 @@
"wallets_add_folder_toggle": "+ Искать кошельки в другой папке…",
"wallets_badge_encrypted": "Зашифрован (защищён паролем)",
"wallets_badge_encrypted_short": "Зашифрован",
"wallets_badge_hd": "HD-кошелёк — невозможно подтвердить seed-фразу без открытия",
"wallets_badge_hd_short": "HD-кошелёк",
"wallets_badge_legacy": "Устаревший кошелёк (без seed-фразы)",
"wallets_badge_legacy_short": "Устаревший",
"wallets_badge_seed": "Кошелёк с seed-фразой (HD)",

View File

@@ -135,25 +135,48 @@
"change_pass_title": "更改密码短语",
"characters": "字符",
"chat": "聊天",
"chat_add_contact": "添加联系人",
"chat_cancel": "取消",
"chat_contact_added": "已添加联系人——可在联系人中重命名",
"chat_contact_request": "联系人请求",
"chat_empty_hint": "暂无对话。您收到的消息将显示在此处。",
"chat_empty_start": "点击\"新建会话\"开始。",
"chat_empty_title": "还没有会话",
"chat_export": "导出聊天…",
"chat_export_done": "会话已导出",
"chat_export_failed": "无法写入导出文件。",
"chat_export_warn": "将解密后的消息保存为纯文本。请妥善保管该文件。",
"chat_hidden_toast": "会话已隐藏——收到新消息后会重新显示",
"chat_hide": "隐藏",
"chat_jump_latest": "最新",
"chat_len_over": "消息过长",
"chat_locked_hint": "解锁钱包以加载您的聊天记录。",
"chat_mute": "静音",
"chat_new_button": "新建对话",
"chat_new_message": "消息",
"chat_new_message_toast": "新的加密聊天消息",
"chat_new_send": "发送请求",
"chat_new_title": "新建对话",
"chat_new_zaddr": "收款方 z 地址",
"chat_no_matches": "没有与搜索匹配的会话。",
"chat_no_z_contacts": "暂无使用隐私地址的联系人",
"chat_pick_contact": "从联系人中选择…",
"chat_retry": "重试",
"chat_search": "搜索会话",
"chat_select_hint": "选择一个对话以查看。",
"chat_send": "发送",
"chat_send_failed": "未发送",
"chat_sending": "发送中…",
"chat_time_now": "刚刚",
"chat_toast_compose_failed": "无法编写该消息(内容过长?)。",
"chat_toast_lite_busy": "已有发送正在进行中,或未打开任何钱包。",
"chat_toast_need_funds": "发送聊天需要少量屏蔽余额(用于支付手续费)。",
"chat_toast_no_zaddr": "没有可用于发送聊天的 z 地址。",
"chat_toast_not_connected": "未连接——聊天消息未发送。",
"chat_toast_request_compose_failed": "无法编写联系人请求(地址或文本无效?)。",
"chat_toast_request_queued": "联系人请求已排队。",
"chat_toast_waiting_reply": "等待该联系人回复——对方回复后您即可向其发送消息。",
"chat_unmute": "取消静音",
"chat_waiting_reply": "等待该联系人回复——对方回复后您即可向其发送消息。",
"chat_you": "我",
"choose_icon": "选择图标",
@@ -305,6 +328,7 @@
"contact_global_tt": "开启:无论您加载哪个钱包,此联系人都保持可见。关闭:它仅属于当前钱包。",
"contact_preview_addr": "地址将显示在此处",
"contact_preview_name": "联系人名称",
"contact_wallet_loading": "钱包仍在加载——请选中“在每个钱包中显示”,或稍后再试。",
"contacts": "联系人",
"contacts_search_no_match": "没有匹配的联系人",
"contacts_search_placeholder": "搜索联系人...",
@@ -977,6 +1001,7 @@
"portfolio_style_featured": "特色行",
"portfolio_style_label": "投资组合样式",
"portfolio_untitled": "未命名",
"portfolio_wallet_loading": "请等待钱包加载完成后再添加分组。",
"price_chart": "价格图表",
"privacy_great": "隐私性极佳!",
"privacy_low": "隐私性低——请屏蔽资金",
@@ -1292,6 +1317,23 @@
"sweep_to": "归集到:",
"sweep_toggle": "归集到我的钱包(不保留密钥)",
"sweep_tx": "交易:",
"switch_corrupt_body": "此钱包似乎已损坏——节点无法打开它。请从备份恢复、重新创建,或尝试修复。",
"switch_corrupt_repair": "尝试修复salvage",
"switch_progress_background": "在后台继续",
"switch_progress_default_wallet": "默认钱包",
"switch_progress_elapsed": "已用时",
"switch_progress_external_wallet": "外部钱包",
"switch_progress_failed_title": "切换钱包失败",
"switch_progress_from_label": "来自",
"switch_progress_hint": "正常关闭最多可能需要一分钟。",
"switch_progress_reconnecting": "正在重新连接",
"switch_progress_starting": "正在以新钱包启动节点",
"switch_progress_stopping": "正在停止当前节点",
"switch_progress_title": "正在切换钱包",
"switch_stopnode_body": "切换钱包会以所选钱包重启节点。正在运行的节点将被停止并以新钱包重新启动——如果你是特意让它保持运行的,它会自动重新启动。",
"switch_stopnode_confirm": "停止节点并切换",
"switch_stopnode_title": "停止正在运行的节点?",
"switch_stopnode_warn": "已有一个并非由此钱包启动的节点正在运行。",
"syncing": "同步中...",
"t_address": "T 地址",
"t_addresses": "T 地址",
@@ -1472,6 +1514,8 @@
"wallets_add_folder_toggle": "+ 扫描其他文件夹中的钱包…",
"wallets_badge_encrypted": "已加密(密码保护)",
"wallets_badge_encrypted_short": "已加密",
"wallets_badge_hd": "HD 钱包 — 打开钱包才能确认助记词",
"wallets_badge_hd_short": "HD 钱包",
"wallets_badge_legacy": "旧版钱包(无助记词)",
"wallets_badge_legacy_short": "旧版",
"wallets_badge_seed": "助记词钱包 (HD)",

View File

@@ -0,0 +1,87 @@
#!/usr/bin/env python3
"""
Build a monochrome Noto Emoji subset for the chat/message UI.
Dear ImGui rasterizes fonts with stb_truetype, which handles only monochrome
(outline `glyf`) fonts — NOT color emoji (CBDT/sbix/COLR). So we use Google's
*monochrome* Noto Emoji (github.com/google/fonts, ofl/notoemoji, OFL-licensed)
and merge it into the text fonts (see Typography::loadFont, the block after the
CJK merge). ImGui renders one glyph per codepoint with no shaping, so ZWJ
sequences / regional-indicator flags won't compose — single-codepoint emoji
(😀 🎉 ❤ 🔥 👍 …) render fine, which covers the overwhelming majority of use.
Source is the variable font pinned to wght=400 → static, then subset to the
emoji planes plus the higher symbol/star ranges the base UI font doesn't cover.
The base Ubuntu font already owns U+260026FF etc.; ImGui's MergeMode gives the
first-loaded glyph precedence, so those stay text-styled and only the codepoints
the base lacks fall through to this font.
Get the source once (OFL, redistributable):
curl -fsSL -o /tmp/NotoEmoji-VF.ttf \
'https://github.com/google/fonts/raw/main/ofl/notoemoji/NotoEmoji%5Bwght%5D.ttf'
Then: python3 scripts/build_emoji_subset.py
Output: res/fonts/NotoEmoji-Subset.ttf (committed; embedded via INCBIN)
"""
import os
from fontTools import ttLib, subset
from fontTools.varLib.instancer import instantiateVariableFont
ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
SOURCE_VF = '/tmp/NotoEmoji-VF.ttf'
STATIC = '/tmp/NotoEmoji-Static.ttf'
OUTPUT = os.path.join(ROOT, 'res', 'fonts', 'NotoEmoji-Subset.ttf')
# Emoji codepoints to keep. Ranges are inclusive.
RANGES = [
(0x1F000, 0x1FAFF), # all the main emoji planes (emoticons, pictographs, transport, supplement, extended)
(0x2600, 0x27BF), # Miscellaneous Symbols + Dingbats
(0x2B00, 0x2BFF), # stars (⭐ 2B50) and misc arrows
(0xFE00, 0xFE0F), # variation selectors (VS16 emoji-style)
(0x2194, 0x21AA), # arrows used as emoji
(0x231A, 0x231B), # ⌚ ⌛
(0x23E9, 0x23FA), # media-control emoji
(0x25AA, 0x25FE), # small squares
]
SINGLES = [0x200D, 0x2934, 0x2935, 0x3030, 0x303D, 0x3297, 0x3299,
0x00A9, 0x00AE, 0x2122, 0x2139, 0x24C2]
def main():
if not os.path.exists(SOURCE_VF):
raise SystemExit(f"missing source font {SOURCE_VF} — see the header for the curl command")
# 1. Pin the weight axis so stb_truetype rasterizes a clean static instance.
f = ttLib.TTFont(SOURCE_VF)
if 'fvar' in f:
instantiateVariableFont(f, {'wght': 400}, inplace=True)
f.save(STATIC)
unicodes = list(SINGLES)
for lo, hi in RANGES:
unicodes.extend(range(lo, hi + 1))
opts = subset.Options()
opts.layout_features = [] # ImGui does no shaping — drop GSUB/GPOS
opts.name_IDs = []
opts.notdef_outline = True
opts.glyph_names = False
opts.drop_tables = ['GSUB', 'GPOS', 'GDEF', 'morx', 'kern']
font = subset.load_font(STATIC, opts)
ss = subset.Subsetter(options=opts)
ss.populate(unicodes=unicodes)
ss.subset(font)
subset.save_font(font, OUTPUT, opts)
out = ttLib.TTFont(OUTPUT)
cmap = out.getBestCmap()
color = any(t in out.reader.keys() for t in ('CBDT', 'sbix', 'COLR'))
print(f"Output: {OUTPUT}")
print(f"Size: {os.path.getsize(OUTPUT)//1024} KB | glyphs: {len(cmap)} | color tables: {color}")
if color:
raise SystemExit("ERROR: subset has color tables — stb_truetype cannot render it")
if __name__ == '__main__':
main()

View File

@@ -334,8 +334,9 @@ bool App::init()
// Ensure ObsidianDragon config directory and template files exist
util::Platform::ensureObsidianDragonSetup();
// Initialize PIN vault
vault_ = std::make_unique<util::SecureVault>();
// Initialize PIN vault, scoped to the active wallet so one wallet's stored passphrase is never
// offered for another (the default wallet keeps the legacy vault.dat).
vault_ = std::make_unique<util::SecureVault>(settings_ ? settings_->getActiveWalletFile() : "");
// Theme is now applied via SkinManager below after UISchema loads.
// The old SetThemeById() C++ fallback is no longer needed at startup
@@ -634,12 +635,7 @@ void App::rebuildLiteWallet(bool force)
// A rebuilt controller may back a different wallet (server switch / re-open); drop any chat
// identity + decrypted messages, lock the DB, and re-arm provisioning so it re-derives (and
// reloads the right wallet's history) from the newly opened wallet's seed.
chat_service_.clearIdentity();
chat_service_.store().clear();
chat_db_.lock();
chat_identity_provisioned_ = false;
chat_identity_fetch_in_flight_ = false;
chat_identity_unavailable_ = false;
resetChatSession();
}
void App::update()
@@ -653,6 +649,9 @@ void App::update()
// capture_mode_ is only set for the offline full sweep, so the live tab-only sweep is unaffected.
if (capture_mode_) return;
// If a wallet switch's daemon failed to start, revert to the previous wallet (main-thread-safe).
processWalletSwitchRevert();
// Track user interaction for auto-lock
if (io.MouseDelta.x != 0 || io.MouseDelta.y != 0 ||
io.MouseClicked[0] || io.MouseClicked[1] ||
@@ -1169,6 +1168,9 @@ void App::update()
} else if (walletDataPage && shouldRefreshRecentTransactions()) {
refreshRecentTransactionData();
}
// 0-conf chat: the normal harvest above only re-scans on a new block, so also do a light
// mempool scan of the chat address every cycle to surface messages before confirmation.
fastScanChatMemos();
}
if (network_refresh_.consumeDue(RefreshTimer::Addresses)) {
if (walletDataPage || addresses_dirty_ || hasTransactionSendProgress()) {
@@ -1504,6 +1506,7 @@ void App::render()
ui::SidebarStatus sbStatus;
sbStatus.peerCount = static_cast<int>(state_.peers.size());
sbStatus.miningActive = state_.mining.generate || state_.pool_mining.xmrig_running;
sbStatus.chatUnreadCount = chatUnreadCount(); // unread badge on the Chat nav item (Q1)
// Load logo texture lazily on first frame (or after theme change)
// Also reload when dark↔light mode changes so the correct variant shows
@@ -2035,6 +2038,7 @@ void App::render()
renderEncryptWalletDialog();
renderDecryptWalletDialog();
renderPinDialogs();
renderSwitchStopDaemonDialog();
// Render notifications (toast messages)
ui::Notifications::instance().render();
@@ -3861,6 +3865,125 @@ void App::renderAntivirusHelpDialog()
#endif
}
void App::renderSwitchStopDaemonDialog()
{
const bool confirm = show_switch_stop_daemon_confirm_;
const bool progress = wallet_switch_dialog_open_.load();
if (!confirm && !progress) return;
const auto phase = static_cast<WalletSwitchPhase>(wallet_switch_phase_.load());
const bool failed = progress && phase == WalletSwitchPhase::Failed;
// Prettify a wallet filename for display (wallet.dat → "Default wallet", wallet-savings.dat → "savings").
auto pretty = [this](const std::string& f) -> std::string {
if (f.empty() || f == "wallet.dat") return TR("switch_progress_default_wallet");
std::string s = f;
if (s.size() > 4 && s.substr(s.size() - 4) == ".dat") s.resize(s.size() - 4);
if (s.rfind("wallet-ip-", 0) == 0) return TR("switch_progress_external_wallet");
if (s.rfind("wallet-", 0) == 0) s = s.substr(7);
return s;
};
const std::string toName = pretty(settings_ ? settings_->getActiveWalletFile() : std::string());
// Progress title carries the target wallet ("Switching wallet — savings"); keep the string alive.
std::string titleStr = confirm ? std::string(TR("switch_stopnode_title"))
: failed ? std::string(TR("switch_progress_failed_title"))
: std::string(TR("switch_progress_title")) + "" + toName;
ui::material::OverlayDialogSpec ov;
ov.title = titleStr.c_str();
// A confirm can be dismissed via X/backdrop (= cancel); a running switch cannot (stay until it's up).
ov.p_open = confirm ? &show_switch_stop_daemon_confirm_ : nullptr;
ov.style = ui::material::OverlayStyle::BlurFloat;
ov.cardWidth = 520.0f; ov.idSuffix = "switchstopnode";
if (!ui::material::BeginOverlayDialog(ov)) {
if (confirm && !show_switch_stop_daemon_confirm_) pending_switch_wallet_file_.clear(); // X = cancel
return;
}
const float dp = ui::Layout::dpiScale();
if (confirm) {
ui::material::DialogWarningHeader(TR("switch_stopnode_warn"));
ImGui::Dummy(ImVec2(0, ui::Layout::spacingSm()));
ImGui::TextWrapped("%s", TR("switch_stopnode_body"));
ImGui::Dummy(ImVec2(0, ui::Layout::spacingMd()));
if (ui::material::TactileButton(TR("switch_stopnode_confirm"), ImVec2(200.0f * dp, 0))) {
const std::string w = pending_switch_wallet_file_;
pending_switch_wallet_file_.clear();
show_switch_stop_daemon_confirm_ = false;
switchToWallet(w, /*stopDaemonConfirmed=*/true); // opens the live-progress phase below
}
ImGui::SameLine();
if (ui::material::TactileButton(TR("cancel"), ImVec2(110.0f * dp, 0))) {
pending_switch_wallet_file_.clear();
show_switch_stop_daemon_confirm_ = false;
}
} else if (failed) {
// The title already says "Wallet switch failed" — put the actual reason in the warning header
// rather than repeating the title.
ui::material::DialogWarningHeader(wallet_switch_error_.c_str());
ImGui::Dummy(ImVec2(0, ui::Layout::spacingMd()));
// If the node reported the target wallet as corrupt, offer a one-click -salvagewallet repair
// (retries the switch to that wallet with recovery enabled).
if (switch_wallet_corrupt_.load()) {
if (ui::material::TactileButton(TR("switch_corrupt_repair"), ImVec2(240.0f * dp, 0))) {
const std::string target = wallet_switch_target_file_;
switchToWallet(target, /*stopDaemonConfirmed=*/true, /*salvage=*/true); // reopens the progress modal
}
ImGui::SameLine();
}
if (ui::material::TactileButton(TR("close"), ImVec2(110.0f * dp, 0))) {
wallet_switch_dialog_open_.store(false);
wallet_switch_phase_.store(static_cast<int>(WalletSwitchPhase::None));
wallet_switch_error_.clear();
}
} else {
// Live progress, kept redrawing by isWalletSwitchInProgress().
// Source context (the target is already in the title).
ImGui::PushFont(ui::material::Type().caption());
ImGui::TextDisabled("%s %s", TR("switch_progress_from_label"), pretty(wallet_switch_prev_file_).c_str());
ImGui::PopFont();
ImGui::Dummy(ImVec2(0, ui::Layout::spacingMd()));
// Primary status + detail. During Reconnecting the node is loading the block index / verifying /
// rescanning — surface its live init stage (state_.warmup_status/description) instead of a bare
// "Reconnecting…", so the ~30-60s wait shows real progress.
std::string primary, detail;
if (phase == WalletSwitchPhase::Stopping) {
primary = TR("switch_progress_stopping");
detail = TR("switch_progress_hint");
} else if (phase == WalletSwitchPhase::Starting) {
primary = TR("switch_progress_starting");
} else { // Reconnecting
if (!state_.warmup_status.empty()) { primary = state_.warmup_status; detail = state_.warmup_description; }
else primary = TR("switch_progress_reconnecting");
}
ui::material::Type().text(ui::material::TypeStyle::Subtitle1,
(primary + ui::material::LoadingDots()).c_str());
if (!detail.empty()) {
ImGui::Dummy(ImVec2(0, ui::Layout::spacingSm()));
ImGui::PushFont(ui::material::Type().body2());
ImGui::TextWrapped("%s", detail.c_str());
ImGui::PopFont();
}
// Elapsed timer — captured on the first progress frame (reset per switch in switchToWallet).
if (wallet_switch_started_time_ <= 0.0) wallet_switch_started_time_ = ImGui::GetTime();
const int el = static_cast<int>(ImGui::GetTime() - wallet_switch_started_time_);
ImGui::Dummy(ImVec2(0, ui::Layout::spacingSm()));
ImGui::PushFont(ui::material::Type().caption());
ImGui::TextDisabled("%s %d:%02d", TR("switch_progress_elapsed"), el / 60, el % 60);
ImGui::PopFont();
ImGui::Dummy(ImVec2(0, ui::Layout::spacingMd()));
// Escape hatch — the switch keeps running in the background (a toast reports the result).
if (ui::material::TactileButton(TR("switch_progress_background"), ImVec2(220.0f * dp, 0)))
wallet_switch_dialog_open_.store(false);
}
ui::material::EndOverlayDialog();
}
void App::refreshNow()
{
// Trigger immediate refresh on all categories
@@ -4133,6 +4256,56 @@ bool App::isEmbeddedDaemonRunning() const
return daemon_controller_ && daemon_controller_->isRunning();
}
bool App::stopDaemonForWalletSwitch()
{
// "Owned" means we spawned the node this session and hold a live process handle. That is the ONLY
// reliable ownership signal here: externalDaemonDetected() is latched only inside EmbeddedDaemon::start(),
// so when the app just direct-connects to an already-running, config-provided node (the common
// keep-node-running case) start() is never called and that flag stays false even though we clearly did
// not start the node. Gate on the handle instead.
const bool owned = daemon_controller_ && isEmbeddedDaemonRunning();
if (owned) {
// We spawned it — stopEmbeddedDaemon() BLOCKS for full process exit (handle wait + SIGTERM/SIGKILL
// escalation), so the datadir lock + RPC port are released by the time it returns; a short port
// check just confirms.
stopEmbeddedDaemon();
for (int i = 0; i < 200 && daemon::EmbeddedDaemon::isRpcPortInUse() && !shutting_down_; ++i)
std::this_thread::sleep_for(std::chrono::milliseconds(100));
return shutting_down_ || !daemon::EmbeddedDaemon::isRpcPortInUse();
}
// Adopted OR direct-connected: no process handle, so RPC "stop" is the only lever, and
// stopEmbeddedDaemon()'s temp connection (autoDetectConfig) can't be trusted here. Send a graceful RPC
// "stop" over the exact creds we're connected with (saved_config_) — guaranteed to reach our node, and
// only OUR node accepts them, so a foreign dragonxd (different rpcpassword) is a safe no-op. switchToWallet
// disconnects rpc_ before this worker runs, so build a fresh temporary connection.
bool sent = false;
{
auto tmp = std::make_unique<rpc::RPCClient>();
if (tmp->connect(saved_config_.host, saved_config_.port, saved_config_.rpcuser,
saved_config_.rpcpassword, saved_config_.use_tls)) {
sent = sendStopCommandSafely(*tmp, "wallet-switch stop");
tmp->disconnect();
}
}
DEBUG_LOGF("[App] wallet-switch stop of unowned node (saved_config_): %s\n", sent ? "sent" : "FAILED");
// CRITICAL: wait for the node to FULLY EXIT before the caller starts the replacement — NOT just for the
// RPC port. On Windows isRpcPortInUse() is a connect() probe that reads "free" the moment the daemon
// stops accepting RPC (early in shutdown), but the process keeps the DATADIR LOCK until it exits, and a
// graceful shutdown can take 60-90s when the node's network threads block on peer timeouts. Starting a
// replacement into a still-locked datadir fails ("Cannot obtain a lock…") and wedges the switch. So gate
// on the PROCESS being gone (isDaemonProcessRunning) AND the port free. Bounded ~120s while stopping; if
// we couldn't even send the stop (foreign node / bad creds → its port never frees), only a brief grace.
const int maxTicks = sent ? 1200 : 50;
auto stillUp = []() {
return daemon::EmbeddedDaemon::isRpcPortInUse() || daemon::EmbeddedDaemon::isDaemonProcessRunning();
};
for (int i = 0; i < maxTicks && stillUp() && !shutting_down_; ++i)
std::this_thread::sleep_for(std::chrono::milliseconds(100));
return shutting_down_ || !stillUp();
}
void App::rescanBlockchain()
{
if (!supportsFullNodeLifecycleActions()) {
@@ -4154,6 +4327,11 @@ void App::rescanBlockchain()
ui::Notifications::instance().warning("A blockchain maintenance operation is already in progress.");
return;
}
// Don't race a wallet switch / seed-adopt / encryption restart, which drive their own daemon stop/start.
if (daemon_restarting_) {
ui::Notifications::instance().warning("The node is busy restarting — try again in a moment.");
return;
}
DEBUG_LOGF("[App] Starting blockchain rescan - stopping daemon first\n");
ui::Notifications::instance().info("Restarting daemon with -rescan flag...");
@@ -4201,6 +4379,10 @@ void App::repairWallet()
ui::Notifications::instance().warning("A blockchain maintenance operation is already in progress.");
return;
}
if (daemon_restarting_) {
ui::Notifications::instance().warning("The node is busy restarting — try again in a moment.");
return;
}
DEBUG_LOGF("[App] Starting wallet repair (-zapwallettxes=2) - stopping daemon first\n");
ui::Notifications::instance().info("Restarting daemon with -zapwallettxes=2 (wallet repair)...");
@@ -4374,6 +4556,10 @@ void App::beginShutdown()
// the adopt task will NOT restart the daemon (it checks shutting_down_ before startEmbeddedDaemon).
if (async_tasks_.isRunning("Adopt seed wallet"))
async_tasks_.join("Adopt seed wallet");
// The wallet-switch task also drives daemon stop/start — let it finish before shutdown touches the
// daemon so it can't orphan a freshly-started dragonxd (it checks shutting_down_ before starting).
if (async_tasks_.isRunning("Switch wallet"))
async_tasks_.join("Switch wallet");
// Signal the RPC worker to stop accepting new tasks (non-blocking), and abort any call
// already in flight so the later join() doesn't wait out a request timeout.
@@ -5114,7 +5300,10 @@ bool App::isFirstRun() const {
}
bool App::hasPinVault() const {
return vault_ && vault_->hasVault() && settings_ && settings_->getPinEnabled();
// Gate purely on the (now per-wallet) vault presence — a vault exists iff a PIN was set for THIS
// wallet. The old extra `getPinEnabled()` gate was a GLOBAL flag, so disabling PIN on one wallet
// wrongly suppressed another wallet's PIN quick-unlock after a switch.
return vault_ && vault_->hasVault();
}
bool App::debugGateRequiresAuth() const {

101
src/app.h
View File

@@ -139,6 +139,9 @@ public:
* @brief Whether we are in the shutdown phase
*/
bool isShuttingDown() const { return shutting_down_; }
// True while the wallet-switch progress modal is open — keeps the frame loop redrawing so its live
// phase/spinner update in real time even when the app is otherwise idle.
bool isWalletSwitchInProgress() const { return wallet_switch_dialog_open_.load(); }
wallet::WalletCapabilities walletCapabilities() const { return wallet::currentWalletCapabilities(); }
bool isLiteBuild() const { return wallet::isLiteBuild(walletCapabilities()); }
bool supportsEmbeddedDaemon() const { return wallet::supportsEmbeddedDaemon(walletCapabilities()); }
@@ -176,6 +179,9 @@ public:
// message (to a conversation whose peer key we know) / a new-conversation contact request.
void sendChatMessage(const std::string& conversationId, const std::string& text);
void startChatConversation(const std::string& peerZaddr, const std::string& text);
// Send a contact request into an existing conversation (used to retry a failed request in place).
void sendContactRequestForCid(const std::string& cid, const std::string& peerZaddr,
const std::string& text);
// Debug/sweep convenience: give the Chat tab a demo identity + a few sample conversations
// (in-memory only, not persisted) so the screenshot sweep captures the populated UI. No-op when
// the chat feature is off.
@@ -198,10 +204,16 @@ public:
data::AddressBook& addressBook() { return address_book_; }
const data::AddressBook& addressBook() const { return address_book_; }
// Hash of the active wallet's identity (derived from its address list), used to scope
// per-wallet data (e.g. address-book contacts). Empty until addresses are known (pre-connect).
// Hash of the active wallet's identity (derived from its address list). This is the tx-history
// cache key — it changes when the address set changes, so DON'T scope persistent user data on it.
std::string activeWalletIdentityHash() const;
// Stable per-wallet id ("w:"+hex) for scoping persistent per-wallet data (address-book contacts).
// Generated once and persisted in the wallet index (keyed by wallet file), never recomputed from
// the mutable address set — so creating addresses / locking / disconnecting never changes it.
// Empty only when there is no active wallet file. Establishes + persists the id on first call.
std::string activeWalletScopeId();
data::WalletIndex& walletIndex() { return wallet_index_; }
const data::WalletIndex& walletIndex() const { return wallet_index_; }
@@ -389,6 +401,14 @@ public:
// True when the current full-node wallet is a legacy, pre-seed-phrase wallet (no BIP39 mnemonic)
// that a capable daemon could migrate — the Migrate-to-seed button glows to nudge the user.
bool isPreSeedWallet() const { return wallet_seed_status_ == WalletSeedStatus::NoMnemonic; }
// Authoritative BIP39-mnemonic status of the ACTIVE wallet, decided at runtime by z_exportmnemonic
// (not the offline file probe, which can't tell an HD-but-no-mnemonic wallet from a seed-phrase one).
// 0 = unknown/undecidable, 1 = has a seed phrase, 2 = legacy (no mnemonic).
int activeWalletSeedBadge() const {
if (wallet_seed_status_ == WalletSeedStatus::HasMnemonic) return 1;
if (wallet_seed_status_ == WalletSeedStatus::NoMnemonic) return 2;
return 0;
}
void showAboutDialog() { show_about_ = true; }
// Legacy tab compat — maps int to NavPage
@@ -407,6 +427,12 @@ public:
// Embedded daemon control
bool startEmbeddedDaemon();
void stopEmbeddedDaemon();
// Stop the node specifically for a wallet switch, which MUST free the RPC port to relaunch on
// -wallet=<name>. Unlike stopEmbeddedDaemon() (whose DisconnectOnly policy leaves an adopted/external
// daemon running), this sends a graceful RPC "stop" — using our own connection creds, so only our
// daemon obeys it — even to an adopted daemon, then waits (bounded) for the port to actually release.
// Returns true once the RPC port is free (or we're shutting down).
bool stopDaemonForWalletSwitch();
bool isEmbeddedDaemonRunning() const;
bool isUsingEmbeddedDaemon() const { return supportsEmbeddedDaemon() && use_embedded_daemon_; }
void setUseEmbeddedDaemon(bool use) { use_embedded_daemon_ = use && supportsEmbeddedDaemon(); }
@@ -474,7 +500,11 @@ public:
// Switch the active wallet: persist the new -wallet=<name>, stop the node, restart on it
// (rescan only if it was never synced in this datadir). Per-wallet data follows automatically
// via the identity-scoped caches (P1). No-op if that wallet is already active.
void switchToWallet(const std::string& walletFile);
// stopDaemonConfirmed: skip the "stop the running node?" confirmation (set true when re-entered from
// that dialog). salvage: start the target node with -salvagewallet (repair a corrupt wallet).
void switchToWallet(const std::string& walletFile, bool stopDaemonConfirmed = false, bool salvage = false);
// Main-thread continuation: if a wallet switch's daemon failed to start, revert active_wallet_file.
void processWalletSwitchRevert();
// Wallet encryption helpers
void encryptWalletWithPassphrase(const std::string& passphrase);
@@ -653,11 +683,30 @@ private:
chat::ChatDatabase chat_db_; // persistent backing (seed-derived encryption at rest)
bool chat_identity_provisioned_ = false; // identity set on the service this session
bool chat_identity_fetch_in_flight_ = false; // a z_exportmnemonic worker job is pending
// Bumped by resetChatSession() on every wallet change; an in-flight identity fetch captures the
// value at post time and its completion callback discards its (previous-wallet) secret if the id
// no longer matches — so wallet A's seed can't be provisioned under wallet B via a stale job.
int chat_session_generation_ = 0;
bool chat_identity_unavailable_ = false; // provisioning failed definitively (e.g. non-mnemonic wallet)
// Per-conversation "last seen" watermark (message timestamp) for unread tracking (Q1). Updated when a
// thread is viewed (markChatConversationSeen); wiped in resetChatSession so unread doesn't leak across
// wallets. In-memory only (resets on app restart).
std::map<std::string, std::int64_t> chat_seen_watermark_;
public:
// Total unread incoming chat messages across all conversations (for the sidebar badge). 0 when the
// feature is off / no identity.
int chatUnreadCount() const;
// Mark a conversation read up to latestTs (called by the Chat tab while a thread is displayed).
void markChatConversationSeen(const std::string& cid, std::int64_t latestTs);
private:
// Provision the chat identity once the wallet seed is reachable+unlocked (per-tick, both
// variants); derives via deriveChatIdentityFromSecret and wipes the secret. No-op when the
// feature is off, already provisioned, in flight, or unavailable.
void maybeProvisionChatIdentity();
// Drop the in-memory chat identity + decrypted message store, lock the chat DB, and re-arm
// provisioning. MUST be called whenever the loaded wallet changes (switch / seed-migration adopt)
// so wallet A's private chat can't surface — or be signed with A's keys — under wallet B.
void resetChatSession();
// One-time nudge: on a full-node wallet that has a mnemonic, remind the user (once per
// install) to back up their seed phrase. Cheap early-outs keep it idle until it can act.
void maybeRemindSeedBackup();
@@ -671,11 +720,21 @@ private:
void pollSweepStatus(); // Confirming step: poll sweep confirmations + legacy balance
void beginAdoptSeedWallet(); // stop daemon -> swap wallet.dat -> restart with -rescan
void provisionChatIdentityFromSecret(std::string secret);
std::string chatReplyZaddr(); // a stable (persisted) wallet z-addr for chat
std::string chatReplyZaddr(); // a stable (persisted) wallet z-addr chat IDENTITY (reply-to)
// A spendable z-address that can actually PAY the fee (balance >= fee), preferring the identity
// reply address. The reply-to in the memo stays the identity address, so paying from a different
// funded note is transport-transparent. Empty if no z-address can cover the fee.
std::string chatPayFromZaddr(double fee) const;
std::string generateChatLocalId(const char* prefix, int numBytes) const; // unique echo id / cid
bool broadcastChatMemos(const chat::OutgoingChatMemos& memos); // returns true if submitted
// Broadcast the memos and, when the async op resolves, flip the echo (echoLocalId) to Sent/Failed.
bool broadcastChatMemos(const chat::OutgoingChatMemos& memos, const std::string& echoLocalId); // true if submitted
bool broadcastChatMemosLite(const chat::OutgoingChatMemos& memos); // lite two-recipient send
void ingestLiteChatMemos(const wallet::LiteWalletAppRefreshModel& model); // lite chat receive harvest
// Full-node 0-conf fast path: re-scan just the chat reply address at minconf=0 every refresh cycle
// so incoming messages surface at mempool speed (before a block). Hidden conversations are skipped
// (they still un-hide via the normal confirmed harvest). Self-gated; no-op without a chat identity.
void fastScanChatMemos();
bool chat_fast_scan_in_flight_ = false; // guard against overlapping fast-scan RPCs
// Lite first-run welcome prompt: dismissed for the session once the user picks an action.
bool lite_firstrun_dismissed_ = false;
// Lite send-time unlock: set to show the unlock modal when a spend is attempted while locked.
@@ -725,6 +784,37 @@ private:
// Daemon restart (e.g. after changing debug log categories)
std::atomic<bool> daemon_restarting_{false};
// Wallet-switch failure recovery: the switch worker sets wallet_switch_failed_ when the new
// wallet's daemon won't start/stay up; the main loop then reverts active_wallet_file to
// wallet_switch_prev_file_ (settings writes stay on the main thread) so a broken wallet isn't
// persisted across restarts. daemon_restarting_ stays set until the revert re-arms the reconnect.
std::atomic<bool> wallet_switch_failed_{false};
// Distinguishes the failure reason for the revert message: true when the switch failed because the
// running node wouldn't release the RPC port in time (not because the target wallet is bad).
std::atomic<bool> switch_stop_failed_{false};
// True from a switch until the new wallet's daemon actually connects (onConnected). If the daemon
// instead crash-wedges (a wallet that fails LATE in init, past the fast start grace), the connect
// loop flags a revert so a broken wallet still can't stick.
std::atomic<bool> wallet_switch_pending_confirm_{false};
std::string wallet_switch_prev_file_;
// Confirm-before-stop for a switch that would stop an ADOPTED (externally-running) node. switchToWallet
// sets these and defers to renderSwitchStopDaemonDialog; confirming re-calls switchToWallet(w, true).
bool show_switch_stop_daemon_confirm_ = false;
std::string pending_switch_wallet_file_;
// Live progress for the switch modal: it stays open from confirm through stop → wait-for-exit → start →
// reconnect and auto-closes when the new node connects (onConnected). Phase is worker-updated;
// dialog_open_ gates rendering — both atomic since onConnected/the worker may run off the main thread.
// error_ is written only on the main thread (processWalletSwitchRevert) and read by the (main-thread) UI.
enum class WalletSwitchPhase : int { None = 0, Stopping, Starting, Reconnecting, Failed };
std::atomic<int> wallet_switch_phase_{0};
std::atomic<bool> wallet_switch_dialog_open_{false};
std::string wallet_switch_error_;
double wallet_switch_started_time_ = 0.0; // ImGui::GetTime() captured on first progress frame (elapsed)
// Set when a failed switch's node output indicates the target wallet is CORRUPT — the Failed modal then
// offers a one-click "-salvagewallet" repair, retrying the switch to wallet_switch_target_file_.
std::atomic<bool> switch_wallet_corrupt_{false};
std::string wallet_switch_target_file_; // the wallet we were switching TO (for a salvage retry)
// Set by the deleteBlockchainData worker (item count); the main loop surfaces a completion toast
// and resets it to -1. Atomic because the worker thread writes it and the UI thread reads/clears it.
std::atomic<int> pending_delete_result_{-1};
@@ -1125,6 +1215,7 @@ private:
void renderDecryptWalletDialog();
void renderPinDialogs();
void renderAntivirusHelpDialog();
void renderSwitchStopDaemonDialog(); // confirm before stopping an adopted node to switch wallets
void processDeferredEncryption();
// Private methods - connection

View File

@@ -34,6 +34,7 @@
#include "rpc/rpc_worker.h"
#include "rpc/connection.h"
#include "chat/chat_identity.h" // deriveChatIdentityFromSecret for HushChat identity provisioning
#include "ui/windows/chat_tab.h" // ui::ResetChatTab — wipe chat UI plaintext on a wallet switch
#include <sodium.h> // sodium_memzero for wiping the fetched mnemonic
#include <cctype>
#include "config/settings.h"
@@ -189,6 +190,17 @@ static WarmupText translateWarmup(const std::string& raw)
return {raw.c_str(), ""};
}
// A node told to open a wallet it can't prints one of these to its console: the BDB corrupt-wallet
// recovery ("Failed to rename … .bak", "wallet.dat corrupt, salvage failed") or a generic load error.
// Used to offer a -salvagewallet repair when a switch fails because the target wallet is corrupt.
static bool walletOutputLooksCorrupt(const std::string& out)
{
return out.find("Failed to rename") != std::string::npos
|| out.find("salvage failed") != std::string::npos
|| out.find("wallet.dat corrupt") != std::string::npos
|| out.find("Error loading wallet") != std::string::npos;
}
// Phrases dragonxd prints to its console while initializing, in the order translateWarmup()
// understands them. The most recent matching console line tells us which stage the node is in
// even when the RPC probe just times out (no -28 reply to read).
@@ -463,6 +475,14 @@ void App::tryConnect()
if (isUsingEmbeddedDaemon() && !isEmbeddedDaemonRunning()) {
// Prevent infinite crash-restart loop
if (daemon_controller_ && daemon_controller_->crashCount() >= 3) {
if (wallet_switch_pending_confirm_.load()) {
// The just-switched-to wallet's daemon keeps crashing (e.g. a wallet that
// fails LATE in init, past the fast start grace) — revert to the previous
// wallet rather than wedging on a broken one. The main thread does the
// settings revert (processWalletSwitchRevert) + resets the crash count.
wallet_switch_pending_confirm_.store(false);
wallet_switch_failed_.store(true);
}
{ char buf[128]; snprintf(buf, sizeof(buf), TR("sb_daemon_crashed"), daemon_controller_->crashCount());
connection_status_ = buf; }
VERBOSE_LOGF("[connect #%d] Daemon crashed %d times — not restarting (use Settings > Restart Daemon to retry)\n",
@@ -496,6 +516,13 @@ void App::tryConnect()
void App::onConnected()
{
state_.connected = true;
const bool completedSwitch = wallet_switch_pending_confirm_.exchange(false); // this connect confirms a switch
// A successful connect completes a switch — close/clear the progress modal (kept open through the
// stop → start → reconnect sequence, or already hidden via "continue in background").
if (completedSwitch || wallet_switch_dialog_open_.load()) {
wallet_switch_dialog_open_.store(false);
wallet_switch_phase_.store(static_cast<int>(WalletSwitchPhase::None));
}
state_.daemon_initializing = false; // RPC is answering now; clear the "initializing" overlay
daemon_wait_attempts_ = 0; // re-arm the port-busy / start-failure notifications
daemon_start_error_shown_ = false;
@@ -977,6 +1004,34 @@ std::string App::activeWalletIdentityHash() const
return data::TransactionHistoryCache::walletIdentityHash(identity);
}
std::string App::activeWalletScopeId()
{
if (!settings_) return std::string();
const std::string file = settings_->getActiveWalletFile();
if (file.empty()) return std::string();
// Reuse the persisted id if we have one for this wallet file.
if (const auto* existing = wallet_index_.find(file)) {
if (!existing->scopeId.empty()) return existing->scopeId;
}
// Establish one now: 16 random bytes, "w:"-prefixed so it's distinguishable from the legacy
// 64-hex address-hash scopes we migrate away from.
unsigned char rnd[16];
randombytes_buf(rnd, sizeof(rnd));
static const char* kHex = "0123456789abcdef";
std::string id = "w:";
for (unsigned char b : rnd) { id.push_back(kHex[b >> 4]); id.push_back(kHex[b & 0x0F]); }
data::WalletIndexEntry e;
if (const auto* existing = wallet_index_.find(file)) e = *existing;
e.fileName = file;
if (e.displayName.empty()) e.displayName = file;
e.scopeId = id;
if (wallet_index_.upsert(e)) wallet_index_.save();
return id;
}
void App::updateWalletIndexForActiveWallet(bool markOpened)
{
if (!settings_) return;
@@ -1014,7 +1069,7 @@ void App::updateWalletIndexForActiveWallet(bool markOpened)
if (wallet_index_.upsert(e)) wallet_index_.save();
}
void App::switchToWallet(const std::string& walletFile)
void App::switchToWallet(const std::string& walletFile, bool stopDaemonConfirmed, bool salvage)
{
if (!supportsFullNodeLifecycleActions()) {
ui::Notifications::instance().warning("Full-node lifecycle actions are unavailable in lite build");
@@ -1029,6 +1084,16 @@ void App::switchToWallet(const std::string& walletFile)
ui::Notifications::instance().warning("The node is busy restarting — try again in a moment.");
return;
}
// Don't race the other daemon-lifecycle operations. rescan/repair set state_.sync.rescanning; the
// seed-migration flow drives its own daemon stop/start; an encryption restart sets daemon_restarting_.
if (state_.sync.rescanning) {
ui::Notifications::instance().warning("A rescan or repair is in progress — try again once it finishes.");
return;
}
if (show_seed_migration_) {
ui::Notifications::instance().warning("Finish or cancel the seed migration before switching wallets.");
return;
}
if (!isUsingEmbeddedDaemon()) {
ui::Notifications::instance().warning("Switching wallets needs the embedded daemon — stop any external dragonxd first.");
return;
@@ -1037,14 +1102,44 @@ void App::switchToWallet(const std::string& walletFile)
ui::Notifications::instance().warning("Finish or cancel the pending send before switching wallets.");
return;
}
// If we're connected to a node this session did NOT spawn (no live process handle — it was left
// running by "keep node running", started by the user, or we just direct-connected to a config-provided
// one), confirm before stopping it: switching must stop+restart it on the new wallet, but the user may
// have kept it running on purpose. A node we started ourselves (handle held) restarts silently.
if (!stopDaemonConfirmed && state_.connected && !isEmbeddedDaemonRunning()) {
pending_switch_wallet_file_ = walletFile;
show_switch_stop_daemon_confirm_ = true;
return;
}
// Rescan only if this wallet was never synced in this datadir (freshly imported/new). One we've
// loaded here before just catches up from its recorded block on start — fast.
bool needRescan = true;
if (const auto* e = wallet_index_.find(walletFile)) needRescan = !e->syncedHere;
const std::string prevWallet = settings_->getActiveWalletFile(); // for revert if the new one fails
wallet_switch_prev_file_ = prevWallet;
wallet_switch_target_file_ = walletFile; // remembered for a "salvage" retry from the failure modal
wallet_switch_failed_.store(false);
switch_stop_failed_.store(false); // reason latch for the revert message; a stale one must not leak
switch_wallet_corrupt_.store(false);
wallet_switch_pending_confirm_.store(true); // cleared on a successful connect (onConnected)
settings_->setActiveWalletFile(walletFile);
settings_->save();
// A prior crash-wedge shouldn't block reconnecting to the freshly-launched wallet daemon.
if (daemon_controller_) daemon_controller_->resetCrashCount();
// Re-scope the PIN vault to the new wallet (its own vault-<scope>.dat), and clear any carried-over
// lock-screen attempt/lockout state + wipe the passphrase/PIN entry buffers — the previous wallet's
// failed-unlock counter and typed secrets must not apply to the new wallet.
if (vault_) vault_->setWalletScope(walletFile);
lock_attempts_ = 0;
lock_lockout_timer_ = 0.0f;
lock_error_msg_.clear();
lock_error_timer_ = 0.0f;
lock_unlock_in_progress_ = false;
util::SecureVault::secureZero(lock_passphrase_buf_, sizeof(lock_passphrase_buf_));
util::SecureVault::secureZero(lock_pin_buf_, sizeof(lock_pin_buf_));
// Same restart coordination as the seed-adopt flow: gate the main-loop reconnect, disconnect,
// then stop + restart on a background thread. syncSettings() re-reads active_wallet_file on
@@ -1053,19 +1148,135 @@ void App::switchToWallet(const std::string& walletFile)
daemon_restarting_ = true;
if (rpc_ && rpc_->isConnected()) rpc_->disconnect();
onDisconnected("Switching wallet");
ui::Notifications::instance().info("Switching wallet — the node will restart…");
// The loaded wallet is changing — drop the old wallet's chat identity + decrypted store so it
// can't surface (or sign outgoing chat) under the new wallet. It re-provisions from the new
// wallet's seed once it connects. onDisconnected alone doesn't cover chat.
resetChatSession();
// Open the switch progress modal — it stays up through stop → wait-for-exit → start → reconnect and
// auto-closes when the new node connects (onConnected). The worker advances the phase from here.
wallet_switch_error_.clear();
wallet_switch_started_time_ = 0.0; // re-captured on the first progress frame → elapsed starts fresh
wallet_switch_phase_.store(static_cast<int>(WalletSwitchPhase::Stopping));
wallet_switch_dialog_open_.store(true);
async_tasks_.submit("Switch wallet", [this, needRescan](const util::AsyncTaskManager::Token&) {
// Stop the node fully so it releases the datadir .lock + RPC port before we relaunch.
stopEmbeddedDaemon();
for (int i = 0; i < 60 && isEmbeddedDaemonRunning(); ++i)
std::this_thread::sleep_for(std::chrono::milliseconds(500));
if (needRescan && daemon_controller_) daemon_controller_->setRescanOnNextStart(true);
if (!shutting_down_) startEmbeddedDaemon();
daemon_restarting_ = false; // re-arm reconnect once the new daemon has been launched
async_tasks_.submit("Switch wallet", [this, needRescan, salvage](const util::AsyncTaskManager::Token&) {
bool ok = false;
// Watermark the node's captured console output so we scan only THIS start's lines for a
// corruption signature (below), not stale output from a previous daemon.
std::size_t out_off = 0;
if (daemon_controller_) daemon_controller_->outputSince(out_off);
try {
// Stop the node so it releases the datadir .lock + RPC port before we relaunch. For an adopted
// (external) daemon this sends a graceful RPC "stop" — stopEmbeddedDaemon()'s policy would leave
// it running — and then waits for the RPC PORT to actually free, the correct readiness signal
// (isEmbeddedDaemonRunning() is process-handle-only and reads false immediately for an adopted
// daemon). The wait breaks promptly on shutdown so beginShutdown's join() doesn't freeze the UI.
const bool port_free = stopDaemonForWalletSwitch();
if (!port_free) {
// The old node wouldn't release the port in time — don't start() into a busy port (that
// fast-fails on the isPortInUse check and gets misread as a bad wallet). Revert with an
// accurate, distinct reason instead.
switch_stop_failed_.store(true);
ok = false;
} else {
// The relaunch is ours — clear the adopted-external latch so the fresh process is treated
// as owned (stop/isRunning/exit behave normally for the next switch and app exit).
wallet_switch_phase_.store(static_cast<int>(WalletSwitchPhase::Starting));
if (daemon_controller_) daemon_controller_->clearExternalDaemonDetected();
if (salvage && daemon_controller_) daemon_controller_->setSalvageOnNextStart(true); // repair a corrupt wallet
else if (needRescan && daemon_controller_) daemon_controller_->setRescanOnNextStart(true); // (salvage implies rescan)
// Start ONCE. Do NOT retry-spawn: a second start while the first is still shutting down leaves
// two dragonxd holding wallet.dat against each other (BDB "Failed to rename … Error"). The
// stopDaemonForWalletSwitch() wait already ensured the old node's process is gone, so a valid
// wallet opens cleanly; a bad/corrupt wallet exits during init and we revert.
ok = shutting_down_ ? true : startEmbeddedDaemon();
// A missing/corrupt wallet makes dragonxd exit during init — confirm the process survived a
// moment; if not, the wallet is bad. (A valid launch keeps the process alive while it syncs.)
if (ok && !shutting_down_) {
std::this_thread::sleep_for(std::chrono::milliseconds(1500));
if (!isEmbeddedDaemonRunning()) ok = false;
}
// If it died in init, scan this start's captured output for a corrupt-wallet signature so the
// failure modal can offer a repair. Give the dying node a moment to flush its final lines.
if (!ok && !shutting_down_ && daemon_controller_) {
for (int i = 0; i < 12 && !shutting_down_; ++i) std::this_thread::sleep_for(std::chrono::milliseconds(100));
if (walletOutputLooksCorrupt(daemon_controller_->outputSince(out_off)))
switch_wallet_corrupt_.store(true);
}
// Process is up and staying up — now waiting for RPC to answer (onConnected closes the modal).
if (ok) wallet_switch_phase_.store(static_cast<int>(WalletSwitchPhase::Reconnecting));
}
} catch (...) {
ok = false; // a throw during stop/start is a failed switch — revert rather than wedge
}
if (ok) {
daemon_restarting_ = false; // re-arm reconnect once the new daemon is up
} else {
// Leave the reconnect GATED (daemon_restarting_ stays true) and hand the revert to the main
// thread — settings writes + vault re-scope must not run on this worker thread. The revert
// re-arms the gate, so it can never get stuck true.
wallet_switch_failed_.store(true);
}
});
}
// Main-thread continuation for a failed wallet switch: restore the previous wallet file so a broken
// active_wallet_file isn't persisted, re-scope the vault back, and re-arm the reconnect so the connect
// loop brings the (good) previous wallet's daemon back up. Called each tick from update().
void App::processWalletSwitchRevert()
{
if (!wallet_switch_failed_.exchange(false)) return;
wallet_switch_pending_confirm_.store(false);
if (settings_ && !wallet_switch_prev_file_.empty() &&
settings_->getActiveWalletFile() != wallet_switch_prev_file_) {
settings_->setActiveWalletFile(wallet_switch_prev_file_);
settings_->save();
if (vault_) vault_->setWalletScope(wallet_switch_prev_file_);
}
// Clear the crash-wedge so the (good) previous wallet's daemon is allowed to start again.
if (daemon_controller_) daemon_controller_->resetCrashCount();
// Accurate reason: "port wouldn't free" (the running node kept the connection); "wallet is corrupt"
// (the node's recovery couldn't open it); else a generic bad-wallet failure.
const std::string reason =
switch_stop_failed_.exchange(false)
? std::string("Couldn't switch wallets — the running node didn't release its connection in time. "
"It's still on the previous wallet.")
: switch_wallet_corrupt_.load()
? std::string(TR("switch_corrupt_body"))
: std::string("Couldn't open that wallet — reverted to the previous one.");
// Surface it in the progress modal if it's still up (keep it open on Failed until the user closes);
// otherwise (the user chose "continue in background") fall back to a toast.
if (wallet_switch_dialog_open_.load()) {
wallet_switch_error_ = reason;
wallet_switch_phase_.store(static_cast<int>(WalletSwitchPhase::Failed));
} else {
ui::Notifications::instance().error(reason);
}
daemon_restarting_ = false; // the connect loop now restarts the previous wallet's daemon
}
int App::chatUnreadCount() const
{
if (!chat::hushChatFeatureEnabledAtBuild() || !chat_service_.hasIdentity()) return 0;
int unread = 0;
const auto& store = chat_service_.store();
for (const auto& cid : store.conversationIds()) {
if (settings_ && settings_->isChatMuted(cid)) continue; // muted conversations don't badge (Q10)
if (settings_ && settings_->isChatHidden(cid)) continue; // hidden conversations don't badge
std::int64_t seen = 0;
const auto it = chat_seen_watermark_.find(cid);
if (it != chat_seen_watermark_.end()) seen = it->second;
for (const auto& m : store.conversation(cid))
if (m.direction == chat::ChatDirection::Incoming && m.timestamp > seen) ++unread;
}
return unread;
}
void App::markChatConversationSeen(const std::string& cid, std::int64_t latestTs)
{
if (latestTs > 0) chat_seen_watermark_[cid] = latestTs;
}
void App::wipePendingTransactionHistoryCachePassphrase()
{
if (!pending_transaction_history_cache_passphrase_.empty()) {
@@ -1507,7 +1718,22 @@ void App::refreshTransactionData()
!result.hushChatMetadata.empty()) {
std::unordered_map<std::string, std::int64_t> chatTxTimes;
for (const auto& tx : result.transactions) chatTxTimes[tx.txid] = tx.timestamp;
chat_service_.ingest(result.hushChatMetadata, chatTxTimes, std::time(nullptr));
std::vector<std::string> newChatCids;
chat_service_.ingest(result.hushChatMetadata, chatTxTimes, std::time(nullptr), &newChatCids);
// A new message un-hides a hidden conversation (you can't un-receive), so nothing is lost.
if (settings_) {
bool unhid = false;
for (const auto& cid : newChatCids)
if (settings_->isChatHidden(cid)) { settings_->setChatHidden(cid, false); unhid = true; }
if (unhid) settings_->save();
}
// Toast when a NON-muted conversation received a new message and we're off the Chat tab.
// Gate on the ingest-reported cids, not a seen-watermark delta — the latter can be swallowed
// by block-time vs wall-clock (echo) skew (Q10 + Q4).
if (current_page_ != ui::NavPage::Chat &&
std::any_of(newChatCids.begin(), newChatCids.end(),
[this](const std::string& cid){ return !(settings_ && settings_->isChatMuted(cid)); }))
ui::Notifications::instance().info(TR("chat_new_message_toast"));
}
NetworkRefreshService::applyTransactionRefreshResult(
state_, cacheUpdate, std::move(result), std::time(nullptr));
@@ -1565,7 +1791,22 @@ void App::refreshRecentTransactionData()
!result.hushChatMetadata.empty()) {
std::unordered_map<std::string, std::int64_t> chatTxTimes;
for (const auto& tx : result.transactions) chatTxTimes[tx.txid] = tx.timestamp;
chat_service_.ingest(result.hushChatMetadata, chatTxTimes, std::time(nullptr));
std::vector<std::string> newChatCids;
chat_service_.ingest(result.hushChatMetadata, chatTxTimes, std::time(nullptr), &newChatCids);
// A new message un-hides a hidden conversation (you can't un-receive), so nothing is lost.
if (settings_) {
bool unhid = false;
for (const auto& cid : newChatCids)
if (settings_->isChatHidden(cid)) { settings_->setChatHidden(cid, false); unhid = true; }
if (unhid) settings_->save();
}
// Toast when a NON-muted conversation received a new message and we're off the Chat tab.
// Gate on the ingest-reported cids, not a seen-watermark delta — the latter can be swallowed
// by block-time vs wall-clock (echo) skew (Q10 + Q4).
if (current_page_ != ui::NavPage::Chat &&
std::any_of(newChatCids.begin(), newChatCids.end(),
[this](const std::string& cid){ return !(settings_ && settings_->isChatMuted(cid)); }))
ui::Notifications::instance().info(TR("chat_new_message_toast"));
}
NetworkRefreshService::applyTransactionRefreshResult(
state_, cacheUpdate, std::move(result), std::time(nullptr));
@@ -2447,7 +2688,7 @@ void App::exportPrivateKey(const std::string& address, std::function<void(const
std::string err;
try {
rpc::RPCClient::TraceScope trace("Settings / Export private key");
key = rpc_->call(method, {address}).get<std::string>();
key = rpc_->callSecretString(method, {address}); // scrubs raw body + json node (B7)
} catch (const std::exception& e) {
err = e.what();
}
@@ -2493,6 +2734,13 @@ void App::provisionChatIdentityFromSecret(std::string secret)
chat_service_.setPersistence(&chat_db_);
if (chat_db_.unlockWithSecret(trimmed)) {
chat_service_.loadFromDatabase();
// Baseline unread: treat everything already in the store at load as read, so only messages
// that arrive while the app is open badge as unread (Q1).
const auto& store = chat_service_.store();
for (const auto& cid : store.conversationIds()) {
const auto msgs = store.conversation(cid);
if (!msgs.empty()) chat_seen_watermark_[cid] = msgs.back().timestamp;
}
}
} else {
chat_identity_unavailable_ = true;
@@ -2506,6 +2754,24 @@ void App::provisionChatIdentityFromSecret(std::string secret)
// tick; cheap early-outs keep it idle until it can act. Both variants derive the SAME identity
// because they feed the SAME SDXLite-compatible mnemonic into the KDF (identity derivation is
// local — peers only ever exchange public keys).
void App::resetChatSession()
{
if (!chat::hushChatFeatureEnabledAtBuild()) return; // constexpr — folds away in OFF builds
// Drop identity + decrypted plaintext in RAM, lock the seed-encrypted DB, re-arm provisioning.
chat_service_.clearIdentity();
chat_service_.store().clear();
chat_seen_watermark_.clear(); // unread state is per-wallet — don't leak it across a switch
ui::ResetChatTab(); // wipe the chat UI's typed plaintext + selection so it can't leak into the next wallet
chat_db_.lock();
chat_identity_provisioned_ = false;
chat_identity_fetch_in_flight_ = false;
chat_fast_scan_in_flight_ = false; // a stale in-flight fast-scan is dropped by its session guard
chat_identity_unavailable_ = false;
// Invalidate any in-flight identity fetch that captured the PREVIOUS wallet's secret, so its
// completion callback can't provision that secret under the new wallet.
++chat_session_generation_;
}
void App::maybeProvisionChatIdentity()
{
if (!chat::hushChatFeatureEnabledAtBuild()) return; // constexpr — folds away in OFF builds
@@ -2514,13 +2780,7 @@ void App::maybeProvisionChatIdentity()
// in-memory identity and re-arm so it re-derives on the next unlock. Unencrypted wallets report
// isLocked()==false and fall through.
if (state_.isLocked()) {
if (chat_identity_provisioned_ || chat_service_.hasIdentity()) {
chat_service_.clearIdentity();
chat_service_.store().clear(); // decrypted plaintext in RAM — drop it; DB reloads on unlock
chat_db_.lock();
chat_identity_provisioned_ = false;
chat_identity_unavailable_ = false;
}
if (chat_identity_provisioned_ || chat_service_.hasIdentity()) resetChatSession();
return;
}
@@ -2547,14 +2807,15 @@ void App::maybeProvisionChatIdentity()
if (!state_.connected || !rpc_ || !worker_) return;
if (!state_.encryption_state_known) return; // don't fetch before we know the lock state
const std::string fallbackZaddr = chatReplyZaddr(); // main thread: stable identity source
const int fetchGen = chat_session_generation_; // wallet epoch this secret belongs to
chat_identity_fetch_in_flight_ = true;
worker_->post([this, fallbackZaddr]() -> rpc::RPCWorker::MainCb {
worker_->post([this, fallbackZaddr, fetchGen]() -> rpc::RPCWorker::MainCb {
std::string secret; // the mnemonic (portable) OR a spending key (legacy fallback)
bool transientFail = false; // connection blip — allow a later retry
bool unavailable = false; // no usable secret — stop retrying this session
rpc::RPCClient::TraceScope trace("HushChat / identity");
try {
auto response = rpc_->call("z_exportmnemonic");
auto response = rpc_->callSecret("z_exportmnemonic"); // zero the raw body too (B7)
if (response.contains("mnemonic") && response["mnemonic"].is_string()) {
// Scrub the json's own copy of the seed after taking ours (the rest of the RPC
// response chain is unmanaged — a fuller fix belongs in the RPC layer).
@@ -2568,7 +2829,14 @@ void App::maybeProvisionChatIdentity()
unavailable = true;
} else {
try {
secret = rpc_->call("z_exportkey", {fallbackZaddr}).get<std::string>();
// Mirror the mnemonic path: take our copy, then scrub the json's own copy of the
// spending key so it isn't left in freed heap (B4). z_exportkey returns a bare string.
auto keyResp = rpc_->callSecret("z_exportkey", {fallbackZaddr}); // zero the raw body too (B7)
if (keyResp.is_string()) {
auto& key = keyResp.get_ref<std::string&>();
secret = key;
if (!key.empty()) sodium_memzero(&key[0], key.size());
}
} catch (const rpc::RpcError&) {
unavailable = true; // no spending key either (view-only?) — give up
} catch (const std::exception&) {
@@ -2578,7 +2846,14 @@ void App::maybeProvisionChatIdentity()
} catch (const std::exception&) {
transientFail = true;
}
return [this, secret = std::move(secret), transientFail, unavailable]() mutable {
return [this, secret = std::move(secret), transientFail, unavailable, fetchGen]() mutable {
// The wallet changed while this fetch ran (resetChatSession bumped the generation): this
// secret belongs to the PREVIOUS wallet — wipe it and do nothing so it can't provision an
// identity under the new wallet. Don't touch the flags either (they belong to the new epoch).
if (fetchGen != chat_session_generation_) {
if (!secret.empty()) sodium_memzero(&secret[0], secret.size());
return;
}
chat_identity_fetch_in_flight_ = false;
if (unavailable) chat_identity_unavailable_ = true;
// Provision only on success AND while still unlocked: a lock (e.g. auto-lock) can
@@ -2626,6 +2901,27 @@ std::string App::chatReplyZaddr()
return best;
}
std::string App::chatPayFromZaddr(double fee) const
{
// z_sendmany spends from ONE z-address, so the "from" must itself hold >= fee. Prefer the identity
// reply address (keeps from == reply-to, the simplest case); otherwise pick the highest-balance
// spendable z-address that can cover the fee. The memo still advertises the identity address as
// reply-to, so paying from a different note doesn't change who the peer replies to.
std::string reply;
if (settings_) reply = settings_->getChatReplyZaddr();
for (const auto& a : state_.z_addresses)
if (a.address == reply && a.has_spending_key && a.balance >= fee) return reply;
std::string best;
double bestBal = -1.0;
for (const auto& a : state_.z_addresses)
if (a.has_spending_key && !a.address.empty() && a.balance >= fee && a.balance > bestBal) {
best = a.address;
bestBal = a.balance;
}
return best; // empty → no z-address can cover the fee
}
// A unique opaque id (hex), used for the local echo id (we never harvest our own sends) and for
// new conversation ids. Random — collisions are astronomically unlikely.
std::string App::generateChatLocalId(const char* prefix, int numBytes) const
@@ -2645,21 +2941,43 @@ std::string App::generateChatLocalId(const char* prefix, int numBytes) const
// LIVE-VERIFY (cannot be proven from source): that dragonxd returns the memo under `memoStr` verbatim
// on receive, that recipient-array order maps to note position (the receive pairing needs the header
// at a lower position), that a 0-value memo-only tx relays, and full SDXL<->DragonX interop.
bool App::broadcastChatMemos(const chat::OutgoingChatMemos& memos)
// A chat send moves 0 value, so the fee is the ONLY thing that forces a real shielded input — a
// 0-value + 0-fee tx builds a degenerate, unrelayable tx (see z_sendmany targetAmount). Never let the
// global default-fee setting drop chat below a working minimum (well above the node's min relay fee).
static constexpr double kChatMinFeeDrgx = 0.0001;
bool App::broadcastChatMemos(const chat::OutgoingChatMemos& memos, const std::string& echoLocalId)
{
if (memos.recipientZaddr.empty()) return false;
// Guard the async resolve against a wallet lock/switch between submit and callback: if the chat
// session was reset in between, this echo belongs to a stale session — don't touch the new store.
const auto sessionGen = chat_session_generation_;
if (lite_wallet_) {
return broadcastChatMemosLite(memos);
const bool ok = broadcastChatMemosLite(memos);
// The lite backend has no per-send completion callback here; resolve optimistically on a
// successful queue (its own broadcast log surfaces a later failure).
if (ok && sessionGen == chat_session_generation_)
chat_service_.resolveOutgoing(echoLocalId, chat::ChatDelivery::Sent);
return ok;
}
if (!state_.connected || !rpc_ || !worker_) {
ui::Notifications::instance().error(TR("chat_toast_not_connected"));
return false;
}
const std::string from = chatReplyZaddr(); // spendable + advertised as the reply-to address
// Chat moves 0 value, and dragonxd REJECTS a 0-value tx whose fee exceeds the default miners fee
// (0.0001) — so pin chat to exactly kChatMinFeeDrgx, independent of the user's default-fee setting
// (which may be higher). It's also well above the node's min relay fee.
const double fee = kChatMinFeeDrgx;
// Pay from a z-address that can actually cover the fee. The memo still advertises the identity
// reply address, so the peer replies to the right place regardless of which note paid.
const std::string from = chatPayFromZaddr(fee);
if (from.empty()) {
ui::Notifications::instance().error(TR("chat_toast_no_zaddr"));
ui::Notifications::instance().error(TR("chat_toast_need_funds"));
return false;
}
@@ -2674,12 +2992,17 @@ bool App::broadcastChatMemos(const chat::OutgoingChatMemos& memos)
recipients.push_back(std::move(recipient));
}
const double fee = settings_ ? settings_->getDefaultFee() : 0.0001;
// markFeeGapRetry=true is deliberate: it suppresses the fee-gap auto-retry, which rebuilds a
// SINGLE-recipient tx from the scalar to/amount/memo and would drop the second (payload) output.
// The callback flips the echo to Sent/Failed once the async op resolves — real delivery status.
submitZSendMany(from, memos.recipientZaddr, 0.0, fee, /*memo*/"", recipients,
"HushChat / broadcast", /*markFeeGapRetry*/ true, /*callback*/{});
return true; // submitted (async build/broadcast; a later failure surfaces via the opid poller)
"HushChat / broadcast", /*markFeeGapRetry*/ true,
[this, echoLocalId, sessionGen](bool ok, const std::string& /*result*/) {
if (sessionGen != chat_session_generation_) return; // wallet locked/switched — stale
chat_service_.resolveOutgoing(echoLocalId,
ok ? chat::ChatDelivery::Sent : chat::ChatDelivery::Failed);
});
return true; // submitted (async build/broadcast; the callback resolves the final status)
}
// Lite variant: two 0-value recipients to the same z-address, RAW memos (the backend does
@@ -2710,12 +3033,17 @@ void App::sendChatMessage(const std::string& conversationId, const std::string&
if (!chat::hushChatFeatureEnabledAtBuild() || !chat_service_.hasIdentity()) return;
if (text.empty() || conversationId.empty()) return;
// The peer's z-address + public key come from a message we already have in this conversation.
// The peer's z-address + public key come from a message we already have in this conversation. Pin to
// the EARLIEST (establishing) values, not the latest: the memo header's `z`/`cid` ride outside the
// AEAD, so trusting the newest would let a later message redirect our replies to an attacker-chosen
// address / splice threads (B2). A full fix binds z+cid into the secretstream additional-data, but
// that's a coordinated HushChat/SDXLite wire-format change; pinning hardens the reply target without it.
std::string peerZaddr;
std::string peerPubKey;
for (const auto& m : chat_service_.store().conversation(conversationId)) {
if (!m.peer_zaddr.empty()) peerZaddr = m.peer_zaddr;
if (!m.peer_public_key_hex.empty()) peerPubKey = m.peer_public_key_hex;
if (peerZaddr.empty() && !m.peer_zaddr.empty()) peerZaddr = m.peer_zaddr;
if (peerPubKey.empty() && !m.peer_public_key_hex.empty()) peerPubKey = m.peer_public_key_hex;
if (!peerZaddr.empty() && !peerPubKey.empty()) break;
}
if (peerPubKey.empty()) {
ui::Notifications::instance().info(TR("chat_toast_waiting_reply"));
@@ -2733,8 +3061,6 @@ void App::sendChatMessage(const std::string& conversationId, const std::string&
ui::Notifications::instance().error(TR("chat_toast_compose_failed"));
return;
}
const bool submitted = broadcastChatMemos(memos);
chat::ChatMessage echo;
echo.direction = chat::ChatDirection::Outgoing;
echo.kind = chat::ChatMessageKind::Message;
@@ -2745,29 +3071,40 @@ void App::sendChatMessage(const std::string& conversationId, const std::string&
echo.timestamp = std::time(nullptr);
echo.txid = generateChatLocalId("out:", 8);
echo.payload_position = 0;
echo.delivery = submitted ? chat::ChatDelivery::Sent : chat::ChatDelivery::Failed;
chat_service_.recordOutgoing(echo);
echo.delivery = chat::ChatDelivery::Sending; // shows immediately; the broadcast callback resolves it
chat_service_.recordOutgoingPending(echo); // in-memory now; persisted with the final status
// A synchronous refusal (not connected / no funds) resolves to Failed right away so the Retry
// affordance appears; otherwise the async callback flips it to Sent/Failed.
if (!broadcastChatMemos(memos, echo.txid))
chat_service_.resolveOutgoing(echo.txid, chat::ChatDelivery::Failed);
}
void App::startChatConversation(const std::string& peerZaddr, const std::string& text)
{
if (!chat::hushChatFeatureEnabledAtBuild() || !chat_service_.hasIdentity()) return;
if (peerZaddr.empty() || text.empty()) return;
sendContactRequestForCid(generateChatLocalId("", 16), peerZaddr, text); // new opaque cid
}
// Compose + broadcast a contact request into a SPECIFIC conversation. startChatConversation() mints a
// fresh cid; a Retry of a failed request reuses its existing cid so it stays in the same thread.
void App::sendContactRequestForCid(const std::string& cid, const std::string& peerZaddr,
const std::string& text)
{
if (!chat::hushChatFeatureEnabledAtBuild() || !chat_service_.hasIdentity()) return;
if (cid.empty() || peerZaddr.empty() || text.empty()) return;
const std::string myReply = chatReplyZaddr();
if (myReply.empty()) {
ui::Notifications::instance().error(TR("chat_toast_no_zaddr"));
return;
}
const std::string cid = generateChatLocalId("", 16); // opaque per-conversation id
chat::OutgoingChatMemos memos;
if (chat_service_.composeContactRequest(myReply, peerZaddr, cid, text, memos)
!= chat::ChatComposeStatus::Ok) {
ui::Notifications::instance().error(TR("chat_toast_request_compose_failed"));
return;
}
const bool submitted = broadcastChatMemos(memos);
chat::ChatMessage echo;
echo.direction = chat::ChatDirection::Outgoing;
echo.kind = chat::ChatMessageKind::ContactRequest;
@@ -2777,9 +3114,12 @@ void App::startChatConversation(const std::string& peerZaddr, const std::string&
echo.timestamp = std::time(nullptr);
echo.txid = generateChatLocalId("out:", 8);
echo.payload_position = 0;
echo.delivery = submitted ? chat::ChatDelivery::Sent : chat::ChatDelivery::Failed;
chat_service_.recordOutgoing(echo);
if (submitted) ui::Notifications::instance().success(TR("chat_toast_request_queued"));
echo.delivery = chat::ChatDelivery::Sending;
chat_service_.recordOutgoingPending(echo);
if (broadcastChatMemos(memos, echo.txid))
ui::Notifications::instance().success(TR("chat_toast_request_queued"));
else
chat_service_.resolveOutgoing(echo.txid, chat::ChatDelivery::Failed);
}
// HushChat (lite variant): the full-node harvest works off z_viewtransaction, but the lite wallet's
@@ -2808,7 +3148,93 @@ void App::ingestLiteChatMemos(const wallet::LiteWalletAppRefreshModel& model)
auto extracted = chat::extractHushChatTransactionMetadata(entry.second, true);
for (auto& meta : extracted.metadata) metadata.push_back(std::move(meta));
}
if (!metadata.empty()) chat_service_.ingest(metadata, txTimestamps, std::time(nullptr));
if (!metadata.empty()) {
std::vector<std::string> newChatCids;
chat_service_.ingest(metadata, txTimestamps, std::time(nullptr), &newChatCids);
// Mirror the full-node paths: a new incoming message un-hides a hidden conversation (you can't
// un-receive), and a non-muted new message toasts when we're off the Chat tab.
if (settings_) {
bool unhid = false;
for (const auto& cid : newChatCids)
if (settings_->isChatHidden(cid)) { settings_->setChatHidden(cid, false); unhid = true; }
if (unhid) settings_->save();
}
if (current_page_ != ui::NavPage::Chat &&
std::any_of(newChatCids.begin(), newChatCids.end(),
[this](const std::string& cid){ return !(settings_ && settings_->isChatMuted(cid)); }))
ui::Notifications::instance().info(TR("chat_new_message_toast"));
}
}
void App::fastScanChatMemos()
{
// Full-node only (lite has its own harvest). Re-scan JUST the chat reply address at minconf=0 every
// cycle so peers' messages surface at mempool speed. The normal, block-tip-gated harvest still
// ingests everything on confirmation (the store dedups on txid+position, so no double-insert).
if (lite_wallet_) return;
if (!chat::hushChatFeatureEnabledAtBuild() || !chat_service_.hasIdentity()) return;
if (!state_.connected || !rpc_ || !worker_) return;
if (chat_fast_scan_in_flight_) return; // don't stack RPCs if a previous scan is still running
const std::string addr = chatReplyZaddr();
if (addr.empty()) return;
chat_fast_scan_in_flight_ = true;
const int scanGen = chat_session_generation_; // guard: drop the result if the wallet switches/locks
worker_->post([this, addr, scanGen]() -> rpc::RPCWorker::MainCb {
std::vector<chat::HushChatTransactionMetadata> metadata;
try {
rpc::RPCClient::TraceScope trace("HushChat / 0-conf fast scan");
nlohmann::json received = rpc_->call("z_listreceivedbyaddress", {addr, 0}); // 0 = include mempool
if (received.is_array()) {
std::unordered_map<std::string, chat::HushChatTransactionInput> byTxid;
std::size_t noteIndex = 0;
for (const auto& note : received) {
const std::size_t fallbackPos = noteIndex++;
if (!note.is_object()) continue;
const std::string txid = note.value("txid", std::string());
const std::string memo = note.value("memoStr", std::string());
if (txid.empty() || memo.empty()) continue;
std::size_t pos = fallbackPos;
for (const char* key : {"position", "outputIndex", "outindex"})
if (note.contains(key) && note[key].is_number_integer() && note[key].get<int>() >= 0) {
pos = static_cast<std::size_t>(note[key].get<int>());
break;
}
auto& in = byTxid[txid];
in.txid = txid;
in.outputs.push_back(chat::HushChatMemoOutput{pos, memo});
}
for (auto& entry : byTxid) {
auto extracted = chat::extractHushChatTransactionMetadata(entry.second);
for (auto& m : extracted.metadata) metadata.push_back(std::move(m));
}
}
} catch (const std::exception&) {}
return [this, scanGen, metadata = std::move(metadata)]() mutable {
// The wallet was switched/locked between post and now — this metadata belongs to the previous
// session. resetChatSession already reset the in-flight flag, so just drop; clearing it here
// would clobber a new session's own in-flight scan (mirrors the broadcast/identity guards).
if (scanGen != chat_session_generation_) return;
chat_fast_scan_in_flight_ = false;
if (metadata.empty()) return;
// Skip HIDDEN conversations — the mempool fast path deliberately doesn't surface them; they
// still come back through the normal confirmed harvest (which un-hides on a new message).
std::vector<chat::HushChatTransactionMetadata> visible;
visible.reserve(metadata.size());
for (auto& m : metadata)
if (!(settings_ && settings_->isChatHidden(m.conversation_id)))
visible.push_back(std::move(m));
if (visible.empty()) return;
std::unordered_map<std::string, std::int64_t> noTimes; // mempool: no block time → ingest uses now
std::vector<std::string> newChatCids;
chat_service_.ingest(visible, noTimes, std::time(nullptr), &newChatCids);
if (current_page_ != ui::NavPage::Chat &&
std::any_of(newChatCids.begin(), newChatCids.end(),
[this](const std::string& cid){ return !(settings_ && settings_->isChatMuted(cid)); }))
ui::Notifications::instance().info(TR("chat_new_message_toast"));
};
});
}
bool App::lockLiteWallet()
@@ -2836,12 +3262,22 @@ void App::seedChatDemoData()
// Demo identity so the tab renders the populated UI (hasIdentity()==true). Injected messages go
// straight into the in-memory store (no DB attached here → not persisted, gone on restart).
chat::ChatKeyPair keys;
if (chat::deriveChatIdentityFromSecret("obsidian-dragon-demo-chat", keys).status
== chat::ChatIdentityStatus::Ready) {
chat_service_.setIdentity(keys);
//
// Two hard rules, both learned the hard way:
// 1. NEVER clobber a real provisioned identity. Overwriting the wallet's seed-derived identity with
// a demo one made two different-seed wallets share an identity — so their own sends looped back
// and DECRYPTED as incoming. Only fabricate an identity when there is no real one.
// 2. Use a RANDOM secret, never a fixed string. A constant demo secret is the SAME public key on
// every install/wallet, i.e. everyone who ran the demo shared one cryptographic identity.
if (!chat_service_.hasIdentity()) {
chat::ChatKeyPair keys;
const std::string demoSecret = generateChatLocalId("demo-chat:", 24); // random, per-run
if (chat::deriveChatIdentityFromSecret(demoSecret, keys).status
== chat::ChatIdentityStatus::Ready) {
chat_service_.setIdentity(keys);
}
chat::wipeChatKeyPair(keys);
}
chat::wipeChatKeyPair(keys);
auto& store = chat_service_.store();
const std::string zAlice = "zs1demoalice6xh2n8fchrz23thcgqqd2353v8ev2pr7p7lq4p3elsyrfkuenq";
@@ -3151,7 +3587,7 @@ void App::exportSeedPhrase(std::function<void(bool, bool, const std::string&, co
bool noMnemonic = false;
rpc::RPCClient::TraceScope trace("Settings / Export seed phrase");
try {
auto response = rpc_->call("z_exportmnemonic");
auto response = rpc_->callSecret("z_exportmnemonic"); // zero the raw body too (B7)
if (response.contains("mnemonic") && response["mnemonic"].is_string()) {
auto& m = response["mnemonic"].get_ref<std::string&>();
phrase = m;
@@ -3429,6 +3865,13 @@ void App::beginAdoptSeedWallet()
daemon_restarting_ = true;
if (rpc_ && rpc_->isConnected()) rpc_->disconnect();
onDisconnected("Adopting seed wallet");
// Adopting swaps in a brand-new seed wallet — drop the legacy wallet's chat identity + store so
// it re-derives from the new seed (the file name is unchanged, so nothing else detects the swap).
resetChatSession();
// The PIN vault is name-scoped, and the file name is unchanged, so the legacy wallet's stored
// passphrase would otherwise stay associated with the new seed wallet — remove it (the new wallet
// has its own passphrase; the user can re-enable PIN quick-unlock for it).
if (vault_) vault_->removeVault();
const std::string base = seed_migration_temp_dir_;
async_tasks_.submit("Adopt seed wallet", [this, base](const util::AsyncTaskManager::Token&) {
namespace fs = std::filesystem;
@@ -3437,11 +3880,14 @@ void App::beginAdoptSeedWallet()
bool swapDone = false;
try {
std::error_code ec;
// 1. Stop the main daemon and wait for it to fully exit.
stopEmbeddedDaemon();
for (int i = 0; i < 60 && isEmbeddedDaemonRunning(); ++i)
std::this_thread::sleep_for(std::chrono::milliseconds(500));
if (isEmbeddedDaemonRunning()) {
// 1. Stop the main daemon and wait for it to FULLY release wallet.dat + the RPC port before we
// swap the file. Use the wallet-switch stop path: for an ADOPTED (external) daemon it sends a
// graceful RPC "stop" and gates on the port actually freeing — isEmbeddedDaemonRunning() is
// process-handle-only and would falsely read "stopped" for an adopted daemon, letting the swap
// run against a live daemon still holding wallet.dat. (For an owned daemon stopEmbeddedDaemon()
// already blocks for full process exit, so the file is closed before the swap.)
const bool port_free = stopDaemonForWalletSwitch();
if (!port_free) {
err = "The daemon did not stop in time; your wallet was not changed.";
} else {
// 2. Swap wallet.dat. Move the legacy one aside to a timestamped backup (NEVER
@@ -3483,6 +3929,11 @@ void App::beginAdoptSeedWallet()
// unless we're quitting, in which case don't resurrect it.
if (swapDone && daemon_controller_) daemon_controller_->setRescanOnNextStart(true);
if (!shutting_down_) {
// We stopped the daemon ourselves (port_free) — clear the adopted-external latch so the
// relaunched process is treated as owned (stop/isRunning/exit behave normally afterward).
// Skip when the stop failed: the old adopted daemon is still up, and we must not mark a
// process we can't control as owned.
if (port_free && daemon_controller_) daemon_controller_->clearExternalDaemonDetected();
if (!startEmbeddedDaemon() && swapDone)
warn = "Your wallet was swapped, but the daemon did not restart — start it from Settings.";
}

View File

@@ -241,16 +241,23 @@ void App::restartDaemonAfterEncryption(const char* taskName, bool announceRestar
// the daemon is restarting.
connection_status_ = TR("restarting_after_encryption");
}
// Gate the main-loop reconnect while the daemon is down (so tryConnect can't hit the stopped
// node or start a duplicate) and block a concurrent wallet switch/rescan, which check this flag.
daemon_restarting_ = true;
// Give daemon a moment to shut down, then restart
// (do this off the main thread to avoid stalling the UI)
async_tasks_.submit(taskName, [this](const util::AsyncTaskManager::Token& token) {
for (int i = 0; i < 20 && !token.cancelled() && !shutting_down_; ++i)
std::this_thread::sleep_for(std::chrono::milliseconds(100));
if (token.cancelled() || shutting_down_) return;
stopEmbeddedDaemon();
if (token.cancelled() || shutting_down_) return;
startEmbeddedDaemon();
// tryConnect will be called by the update loop
// daemon_restarting_ MUST be cleared on every exit (incl. an early-out or a throw), else it
// stays stuck true and wedges reconnect + all future switch/rescan/encryption operations.
try {
for (int i = 0; i < 20 && !token.cancelled() && !shutting_down_; ++i)
std::this_thread::sleep_for(std::chrono::milliseconds(100));
if (!token.cancelled() && !shutting_down_) {
stopEmbeddedDaemon();
if (!token.cancelled() && !shutting_down_) startEmbeddedDaemon();
}
} catch (...) {}
daemon_restarting_ = false; // re-arm reconnect (tryConnect runs from the update loop)
});
} else {
ui::Notifications::instance().warning(
@@ -892,8 +899,8 @@ void App::renderLockScreen() {
cy += captionFont->LegacySize + 12.0f * dp;
}
// Check if PIN vault is available
bool hasPinVault = vault_ && vault_->hasVault() && settings_ && settings_->getPinEnabled();
// Check if PIN vault is available (per-wallet vault presence; not the global getPinEnabled flag).
bool hasPinVault = vault_ && vault_->hasVault();
// Mode toggle (PIN / Passphrase) — only show if PIN vault exists
if (hasPinVault) {

View File

@@ -376,6 +376,14 @@ void App::buildSweepCatalog()
add("modal-antivirus", ui::NavPage::Mining,
[](App& a) { a.pending_antivirus_dialog_ = true; },
[](App& a) { a.pending_antivirus_dialog_ = false; });
add("modal-switch-stopnode", ui::NavPage::Settings,
[](App& a) { a.pending_switch_wallet_file_ = "wallet-savings.dat"; a.show_switch_stop_daemon_confirm_ = true; },
[](App& a) { a.show_switch_stop_daemon_confirm_ = false; a.pending_switch_wallet_file_.clear(); });
add("modal-switch-progress", ui::NavPage::Settings,
[](App& a) { a.wallet_switch_phase_.store(static_cast<int>(App::WalletSwitchPhase::Stopping));
a.wallet_switch_dialog_open_.store(true); },
[](App& a) { a.wallet_switch_dialog_open_.store(false);
a.wallet_switch_phase_.store(static_cast<int>(App::WalletSwitchPhase::None)); });
// Wave-2 fund/secret dialogs (setup never fires the async RPC — no button is clicked).
add("modal-shield", ui::NavPage::Send,
[](App&) { ui::ShieldDialog::showShieldCoinbase(); },

View File

@@ -129,6 +129,35 @@ bool ChatDatabase::append(const ChatMessage& message)
return sqlite3_changes(db_) > 0;
}
bool ChatDatabase::upsert(const ChatMessage& message)
{
if (!key_ready_ || !ensureOpen()) return false;
std::vector<unsigned char> nonce;
std::vector<unsigned char> cipher;
std::string plain = serialize(message);
const bool encrypted = encrypt(plain, nonce, cipher);
if (!plain.empty()) sodium_memzero(&plain[0], plain.size());
if (!encrypted) return false;
const std::string dedup = dedupHash(message.txid, message.payload_position);
sqlite3_stmt* stmt = nullptr;
if (sqlite3_prepare_v2(db_,
"INSERT INTO chat_messages (wallet_tag, dedup_hash, nonce, payload) VALUES (?, ?, ?, ?) "
"ON CONFLICT(wallet_tag, dedup_hash) DO UPDATE SET nonce=excluded.nonce, payload=excluded.payload",
-1, &stmt, nullptr) != SQLITE_OK) {
return false;
}
sqlite3_bind_text(stmt, 1, wallet_tag_.c_str(), -1, SQLITE_TRANSIENT);
sqlite3_bind_text(stmt, 2, dedup.c_str(), -1, SQLITE_TRANSIENT);
sqlite3_bind_blob(stmt, 3, nonce.data(), static_cast<int>(nonce.size()), SQLITE_TRANSIENT);
sqlite3_bind_blob(stmt, 4, cipher.data(), static_cast<int>(cipher.size()), SQLITE_TRANSIENT);
const bool done = sqlite3_step(stmt) == SQLITE_DONE;
sqlite3_finalize(stmt);
return done;
}
std::vector<ChatMessage> ChatDatabase::load()
{
std::vector<ChatMessage> out;
@@ -267,7 +296,10 @@ bool ChatDatabase::deserialize(const std::string& json, ChatMessage& out) const
out.body = parsed.value("b", std::string());
out.timestamp = parsed.value("ts", static_cast<std::int64_t>(0));
out.payload_position = static_cast<std::size_t>(parsed.value("pos", static_cast<std::uint64_t>(0)));
out.delivery = static_cast<ChatDelivery>(parsed.value("dl", 0)); // old rows → Sent
out.delivery = static_cast<ChatDelivery>(parsed.value("dl", 0)); // old rows → Sent (0)
// A persisted "Sending" means we crashed mid-broadcast; the outcome is unknown. Resolve it
// optimistically to Sent on load so it can't show a stuck spinner forever.
if (out.delivery == ChatDelivery::Sending) out.delivery = ChatDelivery::Sent;
return true;
} catch (const std::exception&) {
return false;

View File

@@ -43,6 +43,11 @@ public:
// Returns true if newly inserted; false on duplicate or while locked.
bool append(const ChatMessage& message);
// Persist-or-overwrite one message by its (txid+position) dedup key. Unlike append(), this
// updates an existing row's payload — used for outgoing echoes whose delivery status changes
// (Sending → Sent/Failed). Returns true on success; false while locked / on error.
bool upsert(const ChatMessage& message);
// Decrypt and return every stored message for the unlocked wallet, in insertion order. Empty
// while locked or if none. Rows that fail to decrypt/parse are skipped.
std::vector<ChatMessage> load();

View File

@@ -10,9 +10,11 @@ namespace dragonx::chat {
enum class ChatDirection { Incoming, Outgoing };
enum class ChatMessageKind { Message, ContactRequest };
// Outgoing delivery: Sent = the broadcast was submitted; Failed = it wasn't (not connected, no
// spendable address, a send already in progress). Always Sent for incoming.
enum class ChatDelivery { Sent, Failed };
// Outgoing delivery status. Sending = broadcast in flight (async op not yet resolved); Sent = the
// daemon accepted + broadcast the tx; Failed = it didn't (not connected, no funded address, rejected).
// Always Sent for incoming. NB: the values are persisted (chat DB serializes the int), so Sent MUST
// stay 0 and new states are APPENDED — never reordered.
enum class ChatDelivery { Sent, Failed, Sending };
struct ChatMessage {
ChatDirection direction = ChatDirection::Incoming;

View File

@@ -25,11 +25,20 @@ void ChatService::clearIdentity() {
int ChatService::ingest(const std::vector<HushChatTransactionMetadata>& metadata,
const std::unordered_map<std::string, std::int64_t>& txTimestamps,
std::int64_t fallbackTimestamp) {
std::int64_t fallbackTimestamp,
std::vector<std::string>* newIncomingCids) {
if (!has_identity_) return 0;
const std::string myPubKey = chatIdentityPublicKeyHex(identity_);
int added = 0;
for (const auto& meta : metadata) {
// A memo whose sender is our OWN identity is something we sent (only we hold our key). The local
// echo already records it as outgoing — ingesting it as incoming would duplicate it as a phantom
// "from peer" message. (This also collapses same-seed self-chat, where the "peer" wallet shares
// our identity, so every message would otherwise loop back.)
if (!myPubKey.empty() && meta.sender_public_key_hex == myPubKey) continue;
ChatMessage message;
message.direction = ChatDirection::Incoming;
message.txid = meta.txid;
@@ -59,6 +68,9 @@ int ChatService::ingest(const std::vector<HushChatTransactionMetadata>& metadata
if (store_.append(message)) {
if (db_) db_->append(message);
++added;
// Every ingested message is incoming — report its cid so the caller can notify without
// relying on a seen-watermark delta (which block-time vs wall-clock skew can swallow).
if (newIncomingCids) newIncomingCids->push_back(message.conversation_id);
}
}
return added;
@@ -105,4 +117,18 @@ bool ChatService::recordOutgoing(const ChatMessage& message) {
return false;
}
bool ChatService::recordOutgoingPending(const ChatMessage& message) {
// Persist immediately (as Sending) so a send survives an app quit before the broadcast resolves;
// resolveOutgoing() then UPSERTS the row to the final status. A stray persisted Sending (crash mid-
// broadcast) loads as Sent (see ChatDatabase::deserialize).
const bool appended = store_.append(message);
if (appended && db_) db_->upsert(message);
return appended;
}
void ChatService::resolveOutgoing(const std::string& txid, ChatDelivery delivery) {
const ChatMessage* updated = store_.updateDelivery(txid, delivery);
if (updated && db_) db_->upsert(*updated); // overwrite the Sending row with the final status
}
} // namespace dragonx::chat

View File

@@ -40,9 +40,13 @@ public:
// when the txid isn't present. Newly-added messages are also persisted (if a database is
// attached). Returns the number of NEW messages added; 0 with no identity. Undecryptable
// Messages are dropped silently (no logging of memo/plaintext).
// When `newIncomingCids` is non-null it is filled with the conversation ids of the genuinely-new
// incoming (non-request) messages appended this call — a reliable "a new message arrived here"
// signal for notifications that doesn't depend on any timestamp/seen-watermark comparison.
int ingest(const std::vector<HushChatTransactionMetadata>& metadata,
const std::unordered_map<std::string, std::int64_t>& txTimestamps,
std::int64_t fallbackTimestamp = 0);
std::int64_t fallbackTimestamp = 0,
std::vector<std::string>* newIncomingCids = nullptr);
// Attach a persistent backing store (Phase 2). Not owned. Pass nullptr to detach. New messages
// from ingest() are written through; loadFromDatabase() rehydrates the in-memory store from it.
@@ -72,6 +76,12 @@ public:
// only local record of what we sent.)
bool recordOutgoing(const ChatMessage& message);
// Two-phase echo for delivery tracking: record + persist immediately as Sending (survives an app
// quit), then resolveOutgoing() upserts the final status once the broadcast completes. A stray
// persisted Sending (crash mid-broadcast) loads back as Sent.
bool recordOutgoingPending(const ChatMessage& message);
void resolveOutgoing(const std::string& txid, ChatDelivery delivery);
const ChatStore& store() const { return store_; }
ChatStore& store() { return store_; }

View File

@@ -2,6 +2,8 @@
#include "chat_store.h"
#include <algorithm>
namespace dragonx::chat {
std::string ChatStore::dedupKey(const ChatMessage& message) {
@@ -19,9 +21,29 @@ std::vector<ChatMessage> ChatStore::conversation(const std::string& conversation
for (const auto& message : messages_) {
if (message.conversation_id == conversationId) out.push_back(message);
}
// Return chronological (oldest→newest). messages_ is in scan/insertion order, which is NOT
// time-ordered (a full scan harvests txids in std::set/unordered_map order) — that would mis-order the
// rendered thread AND let the reply-target pin (first-seen peer address) latch onto a non-establishing
// message (B2). timestamp is the block/tx time (peer can't set it); tie-break txid + payload_position
// for determinism.
std::stable_sort(out.begin(), out.end(), [](const ChatMessage& a, const ChatMessage& b) {
if (a.timestamp != b.timestamp) return a.timestamp < b.timestamp;
if (a.txid != b.txid) return a.txid < b.txid;
return a.payload_position < b.payload_position;
});
return out;
}
const ChatMessage* ChatStore::updateDelivery(const std::string& txid, ChatDelivery delivery) {
for (auto& message : messages_) {
if (message.txid == txid) {
message.delivery = delivery;
return &message;
}
}
return nullptr;
}
std::vector<std::string> ChatStore::conversationIds() const {
std::vector<std::string> ids;
std::unordered_set<std::string> seenIds;

View File

@@ -21,6 +21,10 @@ public:
// Messages in a conversation, in insertion order.
std::vector<ChatMessage> conversation(const std::string& conversationId) const;
// Update an outgoing echo's delivery status by its local txid. Returns a pointer to the updated
// message (for the caller to persist), or nullptr if no message has that txid.
const ChatMessage* updateDelivery(const std::string& txid, ChatDelivery delivery);
// Distinct conversation ids, in first-seen order.
std::vector<std::string> conversationIds() const;

View File

@@ -147,6 +147,16 @@ bool Settings::load(const std::string& path)
loadScalar(j, "language", language_);
loadScalar(j, "skin_id", skin_id_);
loadScalar(j, "chat_reply_zaddr", chat_reply_zaddr_);
if (j.contains("muted_chat_cids") && j["muted_chat_cids"].is_array()) {
muted_chat_cids_.clear();
for (const auto& c : j["muted_chat_cids"])
if (c.is_string()) muted_chat_cids_.push_back(c.get<std::string>());
}
if (j.contains("hidden_chat_cids") && j["hidden_chat_cids"].is_array()) {
hidden_chat_cids_.clear();
for (const auto& c : j["hidden_chat_cids"])
if (c.is_string()) hidden_chat_cids_.push_back(c.get<std::string>());
}
loadScalar(j, "acrylic_enabled", acrylic_enabled_);
loadScalar(j, "acrylic_quality", acrylic_quality_);
loadScalar(j, "blur_multiplier", blur_multiplier_);
@@ -422,6 +432,12 @@ bool Settings::save(const std::string& path)
j["language"] = language_;
j["skin_id"] = skin_id_;
j["chat_reply_zaddr"] = chat_reply_zaddr_;
j["muted_chat_cids"] = json::array();
for (const auto& c : muted_chat_cids_)
j["muted_chat_cids"].push_back(c);
j["hidden_chat_cids"] = json::array();
for (const auto& c : hidden_chat_cids_)
j["hidden_chat_cids"].push_back(c);
j["acrylic_enabled"] = acrylic_enabled_;
j["acrylic_quality"] = acrylic_quality_;
j["blur_multiplier"] = blur_multiplier_;

View File

@@ -118,6 +118,31 @@ public:
std::string getChatReplyZaddr() const { return chat_reply_zaddr_; }
void setChatReplyZaddr(const std::string& z) { chat_reply_zaddr_ = z; }
// Muted chat conversations (by cid) — muted conversations don't badge or raise a toast (Q10).
bool isChatMuted(const std::string& cid) const {
return std::find(muted_chat_cids_.begin(), muted_chat_cids_.end(), cid) != muted_chat_cids_.end();
}
void setChatMuted(const std::string& cid, bool muted) {
const bool already = isChatMuted(cid);
if (muted && !already) muted_chat_cids_.push_back(cid);
else if (!muted && already)
muted_chat_cids_.erase(std::remove(muted_chat_cids_.begin(), muted_chat_cids_.end(), cid),
muted_chat_cids_.end());
}
// Hidden chat conversations (by cid) — hidden ones are filtered out of the list; a new incoming
// message un-hides them (you can't un-receive) so nothing is silently lost.
bool isChatHidden(const std::string& cid) const {
return std::find(hidden_chat_cids_.begin(), hidden_chat_cids_.end(), cid) != hidden_chat_cids_.end();
}
void setChatHidden(const std::string& cid, bool hidden) {
const bool already = isChatHidden(cid);
if (hidden && !already) hidden_chat_cids_.push_back(cid);
else if (!hidden && already)
hidden_chat_cids_.erase(std::remove(hidden_chat_cids_.begin(), hidden_chat_cids_.end(), cid),
hidden_chat_cids_.end());
}
// Privacy
bool getSaveZtxs() const { return save_ztxs_; }
void setSaveZtxs(bool save) { save_ztxs_ = save; }
@@ -473,6 +498,8 @@ private:
std::string theme_ = "dragonx";
std::string skin_id_ = "dragonx";
std::string chat_reply_zaddr_;
std::vector<std::string> muted_chat_cids_; // muted chat conversations by cid (Q10)
std::vector<std::string> hidden_chat_cids_; // hidden chat conversations by cid
bool save_ztxs_ = true;
bool auto_shield_ = true;
bool use_tor_ = false;

View File

@@ -61,6 +61,11 @@ bool DaemonController::externalDaemonDetected() const
return daemon_->externalDaemonDetected();
}
void DaemonController::clearExternalDaemonDetected()
{
daemon_->clearExternalDaemonDetected();
}
DaemonController::State DaemonController::state() const
{
return daemon_->getState();
@@ -116,6 +121,11 @@ void DaemonController::setZapOnNextStart(bool enabled)
daemon_->setZapOnNextStart(enabled);
}
void DaemonController::setSalvageOnNextStart(bool enabled)
{
daemon_->setSalvageOnNextStart(enabled);
}
bool DaemonController::zapOnNextStart() const
{
return daemon_->zapOnNextStart();

View File

@@ -93,6 +93,7 @@ public:
bool isRunning() const;
bool externalDaemonDetected() const;
void clearExternalDaemonDetected();
State state() const;
const std::string& lastError() const;
int crashCount() const;
@@ -106,6 +107,7 @@ public:
bool rescanOnNextStart() const;
void setZapOnNextStart(bool enabled);
bool zapOnNextStart() const;
void setSalvageOnNextStart(bool enabled);
static ShutdownDecision evaluateShutdownPolicy(bool hasDaemon,
bool externalDaemonDetected,

View File

@@ -386,52 +386,80 @@ static std::string getPortOwnerInfo(int port)
#endif
}
// Check if a TCP port is already in use (something is LISTENING)
// Check if a TCP port is already in use (something is LISTENING). The daemon binds BOTH 127.0.0.1 (IPv4)
// and ::1 (IPv6); during shutdown one can linger after the other releases (and a "Binding RPC on ::1 …
// failed" is fatal to a fresh start), so we treat the port as in use if EITHER localhost family has it.
static bool isPortInUse(int port)
{
#ifdef _WIN32
WSADATA wsa;
if (WSAStartup(MAKEWORD(2, 2), &wsa) != 0) return false;
SOCKET sock = socket(AF_INET, SOCK_STREAM, IPPROTO_TCP);
if (sock == INVALID_SOCKET) { WSACleanup(); return false; }
struct sockaddr_in addr;
addr.sin_family = AF_INET;
addr.sin_port = htons(static_cast<u_short>(port));
addr.sin_addr.s_addr = inet_addr("127.0.0.1");
int result = connect(sock, (struct sockaddr*)&addr, sizeof(addr));
closesocket(sock);
bool inUse = false;
{ // IPv4 127.0.0.1
SOCKET sock = socket(AF_INET, SOCK_STREAM, IPPROTO_TCP);
if (sock != INVALID_SOCKET) {
struct sockaddr_in addr; memset(&addr, 0, sizeof(addr));
addr.sin_family = AF_INET;
addr.sin_port = htons(static_cast<u_short>(port));
addr.sin_addr.s_addr = inet_addr("127.0.0.1");
if (connect(sock, (struct sockaddr*)&addr, sizeof(addr)) == 0) inUse = true;
closesocket(sock);
}
}
if (!inUse) { // IPv6 ::1
SOCKET sock = socket(AF_INET6, SOCK_STREAM, IPPROTO_TCP);
if (sock != INVALID_SOCKET) {
struct sockaddr_in6 addr; memset(&addr, 0, sizeof(addr));
addr.sin6_family = AF_INET6;
addr.sin6_port = htons(static_cast<u_short>(port));
addr.sin6_addr = in6addr_loopback; // ::1 — avoids inet_pton's _WIN32_WINNT gating on mingw
if (connect(sock, (struct sockaddr*)&addr, sizeof(addr)) == 0) inUse = true;
closesocket(sock);
}
}
WSACleanup();
return (result == 0);
return inUse;
#else
// On macOS /proc doesn't exist; on Linux prefer /proc/net/tcp to avoid
// creating sockets. Fall back to connect() if /proc is unavailable.
FILE* fp = fopen("/proc/net/tcp", "r");
if (fp) {
char line[256];
// On macOS /proc doesn't exist; on Linux prefer /proc/net/tcp{,6} to avoid creating sockets. The
// parse is family-agnostic: %*X skips the local IP (8 hex for v4, 32 for v6), %X grabs the port.
auto scanProc = [port](const char* path) -> bool {
FILE* fp = fopen(path, "r");
if (!fp) return false;
char line[512];
unsigned int localPort, state;
bool found = false;
while (fgets(line, sizeof(line), fp)) {
if (sscanf(line, " %*d: %*X:%X %*X:%*X %X", &localPort, &state) == 2) {
if (localPort == static_cast<unsigned int>(port) && state == 0x0A) {
found = true;
break;
}
if (localPort == static_cast<unsigned int>(port) && state == 0x0A) { found = true; break; }
}
}
fclose(fp);
return found;
};
if (FILE* probe = fopen("/proc/net/tcp", "r")) { // /proc available → authoritative LISTEN check
fclose(probe);
return scanProc("/proc/net/tcp") || scanProc("/proc/net/tcp6");
}
// Fallback (macOS): try to connect
int sock = socket(AF_INET, SOCK_STREAM, 0);
if (sock < 0) return false;
struct sockaddr_in addr;
memset(&addr, 0, sizeof(addr));
addr.sin_family = AF_INET;
addr.sin_port = htons(static_cast<uint16_t>(port));
addr.sin_addr.s_addr = htonl(INADDR_LOOPBACK);
int result = connect(sock, (struct sockaddr*)&addr, sizeof(addr));
close(sock);
return (result == 0);
// Fallback (macOS): connect() probe on both loopback families.
auto connProbe = [port](int family, const char* addr) -> bool {
int sock = socket(family, SOCK_STREAM, 0);
if (sock < 0) return false;
bool ok = false;
if (family == AF_INET) {
struct sockaddr_in a; memset(&a, 0, sizeof(a));
a.sin_family = AF_INET; a.sin_port = htons(static_cast<uint16_t>(port));
a.sin_addr.s_addr = htonl(INADDR_LOOPBACK);
ok = (connect(sock, (struct sockaddr*)&a, sizeof(a)) == 0);
} else {
struct sockaddr_in6 a; memset(&a, 0, sizeof(a));
a.sin6_family = AF_INET6; a.sin6_port = htons(static_cast<uint16_t>(port));
inet_pton(AF_INET6, addr, &a.sin6_addr);
ok = (connect(sock, (struct sockaddr*)&a, sizeof(a)) == 0);
}
close(sock);
return ok;
};
return connProbe(AF_INET, "127.0.0.1") || connProbe(AF_INET6, "::1");
#endif
}
@@ -496,9 +524,16 @@ bool EmbeddedDaemon::start(const std::string& binary_path)
args.push_back("-wallet=" + wallet_file_);
}
// Add wallet-repair flag if requested (one-shot). -zapwallettxes=2 wipes all wallet tx/note
// records and rebuilds them from the chain; it implies -rescan, so don't also pass -rescan.
if (zap_on_next_start_.exchange(false)) {
// Add wallet-repair flag if requested (one-shot). Precedence: salvage > zap > rescan; each implies a
// rescan in the daemon, so we don't stack them.
if (salvage_on_next_start_.exchange(false)) {
// -salvagewallet recovers readable keypairs from a corrupt wallet.dat; the daemon then implies -rescan.
DEBUG_LOGF("[INFO] Adding -salvagewallet flag to recover a corrupt wallet\n");
args.push_back("-salvagewallet");
zap_on_next_start_.store(false);
rescan_on_next_start_.store(false);
} else if (zap_on_next_start_.exchange(false)) {
// -zapwallettxes=2 wipes all wallet tx/note records and rebuilds them from the chain (implies -rescan).
DEBUG_LOGF("[INFO] Adding -zapwallettxes=2 flag for wallet repair (zap & rebuild)\n");
args.push_back("-zapwallettxes=2");
rescan_on_next_start_.store(false); // implied by zap; avoid redundant -rescan
@@ -655,18 +690,25 @@ static DWORD findProcessByName(const char* name)
{
HANDLE snap = CreateToolhelp32Snapshot(TH32CS_SNAPPROCESS, 0);
if (snap == INVALID_HANDLE_VALUE) return 0;
PROCESSENTRY32 entry;
// Use the explicit WIDE Toolhelp API + a wide compare so this is correct regardless of the UNICODE
// macro. (The non-suffixed PROCESSENTRY32/Process32First map to the wide variants when UNICODE is
// defined, in which case szExeFile is WCHAR[] and an ANSI _stricmp would compare garbage and NEVER
// match — silently making findProcessByName a no-op that returns 0 for a running process.)
wchar_t wname[MAX_PATH];
if (MultiByteToWideChar(CP_ACP, 0, name, -1, wname, MAX_PATH) == 0) { CloseHandle(snap); return 0; }
PROCESSENTRY32W entry;
entry.dwSize = sizeof(entry);
DWORD pid = 0;
if (Process32First(snap, &entry)) {
if (Process32FirstW(snap, &entry)) {
do {
if (_stricmp(entry.szExeFile, name) == 0) {
if (lstrcmpiW(entry.szExeFile, wname) == 0) { // Win32 case-insensitive wide compare
pid = entry.th32ProcessID;
break;
}
} while (Process32Next(snap, &entry));
} while (Process32NextW(snap, &entry));
}
CloseHandle(snap);
return pid;
@@ -1247,5 +1289,28 @@ bool EmbeddedDaemon::tcpPortInUse(int port)
return isPortInUse(port);
}
bool EmbeddedDaemon::isDaemonProcessRunning()
{
#ifdef _WIN32
return findProcessByName("dragonxd.exe") != 0;
#elif defined(__linux__)
// Scan /proc for a process whose comm is exactly "dragonxd". Iterate with an error_code so a proc
// entry vanishing mid-scan (a process exiting) can't throw.
std::error_code ec;
fs::directory_iterator it("/proc", ec), end;
for (; !ec && it != end; it.increment(ec)) {
const std::string pid = it->path().filename().string();
if (pid.empty() || pid[0] < '0' || pid[0] > '9') continue; // numeric pid dirs only
std::ifstream f((it->path() / "comm").string());
std::string comm;
if (f && std::getline(f, comm) && comm == "dragonxd") return true;
}
return false;
#else
// macOS has no /proc; fall back to the RPC-port probe (best-effort).
return isPortInUse(std::atoi(DRAGONX_DEFAULT_RPC_PORT));
#endif
}
} // namespace daemon
} // namespace dragonx

View File

@@ -142,6 +142,10 @@ public:
* When true the wallet should connect to it instead of showing an error.
*/
bool externalDaemonDetected() const { return external_daemon_detected_; }
// Clear the adopted-external latch before relaunching our OWN process (e.g. a wallet switch that
// stopped the adopted daemon), so the freshly spawned daemon is treated as owned. start() also
// clears it on the port-free fall-through, but only if not short-circuited by an early guard.
void clearExternalDaemonDetected() { external_daemon_detected_ = false; }
/**
* @brief Set callback for state changes
@@ -197,6 +201,11 @@ public:
void setZapOnNextStart(bool v) { zap_on_next_start_ = v; }
bool zapOnNextStart() const { return zap_on_next_start_.load(); }
// -salvagewallet: attempt to recover keys from a corrupt wallet.dat on startup (implies -rescan in
// the daemon). One-shot, consumed on the next start. Used to repair a wallet a switch flagged corrupt.
void setSalvageOnNextStart(bool v) { salvage_on_next_start_ = v; }
bool salvageOnNextStart() const { return salvage_on_next_start_.load(); }
/**
* @brief One-shot isolated-datadir override for the NEXT start(): run the daemon against a
* different datadir (with its own DRAGONX.conf) plus the given extra args. Used by the
@@ -217,6 +226,15 @@ public:
*/
void setSkipPortCheck(bool v) { skip_port_check_ = v; }
/**
* @brief True while ANY dragonxd process is running (by process name), regardless of who started it.
* Unlike isRpcPortInUse()/isRunning(), this reflects the actual PROCESS still being alive — a
* graceful shutdown stops accepting RPC (port reads "free") but keeps the datadir lock until the
* process exits, which can take up to ~90s. Use this to know a stopped node has FULLY released
* the datadir before starting a replacement. Matches the daemon binary name on all platforms.
*/
static bool isDaemonProcessRunning();
/** @brief Is an arbitrary TCP port currently in use on localhost? (used to pick a free port) */
static bool tcpPortInUse(int port);
@@ -261,6 +279,7 @@ private:
std::atomic<int> crash_count_{0}; // consecutive crash counter
std::atomic<bool> rescan_on_next_start_{false}; // -rescan flag for next start
std::atomic<bool> zap_on_next_start_{false}; // -zapwallettxes=2 flag for next start
std::atomic<bool> salvage_on_next_start_{false}; // -salvagewallet flag for next start
std::string override_datadir_; // one-shot: -datadir for the next start
std::vector<std::string> override_extra_args_; // one-shot: extra args for the next start
bool skip_port_check_ = false; // isolated instance on a non-default port

View File

@@ -110,9 +110,13 @@ SeedWalletResult SeedWalletCreator::create(bool keepDatadir,
// 6. Export the new seed phrase + a fresh shielded receive address (the future sweep target).
try {
auto m = cli.call("z_exportmnemonic");
if (m.contains("mnemonic") && m["mnemonic"].is_string())
r.seedPhrase = m["mnemonic"].get<std::string>();
auto m = cli.callSecret("z_exportmnemonic"); // zero the raw body too (B7)
if (m.contains("mnemonic") && m["mnemonic"].is_string()) {
// Take our copy, then scrub the json node's own copy so it isn't freed in the clear (B7).
auto& mn = m["mnemonic"].get_ref<std::string&>();
r.seedPhrase = mn;
if (!mn.empty()) sodium_memzero(&mn[0], mn.size());
}
r.destAddress = cli.call("z_getnewaddress").get<std::string>();
r.ok = !r.seedPhrase.empty() && !r.destAddress.empty();
if (!r.ok) r.error = "The isolated node returned an empty seed or address.";

View File

@@ -144,6 +144,20 @@ bool AddressBook::removeEntry(size_t index)
return save();
}
int AddressBook::reattachLegacyScopes(const std::string& scopeId)
{
if (scopeId.empty()) return 0;
int rescoped = 0;
for (auto& e : entries_) {
if (e.isGlobal()) continue; // global stays global
if (e.scope.rfind("w:", 0) == 0) continue; // already a stable scope
e.scope = scopeId;
++rescoped;
}
if (rescoped > 0) save();
return rescoped;
}
int AddressBook::findByAddress(const std::string& address) const
{
for (size_t i = 0; i < entries_.size(); i++) {

View File

@@ -86,6 +86,15 @@ public:
*/
bool removeEntry(size_t index);
/**
* @brief Re-attach contacts stuck on a legacy (drifting address-hash) scope to a stable wallet id.
* Rewrites every non-global entry whose scope is NOT already a stable "w:"-prefixed id to
* `scopeId`, and saves once if anything changed. Recovery for contacts orphaned when the
* old address-set-hash scope shifted (e.g. after creating a new address).
* @return number of entries re-scoped.
*/
int reattachLegacyScopes(const std::string& scopeId);
/**
* @brief Find entry by address (any scope). Used for contact-label lookups.
* @param address Address to search for

View File

@@ -25,6 +25,7 @@ bool sameEntry(const WalletIndexEntry& a, const WalletIndexEntry& b)
return a.fileName == b.fileName
&& a.displayName == b.displayName
&& a.walletIdentityHash == b.walletIdentityHash
&& a.scopeId == b.scopeId
&& a.cachedBalance == b.cachedBalance
&& a.cachedAddressCount == b.cachedAddressCount
&& a.lastOpenedEpoch == b.lastOpenedEpoch
@@ -61,6 +62,7 @@ bool WalletIndex::load()
if (w.fileName.empty()) continue;
w.displayName = e.value("name", w.fileName);
w.walletIdentityHash = e.value("identity", "");
w.scopeId = e.value("scopeId", "");
w.cachedBalance = e.value("balance", -1.0);
w.cachedAddressCount = e.value("addresses", (long long)-1);
w.lastOpenedEpoch = e.value("lastOpened", (long long)0);
@@ -96,6 +98,7 @@ bool WalletIndex::save()
e["file"] = w.fileName;
e["name"] = w.displayName;
e["identity"] = w.walletIdentityHash;
e["scopeId"] = w.scopeId;
e["balance"] = w.cachedBalance;
e["addresses"] = w.cachedAddressCount;
e["lastOpened"] = w.lastOpenedEpoch;

View File

@@ -22,6 +22,8 @@ struct WalletIndexEntry {
std::string fileName; // plain wallet filename in the datadir (e.g. "wallet.dat")
std::string displayName; // user-facing name (defaults to fileName)
std::string walletIdentityHash; // address-derived identity of the last load; "" = unknown
std::string scopeId; // stable per-wallet id ("w:"+hex) for scoping contacts etc.;
// generated once, never recomputed from the (mutable) address set
double cachedBalance = -1.0; // last-known total balance; < 0 = unknown (never opened)
long long cachedAddressCount = -1;// < 0 = unknown
long long lastOpenedEpoch = 0; // unix seconds of last open; 0 = never opened

View File

@@ -15,3 +15,4 @@ INCBIN(ubuntu_mono, "@CMAKE_SOURCE_DIR@/res/fonts/UbuntuMono-R.ttf");
INCBIN(material_icons, "@CMAKE_SOURCE_DIR@/res/fonts/MaterialIcons-Regular.ttf");
INCBIN(mdi_pickaxe_subset, "@CMAKE_SOURCE_DIR@/res/fonts/MaterialDesignIcons-Pickaxe-Subset.ttf");
INCBIN(noto_cjk_subset, "@CMAKE_SOURCE_DIR@/res/fonts/NotoSansCJK-Subset.ttf");
INCBIN(noto_emoji_subset, "@CMAKE_SOURCE_DIR@/res/fonts/NotoEmoji-Subset.ttf");

View File

@@ -35,4 +35,7 @@ extern "C" {
extern const unsigned char g_noto_cjk_subset_data[];
extern const unsigned int g_noto_cjk_subset_size;
extern const unsigned char g_noto_emoji_subset_data[];
extern const unsigned int g_noto_emoji_subset_size;
}

View File

@@ -439,8 +439,16 @@ static bool InitImGui(SDL_Window* window, SDL_GLContext gl_context);
static void Shutdown(SDL_Window* window, SDL_GLContext gl_context);
#endif
// Global single instance lock
// Global single instance lock. Keyed PER VARIANT so the full node and Lite can run side by side —
// their config dirs are already separate (DRAGONX_APP_NAME), so only this lock kept them apart.
// NB: DRAGONX_LITE_BUILD is ALWAYS defined (0 for the full node, 1 for Lite) via $<BOOL:...>, so this
// must be #if (value), not #ifdef (existence) — #ifdef is true for both and made the full node grab
// the Lite lock.
#if DRAGONX_LITE_BUILD
static dragonx::util::SingleInstance g_single_instance("obsidiandragonlite");
#else
static dragonx::util::SingleInstance g_single_instance("obsidiandragon");
#endif
// Check for payment URI in command line args
static std::string findPaymentURI(int argc, char* argv[])
@@ -764,11 +772,12 @@ int main(int argc, char* argv[])
// Check for existing instance
if (!g_single_instance.tryLock()) {
fprintf(stderr, "Another instance of ObsidianDragon is already running.\n");
fprintf(stderr, "Another instance of %s is already running.\n", DRAGONX_APP_NAME);
DEBUG_LOGF("Please close the existing instance first.\n");
#ifdef _WIN32
MessageBoxW(nullptr, L"Another instance of ObsidianDragon is already running.\nPlease close it first.",
L"ObsidianDragon", MB_OK | MB_ICONINFORMATION);
const std::string msg = std::string("Another instance of ") + DRAGONX_APP_NAME +
" is already running.\nPlease close it first.";
MessageBoxA(nullptr, msg.c_str(), DRAGONX_APP_NAME, MB_OK | MB_ICONINFORMATION);
#endif
return 1;
}
@@ -1966,6 +1975,7 @@ int main(int argc, char* argv[])
bool backdropNeedsFrames = (backdrop_active || app.getGradientTexture() != 0)
&& !opaqueBackground;
bool animating = app.isShuttingDown()
|| app.isWalletSwitchInProgress()
|| backdropNeedsFrames
|| app.hasTransactionSendProgress()
|| app.isTransactionRefreshInProgress()

View File

@@ -23,6 +23,17 @@ namespace rpc {
namespace {
// Recursively zero every string value in a JSON tree in place — used to wipe a discarded parse tree
// that held a secret (B7). Operates on the underlying std::string buffers via get_ref.
void scrubJsonSecrets(nlohmann::json& j) {
if (j.is_string()) {
auto& s = j.get_ref<std::string&>();
if (!s.empty()) sodium_memzero(&s[0], s.size());
} else if (j.is_object() || j.is_array()) {
for (auto& el : j) scrubJsonSecrets(el);
}
}
std::mutex g_trace_mutex;
RPCClient::TraceCallback g_trace_callback;
std::atomic_bool g_trace_enabled{false};
@@ -317,7 +328,7 @@ std::string RPCClient::performCall(const std::string& method, const json& params
return response_data;
}
json RPCClient::parseRpcResult(long httpCode, const std::string& body)
json RPCClient::parseRpcResult(long httpCode, const std::string& body, bool scrubSource)
{
// Bitcoin/Hush RPC returns HTTP 500 for application-level errors
// (insufficient funds, bad params, etc.) with a valid JSON body.
@@ -355,7 +366,9 @@ json RPCClient::parseRpcResult(long httpCode, const std::string& body)
throw RpcError(errCode, "RPC error: " + err_msg);
}
return response["result"];
json result = response["result"]; // a COPY of the subobject (operator[] yields an lvalue ref)
if (scrubSource) scrubJsonSecrets(response); // zero the discarded tree's secret before it frees (B7)
return result;
}
json RPCClient::call(const std::string& method, const json& params)
@@ -370,6 +383,56 @@ json RPCClient::call(const std::string& method, const json& params)
return parseRpcResult(http_code, response_data);
}
json RPCClient::callSecret(const std::string& method, const json& params)
{
std::lock_guard<std::recursive_mutex> lk(curl_mutex_);
if (!impl_->curl) {
throw std::runtime_error("Not connected");
}
// Zero the raw response body whether parsing succeeds or throws — it holds the secret in the
// clear (the curl write buffer performCall returns), and would otherwise be freed un-wiped (B7).
long http_code = 0;
std::string response_data = performCall(method, params, http_code);
try {
json result = parseRpcResult(http_code, response_data, /*scrubSource=*/true);
if (!response_data.empty()) sodium_memzero(&response_data[0], response_data.size());
return result;
} catch (...) {
if (!response_data.empty()) sodium_memzero(&response_data[0], response_data.size());
throw;
}
}
std::string RPCClient::callSecretString(const std::string& method, const json& params)
{
std::lock_guard<std::recursive_mutex> lk(curl_mutex_);
if (!impl_->curl) {
throw std::runtime_error("Not connected");
}
long http_code = 0;
std::string response_data = performCall(method, params, http_code);
try {
json result = parseRpcResult(http_code, response_data, /*scrubSource=*/true);
std::string out;
if (result.is_string()) {
// Copy the secret out, then zero the json node's OWN heap buffer — parseRpcResult's
// json::parse allocates this independently of response_data, so scrubbing the raw body
// alone would leave it behind (B7). `out` is now the only live copy; the caller wipes it.
auto& s = result.get_ref<std::string&>();
out = s;
if (!s.empty()) sodium_memzero(&s[0], s.size());
}
if (!response_data.empty()) sodium_memzero(&response_data[0], response_data.size());
if (!result.is_string()) throw std::runtime_error("RPC result is not a string");
return out;
} catch (...) {
if (!response_data.empty()) sodium_memzero(&response_data[0], response_data.size());
throw;
}
}
json RPCClient::call(const std::string& method, const json& params, long timeoutSec)
{
std::lock_guard<std::recursive_mutex> lk(curl_mutex_);

View File

@@ -133,6 +133,26 @@ public:
*/
json call(const std::string& method, const json& params = json::array());
/**
* @brief Like call(), but zeroes the raw HTTP response body after parsing (B7).
*
* For RPCs whose response carries a secret (z_exportmnemonic / z_exportkey), the parsed
* value is scrubbed by the caller — but the raw response string this method builds also
* holds that secret and is otherwise freed without zeroing. Use this variant so the largest,
* longest-lived plaintext copy doesn't linger in freed heap. Slightly slower (an extra wipe);
* only worth it for secret-bearing calls.
*/
json callSecret(const std::string& method, const json& params = json::array());
/**
* @brief callSecret() for RPCs whose result is a bare secret string (z_exportkey / dumpprivkey /
* z_exportviewingkey). Returns the result string with BOTH the raw response body AND the
* parsed json's own copy of the secret zeroed — so no un-scrubbed heap copy survives the
* call. The returned std::string is the only remaining copy; the caller owns it and must
* wipe it (sodium_memzero) when done. Throws if the result is not a JSON string.
*/
std::string callSecretString(const std::string& method, const json& params = json::array());
/**
* @brief Make a raw RPC call with a custom timeout
* @param method RPC method name
@@ -250,8 +270,10 @@ private:
// hold curl_mutex_ and have verified impl_->curl.
std::string performCall(const std::string& method, const json& params, long& httpCodeOut);
// Centralizes the HTTP-code check and JSON error->RpcError extraction, returning
// response["result"] on success.
static json parseRpcResult(long httpCode, const std::string& body);
// response["result"] on success. When scrubSource is true (secret-bearing calls), the intermediate
// parse tree's string values are zeroed before it is discarded — parseRpcResult returns a COPY of
// the result node, so its own tree would otherwise free the secret un-wiped (B7).
static json parseRpcResult(long httpCode, const std::string& body, bool scrubSource = false);
// Splits a UnifiedCallback into the (Callback, ErrorCallback) pair used by doRPC.
static std::pair<Callback, ErrorCallback> splitUnified(UnifiedCallback cb);

View File

@@ -168,20 +168,6 @@ void appendExtractedHushChatMetadata(std::vector<chat::HushChatTransactionMetada
}
}
void appendExtractedHushChatMetadata(std::vector<chat::HushChatTransactionMetadata>& destination,
const std::string& txid,
const NetworkRefreshService::TransactionViewCacheEntry& entry)
{
if (!chat::hushChatFeatureEnabledAtBuild()) return;
std::vector<chat::HushChatMemoOutput> outputs;
outputs.reserve(entry.outgoing_outputs.size());
for (const auto& output : entry.outgoing_outputs) {
if (!output.memo.empty()) outputs.push_back(chat::HushChatMemoOutput{output.position, output.memo});
}
appendExtractedHushChatMetadata(destination, txid, outputs);
}
void appendExtractedHushChatMetadata(std::vector<chat::HushChatTransactionMetadata>& destination,
const HushChatMemoOutputMap& outputsByTxid)
{
@@ -920,7 +906,10 @@ NetworkRefreshService::TransactionRefreshResult NetworkRefreshService::collectTr
if (cached != snapshot.viewTxCache.end()) {
if (!trackedSend || !cached->second.outgoing_outputs.empty()) {
appendViewTransactionOutputs(result.transactions, txid, cached->second);
appendExtractedHushChatMetadata(result.hushChatMetadata, txid, cached->second);
// NB: do NOT extract chat metadata from our OWN outgoing memos here — ingest marks
// everything Incoming, so that duplicates every sent message as a phantom "from peer"
// entry. Genuine incoming comes from the z_listreceivedbyaddress path; our sends are
// recorded by the local echo. (The recent-refresh variant already omits this.)
continue;
}
}
@@ -945,7 +934,8 @@ NetworkRefreshService::TransactionRefreshResult NetworkRefreshService::collectTr
auto entry = parseViewTransactionCacheEntry(viewTransaction);
appendViewTransactionOutputs(result.transactions, txid, entry);
appendExtractedHushChatMetadata(result.hushChatMetadata, txid, entry);
// (Chat metadata is harvested only from RECEIVED notes, not our own outgoing memos — see
// the cached-view branch above.)
json rawTransaction;
bool hasRawTransaction = false;

View File

@@ -325,11 +325,50 @@ ImFont* Typography::loadFont(ImGuiIO& io, int weight, float size, const char* na
name, g_noto_cjk_subset_size);
}
}
// Merge monochrome emoji for chat + user text (Q12). Only into the small text fonts — baking
// ~1400 emoji at heading sizes would bloat the atlas for glyphs no heading needs. ImGui does no
// shaping, so single-codepoint emoji render but ZWJ sequences / flags won't compose. The
// >0xFFFF ranges require IMGUI_USE_WCHAR32 (imconfig.h).
static const char* const kEmojiFonts[] = {
"Body1", "Body2", "Subtitle1", "Subtitle2", "Caption", "Overline", "Button", "ButtonSm"
};
bool wantEmoji = false;
for (const char* n : kEmojiFonts) if (strcmp(name, n) == 0) { wantEmoji = true; break; }
if (wantEmoji && g_noto_emoji_subset_size > 0) {
void* emojiCopy = IM_ALLOC(g_noto_emoji_subset_size);
memcpy(emojiCopy, g_noto_emoji_subset_data, g_noto_emoji_subset_size);
ImFontConfig emojiCfg;
emojiCfg.FontDataOwnedByAtlas = true;
emojiCfg.MergeMode = true; // merge into the text font just loaded
emojiCfg.OversampleH = 1;
emojiCfg.OversampleV = 1;
emojiCfg.PixelSnapH = true;
emojiCfg.GlyphMinAdvanceX = 0;
// The base Ubuntu font already owns U+260026FF etc.; MergeMode keeps the first-loaded glyph,
// so its text-style symbols win and only the codepoints it lacks fall through to emoji.
static const ImWchar emojiRanges[] = {
0x2600, 0x27BF, // Misc Symbols + Dingbats
0x2B00, 0x2BFF, // stars (⭐) + arrows
0x1F000, 0x1FAFF, // emoji planes (emoticons, pictographs, transport, supplement, extended)
0,
};
emojiCfg.GlyphRanges = emojiRanges;
snprintf(emojiCfg.Name, sizeof(emojiCfg.Name), "NotoEmoji %.0fpx (merge)", size);
ImFont* emojiMerge = io.Fonts->AddFontFromMemoryTTF(emojiCopy, g_noto_emoji_subset_size, size, &emojiCfg);
if (emojiMerge) {
DEBUG_LOGF("Typography: Merged emoji (%u bytes) into %s OK\n", g_noto_emoji_subset_size, name);
} else {
DEBUG_LOGF("Typography: WARNING — emoji merge FAILED for %s (size=%u)\n", name, size);
}
}
} else {
DEBUG_LOGF("Typography: Failed to load %s\n", name);
IM_FREE(fontDataCopy);
}
return font;
}

View File

@@ -149,6 +149,7 @@ struct SidebarStatus {
int unconfirmedTxCount = 0; // badge on History
bool miningActive = false; // green dot on Mining
int peerCount = 0; // badge on Peers
int chatUnreadCount = 0; // badge on Chat (unread incoming messages)
// Exit
bool exitClicked = false;
// Branding logo (optional — loaded at startup)
@@ -715,6 +716,8 @@ inline bool RenderSidebar(NavPage& current, float sidebarWidth, float contentHei
dotOnly = true; badgeCol = Success();
} else if (item.page == NavPage::Peers && status.peerCount > 0) {
badgeCount = status.peerCount;
} else if (item.page == NavPage::Chat && status.chatUnreadCount > 0) {
badgeCount = status.chatUnreadCount;
}
if (badgeCount > 0 || dotOnly) {

View File

@@ -2,21 +2,32 @@
// Copyright 2024-2026 The Hush Developers
// Released under the GPLv3
//
// chat_tab.cpp — read-only HushChat view (Phase 3): a conversation list + the
// selected thread, both read from the App-owned ChatService store.
// chat_tab.cpp — HushChat view: a conversation list + the selected thread (both read
// from the App-owned ChatService store), with a composer and a new-conversation flow.
#include "chat_tab.h"
#include "../../app.h"
#include "../../data/address_book.h"
#include "../../chat/chat_service.h"
#include "../../util/i18n.h"
#include "../../util/platform.h" // getConfigDir + writeFileAtomically — conversation export (Q11)
#include "../../config/settings.h" // per-conversation mute (Q10)
#include "../material/colors.h"
#include "../material/color_theme.h" // WithAlpha
#include "../material/type.h"
#include "../material/draw_helpers.h" // TactileButton / LabeledInput / BeginOverlayDialog
#include "../material/project_icons.h" // ICON_MD_*
#include "../layout.h" // Layout::dpiScale()
#include "../notifications.h" // Notifications — add-to-contacts confirmation
#include "imgui.h"
#include <sodium.h> // sodium_memzero — wipe typed plaintext on a wallet switch
#include <algorithm>
#include <cctype>
#include <cfloat>
#include <cstdint>
#include <cstring>
#include <ctime>
#include <string>
#include <vector>
@@ -32,13 +43,25 @@ std::string s_scroll_to_cid; // when set, scroll the thread to the bottom next
// Composer + new-conversation UI state.
char s_compose[512] = "";
std::string s_compose_cid; // the conversation s_compose is a draft for; draft is wiped when it changes
bool s_show_new_convo = false;
char s_new_zaddr[128] = "";
char s_new_msg[256] = "";
char s_search[80] = ""; // conversation-list filter (Q8)
// Effective draw-list font size for a material font (mirrors sidebar's ScaledFontSize).
float scaledSize(ImFont* f) { return f->LegacySize * ImGui::GetStyle().FontScaleMain; }
// Case-insensitive substring match (ASCII) for the conversation search (Q8).
bool containsCI(const std::string& hay, const std::string& needle) {
if (needle.empty()) return true;
const auto it = std::search(hay.begin(), hay.end(), needle.begin(), needle.end(),
[](char a, char b) {
return std::tolower(static_cast<unsigned char>(a)) == std::tolower(static_cast<unsigned char>(b));
});
return it != hay.end();
}
std::string shorten(const std::string& s, std::size_t head = 12, std::size_t tail = 6) {
if (s.size() <= head + tail + 3) return s;
return s.substr(0, head) + "..." + s.substr(s.size() - tail);
@@ -54,6 +77,23 @@ std::string formatTime(std::int64_t ts) {
return buf;
}
// Compact relative time for the list ("now", "5m", "3h", "2d", then "Mon DD"). UI thread only.
std::string relativeTime(std::int64_t ts) {
if (ts <= 0) return "";
std::int64_t d = static_cast<std::int64_t>(std::time(nullptr)) - ts;
if (d < 0) d = 0;
if (d < 45) return TR("chat_time_now");
if (d < 3600) return std::to_string(d / 60) + "m";
if (d < 86400) return std::to_string(d / 3600) + "h";
if (d < 7 * 86400) return std::to_string(d / 86400) + "d";
std::time_t t = static_cast<std::time_t>(ts);
std::tm* tm = std::localtime(&t);
if (!tm) return "";
char buf[16];
std::strftime(buf, sizeof(buf), "%b %d", tm);
return buf;
}
// One line of collapsed body text for the list preview (newlines flattened).
std::string previewOf(const std::string& body) {
std::string out = body;
@@ -62,6 +102,32 @@ std::string previewOf(const std::string& body) {
return out;
}
// A stable, legible avatar color for a conversation (FNV-1a of the cid → a fixed material palette).
ImU32 avatarColor(const std::string& seed) {
std::uint32_t h = 2166136261u;
for (unsigned char ch : seed) { h ^= ch; h *= 16777619u; }
static const ImU32 kPalette[] = {
IM_COL32(0xEF,0x53,0x50,255), IM_COL32(0xAB,0x47,0xBC,255), IM_COL32(0x5C,0x6B,0xC0,255),
IM_COL32(0x29,0xB6,0xF6,255), IM_COL32(0x26,0xA6,0x9A,255), IM_COL32(0x66,0xBB,0x6A,255),
IM_COL32(0xFF,0xA7,0x26,255), IM_COL32(0x8D,0x6E,0x63,255),
};
return kPalette[h % (sizeof(kPalette) / sizeof(kPalette[0]))];
}
// Uppercase first glyph of a display name (UTF-8 aware) for a letter-avatar.
std::string initialOf(const std::string& name) {
std::size_t i = 0;
while (i < name.size() && static_cast<unsigned char>(name[i]) <= ' ') ++i;
if (i >= name.size()) return "?";
const unsigned char c = name[i];
if (c < 0x80) {
const char u = (c >= 'a' && c <= 'z') ? static_cast<char>(c - 32) : static_cast<char>(c);
return std::string(1, u);
}
const std::size_t len = (c >= 0xF0) ? 4 : (c >= 0xE0) ? 3 : 2; // UTF-8 lead byte → sequence length
return name.substr(i, std::min(len, name.size() - i));
}
struct ConvSummary {
std::string cid;
std::string peerZaddr;
@@ -88,6 +154,51 @@ void centeredHint(const char* text) {
ImGui::PopFont();
}
// Centered empty state: big muted icon + title + optional wrapped hint (V4).
void centeredEmptyState(const char* icon, const char* title, const char* hint) {
const ImVec2 avail = ImGui::GetContentRegionAvail();
const ImVec2 origin = ImGui::GetCursorPos();
ImFont* iconF = material::Type().iconXL();
ImFont* titleF = material::Type().subtitle1();
ImFont* hintF = material::Type().body2();
const float gap = 8.0f * Layout::dpiScale();
const float wrap = std::min(avail.x - 40.0f, 360.0f);
const float iconSz = iconF ? scaledSize(iconF) : 40.0f;
const float iconH = iconF ? iconF->CalcTextSizeA(iconSz, FLT_MAX, 0.0f, icon).y : 0.0f;
const float titleH = titleF->CalcTextSizeA(scaledSize(titleF), FLT_MAX, 0.0f, title).y;
const float hintH = hint ? hintF->CalcTextSizeA(scaledSize(hintF), wrap, wrap, hint).y : 0.0f;
const float totalH = iconH + gap + titleH + (hint ? gap + hintH : 0.0f);
float y = origin.y + std::max(0.0f, (avail.y - totalH) * 0.5f);
if (iconF) {
const float iw = iconF->CalcTextSizeA(iconSz, FLT_MAX, 0.0f, icon).x;
ImGui::SetCursorPos(ImVec2(origin.x + (avail.x - iw) * 0.5f, y));
ImGui::PushFont(iconF);
ImGui::PushStyleColor(ImGuiCol_Text, material::WithAlpha(material::OnSurface(), 70));
ImGui::TextUnformatted(icon);
ImGui::PopStyleColor(); ImGui::PopFont();
y += iconH + gap;
}
{
const float tw = titleF->CalcTextSizeA(scaledSize(titleF), FLT_MAX, 0.0f, title).x;
ImGui::SetCursorPos(ImVec2(origin.x + (avail.x - tw) * 0.5f, y));
ImGui::PushFont(titleF);
ImGui::PushStyleColor(ImGuiCol_Text, material::OnSurfaceMedium());
ImGui::TextUnformatted(title);
ImGui::PopStyleColor(); ImGui::PopFont();
y += titleH + gap;
}
if (hint) {
ImGui::SetCursorPos(ImVec2(origin.x + (avail.x - wrap) * 0.5f, y));
ImGui::PushFont(hintF);
ImGui::PushStyleColor(ImGuiCol_Text, material::WithAlpha(material::OnSurface(), 120));
ImGui::PushTextWrapPos(ImGui::GetCursorPos().x + wrap);
ImGui::TextUnformatted(hint);
ImGui::PopTextWrapPos();
ImGui::PopStyleColor(); ImGui::PopFont();
}
}
} // namespace
void RenderChatTab(App* app)
@@ -98,21 +209,22 @@ void RenderChatTab(App* app)
// Not unlocked / identity not derived yet → nothing to show.
if (!service.hasIdentity()) {
centeredHint(TR("chat_locked_hint"));
centeredEmptyState(ICON_MD_LOCK, TR("chat_locked_hint"), nullptr);
return;
}
// Build conversation summaries (single scan per conversation), sorted by most-recent activity.
std::vector<ConvSummary> convs;
for (const auto& cid : store.conversationIds()) {
if (app->settings() && app->settings()->isChatHidden(cid)) continue; // hidden — a new message un-hides it
const auto messages = store.conversation(cid);
if (messages.empty()) continue;
ConvSummary c;
c.cid = cid;
c.count = static_cast<int>(messages.size());
for (const auto& m : messages) { // last non-empty peer z-addr / key across the thread
if (!m.peer_zaddr.empty()) c.peerZaddr = m.peer_zaddr;
if (!m.peer_public_key_hex.empty()) c.peerPubKey = m.peer_public_key_hex;
for (const auto& m : messages) { // pin to the EARLIEST (establishing) peer z-addr / key (B2 — the
if (c.peerZaddr.empty() && !m.peer_zaddr.empty()) c.peerZaddr = m.peer_zaddr; // memo header rides
if (c.peerPubKey.empty() && !m.peer_public_key_hex.empty()) c.peerPubKey = m.peer_public_key_hex; // outside the AEAD)
}
const auto& last = messages.back();
c.lastBody = last.body;
@@ -132,6 +244,12 @@ void RenderChatTab(App* app)
s_selected_cid = convs.front().cid;
s_scroll_to_cid = s_selected_cid;
}
// The composer holds a single draft — wipe it when the active conversation changes so text typed
// for one contact can't be sent to another (B5).
if (s_selected_cid != s_compose_cid) {
sodium_memzero(s_compose, sizeof(s_compose));
s_compose_cid = s_selected_cid;
}
const ImVec2 avail = ImGui::GetContentRegionAvail();
const float listW = std::clamp(avail.x * 0.32f, 220.0f, 360.0f);
@@ -139,7 +257,7 @@ void RenderChatTab(App* app)
// fonts. Left raw, at higher DPI the row was too short for the enlarged text and the preview's
// right margin (rowW - pad) shrank to ~zero, clipping the last glyph mid-word.
const float pad = 10.0f * Layout::dpiScale();
const float rowH = 52.0f * Layout::dpiScale();
const float rowH = 58.0f * Layout::dpiScale();
ImFont* nameFont = material::Type().body2();
ImFont* metaFont = material::Type().caption();
@@ -147,13 +265,27 @@ void RenderChatTab(App* app)
const float metaSz = scaledSize(metaFont);
// ---- Left: new-conversation button + conversation list ----
// Faint sidebar tint so the list reads as distinct from the thread pane (V6).
ImGui::PushStyleColor(ImGuiCol_ChildBg, ImGui::ColorConvertU32ToFloat4(material::WithAlpha(material::OnSurface(), 10)));
ImGui::BeginChild("##ChatList", ImVec2(listW, avail.y), true);
{
if (ImGui::Button(TR("chat_new_button"), ImVec2(-FLT_MIN, 0.0f))) {
// Accented "New conversation" action (house tactile style).
ImGui::PushStyleColor(ImGuiCol_Button, ImGui::ColorConvertU32ToFloat4(material::WithAlpha(material::Primary(), 205)));
ImGui::PushStyleColor(ImGuiCol_ButtonHovered, ImGui::ColorConvertU32ToFloat4(material::Primary()));
ImGui::PushStyleColor(ImGuiCol_ButtonActive, ImGui::ColorConvertU32ToFloat4(material::WithAlpha(material::Primary(), 235)));
const bool newClicked = material::TactileButton(TR("chat_new_button"), ImVec2(-FLT_MIN, 0.0f));
ImGui::PopStyleColor(3);
if (newClicked) {
s_show_new_convo = true;
s_new_zaddr[0] = '\0';
s_new_msg[0] = '\0';
}
// Search filter (Q8) — only worth showing once there's more than one conversation.
if (convs.size() > 1) {
ImGui::SetNextItemWidth(-FLT_MIN);
ImGui::InputTextWithHint("##chatsearch", TR("chat_search"), s_search, sizeof(s_search));
}
const std::string search = s_search;
ImGui::Separator();
if (convs.empty()) {
ImGui::PushFont(metaFont);
@@ -166,42 +298,75 @@ void RenderChatTab(App* app)
}
ImDrawList* dl = ImGui::GetWindowDrawList(); // the list child's draw list (correct clip/z-order)
const ImU32 selBg = ImGui::GetColorU32(ImGuiCol_Header);
const ImU32 hoverBg = ImGui::GetColorU32(ImGuiCol_HeaderHovered);
const float dp = Layout::dpiScale();
const float round = 6.0f * dp;
const float avR = rowH * 0.30f; // letter-avatar radius
int shown = 0;
for (std::size_t i = 0; i < convs.size(); ++i) {
const ConvSummary& c = convs[i];
ImGui::PushID(static_cast<int>(i));
if (!search.empty() && !containsCI(c.peerName, search) && !containsCI(c.lastBody, search))
continue; // filtered out by search (Q8) — thread pane still keeps it open
++shown;
ImGui::PushID(c.cid.c_str()); // stable id — the list re-sorts by lastTs each frame (B6)
const ImVec2 p = ImGui::GetCursorScreenPos();
const float rowW = ImGui::GetContentRegionAvail().x;
const bool clicked = ImGui::InvisibleButton("##row", ImVec2(rowW, rowH));
const bool hovered = ImGui::IsItemHovered();
const bool selected = (c.cid == s_selected_cid);
if (clicked) { s_selected_cid = c.cid; s_scroll_to_cid = c.cid; }
if (selected || hovered)
dl->AddRectFilled(p, ImVec2(p.x + rowW, p.y + rowH), selected ? selBg : hoverBg, 6.0f);
if (hovered && !selected) ImGui::SetMouseCursor(ImGuiMouseCursor_Hand);
// Name (top-left).
dl->AddText(nameFont, nameSz, ImVec2(p.x + pad, p.y + pad), material::OnSurface(),
c.peerName.c_str());
// Time (top-right, muted).
const std::string when = formatTime(c.lastTs);
// Row background — house tactile card style (matches contacts_tab).
const ImVec2 mn = p, mx(p.x + rowW, p.y + rowH);
const ImU32 fill = selected ? material::WithAlpha(material::Primary(), 42)
: hovered ? material::WithAlpha(material::OnSurface(), 26)
: material::WithAlpha(material::OnSurface(), 10);
dl->AddRectFilled(mn, mx, fill, round);
if (selected) dl->AddRect(mn, mx, material::WithAlpha(material::Primary(), 150), round, 0, 1.6f * dp);
// Leading letter-avatar (deterministic color circle + the peer's initial).
const ImVec2 avC(mn.x + pad + avR, mn.y + rowH * 0.5f);
dl->AddCircleFilled(avC, avR, avatarColor(c.cid), 24);
{
const std::string init = initialOf(c.peerName);
const ImVec2 isz = nameFont->CalcTextSizeA(nameSz, FLT_MAX, 0.0f, init.c_str());
dl->AddText(nameFont, nameSz, ImVec2(avC.x - isz.x * 0.5f, avC.y - isz.y * 0.5f),
IM_COL32(255, 255, 255, 235), init.c_str());
}
const float textX = avC.x + avR + pad;
// Name (top).
dl->AddText(nameFont, nameSz, ImVec2(textX, p.y + pad), material::OnSurface(), c.peerName.c_str());
// Time (top-right, muted) — compact relative form (Q5).
const std::string when = relativeTime(c.lastTs);
if (!when.empty()) {
const ImVec2 wsz = metaFont->CalcTextSizeA(metaSz, FLT_MAX, 0.0f, when.c_str());
dl->AddText(metaFont, metaSz, ImVec2(p.x + rowW - pad - wsz.x, p.y + pad + 1.0f),
dl->AddText(metaFont, metaSz, ImVec2(mx.x - pad - wsz.x, p.y + pad + 1.0f),
material::OnSurfaceMedium(), when.c_str());
}
// Preview (bottom, clipped, muted).
// Preview (bottom, clipped to the text column, muted).
const std::string preview = previewOf(c.lastBody);
dl->PushClipRect(p, ImVec2(p.x + rowW - pad, p.y + rowH), true);
dl->AddText(metaFont, metaSz, ImVec2(p.x + pad, p.y + rowH - pad - metaSz),
dl->PushClipRect(ImVec2(textX, p.y), ImVec2(mx.x - pad, mx.y), true);
dl->AddText(metaFont, metaSz, ImVec2(textX, p.y + rowH - pad - metaSz),
material::OnSurfaceMedium(), preview.c_str());
dl->PopClipRect();
ImGui::PopID();
ImGui::Dummy(ImVec2(0.0f, 3.0f * dp)); // small gap between cards
}
if (!search.empty() && shown == 0) {
ImGui::PushFont(metaFont);
ImGui::PushStyleColor(ImGuiCol_Text, material::OnSurfaceMedium());
ImGui::PushTextWrapPos(0.0f);
ImGui::TextUnformatted(TR("chat_no_matches"));
ImGui::PopTextWrapPos();
ImGui::PopStyleColor();
ImGui::PopFont();
}
}
ImGui::EndChild();
ImGui::PopStyleColor(); // list ChildBg tint (V6)
ImGui::SameLine();
ImGui::SameLine(0.0f, 1.0f * Layout::dpiScale()); // tight single-line seam between the panes
// ---- Right: selected conversation thread ----
ImGui::BeginChild("##ChatThread", ImVec2(0, avail.y), true);
@@ -210,8 +375,22 @@ void RenderChatTab(App* app)
for (const auto& c : convs) if (c.cid == s_selected_cid) { sel = &c; break; }
if (sel) {
// Header: peer name + z-address.
app->markChatConversationSeen(sel->cid, sel->lastTs); // viewing the thread clears its unread (Q1)
// Header: peer avatar + name (V7).
ImGui::PushFont(material::Type().subtitle1());
{
const float nameH = ImGui::GetTextLineHeight();
const float avR = nameH * 0.6f;
const ImVec2 hp = ImGui::GetCursorScreenPos();
ImDrawList* hdl = ImGui::GetWindowDrawList();
const ImVec2 avC(hp.x + avR, hp.y + nameH * 0.5f);
hdl->AddCircleFilled(avC, avR, avatarColor(sel->cid), 24);
const std::string init = initialOf(sel->peerName);
const ImVec2 isz = ImGui::CalcTextSize(init.c_str());
hdl->AddText(ImVec2(avC.x - isz.x * 0.5f, avC.y - isz.y * 0.5f),
IM_COL32(255, 255, 255, 235), init.c_str());
ImGui::SetCursorScreenPos(ImVec2(hp.x + 2.0f * avR + 8.0f * Layout::dpiScale(), hp.y));
}
ImGui::TextUnformatted(sel->peerName.c_str());
ImGui::PopFont();
if (!sel->peerZaddr.empty()) {
@@ -220,43 +399,173 @@ void RenderChatTab(App* app)
ImGui::TextUnformatted(shorten(sel->peerZaddr, 20, 12).c_str());
ImGui::PopStyleColor();
ImGui::PopFont();
// Header actions: copy the peer z-address (Q3), and add them to contacts when unknown (Q2).
if (ImGui::SmallButton(TR("copy_address"))) ImGui::SetClipboardText(sel->peerZaddr.c_str());
if (book.findByAddress(sel->peerZaddr) < 0) {
ImGui::SameLine();
if (ImGui::SmallButton(TR("chat_add_contact"))) {
data::AddressBookEntry e(sel->peerName, sel->peerZaddr); // label = shortened addr; rename in Contacts
if (book.addEntry(e)) Notifications::instance().success(TR("chat_contact_added"));
else Notifications::instance().error(TR("address_book_exists"));
}
}
// Export the decrypted conversation to a plain-text file (Q11). Written with restricted
// permissions; the tooltip warns it's plaintext.
ImGui::SameLine();
const bool exportClicked = ImGui::SmallButton(TR("chat_export"));
if (ImGui::IsItemHovered()) material::Tooltip("%s", TR("chat_export_warn"));
if (exportClicked) {
std::string content = "DragonX chat export\n";
content += sel->peerName + " <" + sel->peerZaddr + ">\n";
content += "cid: " + sel->cid + "\n\n";
for (const auto& m : store.conversation(sel->cid)) {
const bool out = (m.direction == chat::ChatDirection::Outgoing);
content += "[" + formatTime(m.timestamp) + "] " +
(out ? std::string(TR("chat_you")) : sel->peerName) + ": " + m.body + "\n";
}
std::string safe;
for (char ch : sel->peerName)
safe += (std::isalnum(static_cast<unsigned char>(ch)) ? ch : '_');
if (safe.empty()) safe = "chat";
const std::string path = util::Platform::getConfigDir() + "/dragonx-chat-" + safe + ".txt";
if (util::Platform::writeFileAtomically(path, content, /*restrictPermissions=*/true))
Notifications::instance().success(std::string(TR("chat_export_done")) + ": " + path);
else
Notifications::instance().error(TR("chat_export_failed"));
}
// Mute toggle — muted conversations don't badge or toast (Q10). Persisted to settings.
ImGui::SameLine();
const bool muted = app->settings() && app->settings()->isChatMuted(sel->cid);
if (ImGui::SmallButton(muted ? TR("chat_unmute") : TR("chat_mute")) && app->settings()) {
app->settings()->setChatMuted(sel->cid, !muted);
app->settings()->save();
}
// Hide conversation — drops it from the list (messages stay in the encrypted store; a
// new incoming message brings it back). Deselect so the thread resets to the next one.
ImGui::SameLine();
if (ImGui::SmallButton(TR("chat_hide")) && app->settings()) {
app->markChatConversationSeen(sel->cid, sel->lastTs); // don't leave a phantom unread
app->settings()->setChatHidden(sel->cid, true);
app->settings()->save();
s_selected_cid.clear();
Notifications::instance().info(TR("chat_hidden_toast"));
}
}
ImGui::Separator();
const float footerH = ImGui::GetTextLineHeightWithSpacing() + 12.0f;
const float dp = Layout::dpiScale();
const float composerBoxH = ImGui::GetTextLineHeight() * 2.6f; // multi-line composer (Q6)
const float footerH = composerBoxH + ImGui::GetTextLineHeight() + 20.0f * dp;
ImGui::BeginChild("##ChatMessages", ImVec2(0, ImGui::GetContentRegionAvail().y - footerH), false);
bool atBottom = true;
const ImVec2 msgWinMin = ImGui::GetWindowPos();
const ImVec2 msgWinSize = ImGui::GetWindowSize();
{
ImDrawList* dl = ImGui::GetWindowDrawList();
const float bpad = 9.0f * dp, bround = 9.0f * dp;
const auto messages = store.conversation(s_selected_cid);
for (const auto& m : messages) {
std::int64_t prevMin = -1; int prevDir = -1;
for (std::size_t mi = 0; mi < messages.size(); ++mi) {
const auto& m = messages[mi];
ImGui::PushID(static_cast<int>(mi));
const bool outgoing = (m.direction == chat::ChatDirection::Outgoing);
const bool request = (m.kind == chat::ChatMessageKind::ContactRequest);
const bool failed = outgoing && m.delivery == chat::ChatDelivery::Failed;
// Meta line: who + time (+ request tag / failed marker).
std::string who = outgoing ? std::string(TR("chat_you")) : sel->peerName;
const std::string when = formatTime(m.timestamp);
if (!when.empty()) who += " " + when;
if (request) who += " [" + std::string(TR("chat_contact_request")) + "]";
if (failed) who += " · " + std::string(TR("chat_send_failed"));
ImGui::PushFont(metaFont);
ImGui::PushStyleColor(ImGuiCol_Text,
failed ? material::Error() : (outgoing ? material::Primary() : material::OnSurfaceMedium()));
ImGui::TextUnformatted(who.c_str());
ImGui::PopStyleColor();
ImGui::PopFont();
// Body (wrapped).
ImGui::PushFont(nameFont);
ImGui::PushTextWrapPos(0.0f);
ImGui::TextWrapped("%s", m.body.c_str());
ImGui::PopTextWrapPos();
ImGui::PopFont();
ImGui::Dummy(ImVec2(0.0f, 6.0f));
const bool request = (m.kind == chat::ChatMessageKind::ContactRequest);
const bool failed = outgoing && m.delivery == chat::ChatDelivery::Failed;
const bool sending = outgoing && m.delivery == chat::ChatDelivery::Sending;
const std::int64_t minute = m.timestamp / 60;
const bool startGroup = (prevDir != static_cast<int>(outgoing)) || (minute != prevMin);
const float availW = ImGui::GetContentRegionAvail().x;
const float maxBubbleW = std::max(140.0f * dp, availW * 0.72f);
const float innerW = maxBubbleW - 2.0f * bpad;
// Grouped meta line (sender + time), once per sender/minute run, aligned to the sender's side.
if (startGroup) {
std::string meta = outgoing ? std::string(TR("chat_you")) : sel->peerName;
const std::string when = formatTime(m.timestamp);
if (!when.empty()) meta += " " + when;
if (request) meta += " [" + std::string(TR("chat_contact_request")) + "]";
const ImVec2 msz = metaFont->CalcTextSizeA(metaSz, FLT_MAX, 0.0f, meta.c_str());
const ImVec2 mp = ImGui::GetCursorScreenPos();
dl->AddText(metaFont, metaSz, ImVec2(outgoing ? mp.x + availW - msz.x : mp.x, mp.y),
material::OnSurfaceMedium(), meta.c_str());
ImGui::Dummy(ImVec2(availW, metaSz + 3.0f * dp));
}
// Direction-aligned rounded bubble.
const ImVec2 tsz = nameFont->CalcTextSizeA(nameSz, innerW, innerW, m.body.c_str());
const float bw = std::min(maxBubbleW, tsz.x + 2.0f * bpad);
const float bh = tsz.y + 2.0f * bpad;
const ImVec2 cur = ImGui::GetCursorScreenPos();
const float bx = outgoing ? (cur.x + availW - bw) : cur.x;
const ImVec2 bmin(bx, cur.y), bmax(bx + bw, cur.y + bh);
const ImU32 bubCol = failed ? material::WithAlpha(material::Error(), 40)
: outgoing ? material::WithAlpha(material::Primary(), 46)
: material::WithAlpha(material::OnSurface(), 22);
dl->AddRectFilled(bmin, bmax, bubCol, bround);
dl->AddText(nameFont, nameSz, ImVec2(bx + bpad, cur.y + bpad), material::OnSurface(),
m.body.c_str(), nullptr, innerW);
ImGui::Dummy(ImVec2(availW, bh));
if (ImGui::IsMouseHoveringRect(bmin, bmax)) {
material::Tooltip("%s", formatTime(m.timestamp).c_str());
if (ImGui::IsMouseClicked(ImGuiMouseButton_Right)) ImGui::OpenPopup("##msgmenu");
}
if (ImGui::BeginPopup("##msgmenu")) {
if (ImGui::MenuItem(TR("copy"))) ImGui::SetClipboardText(m.body.c_str());
ImGui::EndPopup();
}
// Failed send → right-aligned "not sent" + Retry (Q7).
if (failed) {
ImGui::Dummy(ImVec2(0.0f, 1.0f * dp));
const float ftw = metaFont->CalcTextSizeA(metaSz, FLT_MAX, 0.0f, TR("chat_send_failed")).x;
const float rtw = ImGui::CalcTextSize(TR("chat_retry")).x + ImGui::GetStyle().FramePadding.x * 2.0f;
ImGui::SetCursorPosX(ImGui::GetCursorPosX() + std::max(0.0f, availW - ftw - rtw - 8.0f * dp));
ImGui::PushFont(metaFont); ImGui::PushStyleColor(ImGuiCol_Text, material::Error());
ImGui::TextUnformatted(TR("chat_send_failed"));
ImGui::PopStyleColor(); ImGui::PopFont();
ImGui::SameLine();
if (ImGui::SmallButton(TR("chat_retry"))) {
// A failed contact request must be re-sent as a request (it has no peer key
// yet) into the SAME conversation — not routed through sendChatMessage,
// which would just say "waiting for reply".
if (request) app->sendContactRequestForCid(sel->cid, m.peer_zaddr, m.body);
else app->sendChatMessage(sel->cid, m.body);
s_scroll_to_cid = sel->cid;
}
}
// In-flight send → subtle right-aligned "sending…"; the op callback resolves it.
if (sending) {
ImGui::Dummy(ImVec2(0.0f, 1.0f * dp));
const float sw = metaFont->CalcTextSizeA(metaSz, FLT_MAX, 0.0f, TR("chat_sending")).x;
ImGui::SetCursorPosX(ImGui::GetCursorPosX() + std::max(0.0f, availW - sw));
ImGui::PushFont(metaFont); ImGui::PushStyleColor(ImGuiCol_Text, material::OnSurfaceMedium());
ImGui::TextUnformatted(TR("chat_sending"));
ImGui::PopStyleColor(); ImGui::PopFont();
}
ImGui::Dummy(ImVec2(0.0f, 5.0f * dp));
ImGui::PopID();
prevMin = minute; prevDir = static_cast<int>(outgoing);
}
if (s_scroll_to_cid == s_selected_cid) {
ImGui::SetScrollHereY(1.0f);
s_scroll_to_cid.clear();
}
atBottom = ImGui::GetScrollY() >= ImGui::GetScrollMaxY() - 4.0f;
}
ImGui::EndChild();
// Jump-to-latest pill when scrolled up (Q9). SetCursorScreenPos moves the parent cursor to
// the child's bottom edge; save + restore it so the composer footer below stays anchored
// (otherwise it renders ~8px too high only while the pill is shown).
if (!atBottom) {
const ImVec2 savedCursor = ImGui::GetCursorScreenPos();
const ImVec2 pill(msgWinMin.x + msgWinSize.x - 84.0f * dp, msgWinMin.y + msgWinSize.y - 34.0f * dp);
ImGui::SetCursorScreenPos(pill);
ImGui::PushStyleColor(ImGuiCol_Button, ImGui::ColorConvertU32ToFloat4(material::WithAlpha(material::Primary(), 220)));
ImGui::PushStyleColor(ImGuiCol_ButtonHovered, ImGui::ColorConvertU32ToFloat4(material::Primary()));
if (ImGui::Button(TR("chat_jump_latest"), ImVec2(74.0f * dp, 26.0f * dp))) s_scroll_to_cid = sel->cid;
ImGui::PopStyleColor(2);
ImGui::SetCursorScreenPos(savedCursor);
}
// Composer footer: message input + send (only once we know the peer's key), else a hint.
ImGui::Separator();
@@ -269,54 +578,130 @@ void RenderChatTab(App* app)
ImGui::PopStyleColor();
ImGui::PopFont();
} else {
const float sendW = 74.0f;
ImGui::SetNextItemWidth(ImGui::GetContentRegionAvail().x - sendW - ImGui::GetStyle().ItemSpacing.x);
bool submit = ImGui::InputText("##compose", s_compose, sizeof(s_compose),
ImGuiInputTextFlags_EnterReturnsTrue);
// Multi-line composer (Q6): Enter sends, Ctrl+Enter inserts a newline. A live byte counter
// tracks the effective on-chain body cap = (512 len("utf8:"))/2 secretstream ABYTES.
static constexpr int kBodyMaxBytes = (512 - 5) / 2 - 17; // = 236 (see chat_outgoing.cpp)
const float sendW = 80.0f * Layout::dpiScale(); // scaled so the translated label never clips (B3)
const float inputW = ImGui::GetContentRegionAvail().x - sendW - ImGui::GetStyle().ItemSpacing.x;
const int used = static_cast<int>(std::strlen(s_compose));
const bool overCap = used > kBodyMaxBytes;
bool submit = ImGui::InputTextMultiline("##compose", s_compose, sizeof(s_compose),
ImVec2(inputW, composerBoxH),
ImGuiInputTextFlags_EnterReturnsTrue | ImGuiInputTextFlags_CtrlEnterForNewLine);
ImGui::SameLine();
if (ImGui::Button(TR("chat_send"), ImVec2(sendW, 0.0f))) submit = true;
if (submit && s_compose[0] != '\0') {
ImGui::PushStyleColor(ImGuiCol_Button, ImGui::ColorConvertU32ToFloat4(material::WithAlpha(material::Primary(), 205)));
ImGui::PushStyleColor(ImGuiCol_ButtonHovered, ImGui::ColorConvertU32ToFloat4(material::Primary()));
ImGui::PushStyleColor(ImGuiCol_ButtonActive, ImGui::ColorConvertU32ToFloat4(material::WithAlpha(material::Primary(), 235)));
ImGui::BeginDisabled(overCap || s_compose[0] == '\0');
if (material::TactileButton(TR("chat_send"), ImVec2(sendW, composerBoxH))) submit = true;
ImGui::EndDisabled();
ImGui::PopStyleColor(3);
// Byte counter (Error tint over cap) with the over-cap label.
ImGui::PushFont(metaFont);
ImGui::PushStyleColor(ImGuiCol_Text, overCap ? material::Error() : material::OnSurfaceMedium());
if (overCap) { ImGui::TextUnformatted(TR("chat_len_over")); ImGui::SameLine(); }
const std::string counter = std::to_string(used) + " / " + std::to_string(kBodyMaxBytes);
const float cw = metaFont->CalcTextSizeA(metaSz, FLT_MAX, 0.0f, counter.c_str()).x;
ImGui::SetCursorPosX(ImGui::GetCursorPosX() +
std::max(0.0f, ImGui::GetContentRegionAvail().x - cw));
ImGui::TextUnformatted(counter.c_str());
ImGui::PopStyleColor();
ImGui::PopFont();
if (submit && s_compose[0] != '\0' && !overCap) {
app->sendChatMessage(sel->cid, s_compose);
s_compose[0] = '\0';
sodium_memzero(s_compose, sizeof(s_compose));
s_scroll_to_cid = sel->cid;
}
}
} else {
centeredHint(convs.empty() ? TR("chat_empty_hint") : TR("chat_select_hint"));
if (convs.empty()) centeredEmptyState(ICON_MD_FORUM, TR("chat_empty_title"), TR("chat_empty_start"));
else centeredEmptyState(ICON_MD_CHAT_BUBBLE_OUTLINE, TR("chat_select_hint"), nullptr);
}
}
ImGui::EndChild();
// ---- New-conversation popup (send a contact request to a z-address) ----
// ---- New-conversation dialog (send a contact request to a z-address) — house BlurFloat overlay ----
if (s_show_new_convo) {
ImGui::OpenPopup("##NewConversation");
s_show_new_convo = false;
}
ImGui::SetNextWindowPos(ImGui::GetMainViewport()->GetCenter(), ImGuiCond_Appearing, ImVec2(0.5f, 0.5f));
if (ImGui::BeginPopupModal("##NewConversation", nullptr, ImGuiWindowFlags_AlwaysAutoResize)) {
ImGui::PushFont(material::Type().subtitle1());
ImGui::TextUnformatted(TR("chat_new_title"));
ImGui::PopFont();
ImGui::Spacing();
ImGui::TextUnformatted(TR("chat_new_zaddr"));
ImGui::SetNextItemWidth(440.0f);
ImGui::InputText("##newz", s_new_zaddr, sizeof(s_new_zaddr));
ImGui::TextUnformatted(TR("chat_new_message"));
ImGui::SetNextItemWidth(440.0f);
ImGui::InputText("##newm", s_new_msg, sizeof(s_new_msg));
ImGui::Spacing();
const bool canSend = s_new_zaddr[0] != '\0' && s_new_msg[0] != '\0';
if (ImGui::Button(TR("chat_new_send"), ImVec2(150.0f, 0.0f)) && canSend) {
app->startChatConversation(s_new_zaddr, s_new_msg);
s_new_zaddr[0] = '\0';
s_new_msg[0] = '\0';
ImGui::CloseCurrentPopup();
const float dp = Layout::dpiScale();
material::OverlayDialogSpec ov;
ov.title = TR("chat_new_title");
ov.p_open = &s_show_new_convo; // X / backdrop closes it
ov.style = material::OverlayStyle::BlurFloat;
ov.cardWidth = 520.0f; ov.idSuffix = "chatnewconvo";
if (material::BeginOverlayDialog(ov)) {
const float fieldW = ImGui::GetContentRegionAvail().x;
material::LabeledInput(TR("chat_new_zaddr"), "##newz", s_new_zaddr, sizeof(s_new_zaddr), fieldW);
// Or pick from contacts — chat needs a shielded z-address, so only z-addr contacts are listed.
// Selecting one fills the field above (manual paste still works).
ImGui::SetNextItemWidth(fieldW);
if (ImGui::BeginCombo("##newzpick", TR("chat_pick_contact"), ImGuiComboFlags_HeightLarge)) {
const std::string activeHash = app->activeWalletScopeId(); // per-wallet contact scope
int shown = 0;
for (const auto& e : book.entries()) {
if (e.address.empty() || e.address[0] != 'z') continue; // chat requires a z-address
// Respect the same per-wallet scope the Contacts tab enforces — don't leak another
// wallet's scoped contact into this wallet's picker (global + legacy fail open).
const bool visible = e.isGlobal() ||
(e.scope.rfind("w:", 0) == 0 ? (!activeHash.empty() && e.scope == activeHash) : true);
if (!visible) continue;
++shown;
const std::string item = e.label + " " + shorten(e.address, 16, 8);
if (ImGui::Selectable(item.c_str())) {
std::strncpy(s_new_zaddr, e.address.c_str(), sizeof(s_new_zaddr) - 1);
s_new_zaddr[sizeof(s_new_zaddr) - 1] = '\0';
}
}
if (shown == 0) {
ImGui::PushStyleColor(ImGuiCol_Text, material::OnSurfaceMedium());
ImGui::TextUnformatted(TR("chat_no_z_contacts"));
ImGui::PopStyleColor();
}
ImGui::EndCombo();
}
ImGui::Dummy(ImVec2(0, Layout::spacingSm()));
material::LabeledInput(TR("chat_new_message"), "##newm", s_new_msg, sizeof(s_new_msg), fieldW);
ImGui::Dummy(ImVec2(0, Layout::spacingMd()));
const bool canSend = s_new_zaddr[0] != '\0' && s_new_msg[0] != '\0';
const float actionW = std::max(130.0f * dp,
ImGui::CalcTextSize(TR("chat_new_send")).x + ImGui::GetStyle().FramePadding.x * 2.0f + 24.0f * dp);
const float actionGap = Layout::spacingSm();
material::BeginOverlayDialogFooter(actionW * 2.0f + actionGap, /*drawSeparator=*/false);
if (!canSend) ImGui::BeginDisabled();
ImGui::PushStyleColor(ImGuiCol_Button, ImGui::ColorConvertU32ToFloat4(material::WithAlpha(material::Primary(), 205)));
ImGui::PushStyleColor(ImGuiCol_ButtonHovered, ImGui::ColorConvertU32ToFloat4(material::Primary()));
ImGui::PushStyleColor(ImGuiCol_ButtonActive, ImGui::ColorConvertU32ToFloat4(material::WithAlpha(material::Primary(), 235)));
const bool doSend = material::TactileButton(TR("chat_new_send"), ImVec2(actionW, 0));
ImGui::PopStyleColor(3);
if (doSend) {
app->startChatConversation(s_new_zaddr, s_new_msg);
sodium_memzero(s_new_zaddr, sizeof(s_new_zaddr)); // wipe the just-sent plaintext
sodium_memzero(s_new_msg, sizeof(s_new_msg));
s_show_new_convo = false;
}
if (!canSend) ImGui::EndDisabled();
ImGui::SameLine(0, actionGap);
if (material::TactileButton(TR("chat_cancel"), ImVec2(actionW, 0))) s_show_new_convo = false;
material::EndOverlayDialog();
}
ImGui::SameLine();
if (ImGui::Button(TR("chat_cancel"), ImVec2(100.0f, 0.0f))) ImGui::CloseCurrentPopup();
ImGui::EndPopup();
}
}
void ResetChatTab()
{
// Securely wipe typed plaintext (a private message / recipient z-addr) so it can't resurface under
// the next wallet after a switch/lock, and drop the selection ids.
sodium_memzero(s_compose, sizeof(s_compose));
sodium_memzero(s_new_zaddr, sizeof(s_new_zaddr));
sodium_memzero(s_new_msg, sizeof(s_new_msg));
s_selected_cid.clear();
s_scroll_to_cid.clear();
s_compose_cid.clear();
s_show_new_convo = false;
}
} // namespace ui
} // namespace dragonx

View File

@@ -11,17 +11,26 @@ class App;
namespace ui {
/**
* @brief Render the Chat tab (read-only HushChat conversation view — Phase 3).
* @brief Render the Chat tab (HushChat conversation view).
*
* A two-pane view: a conversation list on the left and the selected thread on
* the right, both read from the App-owned ChatService store (App::chatService()).
* Peer z-addresses are resolved to contact names via the address book when known.
* Read-only for now — composing/sending arrives in a later phase. The tab is only
* reachable when built with DRAGONX_ENABLE_CHAT (gated via WalletUiSurface::Chat).
* A two-pane view: a conversation list on the left and the selected thread on the
* right, both read from the App-owned ChatService store (App::chatService()). Peer
* z-addresses are resolved to contact names via the address book when known.
* Composing/sending and starting new conversations are wired (App::sendChatMessage /
* startChatConversation). Reachable when built with DRAGONX_ENABLE_CHAT
* (gated via WalletUiSurface::Chat).
*
* @param app Pointer to the app instance.
*/
void RenderChatTab(App* app);
/**
* @brief Securely wipe the Chat tab's UI-local state (composer / new-conversation
* plaintext buffers + the selected-conversation ids). Called by
* App::resetChatSession() on a wallet switch/lock so one wallet's typed
* plaintext can't resurface (or linger unwiped in RAM) under the next wallet.
*/
void ResetChatTab();
} // namespace ui
} // namespace dragonx

View File

@@ -511,8 +511,7 @@ void ConsoleTab::renderToolbar(ConsoleCommandExecutor& exec)
// Clear button
if (TactileButton(TR("console_clear"), ImVec2(0, 0), schema::UI().resolveFont("button"))) {
clear();
selection_.clear();
clear(); // also drops the stale visible_indices_ + selection (see ConsoleTab::clear)
}
ImGui::SameLine();
@@ -1183,9 +1182,9 @@ void ConsoleTab::drawOutputContextMenu()
}
ImGui::Separator();
if (ImGui::MenuItem(TR("console_clear_console"))) {
// View-only clear (main thread) — drop the visible lines and the selection.
model_.clear();
selection_.clear();
// View-only clear — route through clear() so the stale visible_indices_/selection are dropped too
// (a bare model_.clear() mid-frame would leave renderOutput() indexing the emptied model_ → crash).
clear();
}
ImGui::EndPopup();
}
@@ -2004,6 +2003,12 @@ void ConsoleTab::clear()
// View-only clear (main thread). The executor keeps its own log cursors, so new output
// still appends. The "cleared" line is ingested and appears on the next frame's drain.
model_.clear();
// visible_indices_ was computed at the top of render() (line 311), BEFORE the toolbar's Clear button
// ran; those indices now point past the emptied model_. renderOutput() (this same frame, after the
// toolbar) indexes model_[visible_indices_[vi]] — so drop them (and the selection, which also holds
// line indices) here to avoid an out-of-bounds crash. computeVisibleLines() rebuilds them next frame.
visible_indices_.clear();
selection_.clear();
stop_confirm_pending_ = false; // a pending 'stop' confirmation is cancelled by clearing
addLine(TR("console_cleared"), ConsoleChannel::Info);
}

View File

@@ -277,9 +277,16 @@ void RenderContactsTab(App* app)
auto& book = app->addressBook();
// The active wallet's identity hash scopes which contacts show + tags newly-added ones.
// Empty pre-connect (addresses unknown) — then we don't filter, to avoid hiding contacts.
const std::string activeHash = app->activeWalletIdentityHash();
// Stable per-wallet id that scopes which contacts show + tags newly-added ones. Unlike the old
// address-hash identity it doesn't drift when the address set changes, and it's known even before
// connect. Empty only when there's no active wallet file.
const std::string activeHash = app->activeWalletScopeId();
// One-time recovery: contacts saved under the old drifting address-hash scope were orphaned when the
// wallet's address set changed. When there's a single known wallet (so attribution is unambiguous),
// re-attach those legacy-scoped contacts to this wallet's stable id. Idempotent (converted contacts
// become "w:"-scoped), so it does real work only once.
if (!activeHash.empty() && app->walletIndex().entries().size() <= 1)
app->addressBook().reattachLegacyScopes(activeHash);
auto clearEditFields = []() {
s_edit_label[0] = '\0';
@@ -763,10 +770,25 @@ void RenderContactsTab(App* app)
};
data::AddressBookEntry entry(trimAB(s_edit_label), trimAB(s_edit_address), s_edit_notes);
entry.avatar = s_edit_avatar;
// Global if the user asked, or as a safe fallback when we don't yet know the wallet
// (pre-connect) — better a visible-everywhere contact than one orphaned to no wallet.
entry.scope = (s_edit_global || activeHash.empty()) ? std::string("global") : activeHash;
if (isEdit) {
// Scope: global if the user asked; otherwise this wallet — but ONLY when we actually
// know which wallet is loaded. While the identity is unknown (the switch / first-load
// window) don't silently make a wallet-scoped contact global: preserve an edited
// contact's existing scope, and refuse a NEW wallet-scoped one (tick global or wait).
bool blocked = false;
if (s_edit_global) {
entry.scope = "global";
} else if (!activeHash.empty()) {
entry.scope = activeHash;
} else if (isEdit && s_selected_index >= 0 && s_selected_index < (int)book.size()) {
entry.scope = book.entries()[s_selected_index].scope; // keep; don't demote to global
if (entry.scope.empty()) entry.scope = "global";
} else {
Notifications::instance().error(TR("contact_wallet_loading"));
blocked = true; // don't create a wallet-scoped contact with an unknown wallet
}
if (blocked) {
// leave the dialog open so the user can tick global or retry once loaded
} else if (isEdit) {
if (app->addressBook().updateEntry(s_selected_index, entry)) {
Notifications::instance().success(TR("address_book_updated"));
s_show_edit_dialog = false;
@@ -895,9 +917,15 @@ void RenderContactsTab(App* app)
for (size_t i = 0; i < book.size(); ++i) {
const auto& e = book.entries()[i];
if (!matchesSearch(e, needle)) continue;
// Scope filter: global contacts + this wallet's contacts. Pre-connect (activeHash empty)
// we don't know the wallet yet, so show everything rather than hide contacts.
if (!activeHash.empty() && !e.visibleInWallet(activeHash)) continue;
// Scope filter: show global + this wallet's contacts. A legacy (non-"w:") scope belongs to a
// wallet whose old address-hash identity has drifted and can't be reattributed here (multi-wallet
// case where recovery didn't run) — fail OPEN so those contacts are never hidden. Stable "w:"
// scopes match strictly. When no wallet is active, show global + legacy only.
bool visible;
if (e.isGlobal()) visible = true;
else if (e.scope.rfind("w:", 0) == 0) visible = !activeHash.empty() && e.scope == activeHash;
else visible = true; // legacy scope → fail open (recovery)
if (!visible) continue;
visibleRows.push_back(i);
}

View File

@@ -4,6 +4,7 @@
#include "export_all_keys_dialog.h"
#include "../../app.h"
#include <sodium.h> // sodium_memzero — wipe exported key material from memory (B7)
#include "../../rpc/rpc_client.h"
#include "../../rpc/rpc_worker.h"
#include "../../util/i18n.h"
@@ -180,13 +181,12 @@ void ExportAllKeysDialog::render(App* app)
for (const auto& addr : z_addrs) {
try {
rpc::RPCClient::TraceScope trace("Settings / Export all keys");
auto result = rpc->call("z_exportkey", {addr});
if (result.is_string()) {
keys += "# Address: " + addr + "\n";
keys += result.get<std::string>() + "\n\n";
exported++; // count only real successes (locked/failed keys don't count)
}
} catch (...) {}
std::string k = rpc->callSecretString("z_exportkey", {addr}); // scrubs raw body + json node (B7)
keys += "# Address: " + addr + "\n";
keys += k + "\n\n";
exported++; // count only real successes (locked/failed keys don't count)
if (!k.empty()) sodium_memzero(&k[0], k.size()); // wipe the transient copy
} catch (...) {} // non-string / locked key → skip (not counted)
}
}
@@ -196,13 +196,12 @@ void ExportAllKeysDialog::render(App* app)
for (const auto& addr : t_addrs) {
try {
rpc::RPCClient::TraceScope trace("Settings / Export all keys");
auto result = rpc->call("dumpprivkey", {addr});
if (result.is_string()) {
keys += "# Address: " + addr + "\n";
keys += result.get<std::string>() + "\n\n";
exported++; // count only real successes (locked/failed keys don't count)
}
} catch (...) {}
std::string k = rpc->callSecretString("dumpprivkey", {addr}); // scrubs raw body + json node (B7)
keys += "# Address: " + addr + "\n";
keys += k + "\n\n";
exported++; // count only real successes (locked/failed keys don't count)
if (!k.empty()) sodium_memzero(&k[0], k.size()); // wipe the transient copy
} catch (...) {} // non-string / locked key → skip (not counted)
}
}
@@ -219,6 +218,7 @@ void ExportAllKeysDialog::render(App* app)
writeOk = true;
}
}
if (!keys.empty()) sodium_memzero(&keys[0], keys.size()); // don't leave every key in freed heap
return [exported, total, filepath, writeOk]() {
s_exported_count = exported;

View File

@@ -5,6 +5,7 @@
#include "key_export_dialog.h"
#include "../../app.h"
#include "../../wallet/lite_wallet_controller.h"
#include <sodium.h> // sodium_memzero — wipe transient secret copies (B7)
#include <nlohmann/json.hpp>
#include "../../rpc/rpc_client.h"
#include "../../rpc/rpc_worker.h"
@@ -37,7 +38,7 @@ std::string KeyExportDialog::s_error;
void KeyExportDialog::releaseQr()
{
if (s_qr_tex) { FreeQRTexture(s_qr_tex); s_qr_tex = 0; }
s_qr_cached.clear();
wallet::secureWipeLiteSecret(s_qr_cached); // held a plaintext copy of the key for the QR
s_show_qr = false;
}
@@ -49,7 +50,7 @@ void KeyExportDialog::show(const std::string& address, KeyType type)
releaseQr();
s_key_type = type;
s_address = address;
s_key.clear();
wallet::secureWipeLiteSecret(s_key); // zero any prior secret before reuse
s_error.clear();
}
@@ -62,7 +63,7 @@ void KeyExportDialog::hide()
{
s_open = false;
s_fetching = false;
s_key.clear();
wallet::secureWipeLiteSecret(s_key); // zero the displayed secret, don't just drop the buffer
s_show_key = false;
s_error.clear();
releaseQr();
@@ -187,18 +188,18 @@ void KeyExportDialog::render(App* app)
std::string error;
try {
rpc::RPCClient::TraceScope trace("Settings / Export key");
auto result = rpc->call(method, {addr});
key = result.get<std::string>();
key = rpc->callSecretString(method, {addr}); // scrubs raw body + json node (B7)
} catch (const std::exception& e) {
error = e.what();
}
return [key, error]() {
return [key = std::move(key), error]() mutable {
if (error.empty()) {
s_key = key;
s_show_key = false; // Don't show by default
} else {
s_error = error;
}
if (!key.empty()) sodium_memzero(&key[0], key.size()); // wipe the last transient copy
s_fetching = false;
};
});
@@ -213,18 +214,18 @@ void KeyExportDialog::render(App* app)
std::string error;
try {
rpc::RPCClient::TraceScope trace("Settings / Export viewing key");
auto result = rpc->call("z_exportviewingkey", {addr});
key = result.get<std::string>();
key = rpc->callSecretString("z_exportviewingkey", {addr}); // scrubs raw body + json node (B7)
} catch (const std::exception& e) {
error = e.what();
}
return [key, error]() {
return [key = std::move(key), error]() mutable {
if (error.empty()) {
s_key = key;
s_show_key = true; // Viewing keys are less sensitive
} else {
s_error = error;
}
if (!key.empty()) sodium_memzero(&key[0], key.size()); // wipe the last transient copy
s_fetching = false;
};
});
@@ -317,8 +318,8 @@ void KeyExportDialog::render(App* app)
if (material::TactileButton(TR("close"), ImVec2(button_width, 0), S.resolveFont(closeBtn.font))) {
s_open = false;
// Clear sensitive data
s_key.clear();
// Zero the secret, don't just drop the buffer (leaves the key in freed heap otherwise).
wallet::secureWipeLiteSecret(s_key);
s_show_key = false;
releaseQr();
}
@@ -326,9 +327,9 @@ void KeyExportDialog::render(App* app)
material::EndOverlayDialog();
}
// Dialog dismissed any other way (scrim click / Esc): drop the key + its QR texture.
// Dialog dismissed any other way (scrim click / Esc): wipe the key + its QR texture.
if (!s_open) {
if (!s_key.empty()) s_key.clear();
wallet::secureWipeLiteSecret(s_key);
s_show_key = false;
releaseQr();
}

View File

@@ -380,14 +380,14 @@ static bool pfIndexVisible(App* app, int i)
const auto& es = app->settings()->getPortfolioEntries();
if (i < 0 || i >= (int)es.size()) return false;
const std::string h = app->activeWalletIdentityHash();
return es[i].scope.empty() || h.empty() || es[i].scope == h;
return es[i].scope.empty() || (!h.empty() && es[i].scope == h);
}
static int pfFirstVisibleIndex(App* app)
{
const auto& es = app->settings()->getPortfolioEntries();
const std::string h = app->activeWalletIdentityHash();
for (int i = 0; i < (int)es.size(); i++)
if (es[i].scope.empty() || h.empty() || es[i].scope == h) return i;
if (es[i].scope.empty() || (!h.empty() && es[i].scope == h)) return i;
return -1;
}
@@ -823,7 +823,9 @@ static void RenderPortfolioEditor(App* app)
const std::string activeHash = app->activeWalletIdentityHash();
std::vector<int> vis;
for (int i = 0; i < (int)entries.size(); i++)
if (entries[i].scope.empty() || activeHash.empty() || entries[i].scope == activeHash)
// Global groups always; this wallet's groups only when the identity is known — never
// another wallet's scoped groups during the switch/first-load window.
if (entries[i].scope.empty() || (!activeHash.empty() && entries[i].scope == activeHash))
vis.push_back(i);
if (vis.empty())
Type().textColored(TypeStyle::Caption, OnSurfaceDisabled(), TR("portfolio_no_entries"));
@@ -905,7 +907,11 @@ static void RenderPortfolioEditor(App* app)
}
ImGui::EndChild();
ImGui::Dummy(ImVec2(0, Layout::spacingSm()));
// Add immediately creates a persisted "Untitled" group and selects it for editing.
// Add immediately creates a persisted "Untitled" group scoped to this wallet — so it's disabled
// while the wallet identity is unknown (switch / first-load window), otherwise a new group would
// get an empty scope and show under every wallet.
const bool pfIdentityKnown = !app->activeWalletIdentityHash().empty();
ImGui::BeginDisabled(!pfIdentityKnown);
if (material::TactileButton(TR("portfolio_add_entry"), ImVec2(masterW, addH))) {
// Auto-save the current group, then start a fresh one selected for editing.
pfCommitIfNeeded(app);
@@ -918,6 +924,9 @@ static void RenderPortfolioEditor(App* app)
s_pfEdit.sel = (int)es.size() - 1;
PortfolioBeginEdit(app, s_pfEdit.sel);
}
ImGui::EndDisabled();
if (!pfIdentityKnown && ImGui::IsItemHovered(ImGuiHoveredFlags_AllowWhenDisabled))
material::Tooltip("%s", TR("portfolio_wallet_loading"));
}
ImGui::EndGroup();
@@ -1967,7 +1976,9 @@ static void mktDrawPortfolio(const MktCtx& cx)
std::vector<int> vis;
for (int i = 0; i < (int)allEntries.size(); i++) {
const auto& e = allEntries[i];
if (e.scope.empty() || activeHash.empty() || e.scope == activeHash) vis.push_back(i);
// Global groups always; this wallet's only when the identity is known (never another
// wallet's scoped groups during the switch/first-load window).
if (e.scope.empty() || (!activeHash.empty() && e.scope == activeHash)) vis.push_back(i);
}
int style = app->settings() ? app->settings()->getPortfolioStyle() : 0;
@@ -2110,7 +2121,7 @@ void RenderMarketTab(App* app)
const std::string pfActiveHash = app->activeWalletIdentityHash();
int pfVisN = 0;
for (const auto& e : pfEntriesGeo)
if (e.scope.empty() || pfActiveHash.empty() || e.scope == pfActiveHash) pfVisN++;
if (e.scope.empty() || (!pfActiveHash.empty() && e.scope == pfActiveHash)) pfVisN++;
int pfStyle = app->settings()->getPortfolioStyle();
float pfRowH = (pfStyle == 0 ? 46.0f : pfStyle == 1 ? 64.0f : 84.0f) * mktDp;
float pfRowGap = Layout::spacingSm();

View File

@@ -288,14 +288,32 @@ public:
// encryption — so absence of a lock never falsely reads as "unencrypted" on a huge wallet.
const ProbeResult pres = probeAt(i); // from the frame-consistent snapshot above
const bool bLock = pres.probed && pres.encrypted;
const bool bSeed = pres.probed && pres.hdSeed;
const bool bLegacy = pres.probed && pres.complete && !pres.hdSeed;
const bool bUnknown = pres.probed && !pres.complete && !pres.encrypted;
// Seed-phrase vs legacy. Runtime status (z_exportmnemonic → activeWalletSeedBadge) is
// authoritative for the ACTIVE wallet; otherwise the offline probe reads the hdchain
// record's fMnemonicSeed flag directly (pres.mnemonic: 1 = BIP39 seed phrase, 2 = HD/legacy
// with no phrase, 0 = couldn't tell) — which, unlike bare HD-record presence, actually
// distinguishes the two. seed uses the same 1/2/0 encoding.
const int activeBadge = rowActive[i] ? app->activeWalletSeedBadge() : 0;
int seed = activeBadge;
if (seed == 0 && pres.probed) {
if (pres.mnemonic != 0) seed = pres.mnemonic; // read the flag off disk
else if (pres.complete && !pres.hdSeed) seed = 2; // no HD records at all → no phrase
}
const bool bSeed = (seed == 1);
const bool bLegacy = (seed == 2);
// seed==0 splits by what the probe DID learn: if it saw HD records we know it's an HD
// wallet (we just couldn't read the phrase flag — e.g. the tier-1 byte-scan fallback), so
// label it "HD wallet" rather than a bare "?"; only a scan that couldn't even establish
// that (incomplete, no HD marker yet) stays "Unknown". Both yield to the Lock badge when
// encrypted, so an encrypted-but-unclassified row shows just the lock.
const bool bHd = (seed == 0) && pres.probed && pres.hdSeed && !pres.encrypted;
const bool bUnknown = (seed == 0) && pres.probed && !pres.hdSeed && !pres.encrypted;
struct Badge { const char* glyph; ImU32 col; const char* label; const char* tip; };
Badge bl[4]; int nb = 0;
Badge bl[4]; int nb = 0; // seed/legacy/hd/unknown are mutually exclusive → at most one + lock
if (bSeed) bl[nb++] = { ICON_MD_ECO, WithAlpha(Success(), 235), TR("wallets_badge_seed_short"), TR("wallets_badge_seed") };
if (bLock) bl[nb++] = { ICON_MD_LOCK, WithAlpha(Warning(), 240), TR("wallets_badge_encrypted_short"), TR("wallets_badge_encrypted") };
if (bLegacy) bl[nb++] = { ICON_MD_HISTORY, WithAlpha(OnSurfaceMedium(), 220), TR("wallets_badge_legacy_short"), TR("wallets_badge_legacy") };
if (bHd) bl[nb++] = { ICON_MD_ACCOUNT_TREE, WithAlpha(OnSurfaceMedium(), 210), TR("wallets_badge_hd_short"), TR("wallets_badge_hd") };
if (bUnknown) bl[nb++] = { ICON_MD_HELP_OUTLINE, WithAlpha(OnSurfaceMedium(), 185), TR("wallets_badge_unknown_short"), TR("wallets_badge_unknown") };
const float bGap = Layout::spacingSm();
float maxLabelW = 0.0f;
@@ -542,6 +560,7 @@ private:
int keyCount = 0; // transparent + shielded spendable keys (≈ addresses, incl. change)
int txCount = 0; // wallet transaction records
long long createdEpoch = 0; // wallet birthday (earliest keymeta nCreateTime); 0 = unknown
int mnemonic = 0; // hdchain fMnemonicSeed flag: 0 unknown, 1 seed-phrase, 2 no phrase
};
struct ProbeBatch {
std::mutex mtx;
@@ -768,6 +787,7 @@ private:
const auto bt = util::parseWalletBtree(t.first, std::min(budget, kPerFile));
if (bt.parsed && bt.complete) {
res = ProbeResult{ true, true, bt.encrypted, bt.hdSeed, true, bt.addresses(), bt.txCount, bt.createdEpoch };
res.mnemonic = bt.mnemonicSeed; // read straight off the hdchain record
budget -= std::min(budget, bt.bytesRead);
} else {
const auto pr = util::probeWalletFile(t.first, std::min(budget, kPerFile));

View File

@@ -226,6 +226,9 @@ void I18n::loadBuiltinEnglish()
strings_["chat_new_message"] = "Message";
strings_["chat_new_send"] = "Send request";
strings_["chat_cancel"] = "Cancel";
strings_["chat_add_contact"] = "Add contact";
strings_["chat_contact_added"] = "Contact added — rename it in Contacts";
strings_["chat_new_message_toast"] = "New encrypted chat message";
strings_["chat_toast_not_connected"] = "Not connected — chat message not sent.";
strings_["chat_toast_no_zaddr"] = "No z-address available to send chat from.";
strings_["chat_toast_lite_busy"] = "A send is already in progress, or no wallet is open.";
@@ -233,6 +236,26 @@ void I18n::loadBuiltinEnglish()
strings_["chat_toast_compose_failed"] = "Could not compose the message (too long?).";
strings_["chat_toast_request_compose_failed"] = "Could not compose the contact request (invalid address / text?).";
strings_["chat_toast_request_queued"] = "Contact request queued.";
strings_["chat_toast_need_funds"] = "Need a small shielded balance to send chat (to cover the fee).";
strings_["chat_sending"] = "sending\xE2\x80\xA6";
strings_["chat_time_now"] = "now";
strings_["chat_retry"] = "Retry";
strings_["chat_jump_latest"] = "Latest";
strings_["chat_empty_title"] = "No conversations yet";
strings_["chat_empty_start"] = "Start one with \"New conversation\".";
strings_["chat_search"] = "Search conversations";
strings_["chat_no_matches"] = "No conversations match your search.";
strings_["chat_export"] = "Export chat\xE2\x80\xA6";
strings_["chat_export_warn"] = "Saves the decrypted messages as plain text. Store the file securely.";
strings_["chat_export_done"] = "Conversation exported";
strings_["chat_export_failed"] = "Could not write the export file.";
strings_["chat_len_over"] = "Message too long";
strings_["chat_mute"] = "Mute";
strings_["chat_unmute"] = "Unmute";
strings_["chat_hide"] = "Hide";
strings_["chat_hidden_toast"] = "Conversation hidden — a new message brings it back";
strings_["chat_pick_contact"] = "Choose from contacts\xE2\x80\xA6";
strings_["chat_no_z_contacts"] = "No shielded-address contacts yet";
// Seed-phrase backup (full-node)
strings_["seed_backup_button"] = "Seed phrase";
strings_["tt_seed_backup"] = "Show and back up your wallet's 24-word recovery seed phrase";
@@ -276,9 +299,11 @@ void I18n::loadBuiltinEnglish()
strings_["wallets_badge_seed"] = "Seed phrase wallet (HD)";
strings_["wallets_badge_legacy"] = "Legacy wallet (no seed phrase)";
strings_["wallets_badge_unknown"] = "Wallet type not fully determined (large file — open to confirm)";
strings_["wallets_badge_hd"] = "HD wallet — can't confirm a seed phrase without opening it";
strings_["wallets_badge_seed_short"] = "Seed phrase";
strings_["wallets_badge_encrypted_short"] = "Encrypted";
strings_["wallets_badge_legacy_short"] = "Legacy";
strings_["wallets_badge_hd_short"] = "HD wallet";
strings_["wallets_badge_unknown_short"] = "Unknown";
strings_["wallets_open"] = "Open";
strings_["wallets_open_folder"] = "Open folder location";
@@ -1037,7 +1062,27 @@ void I18n::loadBuiltinEnglish()
strings_["confirm_transaction"] = "Confirm Transaction";
strings_["confirm_and_send"] = "Confirm & Send";
strings_["cancel"] = "Cancel";
// Wallet-switch "stop the running node?" confirmation
strings_["switch_stopnode_title"] = "Stop the running node?";
strings_["switch_stopnode_warn"] = "A node is already running that this wallet didn't start.";
strings_["switch_stopnode_body"] = "Switching wallets restarts the node on the wallet you selected. The running node will be stopped and relaunched on the new wallet — if you kept it running on purpose, it comes back up automatically.";
strings_["switch_stopnode_confirm"] = "Stop node & switch";
// Wallet-switch live progress modal
strings_["switch_progress_title"] = "Switching wallet";
strings_["switch_progress_failed_title"] = "Wallet switch failed";
strings_["switch_progress_stopping"] = "Stopping the current node";
strings_["switch_progress_starting"] = "Starting the node on the new wallet";
strings_["switch_progress_reconnecting"] = "Reconnecting";
strings_["switch_progress_hint"] = "A graceful shutdown can take up to a minute.";
strings_["switch_progress_background"] = "Continue in background";
strings_["switch_progress_from_label"] = "from";
strings_["switch_progress_elapsed"] = "Elapsed";
strings_["switch_progress_default_wallet"] = "Default wallet";
strings_["switch_progress_external_wallet"] = "External wallet";
strings_["switch_corrupt_body"] = "This wallet appears corrupt — the node couldn't open it. Restore it from a backup, re-create it, or try to repair it.";
strings_["switch_corrupt_repair"] = "Try to repair (salvage)";
// Receive Tab
strings_["receiving_addresses"] = "Your Receiving Addresses";
strings_["new_z_shielded"] = "New z-Address (Shielded)";
@@ -1245,6 +1290,7 @@ void I18n::loadBuiltinEnglish()
strings_["contact_avatar_remove"] = "Remove";
strings_["contact_avatar_image_hint"] = "The image is copied into the app so it stays available if the original moves.";
strings_["contact_avatar_copy_failed"] = "Could not copy that image.";
strings_["contact_wallet_loading"] = "The wallet is still loading — tick \"Show in every wallet\", or try again in a moment.";
strings_["contact_avatar_bad_image"] = "That image couldn't be loaded.";
strings_["contact_global_badge_tt"] = "Global contact — visible in every wallet";
strings_["address_book_added"] = "Address added to book";
@@ -1520,6 +1566,7 @@ void I18n::loadBuiltinEnglish()
strings_["portfolio_no_addr_match"] = "No addresses match";
strings_["portfolio_manage_title"] = "Manage portfolio";
strings_["portfolio_add_entry"] = "Add entry";
strings_["portfolio_wallet_loading"] = "Wait until the wallet finishes loading to add a group.";
strings_["portfolio_new_entry"] = "New entry";
strings_["portfolio_label"] = "Label";
strings_["portfolio_addresses_sel"] = "%d selected";

View File

@@ -9,6 +9,8 @@
#include <fstream>
#include <filesystem>
#include <cctype>
#include <cstdio>
#include <cstring>
#include <algorithm>
#include "../util/logger.h"
@@ -31,18 +33,38 @@ static constexpr uint8_t VAULT_VERSION = 0x01;
static constexpr unsigned long long ARGON2_MEMLIMIT = crypto_pwhash_MEMLIMIT_MODERATE;
static constexpr unsigned long long ARGON2_OPSLIMIT = crypto_pwhash_OPSLIMIT_MODERATE;
SecureVault::SecureVault() {
SecureVault::SecureVault(const std::string& walletFile) {
// Ensure libsodium is initialized
if (sodium_init() < 0) {
// sodium_init returns 0 on success, 1 if already initialized, -1 on failure
// We'll proceed anyway — the functions will fail gracefully
}
setWalletScope(walletFile);
}
SecureVault::~SecureVault() = default;
std::string SecureVault::getVaultPath() {
return (fs::path(Platform::getConfigDir()) / "vault.dat").string();
void SecureVault::setWalletScope(const std::string& walletFile) {
// The default wallet keeps the legacy global "vault.dat" (empty scope) for backward compatibility;
// any other wallet gets a per-wallet tag. A readable sanitized prefix PLUS an 8-hex FNV-1a of the
// RAW filename — the hash disambiguates so two distinct files can never share a vault even if their
// sanitized forms collide (e.g. "wallet foo.dat" vs "wallet_foo.dat").
if (walletFile.empty() || walletFile == "wallet.dat") { scope_.clear(); return; }
std::string s;
s.reserve(walletFile.size());
for (unsigned char c : walletFile)
s += (std::isalnum(c) || c == '-' || c == '_') ? static_cast<char>(c) : '_';
uint64_t h = 1469598103934665603ULL; // FNV-1a of the raw name
for (unsigned char c : walletFile) { h ^= c; h *= 1099511628211ULL; }
char suffix[9];
std::snprintf(suffix, sizeof(suffix), "%08x", static_cast<unsigned>(h & 0xffffffffu));
if (s.size() > 48) s.resize(48);
scope_ = s + "-" + suffix;
}
std::string SecureVault::getVaultPath() const {
const std::string name = scope_.empty() ? std::string("vault.dat") : ("vault-" + scope_ + ".dat");
return (fs::path(Platform::getConfigDir()) / name).string();
}
bool SecureVault::hasVault() const {

View File

@@ -29,9 +29,15 @@ namespace util {
*/
class SecureVault {
public:
SecureVault();
// `walletFile` scopes the vault to a specific wallet.dat so one wallet's PIN passphrase is never
// offered for another. The default wallet ("wallet.dat" / empty) keeps the legacy global vault.dat
// path for backward compatibility; other wallets get their own vault-<scope>.dat.
explicit SecureVault(const std::string& walletFile = "");
~SecureVault();
// Re-scope to a different wallet (called when the active wallet changes).
void setWalletScope(const std::string& walletFile);
/**
* @brief Check if a vault file exists on disk
*/
@@ -78,15 +84,17 @@ public:
static void secureZero(void* ptr, size_t len);
/**
* @brief Get the vault file path
* @brief Get this vault's file path (scoped to its wallet)
*/
static std::string getVaultPath();
std::string getVaultPath() const;
private:
// Derive a 32-byte key from PIN + salt using Argon2id
bool deriveKey(const std::string& pin,
const uint8_t* salt, size_t saltLen,
uint8_t* key, size_t keyLen) const;
std::string scope_; // "" = default/legacy wallet (vault.dat); else a sanitized wallet-file tag
};
} // namespace util

View File

@@ -138,10 +138,33 @@ struct WalletBtreeStats {
long long createdEpoch = 0; ///< earliest keymeta nCreateTime (wallet birthday); 0 = unknown
bool encrypted = false; ///< saw an mkey record
bool hdSeed = false; ///< saw hdseed/chdseed/hdchain
// BIP39 seed-phrase detection read straight off the hdchain record's fMnemonicSeed flag (which the
// daemon keeps PLAINTEXT even in an encrypted wallet — only the seed itself is crypted):
// 0 = undecidable, 1 = mnemonic seed-phrase wallet (z_exportmnemonic works), 2 = HD/legacy, no phrase.
int mnemonicSeed = 0;
std::size_t bytesRead = 0; ///< bytes actually read (for budgeting)
int addresses() const { return transparentKeys + shieldedKeys; }
};
// Recover the BIP39-mnemonic flag from a serialized CHDChain record value (the plaintext "hdchain"
// record). The daemon serializes CHDChain in Bitcoin/Zcash little-endian byte order as:
// nVersion:int32 | seedFp:32B | nCreateTime:int64 | saplingAccountCounter:uint32
// | [nVersion>=2 (VERSION_HD_TRANSPARENT)] transparentChildCounter:uint32
// | [nVersion>=3 (VERSION_HD_MNEMONIC)] fMnemonicSeed:bool(1B)
// so fMnemonicSeed sits at byte offset 52 (= 4+32+8+4+4) once nVersion>=3. Returns 1 = mnemonic,
// 2 = HD but not mnemonic (raw-entropy seed, or a pre-mnemonic v1/v2 wallet), 0 = undecidable.
inline int hdChainMnemonicFlag(const unsigned char* val, uint32_t len) {
if (!val || len < 4) return 0;
const uint32_t v = (uint32_t)val[0] | ((uint32_t)val[1] << 8) |
((uint32_t)val[2] << 16) | ((uint32_t)val[3] << 24);
const int32_t nVersion = (int32_t)v;
if (nVersion < 1 || nVersion > 100) return 0; // implausible version → treat as unreadable, not "legacy"
if (nVersion < 3) return 2; // VERSION_HD_MNEMONIC not reached → field absent → no phrase
constexpr uint32_t kMnemonicOff = 52;
if (len <= kMnemonicOff) return 0; // truncated/unexpected value → can't read the flag
return val[kMnemonicOff] ? 1 : 2;
}
inline WalletBtreeStats parseWalletBtree(const std::string& path,
std::size_t maxBytes = 256u * 1024u * 1024u) {
WalletBtreeStats st;
@@ -287,7 +310,14 @@ inline WalletBtreeStats parseWalletBtree(const std::string& path,
else if (is("name")) st.addressBook++;
else if (is("tx")) st.txCount++;
else if (is("mkey")) st.encrypted = true;
else if (is("hdseed") || is("chdseed") || is("hdchain")) st.hdSeed = true;
else if (is("hdseed") || is("chdseed")) st.hdSeed = true;
else if (is("hdchain")) {
st.hdSeed = true;
// The hdchain VALUE carries fMnemonicSeed — read it to tell a BIP39 seed-phrase wallet
// apart from a legacy/raw-entropy HD wallet (bare record presence can't). First one wins.
if (st.mnemonicSeed == 0 && dtype == B_KEYDATA && dp)
st.mnemonicSeed = hdChainMnemonicFlag(dp, dl);
}
else if (is("keymeta")) {
// CKeyMetadata data = nVersion(int32) + nCreateTime(int64) + …, all little-endian (Bitcoin
// serialization). The earliest non-zero nCreateTime is the wallet birthday (daemon's

View File

@@ -6,6 +6,7 @@
#include "data/transaction_history_cache.h"
#include "data/address_book.h"
#include "data/wallet_index.h"
#include "util/secure_vault.h"
#include "daemon/lifecycle_adapters.h"
#include "data/wallet_state.h"
#include "rpc/connection.h"
@@ -827,6 +828,63 @@ void testWalletFileProbe()
EXPECT_FALSE(dragonx::util::parseWalletBtree((dir / "junk.dat").string()).parsed);
}
// 8) Mnemonic-flag decode (hdChainMnemonicFlag): the fMnemonicSeed byte lives at offset 52 of the
// CHDChain value once nVersion>=3; v1/v2 have no such field (→ "no phrase"), and a short/garbage
// value is undecidable (0), never mislabeled "legacy".
{
using dragonx::util::hdChainMnemonicFlag;
auto chdchain = [](int32_t ver, int flag /* -1 = omit the trailing byte */) {
std::string v;
for (int k = 0; k < 4; ++k) v += (char)(((uint32_t)ver >> (8 * k)) & 0xff); // nVersion, LE
v.append(32 + 8 + 4 + 4, '\x07'); // seedFp+times+counters (48B)
if (flag >= 0) v += (char)(flag ? 1 : 0); // fMnemonicSeed @ offset 52
return v;
};
auto flagOf = [&](const std::string& v) {
return hdChainMnemonicFlag(reinterpret_cast<const unsigned char*>(v.data()), (uint32_t)v.size());
};
EXPECT_EQ(flagOf(chdchain(3, 1)), 1); // v3, fMnemonicSeed=1 → seed phrase
EXPECT_EQ(flagOf(chdchain(3, 0)), 2); // v3, fMnemonicSeed=0 → HD, no phrase
EXPECT_EQ(flagOf(chdchain(2, -1)), 2); // v2 (field not serialized) → no phrase
EXPECT_EQ(flagOf(chdchain(1, -1)), 2); // v1 → no phrase
EXPECT_EQ(flagOf(chdchain(3, -1)), 0); // v3 but value ends before offset 52 → undecidable
EXPECT_EQ(flagOf(chdchain(999, 1)), 0); // implausible version → undecidable, NOT legacy
EXPECT_EQ(hdChainMnemonicFlag(nullptr, 0), 0);
EXPECT_EQ(flagOf(std::string(3, '\0')), 0); // < 4 bytes → undecidable
}
// 9) End-to-end: a minimal btree whose single record is an hdchain with a v3 CHDChain value →
// parseWalletBtree surfaces hdSeed AND the decoded mnemonicSeed (1 for a seed-phrase wallet, 2 when
// the flag is clear). Exercises the value-reading path inside the real walk, not just the decoder.
{
auto buildHdchainWallet = [](const std::string& chdValue) {
std::string w(1024, '\0');
auto put16 = [&](std::size_t o, unsigned v){ w[o]=(char)(v&0xff); w[o+1]=(char)((v>>8)&0xff); };
auto put32 = [&](std::size_t o, unsigned v){ for(int k=0;k<4;k++) w[o+k]=(char)((v>>(8*k))&0xff); };
put32(12, 0x00053162u); put32(20, 512); w[25]=(char)9; put32(88, 1); // metapage → root = page 1
const std::size_t P = 512; // page 1: one (key,data) pair
put16(P+20, 2); w[P+24]=(char)1; w[P+25]=(char)5; // entries=2, level 1, P_LBTREE
put16(P+26, 490); put16(P+28, 430); // item offsets: key@490, data@430
std::string kd; kd += (char)7; kd += "hdchain"; // key BKEYDATA: CompactSize(7)+name
put16(P+490, (unsigned)kd.size()); w[P+490+2]=(char)1;
for (std::size_t k=0;k<kd.size();++k) w[P+490+3+k]=kd[k];
put16(P+430, (unsigned)chdValue.size()); w[P+430+2]=(char)1; // data BKEYDATA: the CHDChain value
for (std::size_t k=0;k<chdValue.size();++k) w[P+430+3+k]=chdValue[k];
return w;
};
std::string v3seed; // v3, fMnemonicSeed=1
for (int k=0;k<4;k++) v3seed += (char)((3u>>(8*k))&0xff);
v3seed.append(48, '\0'); v3seed += (char)1;
writef(dir / "hdchain_seed.dat", buildHdchainWallet(v3seed));
auto s1 = dragonx::util::parseWalletBtree((dir / "hdchain_seed.dat").string());
EXPECT_TRUE(s1.parsed); EXPECT_TRUE(s1.hdSeed); EXPECT_EQ(s1.mnemonicSeed, 1);
std::string v3nophrase = v3seed; v3nophrase.back() = (char)0; // fMnemonicSeed=0
writef(dir / "hdchain_legacy.dat", buildHdchainWallet(v3nophrase));
auto s2 = dragonx::util::parseWalletBtree((dir / "hdchain_legacy.dat").string());
EXPECT_TRUE(s2.parsed); EXPECT_TRUE(s2.hdSeed); EXPECT_EQ(s2.mnemonicSeed, 2);
}
fs::remove_all(dir, ec);
}
@@ -2139,6 +2197,34 @@ void testOperationStatusPollParsing()
EXPECT_TRUE(malformed.staleOpids.empty());
}
void testSecureVaultScope()
{
using dragonx::util::SecureVault;
auto endsWith = [](const std::string& s, const std::string& suf) {
return s.size() >= suf.size() && s.compare(s.size() - suf.size(), suf.size(), suf) == 0;
};
// Default wallet (empty or "wallet.dat") keeps the legacy global vault.dat for back-compat.
EXPECT_TRUE(endsWith(SecureVault("").getVaultPath(), "vault.dat"));
EXPECT_TRUE(endsWith(SecureVault("wallet.dat").getVaultPath(), "vault.dat"));
// A non-default wallet gets its own, distinct vault (not the legacy path).
SecureVault vb("wallet-savings.dat");
const std::string pb = vb.getVaultPath();
EXPECT_TRUE(pb.find("vault-") != std::string::npos);
EXPECT_TRUE(pb != SecureVault("").getVaultPath());
EXPECT_TRUE(pb != SecureVault("wallet-cold.dat").getVaultPath());
// The FNV suffix disambiguates sanitize-colliding names, so distinct files never share a vault.
EXPECT_TRUE(SecureVault("wallet foo.dat").getVaultPath() != SecureVault("wallet_foo.dat").getVaultPath());
// Re-scoping moves the path; scoping back to the default returns to vault.dat.
vb.setWalletScope("wallet-cold.dat");
EXPECT_TRUE(vb.getVaultPath() == SecureVault("wallet-cold.dat").getVaultPath());
vb.setWalletScope("wallet.dat");
EXPECT_TRUE(endsWith(vb.getVaultPath(), "vault.dat"));
}
void testWalletSecurityController()
{
using dragonx::services::WalletSecurityController;
@@ -6390,6 +6476,7 @@ int main()
testNetworkRefreshRpcCollectors();
testNetworkRefreshResultModels();
testOperationStatusPollParsing();
testSecureVaultScope();
testWalletSecurityController();
testWalletSecurityWorkflow();
testWalletSecurityWorkflowExecutor();