123 Commits

Author SHA1 Message Date
cc2d383166 Merge dev: lite6 + lite7 default servers 2026-09-02 04:20:45 -05:00
f30fdc5ed1 feat(lite): add lite6 + lite7 to the default lite-wallet server list
Ship https://lite6.dragonx.is and https://lite7.dragonx.is as default
lightwalletd servers in both the Settings defaults and the connection-service
fallback list. Existing installs (whose saved server list predates these)
pick them up via a load-time merge that appends any missing default server —
safe because servers are hidden, never deleted, so a removed server isn't
resurrected. Consolidates the Settings default list into a single
Settings::defaultLiteServers() source of truth.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-09-02 04:15:29 -05:00
290d3c31a2 Merge dev: i18n back-fill (313 strings × 8 languages) 2026-09-02 03:30:16 -05:00
27fb83ba04 i18n: back-fill 313 UI strings across all 8 languages
Complete the de/es/fr/ja/ko/pt/ru/zh translations so every English source
key in i18n.cpp is now covered (2341 keys per language, 0 missing). Covers
the chat delete/block flow, the block-database reindex + wallet-recovery
prompts, node auto-shield status, the in-app FAQ (full-node + lite), the
daemon updater/version picker, the debug-options gate, and more.

Additive-only (no existing key changed). Printf format signatures preserved
and validated against i18n.cpp formatSignature; newlines, the "→" arrow, and
brand/technical identifiers kept verbatim. Terminology grounded on each
language's existing translations for consistency.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-09-02 03:30:03 -05:00
0e78fe02e4 Merge dev: macOS Berkeley DB build fix (build.sh)
Derive the macOS BDB depends triple from the build arch + pass the BDB paths
to CMake, so the full-node mac release builds the wallet-rebuild helper on
Intel (x86_64) as well as Apple Silicon.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-09-02 02:29:37 -05:00
7914e9e9bb fix(build): derive the macOS Berkeley DB depends triple from the build arch
The macOS release path hardcoded aarch64-apple-darwin for the wallet-rebuild
helper's static libdb and — unlike the Linux/Windows paths — never passed
-DBDB_INCLUDE_DIR/-DBDB_LIBRARY to CMake. So on an Intel Mac the helper either
couldn't find libdb, or picked up macOS's SDK stub /usr/include/db.h (a DB 1.85
shim with no db_create) and failed to compile.

- mac_bdb_dir(): map the target arch to the depends triple (x86_64 ->
  x86_64-apple-darwin, arm64 -> aarch64-apple-darwin; universal / unknown falls
  back to the host arch).
- Pass -DBDB_INCLUDE_DIR / -DBDB_LIBRARY (when the vendored libdb is present) in
  BOTH the native and osxcross configures, so CMake uses our BDB 6.2 header
  instead of the SDK stub. Full-node only (lite has no BDB wallet.dat).
- Use the derived triple in require_wallet_rebuild_helper's fix hint.

Verified end-to-end by building both ObsidianDragon 2.0.1 and ObsidianDragonLite
1.1.0 .dmg/.app on an Intel Mac (x86_64; built single-arch via DRAGONX_MAC_ARCHS
since the vendored deps are x86_64-only).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-09-02 02:20:54 -05:00
06d34dff5e Merge dev: ObsidianDragon 2.0.1 / ObsidianDragonLite 1.1.0
Brings the full 2.0.x line to master: security-audit remediations, diagnostics/
logging, mining overhaul, HiDPI/UI audit, wallet recovery + migrate-to-seed,
daemon-startup hardening, seed-phrase backup, in-app FAQ, i18n + 8-language
translations, chat delete/block, per-frame render perf, and the Lite variant
(1.1.0) with its variant-aware FAQ. Windows app exe now stripped.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-09-02 01:14:27 -05:00
c7e48c16f8 feat(lite): version 1.1.0 + variant-aware FAQ
Bump DRAGONX_LITE_VERSION 1.0.0 -> 1.1.0: since lite-1.0.0 the Lite variant
gained the whole 2.0.x shared UI/UX + diagnostics + i18n + perf work and this
session's chat delete/block — a minor bump (new features, no breaking change).

Make the FAQ variant-aware (walletFaq(fullNode)) so the Lite build reads
correctly: "What is ObsidianDragonLite?", encryption in the Wallet tab (not
Node & Security), migrate-to-seed hidden, node-specific answers reworded
neutrally ("the wallet syncs"), a new "Lite Wallet" subcategory (server model +
privacy tradeoff) standing in for the hidden Daemon group, and the redundant
single "Wallet" group tab dropped when there's no Daemon group to switch to.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-09-02 01:12:08 -05:00
4ee0f524d4 build(release): strip the Windows app exe
The Linux and macOS release paths already strip the main binary (and the
Windows path even stripped the dragonx-wallet-rebuild helper), but the
Windows app exe was shipping unstripped — ~5 MB of symbols on the full
node, ~19 MB on the params-heavy lite build. Strip it right after the
Windows build, best-effort with a warning fallback.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-09-02 01:12:08 -05:00
43c7be55c6 fix(ui): relocate + reword node auto-shield status; add chat-management FAQ entry
- Auto-shield status: the node's z_autoshieldstatus disabled_reason was echoed
  verbatim ("HD seed origin is not known-recoverable; back the seed up and pass
  -autoshield=1") and drawn INSIDE the Wallet OPTIONS checkbox grid, wedging a
  full-width line between the checkboxes. Move it to a full-width note BELOW the
  grid, and replace the raw daemon text with friendly, actionable wording keyed
  on the seed_recoverable flag (back up your seed to enable it); the raw daemon
  reason is kept on hover. Adds App::daemonAutoShieldSeedRecoverable().
- FAQ: add a Chat & Contacts entry ("How do I hide, delete, or block a
  conversation?") covering the hide / delete-revive / delete-&-block actions and
  the local-only caveat — data + i18n only, no UI code change.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-09-01 23:42:37 -05:00
d9fa00bb38 perf: memoize per-frame render hot paths (console, transactions, recent lists)
Immediate-mode render functions re-run every frame; these rebuilt O(N)
state each time even when nothing changed. From a 6-lens perf audit, each
finding verified on a hot-path basis:

- Console: ConsoleModel gains revision(); the full-model filter scan and the
  glyph-by-glyph text-layout pass (BuildConsoleLayout) rebuild only when the
  model / filter / wrap-width / zoom change — previously it re-shaped up to
  10,000 lines every frame even when idle/scrolled. clear() force-invalidates
  the memo mid-render (no OOB on the just-emptied visible set).
- Transactions: the summary-card totals memoize behind the tab's existing
  FNV-1a fingerprint (also folds away a now-duplicate O(N) display-key pass).
- Send / Receive recent lists: early-exit the prefix scan (state.transactions
  is kept newest-first) instead of filtering the whole tx history every frame.
- network_refresh_service: O(new x total) txid find-and-replace -> hash map.
- Sidebar unconfirmed-tx badge cached on last_tx_update + tx count; the
  daemon-memory probe (/proc scan on Linux, popen on macOS) throttled to ~1.5s.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-09-01 22:55:04 -05:00
0a042df8e0 feat(chat): per-conversation delete (revive + block); memoize chat/badge render
Adds a per-conversation "delete" with two modes, and removes the per-frame
rescans of the chat history that shared these files.

Delete conversation (header trash icon → confirm dialog):
- Delete (revive-on-new-message): clears local history and tombstones the
  messages (new chat_deleted table, keyed dedup hashes) so the every-few-
  seconds memo re-scan can't re-import them; a genuinely NEW message (new
  txid) revives the thread.
- Delete & block: removes history WITHOUT a tombstone and records the cid as
  blocked (settings); ChatService::ingest drops that conversation's messages
  — old and future — until unblocked from the "Blocked" manager, which then
  re-imports the conversation from chain.
- Local-only (messages remain on-chain; the peer keeps their copy).
  deleteConversation() deletes the DB rows FIRST and only then mutates the
  store, so a failed write can't leave the two diverged.
Unit-tested (revive / tombstone-survives-reload / block / unblock) and
adversarially reviewed (store/DB divergence, half-open DB, revive-unread).

Performance (chat + badge hot paths, from the perf audit):
- ChatStore gains revision(); the Chat unread badge is now a single O(N)
  no-alloc pass cached on it (was O(conversations x messages) copy+sort every
  frame). The conversation list and open thread are memoized on revision()
  (+ show-hidden and AddressBook::revision() for peer-name resolution).
- AddressBook gains revision() so an in-place contact rename invalidates the
  chat memo (an edit keeps entries().size() constant).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-09-01 22:54:51 -05:00
a60a2f8e39 fix(ui): stop sidebar badges from displacing button text
The Chat/History nav buttons centered their icon+label in a region that
shrank when a badge (unread count / mining dot) was present, and the
"has badge" test read the LIVE count — so the text jumped sideways the
moment a count toggled (e.g. a new chat message arrived). Reserve badge
clearance by whether the page CAN show a badge (constant per item), center
in the full button width regardless, and cap the label with symmetric
clearance so a long label still can't run under the corner badge.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-09-01 22:54:36 -05:00
d27f387d6d feat(ui): in-app FAQ, DPI-scaling audit fixes, mining/stratum polish + localize strings
Bundles the session's UI work (the touched files carry several of these
changes together, so they are committed as one coherent UI batch):

- FAQ: new RenderFaqDialog + data-driven faq_content, opened from a
  status-bar "?" (and the Windows title bar), styled like the Wallets
  modal with search, Wallet/Daemon tabs, and smooth scroll.
- DPI/font-scale audit: multiply hand-drawn absolute geometry by
  Layout::dpiScale() across ~30 files so nothing renders native-size at
  HiDPI / font_scale 1.5 (verified with a full sweep at 1.5x).
- Mining: chart now fills the horizontal space; thread stepper +/- buttons
  match the input-box height; move the stratum-host toggle into
  Node & Security (v1.3.0+).
- Settings: fix the auto-shield status text overlapping the grid.
- Sidebar: drop the peer-count badge on the Network button.
- i18n: wrap 193 hardcoded literals with TR() (keys/translations added in
  the preceding i18n commit), so the security/PIN/lock flow, seed-backup
  wizard, and witness-rebuild dialog localize.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-09-01 19:37:16 -05:00
ed675f90d8 feat(i18n): add English source + 8-language translations for 193 UI strings
Adds the i18n source for 193 previously-hardcoded UI strings (the
security / PIN / lock flow, first-run seed-backup wizard, witness-rebuild
shutdown dialog, and balance / mining / explorer labels) so they can be
localized. The TR() call-site wrapping rides in the accompanying UI commit.

- 193 keys added to loadBuiltinEnglish() (the English source of truth),
  including one pre-existing missing key (copied_to_clipboard).
- Additive res/lang/{de,es,fr,ja,ko,pt,ru,zh}.json (+193 each); every
  translation's printf format-signature was validated against English
  (0 mismatches) so the runtime validator accepts them.
- Rebuilt res/fonts/NotoSansCJK-Subset.ttf to cover ~30 new Han/Hangul
  glyphs introduced by the zh/ja/ko translations (tofu-free).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-09-01 19:36:50 -05:00
4492aa3425 fix(sync): prioritize getblockchaininfo and pause chat scans while behind
The node kept falling behind the network near the tip because sync
DETECTION was starved: getblockchaininfo was queued behind the
O(mapWallet) wallet RPCs (z_gettotalbalance / z_listunspent), so
longestchain went stale, the wallet decided it was "synced", and it
resumed hammering cs_main — a feedback loop.

- Issue getblockchaininfo FIRST each cycle and skip the balance/address/
  tx refresh entirely while behind, so sync state (and kSyncProfile)
  updates before any heavy wallet scan runs.
- Gate the two chat note scans (refreshChatNoteBudgetNode /
  fastScanChatMemos) on effectivelySyncing() and the active page, so chat
  memo scanning no longer competes with block connection during sync.
- Windows debug.log tailer: reset the read offset when dragonxd truncates
  the log on startup (it was stranding at Block:0 with no witness/rescan
  progress).
- Tests cover the getblockchaininfo-first ordering and behind-skip.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-09-01 19:36:30 -05:00
56d93b6128 fix(shutdown): don't warn "node is rebuilding" for routine witness activity / on v1.3.0
The shutdown guard fired its "Node is rebuilding its witness cache" prompt almost always on an
active wallet: the daemon does a per-tx VerifyAndSetInitialWitness as each newly received wallet
tx lands during normal sync, and those markers (sparse — minutes apart — with no matching
"rebuilt N note witness cache(s) … in Xms") kept the heuristic latched "active".

- daemonWitnessRebuildActive() now requires the last progress marker to be part of the CURRENT
  log activity (within ~15s of the newest log line, via a same-log timestamp delta), so routine
  minutes-old per-tx witness sets no longer count as an ongoing rebuild.
- shouldConfirmDaemonStop() suppresses the prompt entirely on v1.3.0+ daemons (version >= 1030000):
  they checkpoint witness-rescan progress, so stopping mid-rebuild resumes on the next start rather
  than redoing it — the warning's "restarts it (several minutes)" premise no longer holds.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-09-01 02:21:49 -05:00
398fb274fa fix(ui): only toast "Blockchain rescan complete" for user-initiated rescans
The daemon-output parser treats autonomous background witness rebuilds as a rescan (they set
state_.sync.rescanning via the foundWitness branch), so one completing fired "Blockchain rescan
complete" even though the user never started a rescan — most visibly after a minimize, where a
whole rebuild's start+finish arrives in one batch.

Add user_initiated_rescan_ (atomic — some triggers run on worker threads), set it at the wallet's
real rescan triggers (the Rescan button, a -rescan/salvage/zap/reindex restart, key import, seed
migration), and gate the three "rescan complete" toasts on it, clearing it when shown. The
rescan/witness progress state machine is untouched — only the toast is gated — so background
rebuilds no longer announce a completed rescan while genuine user rescans still do (and can't
double-toast).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-09-01 01:18:57 -05:00
0343d48c13 feat(ui): keep the wallet syncing while minimized
Previously the main loop just did SDL_Delay+continue when minimized, skipping app.update() —
so the wallet stopped draining RPC results, ticking the refresh scheduler, and reconnecting
until it was restored (and a large daemon-output backlog piled up, which is what produced the
spurious "rescan complete" toast on restore).

Now app.update() runs while minimized (it only reads GetIO/GetTime/IsAnyItemActive, all valid
outside a NewFrame) with a real-clock DeltaTime, skipping only the ImGui frame + GPU present,
throttled to ~5 Hz so CPU stays near-idle. Also clamp io.DeltaTime at the top of App::update()
so a long minimize (or machine sleep) can't report a huge delta and fire every refresh/animation
timer at once on the next update. No backlog now builds, so skipDaemonOutputBacklog becomes a
harmless no-op.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-09-01 01:09:50 -05:00
a3892c0fd3 fix(ui): stop spurious "Blockchain rescan complete" toast after restoring from minimize
While minimized the main loop skips the frame body (SDL_Delay+continue), so App::update() —
which drains the daemon output via outputSince(daemon_output_offset_) — never runs and the
offset isn't advanced. On restore the whole accumulated backlog is parsed in one batch: a
background witness rebuild's progress lines (parsed as a rescan → state_.sync.rescanning=true)
AND its "rebuilt … in Xms" completion (parsed as finished) arrive together and fire
"Blockchain rescan complete" for a scan the user never initiated.

On WINDOW_RESTORED, discard the daemon-output backlog (advance the offset to the current end,
via App::skipDaemonOutputBacklog) before the resumed update parses it — so only new output is
parsed. Genuine user-initiated rescans still surface completion via the getrescaninfo monitor.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-09-01 00:49:06 -05:00
f7df315695 fix(ui): remove the "Taking longer than expected" startup stall notice
It added clutter to the loading screen (the yellow title + two lines of explanatory text).
The live daemon-output panel below it is the real progress signal. connect_stall_since_ stays
maintained in app_network.cpp for connection bookkeeping; it just no longer drives any on-screen
text (loading_stall_* i18n strings + util::connectHasStalled are now unused).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-09-01 00:15:27 -05:00
aec996a9ce feat(mining): host a RandomX stratum pool from the node (v1.3.0+)
Add an opt-in "Host a mining pool (stratum)" toggle so a v1.3.0+ node can run its native
RandomX stratum server for other miners to point at. Settings toggle + optional allow-IP/CIDR;
passes -stratum (+ -stratumallowip) to the daemon launch args, mirroring the -maxconnections
plumbing (EmbeddedDaemon::setStratumHosting <- DaemonController::syncSettings <- Settings).

Backwards compatible / safe by default:
- UI gated on daemon_version >= 1030000, so it's never offered where it would do nothing.
- The launch flag is harmless on older daemons (they ignore unknown args), and the toggle can
  only be enabled while connected to a v1.3.0+ node anyway.
- Blank allow-IP => the daemon serves loopback only (its safe default); entering a subnet opens
  it to that LAN, with an explicit exposure warning in the UI.
- Takes effect on the next daemon start/restart.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-08-31 23:19:48 -05:00
90e02b1ddd feat(ui): surface daemon DEGRADED mode + v1.3.0 auto-shield status
Rec3 — v1.3.0+ opens a wallet that lost its hdchain in DEGRADED mode (existing funds spendable,
but no new HD-key derivation) instead of aborting. Add a daemon-log classifier
(walletOpenedDegraded) + detectWalletDegraded(), warned once per session. Pre-1.3.0 daemons
never emit that line, so it's a no-op there.

O1 — probe z_autoshieldstatus once per connection (now decoupled from our own toggle/balance) and,
in Settings, show whether the node handles auto-shield itself (+ its destination, or the daemon's
disabled_reason). The checkbox now governs only the wallet's fallback shielder, which defers to the
node. Nothing renders on pre-1.3.0 daemons (no such RPC), so behaviour is unchanged.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-08-31 22:48:46 -05:00
ef8ceeaf9a feat(net): seed via the round-robin DNS record + node1/node5 (backwards-compatible)
Replace the wallet's stale hardcoded -addnode list (node. + node1-4.dragonx.is — a drifted
subset with a bogus bare 'node.') with the daemon's authoritative vSeeds: seed.dragonx.is (a
round-robin A record over the live seed set, so it self-updates without a wallet release) plus
node1/node5 as static fallbacks. Applied in both seeding sites: the launch args
(embedded_daemon.cpp) and the generated DRAGONX.conf (connection.cpp).

Kept (not deleted) — pre-1.3.0 daemons had broken peer discovery and rely on these -addnode
entries to find peers at all; plain hostname resolution works on every daemon version.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-08-31 22:48:46 -05:00
ba1d760bb3 fix(autoshield): defer to the daemon's own coinbase auto-shield on v1.3.0+
v1.3.0 daemons auto-shield coinbase by default (when the HD seed is recoverable — which
every ObsidianDragon-created wallet is, via -usemnemonic=1). The wallet also ran its own
client-side auto-shield every refresh tick, so both raced for the same coinbase UTXOs and
split funds across different z-addresses (the wallet picks the first z_listaddresses entry;
the daemon uses a seed-hardened derivation).

Probe z_autoshieldstatus once per connection (while synced, so the daemon is past warmup)
and skip the wallet's client-side shield when the daemon reports it active. Fail-closed: a
pre-1.3.0 daemon has no such RPC, so the probe returns active=false and the wallet keeps
shielding — no regression on the currently-bundled v1.0.3. Re-probes on reconnect (handles
a live daemon upgrade/swap).

Follow-up (not done): drive the Settings "Auto-shield" toggle + disabled_reason from
z_autoshieldstatus (O1) — needs runtime verification of the RPC fields on a live v1.3.0 node.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-08-31 22:33:12 -05:00
0942691eb3 fix(win): route shell-outs through a windowless helper (no cmd.exe flash)
_popen/_popen-style shell-outs flash a cmd.exe console window on Windows. Add
Platform::runHiddenCapture() — CreateProcess + CREATE_NO_WINDOW capturing stdout on
Windows, popen on POSIX — and route the remaining shell-outs through it:
- GPU-aware idle detection (getGpuUtilization: "where nvidia-smi" / "nvidia-smi --query-gpu")
- xmrig discovery + version (findXmrigBinary "where xmrig.exe"; "<bin> --version", stderr merged)
- wallet-rebuild helper (app_network) — keeps its exit-code check via the new exitCode out-param

None of these are on the launch path (that was the daemon spawn, fixed in a2f84be); each
would flash a console only when it ran (idle-GPU mining, mining tab, wallet recovery).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-08-30 23:58:54 -05:00
a2f84be2d4 fix(win): stop console-window flash on launch (spawn daemon with CREATE_NO_WINDOW)
The embedded daemon was launched with CREATE_NEW_CONSOLE + SW_HIDE. CREATE_NEW_CONSOLE
allocates a console window that flashes on screen before SW_HIDE hides it — visible as a
console-window flash every time the wallet starts dragonxd (i.e. on launch). Switch to
CREATE_NO_WINDOW (the console child gets no window at all, matching the xmrig launcher);
dragonxd logs to debug.log, not a console, so nothing is lost.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-08-30 23:43:12 -05:00
7e8b99a82b feat(shutdown): confirm before stopping the daemon mid witness-cache rebuild
Stopping dragonxd while it's rebuilding the Sapling witness cache discards the
in-progress work — BuildWitnessCache aborts on shutdown without persisting — so
the next launch redoes a multi-minute rebuild (the "Activating best chain…" hang).
This bites especially with stop_external_daemon enabled, where wallet exit sends
the node a stop.

beginShutdown() now defers when it would StopDaemon while a rebuild is active and
shows a confirm modal: "Keep node running & quit" (DisconnectOnly — leaves it up
to finish), "Stop anyway & quit", or "Cancel". Rebuild detection reads the
debug.log tail markers (Cleared witness data / Setting Initial Sapling Witness /
Reading blocks for witness rebuild, vs. the "rebuilt … in …ms" / abort lines).
The gate lives entirely in beginShutdown()/render() — no SDL event-loop changes.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-08-30 23:32:03 -05:00
29274c2f48 fix(ui): trim verbose startup notice; show daemon output on shutdown for external daemons
- Loading "taking longer than expected" notice: shorten the body + hint so the
  startup screen reads less wordy (same info, ~half the text).
- Shutdown screen: when the wallet attached to an EXTERNAL daemon (no captured
  stdout — debug_log_path_ is only set when we spawn it), the "dragonxd output"
  panel was always empty, leaving just a spinner. Fall back to tailing the
  daemon's debug.log so the user can watch the node flush the block index and
  exit. Adds App::tailDaemonDebugLog() (best-effort, reads only the file tail).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-08-30 23:09:17 -05:00
870793433b fix(sync): stop large-wallet balance polling from starving block connection
On a fully-shielded (ac_private=1) chain, z_gettotalbalance is O(mapWallet) and
holds the daemon's cs_main for its whole duration — ~20s on a ~5k-tx wallet. The
Overview refresh polled it every ~2s (twice: minconf 0 and 1), so cs_main was
held almost continuously, starving the single block-connection thread: the node
connected blocks only in the gaps between polls and could fall further behind
the tip than it caught up (observed live: gap growing 58→100 blocks while the
GUI was open, one core pegged on GetFilteredNotes, 22 idle, ~17 B/s download).

Two hardening changes on top of the existing "skip balance while syncing" guard:

- Hysteresis: keep the low-impact sync profile (and balance suppression) for a
  short settle window after catching up, so a large-wallet scan can't
  immediately re-starve connection and bounce the node back into syncing. Armed
  only on the syncing→caught-up edge, so a wallet synced from the start is never
  throttled at connect (effectivelySyncing()).

- Adaptive balance cadence: time each z_gettotalbalance scan and require the
  next poll to wait at least (cost / 10%), so balance scanning never occupies
  more than ~10% of wall-clock. Cheap wallets are unaffected (the tab's Core
  timer stays the cadence); a ~20s scan backs off to ~200s. Wallet mutations
  (send/shield) force the next poll through so the user's own action updates the
  balance immediately (balanceRefreshDue()).

getblockchaininfo keeps its normal cadence throughout, so sync progress stays
live. Build + test_phase4 green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-08-30 22:45:51 -05:00
08cfeb0e08 feat(ui): rework the consolidate/merge modal
Make Merge to Address actually serve wallet-bloat consolidation and be far less
opaque. New ShieldDialog::showConsolidate() preset (used by the large-wallet
Settings banner + alert action) frames it as "Consolidate funds" and targets
shielded notes — the bloat the nudge warns about.

- Source selector: consolidate shielded notes (ANY_SAPLING), transparent
  (ANY_TADDR), or both (*) — previously hardcoded to ANY_TADDR, which never
  reduced the shielded-witness bloat. Batch limit now applies to the right side.
- Scope: on open, count spendable UTXOs + notes (listunspent / z_listunspent)
  and show "N transparent + M shielded · ~X DRGX"; warn "repeat to finish" when
  the set exceeds one batch.
- Destination auto-selects the best spendable z-address (button enabled by
  default); empty wallets get an inline "Create shielded address" (z_getnewaddress).
- Advanced disclosure hides Fee + "Max inputs per batch" (renamed from the "UTXO
  Limit" jargon) with sane defaults.
- Inline confirm step before the fund-moving call (amount + input count + dest).
- Live progress: self-polls z_getoperationstatus to show Consolidating… →
  Done/Failed, replacing the raw opid + manual "Check status" button.

All three merge entry points now use the typed showMerge()/showConsolidate()
(no stale-static leaks from direct show(MergeToAddress)). Shield-coinbase mode
keeps working. New i18n keys fall back to English.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-08-20 22:33:59 -05:00
5daf2d83b6 feat(ui): large-wallet nudge as a one-time toast + clickable alert
Extend the wallet-bloat warning beyond the Settings banner: when wallet.dat
first crosses 500 MB (full-node, synced), fire a one-time warning toast plus a
clickable "Consolidate notes…" entry in the bell/alerts panel that opens Merge
to Address. The persisted large_wallet_warned flag keeps it once-only and
re-arms if the file later shrinks back under the threshold.

- AlertRecord gains an optional onClick + actionHint; Notifications::action()
  pushes a toast and a clickable history entry. renderAlertHistoryPanel() now
  renders the accent action link (under the message) and measures true content
  height so wrapped messages + the link aren't clipped.
- App::maybeWarnLargeWallet() (mirrors maybeRemindSeedBackup) runs once per
  launch from update(); reuses the existing wallet_size_warn/consolidate strings.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-08-20 18:09:36 -05:00
6d26ccd0ed feat(ui): large-wallet nudge in Node & Security
The BDB wallet.dat bloats with shielded-note witness data and never shrinks
in place, so a mining/shielded wallet can grow past 500 MB. Below the Wallet
Size row, show a one-line amber hint once wallet.dat crosses 500 MB with a
"Consolidate notes…" shortcut that opens the Merge to Address (z_mergetoaddress)
dialog. Full-node only (lite has no wallet.dat here); threshold is a single
named constant. i18n keys fall back to English for non-English locales.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-08-20 17:41:25 -05:00
a7514becbc feat(ui): credit The DragonX Developers in the About tab
Add "The DragonX Developers" to the About-tab credits (after The Hush
Developers), acknowledging the DragonX chain/daemon this wallet drives.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-08-20 17:17:03 -05:00
558cfcbe56 fix(ui): restore ObsidianDragon logo in header and About tab
ensureLogoTexture() rasterized the embedded DragonX SVG into logo_tex_ and
returned early (added in 1752500 "themed DragonX logo"), so the app/product
branding — the top-left header (app.cpp AddImage) and the About tab
(getLogoTexture) — showed the DragonX coin mark instead of the ObsidianDragon
logo. Drop that step so logo_tex_ resolves via the intended path: active-skin
override → ui.toml header-icon → bundled ObsidianDragon dark/light PNG (disk,
then embedded RESOURCE_LOGO). The DragonX SVG stays for coin_logo_tex_ (balance
card) and drgx_emoji_tex_ (chat emoji), which are the currency mark and correct.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-08-20 17:06:04 -05:00
d0bd55b9c1 feat(ui): settings polish — button retune, daemon card, RPC 2-row, chat preview
Settings tabs brought closer to the approved mockup:
- ActionButton/renderCardButton retune (settings-scoped): 7px radius, 9px
  padX, Primary → accent-outline chip, Secondary/card buttons more defined.
- Daemon-binary card: compact status right-aligned on the DAEMON BINARY
  heading (Up to date / Version differs / Not installed), filled/rounded
  status box, neutral danger divider (was alarming red), roomier spacing.
- RPC Connection: two-row column-aligned layout (Host | Port, then
  Username | Password) so the password no longer clips off the card edge.
- Chat settings tab: live conversation preview below the Appearance /
  Messaging cards; "Focus input on open" checkbox reflowed onto the console
  color-toggle row.
- Debug Options: "Current theme only" toggle restricts either screenshot
  sweep to the active theme instead of cycling every skin.
- Tabs fill the full content width (content-max-width cap disabled) and the
  sidebar nav panel centers within the true visible area.
- i18n: new keys for the above (untranslated keys fall back to English).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-08-20 16:59:03 -05:00
b37d3d97b6 fix(send/receive): unify card width, justify receive footer, fix recipient-row button height/clip/glyph
Send and Receive are now consistent in layout, and the Send recipient row's buttons
render correctly.

Card envelope (Send ⇄ Receive consistency):
- Add Layout::mainComposeCardBox(availW) — a single shared source for the compose card's
  width + centering (fill the available column up to content-max-width, then center). Both
  tabs derive their card from it, so they can't drift again. Previously Send capped at
  760dp and Receive at 860dp, so the Send card rendered ~150px narrower on any window wider
  than ~860dp; now they fill available width identically.

Receive:
- Justify the footer buttons edge-to-edge (equal shares over the live count) instead of
  left-clustering with dead space, matching Send's full-width footer rhythm.
- Build the address-dropdown preview to the combo's real pixel width so the trailing
  balance ("— 12.00000000 DRGX") no longer hard-clips at 150% (was char-count truncation).

Send recipient row (input | Paste | contacts-icon):
- Pin the contacts icon button to the frame height so the larger iconMed font doesn't
  auto-size it taller than Paste/the input.
- Reserve the real ItemSpacing.x gaps (not the smaller spacingSm token) so the row no
  longer overshoots the card and clips the icon's right border.

draw_helpers (root cause, app-wide):
- TactileButton's icon path measured/drew the label INCLUDING the "##id" suffix (which
  CalcTextSizeA/AddText don't strip the way ImGui's text render does), shoving the glyph
  off-center-left. Strip at "##" before measuring/drawing. Corrects any icon button that
  passes an explicit size and a "##id" label; no-op for labels without "##".

Verified via headless sweeps at 1.0x and 1.5x, plus a real 3800px-wide render (both cards
byte-identical at L=1174/R=2773). ctest 1/1.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-08-19 16:10:02 -05:00
8778398d31 feat(ui): layout polish — fill dead space, pair/box/collapse, warning weight, dialog glass
Implements the layout-improvement suggestions from the layout audit (visual arrangement
only; no functionality added or removed):

- Send/Receive: the recent-activity list now grows to fill the space below the fixed
  compose/receive card (more history visible), with a centered empty-state when there is
  none — instead of leaving dead canvas.
- Shield/Merge: pair the Fee and UTXO-Limit fields on one row to tighten vertical rhythm.
- Market: extend + frame the portfolio group-list as one contained panel (with a bottom
  edge) and center its empty-state, closing the previously un-anchored gap.
- Overlay dialogs: raise the card glass fill/border alpha (35/50 -> 60/90 of 255) so the
  dialog card reads as a distinct surface over busy backdrops (global, all overlays).
- Wallets: size the list height to the actual wallet count instead of always reserving 7
  rows, removing the large gap before the scan/create prompts (still scrolls when many).
- Contacts: width-aware address truncation shows more of the address on wide rows.
- Transfer Funds: give the "sends the full balance" warning a warning icon + color so the
  stakes stand out from the neutral result-preview lines.
- First-run wizard: collapse a completed Step 1 (Appearance) to the compact pill like
  Step 2, so a finished step is no longer taller than the active one.
- Explorer: distribute the Chain card's two stats to match the density of the sibling
  metrics grid.
- Validate Address: a "Results will appear here" caption fills the pre-interaction blank.
- Change Passphrase: add the warning banner its sibling security dialogs have.
- Migration ShowSeed: box the 24-word mnemonic grid (a GlassSectionScope behind the
  existing RenderSeedWordGrid) so the critical secret reads as a distinct artifact —
  purely visual, no seed/logic/state change.

Verified at 1280 across full-node + Lite + Windows (ctest green) and an adversarial diff
review (clean). New i18n key backfilled into all 8 languages.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-08-19 13:56:13 -05:00
d5c30237d2 refactor(ui): consolidate dialog footers + shared empty-state, unify buttons/rounding
Addresses the cross-screen inconsistencies from the layout audit by routing screens onto
the design system's own (previously under-used) shared helpers:

- Dialog footers: migrate ~9 overlay dialogs off hand-rolled placement onto the shared
  helpers — DialogActionFooter (primary+Close), DialogConfirmFooter, or
  BeginOverlayDialogFooter for custom/multi-button rows — so footers share one centered
  treatment. All footer/action buttons now use TactileButton (glass press) instead of the
  bare StyledButton some dialogs used.
- Empty states: add a shared material::DrawEmptyState(icon, title, hint) (centered icon +
  title + wrapped hint) and adopt it in Peers, Transactions, and Market-portfolio, which
  previously showed a bare left-aligned caption.
- Security dialogs: add the missing Cancel to Change Passphrase and Set PIN so the whole
  security family shares a two-button footer (Cancel dismisses without applying).
- Transactions pager: shared TactileButton helpers (matching Explorer).
- Frosted-pane rounding: Contacts/Chat use Layout::glassRounding() instead of hardcoded
  12/10/8px literals, matching Peers.
- "Set Label..." title loses its stray trailing ellipsis.

Preserves every button's label and action; the transfer footer's order becomes
[Confirm][Cancel] to match the shared helper's primary-first convention. Verified at 1280
across full-node + Lite + Windows (ctest green) and an adversarial diff review (clean).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-08-19 11:23:49 -05:00
a598217975 fix(ui): cut-off/clipping — recent-tx collisions, updater note wrap, request-payment URI, console filter, icon-grid scroll
Fixes the cut-off/clipping bugs from the layout audit (all visible at the default
1280/1024 window sizes):

- Receive "Recent Received" rows: the amount collided with the relative-time
  ("+15.7500 DRGX14 days ago") and the type label touched the address at narrow
  widths. Use the shared short time format (formatTimeAgoShort, matching Overview),
  chain the amount's right edge off the measured time width, and start the address
  after the measured type-label width — so neither pair can collide.
- Daemon & xmrig updater verify-note: drawn unwrapped and clipped at the card's right
  edge; wrap it (PushTextWrapPos) within the already-reserved height.
- Request Payment: the three footer buttons shared one fixed width (clipping "Copy
  Full Address"); size each to its own label. The Payment URI overflowed a plain
  field; render it in a bordered read-only box (bounded, un-chunked).
- Console: the filter input shrank below its own placeholder (gone entirely at 1024);
  give it a min width >= the placeholder and drop the "N lines" count when the row
  can't fit both.
- Address-label "Choose Icon" grid: had NoScrollbar hiding most of the catalog with
  no cue; give it a real scrollbar.
- Overview "Recent Transactions": drop the 4th row at 1024 (it clipped off-screen) by
  capping to rows that fully fit the reserved height.
- Sidebar: reserve the unread-badge width in the nav-label centering so History/Chat
  labels no longer collide with their badge.

Verified at 1024 and 1280 across full-node + Lite + Windows (ctest green) and an
adversarial diff review (clean). Skipped the legacy settings_window overlay footer
(dead code / removal candidate).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-08-19 10:11:55 -05:00
9205addf55 feat(ui): width-responsiveness — content max-width cap + per-surface form/input clamps
Wide/ultrawide (1440-3440px) responsiveness was unhealthy: no page/content-level
max-width cap existed, and every card/form/table derived width from raw
GetContentRegionAvail().x with floor-only clamps, so surfaces stretched edge-to-edge
(2000-3000px inputs, ballooning cards, giant grid cells, 2000px+ dead row-voids).

Root cause: adopt the (previously dead-code) clamp helpers.
- New Layout::kContentMaxWidth() (~1600dp, tunable via ui.toml [layout]
  content-max-width; <=0 disables). Cap ##ContentArea to it and center the column in
  wider windows — every tab derives from this child, so one change tames the app at
  wide widths. No-op below the cap (fills as before), so 1080p/1440p are unaffected.

Per-surface upper-clamps (std::min(cap*dp, expr), floors preserved) where a single
element is still too wide even within the capped column:
- Settings: Theme/Layout/Language combos, the font-scale slider (~3000px -> 360dp),
  the effect sliders, Explorer URL and RPC credential fields.
- Send / Receive: cap the compose / receive cards to a readable form width and center
  them (Indent(pad+offset) so the auto-layout fields align with the hand-drawn card);
  the recent-tx lists below keep the full column width.
- Chat message bubbles + composer, mining pool URL/payout inputs + stats left/right
  split, contacts search, and the lite-network add-server row / server cards / status
  panel (capped + centered).
- Wizard: vertically center the cards when they fit (was top-anchored, leaving a void
  on tall monitors), compensating the content-height measurement so it can't oscillate.

The 1600 cap also subsumes the fixed-4-column balance grids (~400px cards) and the
right-anchored row dead-gaps (voids shrink from ~2700px to ~800px), so those are left
to the cap rather than blind column/row redesigns.

Verified at 1024/1280 (and via a temporary 900dp cap to exercise the cap+center path,
since the test display clamps to 1280) across full-node + Lite + Windows (ctest green)
and an adversarial diff review (clean). The true wide/ultrawide look and the 1600dp cap
value still want eyes on a real wide monitor.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-08-18 23:15:39 -05:00
ce8c7696d4 fix(ui): finish HiDPI pass — cosmetic ×dpiScale, narrow-width reflow, recent-list reserves
The tail of the DPI/font-scale/responsiveness audit — ~26 remaining findings.

Container / recent-list (Theme-1 leftovers):
- Send: drop NoScrollbar|NoScrollWithMouse on ##SendFormScroll so Recent Sends is
  reachable at font_scale 1.5 (parity with receive).
- Receive: cap the QR/form card via std::min(mainCardTargetH, availH - recentReserve)
  so RECENT RECEIVED stays on-screen (identity at 1.0x).
- Wallets dialog: size the capped-mode list to whole rows so it no longer clips a
  partial row / crowds "Create a new wallet".

Narrow-width (1024px) reflow:
- Console toolbar reserves space for ALL trailing controls (both icon toggles + zoom
  buttons) so the +/- zoom no longer runs off-window.
- History sort combo sized to its measured widest localized label ("Newest first").
- Settings Theme/Layout/Language row: scale the wide→stacked breakpoint by dpiScale so
  it drops to full-width stacked combos at 1.5x (Consolidated Card no longer clips).
- Recent-tx type label: derive the address column X from the measured label width so it
  can't collide at narrow widths.
- Mining Recent Pool Payouts: floor the panel height to fit the empty-state caption.

Cosmetic ×dpiScale() on absolute geometry (no-ops at 1.0x): mining SOLO|POOL toggle &
idle combos, market pair-chips, password/PIN strength bars, receive/send currency
toggles, explorer search bar/rows/rounding, About-card logo, chat empty-state wrap,
recent-list address/time offsets, address-toolbar & two-row action buttons, console
line-gap/status-dot/pane rounding.

Verified at font_scale 1.5 and at 1024px across full-node + Lite + Windows (ctest
green) and an adversarial diff review (one over-reserve regression fixed).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-08-18 21:29:10 -05:00
99d1e73676 fix(ui): scale unscaled geometry at HiDPI — inputs, send progress cards, chat/pool overlaps, peer rows
At font_scale 1.5 ~13 sites read absolute geometry (schema .size/.width) straight
into ImGui without ×dpiScale(), so they stayed native-size while the font grew and
overlapped/clipped real text or money:

- Shield/Merge fee + UTXO inputs, Request Payment amount, Block Info height input:
  ×dpiScale() so the value no longer clips (e.g. 0.00010000 -> 0.00010).
- Send: the confirm-popup Amount Details divider (floored the row step at the scaled
  caption height so it no longer strikes the Fee row), the tx-progress error and
  sending/success cards, and the zero-balance CTA button — all ×dp.
- Mining pool row: ellipsis-truncate the hostname so it can't collide with the
  right-aligned hashrate.
- Chat conversation list: scale the pane-width clamp AND clip the peer name to the
  column left of the timestamp (measure-then-clip) so name and time never overlap.
- Explorer block-detail label column, Peers row offsets, and DialogConfirmFooter
  button height — ×dp.

transaction_details keeps its negative fill-sentinel widths unscaled (a content
margin, not raw px). Verified at font_scale 1.5 across full-node + Lite + Windows
(ctest green) and an adversarial diff review (three wrong-scale regressions fixed).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-08-18 20:53:29 -05:00
755cf22ad0 fix(ui): make HiDPI-overflowing containers scrollable — wizard, overlay dialogs, balance recent-tx
At font_scale 1.5 (dpiScale 1.5) three fixed, non-scrolling containers clipped
content off the bottom with no scroll escape:

- First-run wizard: the hand-drawn cards grow ~1.5x past the fixed window,
  pushing Continue / Encrypt & Continue / Skip off-screen (a setup blocker).
  Inject a wheel-driven scroll offset into the layout seed + a scroll indicator;
  gate the wheel on !IsPopupOpen + NoPopupHierarchy so an open combo popup does
  not scroll the wizard behind it. No-op at 1.0x.
- Overlay dialogs (BeginOverlayDialog): auto-height cards taller than the
  viewport (About, Request Payment) ran their footer off the bottom. Add a
  sticky per-open overflow flag that clamps the card to the viewport and makes
  the content child scrollable; short dialogs still center unchanged. Give the
  nested settings clear-history confirm its own idSuffix so it can't inherit the
  parent dialog's overflow state or collide on the child window id.
- Balance Recent Transactions: the dp-scaled address card evicted the recent-tx
  list off the non-scrolling tab host. Cap the card inside RenderSharedAddressList
  against the space that actually remains (minus a caller-provided reserve) so
  the section below stays on-screen — covers all 10 balance layouts.

Verified at font_scale 1.5 across full-node + Lite + Windows (ctest green) and an
adversarial diff review (two low-severity regressions found + fixed).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-08-18 20:12:01 -05:00
e24ca015d1 feat(ui): implement UI/UX audit — i18n, HiDPI, theme, destructive-action & overflow fixes
Implements all 26 confirmed UI/UX audit findings plus the 4 dashboard/timeline
tile labels. Verified across full-node + Lite + Windows builds (ctest green) and
an adversarial diff review (one market-tab delete regression caught + fixed).

i18n coverage:
- Balance hero/quick-actions/toasts + dashboard & timeline tiles (Total Balance,
  Shielded, Transparent, Quick Send, Quick Receive, Click to open, Market)
- Send: Review Send / Cancel / Paste / view-only tooltip / memo byte counter
- Settings: Lite lifecycle errors, plaintext-RPC security warning, 8 toasts
- Mining pool-payout tooltip
- Resolve daemon_update_title double-assignment collision (new daemon_update_prompt_title)
- 23 new keys translated into all 8 locales; CJK subset font rebuilt

HiDPI: DPI-scale the Send amount bar, mining thread-tile clamp bounds, receive
loading skeleton, and sidebar notification-badge insets.

Light theme: theme-aware material::SurfaceOverlay() for balance bar tracks and
row-hover highlights; contacts active-pill foreground uses OnPrimary().

Destructive actions: arm/confirm for portfolio-group delete, saved pool/worker
remove, and avatar-image delete.

Interaction/overflow: route balance star/eye buttons and the console fold-toggle
through popup-safe guards; clip peer addr/subver; truncate the balance custom
label; measure Settings tool-button widths; mining stepper disabled-state
feedback; +/- stepper buttons restyled to match the thread tiles.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-08-18 17:54:15 -05:00
0de44569b7 feat(mining): thread-count stepper, adaptive tiles, dropdown click-through fix, xmrig version state
Mining-tab UI improvements:
- Thread selector: a centered [-] N [+] stepper (top-aligned in the header) to pick an exact
  thread count — number centered, -/+ step by one (clamped to [1, cores]), still typeable
  (commits on Enter so it doesn't restart the miner mid-typing).
- Thread tiles now render at an adaptive step (1/2/4/8 by core count) plus 1 and the max, so a
  high-core CPU (e.g. a 192-thread EPYC) shows ~25 tiles instead of one-per-thread and no longer
  overflows the card. Unchanged for <=24-core machines.
- Fix: clicking the X (or a row) in an open saved-pools / payout-address dropdown no longer bleeds
  through to the thread tiles / Mine button — the custom drawlist hit-tests now gate on
  IsPopupOpen(AnyPopup).
- xmrig update button: shows "xmrig releases" when installed >= latest (numeric version compare),
  else "Update <latest>"; the "Current: <ver>" text and the button are subtle green when up to date
  and subtle orange when an update is available (neutral when either version is unknown).

New i18n keys back-filled across all 8 languages; CJK subset font rebuilt for the new glyphs.
Verified across full-node, lite, and Windows builds; the stepper layout confirmed via the UI sweep.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-08-18 15:39:43 -05:00
06afbee4f8 fix(mining): remediate mining-tab audit (22 findings) — crash-safety, async control, validation, math
Fixes all 22 confirmed findings from the mining-tab audit (10 Medium, 12 Low; 0 Critical/High),
adversarially reviewed (6 follow-ups found + fixed, incl. the review-caught idle-auto-start bypass
and a wrong benchmark-restore condition).

Crash-safety & lifecycle:
- M-04: join a stale/finished monitor thread in XmrigManager::start() and ~XmrigManager so an xmrig
  crash-then-restart (or quit) no longer std::terminate()s the wallet.
- L-03/L-10: surface an unexpected miner exit once and clear the stale running flag.

UI never blocks (M-03/L-06/L-08/L-09/L-13): pool start/stop now run on a dedicated serialized FIFO
mining-control thread (joined before teardown), so the ~13 call sites don't block the render thread on
stop()'s SIGTERM->SIGKILL->join; the spawn result marshals back to the UI.

Miner-process / pool trust boundary:
- M-01: validate the payout address (util::isValidRecipientAddress) at EVERY start path — the UI gate
  AND App::startPoolMining() (idle auto-start / thread scaling) — so a stale/wrong-chain address can't
  silently lose rewards.
- M-09: SSRF guard skips the background pool-stats GET for loopback/private/link-local/single-label hosts.
- M-02/L-02: cap the pool-stats + xmrig-API HTTP response bodies.
- L-01: write the xmrig config 0600 at creation (POSIX open with mode) — no world/group-readable window.
- M-10: reject shell-metacharacter binary paths before the version popen (excluding '()' so Program Files
  (x86) still works).

Solo mining: M-06/M-08 clamp thread count to [1, cores] at the setgenerate/xmrig boundary; M-07 notify +
don't lie on stop failure.

Correctness: L-05 block-time constant 75->150s (chainparams); M-05 discloses pool-mode "Est. Daily" as a
rough solo-equivalent; L-04/L-11/L-12 benchmark lifecycle (cancel on nav-away / mode-switch with restore,
skip rebalance mid-benchmark); L-07 honor cancel mid-extract in both the xmrig and daemon updaters.

Two new i18n keys back-filled across all 8 languages; CJK subset font rebuilt for the new glyphs.
Verified across full-node, lite, and Windows builds; tests pass.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-08-18 14:53:46 -05:00
6ee81a5abe fix: security-audit remediation (15 findings), empty-wallet warning, and send/chat/console/shutdown UX
Security audit remediation (15 confirmed findings from the codebase audit):
- H-02: scrub+delete the decrypt-flow plaintext key export on ALL exit paths
  (RAII guard) and purge stale obsidiandecryptexport* files at startup.
- M-01/L-03/L-04/L-05/L-07: sodium_memzero the Set-PIN and encrypt-PIN worker
  passphrase/PIN copies, the RPC Basic-auth string (auth_), the exported/imported
  key buffers (App::wipeSecrets, called from ~App and before main's _Exit), and
  the first-run wizard "Skip" buffers.
- M-03/M-04/M-05/L-06: return locked COPIES from XmrigManager/EmbeddedDaemon
  getters (dedicated error_mutex_; DaemonController::lastError now by value),
  route xmrig last_error_ writes through a locked setter, and wrap
  shutdown_status_/wizard_stop_status_ in a locking GuardedStatus
  (wizard_stopping_external_ -> std::atomic).
- M-02: persist after a console send/shield/import in the lite backend.
- L-01: require the confirm click for z_shieldcoinbase/z_mergetoaddress.
- L-02: quote/escape each Windows daemon argv per the MSDN CommandLineToArgvW rules.
- L-08: pin json/tomlplusplus/libwebp FetchContent to immutable commit SHAs.
- I-01: extract updater archives from the already-verified in-memory buffer
  (no disk re-read TOCTOU).

Feature: warn once (full-node) when the active wallet loads empty while a sibling
wallet file in the datadir holds keys. A funded salvage wallet.<ts>.bak routes to
the recovery/Restore flow; a funded sibling .dat routes to the wallet manager.
Per-wallet-file dismissal; gated on synced + address-list-loaded to avoid false
positives on warm reconnect / spent-down wallets.

UX fixes:
- send: show the TOTAL balance (with a spendable "available" note) in the source
  dropdown and keep pending-change addresses visible.
- chat: insert emoji at the cursor position; restrict new-chat recipients to
  shielded (z) addresses.
- console: optional auto-focus of the command input on tab open (off by default).
- shutdown: when "stop external daemon" is on, keep the shutdown screen up until
  the external node actually exits, showing live status.

Adversarially reviewed; verified across full-node, lite, and Windows builds; tests pass.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-08-18 13:18:49 -05:00
ea26c0cbbb fix(balance): stop the displayed balance cratering during a pending shielded send
Sending a small amount from an address holding a large balance made the displayed
balance collapse to ~0 until the tx confirmed. A shielded spend consumes the whole
source note; the change returns as a 0-confirmation note, and every balance query
used the default minconf=1 — so the spent note dropped out and the change wasn't
counted yet.

Split every balance into two views:

- DISPLAY (balance / privateBalance / transparentBalance / totalBalance) — now
  queried at minconf=0, so it INCLUDES the user's own pending change and no longer
  craters. This is what the Overview, balance tab, market portfolio and receive
  tab show. unconfirmedBalance is now populated (= total - spendable).
- SPENDABLE (new spendableBalance / spendable*Balance) — confirmed (minconf>=1),
  what z_sendmany (run at minconf=1) can actually spend. The Send form's available/
  Max/validation, the from-address selection, the drag-to-transfer dialog cap, the
  chat pay-from and the auto-shield gate all size off these, so they never offer
  0-conf change the daemon would reject.

Implementation: a single z_listunspent(0)/listunspent(0), partitioned per-note by
"confirmations">=1; z_gettotalbalance called at minconf 0 (display) and 1
(spendable); the z_getbalance fallback queries both. applyPendingSendDelta (the
optimistic post-send debit) now touches ONLY the spendable fields — debiting the
display too would re-crater it on top of the honest minconf=0 RPC. Lite mirrors
spendableBalance = balance (its per-address balance is already confirmed) so lite
sends aren't zeroed. The confirmed-only gates (seed-migration/sweep z_gettotalbalance,
sweep z_getbalance(addr,1), z_sendmany's minconf arg) are untouched.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-08-13 16:40:14 -05:00
13b225d8f5 fix(send): prefix shielded-send memos with "utf8:" so the daemon accepts them
A shielded send with a memo failed: "Invalid parameter, expected memo data in
hexadecimal format or to use 'utf8:' prefix." The Send-tab path (and its fee-gap
retry) put the user's plain-text memo straight into the z_sendmany recipient,
which the daemon now rejects — it wants the memo hex-encoded or with a "utf8:"
prefix. The chat path already prefixes with "utf8:"; do the same for user memos.

Only the RPC recipient["memo"] is prefixed; the raw memo is still what's stored
for the transaction-history display, and the daemon returns the decoded memoStr
to receivers, so it round-trips as plain text.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-08-13 15:49:09 -05:00
08aed34bbe fix(daemon): actually stop an external daemon on quit when the setting is on
"Stop external daemon" silently left an external dragonxd running. beginShutdown()
calls rpc_->requestAbort() (a sticky abort flag, cleared only by connect()) to
unblock in-flight requests; the shutdown thread's stopEmbeddedDaemon() then sent
the graceful "stop" over that same connection, so curl self-aborted it
(CURLE_ABORTED_BY_CALLBACK). doRPC swallowed the error but stop_sent was set true
anyway, skipping the temp-connection fallback that would have worked — so the
daemon never received "stop" and only died via the 20s by-name force-kill (which
collides with the 8s "Force Quit / may corrupt chain data" prompt, so it read as
"doesn't work").

Clear the abort before the shutdown stop and send it synchronously via
sendStopCommandSafely so real delivery success is surfaced (and the fallback can
still run on failure). The graceful stop now reaches the daemon, it exits in a
second or two, and the 20s stall / Force-Quit prompt no longer appears.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-08-12 00:08:08 -05:00
5edbe8a276 feat(recovery): redesign the wallet auto-recovery flow
Turns the "Daemon Error + raw log dump" moment into one calm, honest recovery
dialog plus a recovery-aware rescan screen. Presentation + orchestration only —
the file-safety logic in rebuildWalletDatabase()/restoreOriginalWallet() (source
selection, verify-before-swap, copy/rename-never-delete, .bak) is unchanged.

- One authoritative dialog with a phase machine Offer -> Working -> Done/Failed.
  The duplicate in-overlay recovery card, the untranslated red "Daemon Error"
  heading, and the raw daemon-log dump are gone for the recovery case (they stay
  for genuine, unrelated crashes).
- Offer is a choice-cards layout: "Repair automatically" (recommended, accent-
  tinted) vs "Restore original", side by side; the rare actions ("Show me the
  files", "Decide later") and a plain-language "What happens to my files?" sit in
  a quiet footer. When the rebuild helper is missing, it collapses to a single
  Restore card — never a dead end.
- Post-repair rescan shows a calm "Finishing your wallet repair" screen with
  elapsed time + the growing wallet size, instead of "RPC timeout / taking longer
  than expected / restart daemon"; the daemon-crash toast is suppressed and the
  detection toast is downgraded from red to info.
- Fixes a confirmed dead-end: if a repair succeeds but the restarted daemon then
  crashes for a *different* reason (block index, disk, OOM), the recovery flags
  now clear (in tryConnect + onConnected) so it surfaces as a normal daemon
  failure instead of freezing forever on a reassuring "don't restart" screen.
- Clickable "Wallet repair available" status-bar chip for re-entry.

The same app.cpp changes HiDPI-harden the surfaces the recovery flow lives on:
the status-bar and loading-overlay hand-drawn geometry are multiplied by dpiScale
(they rendered native-size and clipped at HiDPI / font_scale>1), the loading-
overlay status text wraps instead of running off both edges, and the node-status
banner floors its height to its DPI-baked font so the title can't clip off the top.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-08-10 22:31:55 -05:00
001e85ac1a fix(ui): prevent text/button cutoff and scale hand-drawn geometry at HiDPI
Findings from a UI-cutoff audit — each is a spot where an in-tree helper
(truncateMiddle / TruncateToWidth / measured button width / the *dpiScale/*hs
factors) was bypassed:

- notifications: the toast-pill height/padding/icon-gap were raw logical px while
  the icon/text drawn inside are DPI-baked, so they clipped the pill at HiDPI.
  Scale the geometry by dpiScale (not the already-scaled glyph metrics).
- settings: in the two-column NODE & SECURITY layout the data-directory path could
  overrun into the Daemon-binary column (shared draw list, no clip rect between
  them). Middle-ellipsize it to the column width; the full path stays in the
  tooltip + click-to-open + copy.
- send: the "Confirm & Send" button width came straight from the schema and was
  never measured against the label, clipping the Russian translation on the
  pre-broadcast dialog. Size to max(schema width, measured label + padding).
- balance: the recent-tx address-column offset missed the `* hs` DPI factor its
  sibling (amount-right-margin) uses, overlapping the type label at HiDPI.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-08-10 22:11:07 -05:00
393f3d147e feat(recovery): bundle & embed the offline wallet-rebuild helper in every release
The dragonx-wallet-rebuild helper is the only thing that repairs a genuinely
BDB-inconsistent wallet.dat — plain "Restore" just re-triggers the daemon's
salvage cascade — yet it was silently dropped from every packaged build:

- Linux zip/AppImage copied a hand-picked file list that omitted it.
- Windows bundled it only behind a soft `[[ -f ]]` guard (silent skip).
- macOS never wired Berkeley DB, never built it, never bundled it.

build.sh now HARD-REQUIRES the helper for full-node releases (fails the build if
the vendored Berkeley DB depends are missing, rather than shipping recovery-less),
and ships it in the Linux zip + AppImage, the Windows zip, and the macOS .app.

It also compiles the helper standalone for Windows and INCBINs it, and
embedded_resources gains ensureWalletRebuildHelperExtracted() to extract it on
demand — so a self-contained ObsidianDragon.exe carries recovery exactly like the
embedded daemon, even on a machine where first-run param extraction already ran.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-08-10 22:10:57 -05:00
ac49f44f84 fix(node): show recovery actions in the daemon-error overlay (not a separate dialog)
Reported with a screenshot: on a salvage-then-abort, the status correctly read
"Wallet needs recovery — see the prompt" but no prompt appeared — the separate
BeginOverlayDialog is occluded by the full-frame loading/daemon-error overlay
that's drawn every frame while the node is down.

Render the recovery actions directly IN the daemon-error overlay when a salvage is
detected: a concise message + prominent one-click "Rebuild wallet database" /
"Restore original" / "Open data folder" buttons (same handlers as the dialog),
placed right after the title and skipping the verbose daemon-output dump so they
stay on-screen. The verbose diagnostics + crash-count hint still show for
non-recovery errors. Build clean, suite green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-08-10 12:28:48 -05:00
3216debc7d fix(node): detect a wallet salvage at startup, not only on connect
Reported: loading a BDB-inconsistent wallet silently renamed it and created a new
one — no recovery dialog. Two causes, both fixed:

1) Detection ran only in onConnected(). The salvage happens at STARTUP, and the
   node may never connect (block-index abort, long sync, crash) — or a long sync
   trims the salvage line out of the rolling output buffer before connect. Extract
   detectWalletAutoRecovery() and run it every tryConnect() tick (every ~5s during
   startup), so the salvage is caught the instant it appears, regardless of whether
   the node connects. Also hold the crash-restart loop while a salvage is pending,
   so the wallet can't be re-salvaged/shrunk while the Rebuild/Restore dialog is up.

2) walletAutoRecovered() only matched the SUCCESSFUL-salvage strings. A
   BDB-inconsistent file makes aggressive salvage FAIL ("found no records"), which
   prints different lines. Broaden the detector to the signals that fire in every
   case: "CDBEnv::Salvage", the "Renamed <wallet> to wallet.<ts>.bak" rename, and
   "found no records in wallet" — while still not matching normal startup or a
   block-DB abort.

Adds the exact failed-salvage sequence to the detector test. Build clean, suite
green (1/1).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-08-10 12:04:07 -05:00
8ffcd9cc8c build(node): bundle dragonx-wallet-rebuild in Linux + Windows releases
Wires the wallet-rebuild recovery helper into the release pipeline so a shipped
build actually carries it (the app locates it next to dragonxd).

- CMakeLists: link the vendored STATIC Berkeley DB for the helper — add
  Threads::Threads + dl (Linux) / ws2_32 (Windows) that the static libdb-6.2 needs
  (the system shared lib pulled those in transitively; the static one doesn't).
- build.sh (Linux + Windows): pass BDB_INCLUDE_DIR/BDB_LIBRARY explicitly at
  configure, pointing at external/dragonx/depends/<triple>/{include,lib/libdb-6.2.a}
  — the same libdb the daemon links, so the helper's output is a v6.2 btree the
  bundled dragonxd reads. Explicit paths bypass find_library (and the mingw
  toolchain's sysroot-only find restriction). Guarded: no depends → helper simply
  not built/bundled. Strip + copy the helper next to dragonxd(.exe) in both bundles.

Verified: Linux links the vendored libdb-6.2.a statically (no dynamic libdb) and
rebuilds the real broken wallet correctly; the helper cross-compiles cleanly with
mingw against the vendored Windows libdb-6.2.a to a PE32+ x64 exe. Full app +
helper build, suite green (1/1), build.sh syntax OK.

Remaining: macOS Berkeley DB (no in-tree depends artifact) + resource-embedding as
an alternative to side-by-side bundling.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-08-10 11:38:20 -05:00
bc183257b7 feat(node): in-app "Rebuild wallet database" recovery for a BDB-inconsistent wallet
Automates the manual recovery that fixed a wallet.dat with stale Berkeley DB
extent metadata (the "main" subdb metapage records a low last_pgno while its live
data spans thousands of pages beyond it). A tolerant page-walk reads every record,
but the daemon's BDB verify rejects the file and auto-salvages it — finding nothing
and shrinking the wallet to empty on each restart (the salvage cascade that looks
like fund loss). Plain "Restore original" can't fix it (hands the same broken file
back → re-salvage); a rebuild must produce a fresh, consistent DB.

Pieces (Approach A from the design workflow — out-of-process helper keeps AGPL
Berkeley DB out of the GPLv3 GUI):
- util/wallet_file_probe.h: extractWalletBtreeRecords() — sibling to parseWalletBtree
  that collects raw (key,value) bytes (same bounds-checked, subdb-aware walk).
  Records copied verbatim → encrypted key material passes through as opaque
  ciphertext (no passphrase). Overflow-page values (only large tx history) are
  skipped + counted; a rescan rebuilds history — funds unaffected.
- tools/wallet_rebuild/main.cpp: dragonx-wallet-rebuild CLI — reads via the tolerant
  reader, writes the records into a fresh BDB "main" btree via libdb (DB_EXCL, never
  overwrites), prints a JSON summary. New BDB-guarded CMake target.
- App::rebuildWalletDatabase(): picks the largest readable wallet/.bak as source,
  stops the daemon, runs the helper, VERIFIES the output (readable BDB with keys)
  before swapping, moves the current wallet aside (kept, timestamped), installs the
  rebuilt one, clears the stale BDB env, sets -rescan, restarts. Copy/rename only —
  never deletes. Result surfaced via the existing pumpWalletRestore channel.
- Wired as the preferred action on the existing wallet-auto-recovery dialog
  (shown only when the helper is present). Full-node only; lite-safe.

Verified end-to-end against the real broken wallet: helper reads 3,808 t-keys + 1
z-key + HD seed and the daemon LOADS the rebuilt output with no salvage. Adds
extractWalletBtreeRecords coverage. Build clean, suite green (1/1).

Remaining (follow-up): release packaging — build.sh bundling the helper built
against the vendored per-platform static libdb (DRAGONX_BDB_ROOT), and a macOS
Berkeley DB port (no in-tree artifact).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-08-10 11:26:55 -05:00
b2e037bb67 chore(gitignore): never track wallet.dat (holds private keys)
A wallet.dat placed in the repo root for recovery was untracked but NOT ignored,
so a stray 'git add .' could commit private keys. *.bak already covered the
salvage backups; add wallet.dat / wallet-*.dat / wallet.dat.* explicitly.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-08-10 10:20:32 -05:00
975650f11b fix(node): restore the LARGEST salvage backup, not the newest (salvage cascade)
The "Restore original wallet" action picked the newest wallet.<ts>.bak — but the
daemon auto-salvages on every failed BDB verify, and each round SHRINKS the wallet
(salvage keeps only readable records + drops the dead-page bloat). In a cascade the
newest .bak is the most-degraded (seen in the wild as "Salvage found no records")
while the original is the oldest and by far the largest.

Pick by file SIZE instead: add largestWalletSalvageBak((name,size) pairs) — the
largest wallet.<digits>.bak is the least-salvaged, i.e. the pristine original (an
emptied salvage is tiny; a real wallet is large); ties break to the newest ts.
Factor the shared parse into parseWalletSalvageBakTs(). restoreOriginalWallet()
now gathers file sizes and uses it (still verifies the pick is a valid BDB before
swapping). newestWalletSalvageBak kept for reference.

Adds a cascade regression test (a 40KB emptied newest .bak must NOT win over the
194MB original). Suite green (1/1).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-08-10 00:52:41 -05:00
384d64ea5d feat(node): one-click "Restore original wallet" after a daemon auto-recovery
Adds the restore action to the wallet-auto-recovery warning: undo the daemon's
salvage by swapping the untouched original (wallet.<ts>.bak) back over the
salvaged copy and clearing the stale BDB env that triggered the false recovery,
then restarting. Modeled on beginAdoptSeedWallet (stop daemon → file ops →
restart on a worker; result pumped to the main thread for notifications).

Safety (fund-adjacent file ops on a real wallet — copy/rename only, never delete
user data):
- picks the newest wallet.<unixtime>.bak via the pure, unit-tested
  newestWalletSalvageBak(); aborts if none.
- verifies the .bak is a real Berkeley DB (probeWalletFile) before touching
  anything — won't overwrite a working wallet with a bad backup.
- stops the daemon first (stopDaemonForWalletSwitch) so wallet.dat is released.
- moves the salvaged copy aside to wallet.dat.salvaged-<ts>.dat (kept), COPIES
  the .bak into place (the .bak stays), moves database/ aside to
  database.pre-restore-<ts>.bak (kept), and drops only the transient __db.*
  BDB region files. Rolls back the move if the copy fails.
- relaunches the node even on failure so it's never left down.

The warning dialog now offers Restore original wallet / Open data folder /
Keep salvaged copy. Full-node only; lite-safe. Build clean, suite green (1/1).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-08-10 00:45:47 -05:00
3b7423f3a1 feat(node): warn when the daemon auto-recovers (salvages) wallet.dat
dragonxd auto-recovers a wallet.dat that fails BDB verification on startup — no
flag needed (CWallet::Verify → CDBEnv::Verify(walletFile, CWalletDB::Recover)):
it moves the original to wallet.<timestamp>.bak, salvages readable keys into a
fresh wallet.dat, and keeps running. The salvage can be incomplete (or the whole
thing a FALSE POSITIVE from stale/cross-platform BDB env state — __db.* / the
database/ dir carried between machines), so the node silently comes up on a
possibly-empty wallet. To the user that reads as fund loss, with no warning.

Detect it and warn loudly instead:
- daemon/daemon_startup_diagnosis.h: pure walletAutoRecovered() (the salvage /
  "Original wallet.dat saved as wallet.<ts>.bak" markers) + newestWalletSalvageBak()
  (picks the wallet.<unixtime>.bak the recovery just made).
- onConnected() scans the node's captured output once per session; on a match it
  shows a warning dialog + notification: the ORIGINAL is safe in wallet.<ts>.bak,
  the shown balance may be incomplete, and here are the exact steps to restore it
  (rename the .bak back + delete the stale database/ + __db.* env). One-click
  "Open data folder" jumps straight there. Full-node only; lite-safe.

Deliberately does NOT auto-swap the wallet files (untested per-platform file
manipulation on a real wallet is not worth the risk) — it informs + guides.

Adds walletAutoRecovered / newestWalletSalvageBak coverage to
testBlockDbOutputDiagnosis. Suite green (1/1).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-08-10 00:30:11 -05:00
d136916e80 feat(node): detect an unreadable block DB on startup and offer a one-click reindex
When a daemon update changes the block-index on-disk format (or the index is
corrupt), dragonxd aborts at startup — "non-canonical optional discriminant" →
"Error loading block database. Aborted." — and the wallet silently shows a zero
balance. Previously the connect loop just crash-restarted into the same abort up
to 3x and then reported a bare "Daemon crashed N times", with no path forward.

Now:
- daemon/daemon_startup_diagnosis.h: pure blockDbOutputLooksBroken() classifies
  the crashed node's captured console output (the fatal block-DB markers).
- The connect loop detects it on the FIRST abort, STOPS crash-restarting into the
  same failure (each retry reloads the whole index — wasteful), and offers a fix.
- A one-shot -reindex flag (EmbeddedDaemon::setReindexOnNextStart → DaemonController
  forwarder → args) rebuilds the block index + chainstate from the intact raw
  blocks; App::reindexBlockDatabase() arms it and un-gates the loop to restart.
- An auto-shown dialog (renderBlockDbReindexDialog) + a notification explain the
  situation ("your coins are safe; the node just can't load the chain") and offer
  a one-click "Rebuild block database". Full-node only (gated), lite-safe.

This is the exact trap behind a real "big wallet shows no funds" report: a
post-format-change daemon over pre-change chaindata. Reindex also fixes a plain
corrupt index.

Adds testBlockDbOutputDiagnosis (the abort sequence + individual markers trip it;
normal startup / wallet-corruption / asmap errors do not). Suite green (1/1).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-08-09 23:49:46 -05:00
f88304fed2 fix(wallets): stop a legacy wallet showing as a seed-phrase wallet when linked
The wallets list badged the active/linked row from the runtime seed status
(activeWalletSeedBadge → wallet_seed_status_, reset only on disconnect) in
preference to the offline on-disk probe. Two issues let a genuinely legacy
wallet render as "seed phrase":

- The offline probe's budget-fallback branch dropped the fMnemonicSeed flag:
  res.mnemonic was set only in the (parsed && complete) branch. The probe shares
  a 768 MB budget across all wallet files, so a large wallet (e.g. a 194 MB one)
  probed after the budget is spent falls into the fallback, loses its seed/legacy
  classification (mnemonic → 0), and the row defers to the runtime badge.
- With mnemonic == 0, the code used the runtime badge, which can still carry a
  HasMnemonic from a previously-active mnemonic wallet — mislabelling the legacy
  wallet.

Fix:
- Carry the definitive positives (fMnemonicSeed/hdSeed/mkey) from a cap-truncated
  btree walk — a found marker is authoritative even when the scan didn't finish.
- Make the on-disk fMnemonicSeed read take precedence: it's the SAME flag the
  daemon's IsMnemonicSeed()/z_exportmnemonic consult, so a definitive read wins;
  the runtime badge is used only when the probe genuinely couldn't decide, and
  never overrides a definitive on-disk classification.

Verified the wallet in question is truly legacy (fMnemonicSeed=false on disk,
matching the daemon's CHDChain serialization + IsMnemonicSeed). The flag reader
(hdChainMnemonicFlag) is already unit-tested; suite green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-08-09 15:29:23 -05:00
5296dd7ae5 fix(parsers): harden RPC/price/updater parsing against valid-but-unhandled input
Audit of the full-node RPC response parsers and updater release-body parsers
(find -> adversarially-verify workflow) surfaced three worth fixing; three
others guard formats the project doesn't emit and are backstopped by signature
verification, so they're documented rather than churned.

- Price (Medium): parseCoinGeckoPriceResponse used .value(key, 0.0), which
  throws type_error on a PRESENT null. CoinGecko emits null for usd_24h_change/
  usd_24h_vol on illiquid tokens (DRGX is one) while still returning a valid
  spot price; the outer catch turned that into no price update at all. Read
  null-tolerantly so the valid usd/btc survives.

- Daemon updater (Medium): parseDaemonChecksums blanked '|'/backtick but not
  markdown emphasis, so a bolded **archive.zip** checksum row was dropped and a
  valid, correctly-signed release would be refused. Also blank '*'/'_' (cannot
  cause a wrong-asset match; the 64-hex + .zip-suffix tests are unchanged).

- Opid poll (Low, severe failure mode): parseOperationStatusPoll read id/status
  via .value() (throws on a present non-string) and the call site parsed OUTSIDE
  its try/catch, so a throw left opid_poll_in_progress_ stuck true and wedged all
  z-operation polling for the session. Type-check the reads and parse inside the
  guard. (dragonxd can't emit non-string id/status; this is defense-in-depth.)

Regression tests: CoinGecko null field; opid non-string id/status; **bold**
checksum row. Suite green (1/1).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-08-09 13:38:50 -05:00
a1d3964e34 fix(lite): require exactly 24 words on first-run restore (crash on valid seed)
The lite first-run restore wizard enabled Restore for {12,15,18,21,24}-word
phrases, but the SDXL backend only accepts 24-word / 32-byte-entropy seeds:
LightWallet::new does copy_from_slice(&phrase.entropy()) into a [u8;32]
(lightwallet.rs:231), which panics on 16/20/24/28-byte entropy. Mnemonic::
from_phrase accepts the shorter valid phrase, and the restore FFI
litelib_initialize_new_from_phrase (lib.rs:127) has no catch_unwind (unlike
litelib_execute), so the panic unwinds across extern "C" -> process abort
(UB on the pinned rustc 1.63). A user restoring a legitimate 12-word seed
from another wallet crashed the app.

The Settings restore gate was already tightened to == 24 (6ff1fda) but the
first-run wizard gate (df14533) was never updated — same restore path, two
verdicts, crash only via the more-common first-run path.

Add shared util/seed_phrase.{h,cpp} as the single source of truth:
- normalizeSeedPhrase: fold NBSP/en/em/ideographic/narrow spaces to ASCII,
  strip zero-width marks, collapse+trim (word bytes untouched)
- seedPhraseWordCount
- isCompleteRecoveryPhrase(int) == 24  (the sole SDXL contract)

Both restore gates now count via the normalizer and gate via
isCompleteRecoveryPhrase, and both submit the normalized phrase. This closes
the crash, reconciles the two gates so they can't drift again, and — because
tiny-bip39 splits on literal ASCII space with no NFKD — makes an NBSP-pasted
24-word seed (common from PDFs/note apps) restore correctly instead of being
undercounted and rejected.

Adds testSeedPhraseHelpers. Suite green (1/1).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-08-09 01:32:40 -05:00
5b6ba5094b docs(lite): clarify SDXL viewing-key HRP differs from the full node
Audit of the lite import path found no false-rejection defect (no client-side
gate; the two-command fallback in importKey makes the U/5/K/L prefix guess
non-binding; lite send reuses the now-P2SH-fixed send_tab helpers). But the
"zxview" viewing-key comment — which was WRONG in the full node (fixed earlier)
— is genuinely CORRECT here: SDXL's import takes an extended full viewing key
(zxviews…, hrp_sapling_viewing_key), whereas the full node's z_importviewingkey
takes an incoming viewing key (zivks…). The two are not interchangeable.

Add a note so nobody "harmonizes" the two gates and reintroduces the full-node
bug. Comment-only; no behavior change.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-08-09 00:59:29 -05:00
c3e81a5fa6 fix(send): accept P2SH/multisig recipients in the send + URI address gates
Same defect class as the import-key fix: a hardcoded prefix/length pre-filter
layered over the checksum validators rejected valid addresses before the daemon
saw them. The send-screen recipient gate required a[0]=='R', and the payment-URI
parser accepted only 'R'/'t' with rigid length bands — so every valid P2SH /
multisig address (DragonX SCRIPT_ADDRESS=85 → 'b…') was silently refused, leaving
the Send button disabled with no usable recipient.

Centralize recipient recognition in util/address_validation:
- isTransparentAddress: Base58Check with a 21-byte version+hash160 payload —
  covers P2PKH ('R…', v60) AND P2SH ('b…', v85) on every network, rejects WIF
  keys / typos by real checksum.
- isShieldedAddress: Bech32 + a Sapling payment-address HRP (zs / ztestsapling /
  zregtestsapling), distinguishing a payment address from a viewing key.
- isValidRecipientAddress: either of the above.

send_tab's two validity helpers (the single choke point for all 5 call sites) and
the payment-URI format check now route through these. The URI parser now
checksum-validates the recipient (fail-fast on transcription errors) rather than
being prefix/length-only.

Tests use real checksummed vectors (P2PKH/P2SH/shielded, WIF- and typo-rejection);
testPaymentUri updated off its old fake fixed-char addresses. Suite green (1/1).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-08-09 00:12:17 -05:00
d603a54618 fix(import): recognize real DragonX key formats in the import gate
The client-side pre-check rejected legitimate keys before the daemon ever
saw them, surfacing "Unrecognized key format" / a cryptic daemon "Invalid"
error. Two concrete defects plus the brittle heuristic behind them:

- Viewing keys: isViewingKey looked for Zcash's "zxview" extended-FVK
  prefix, but DragonX's z_exportviewingkey emits a Sapling *incoming*
  viewing key (HRP "zivks"), which z_importviewingkey is the only form the
  daemon decodes. Every real DragonX viewing key was refused. (F1)
- Uncompressed transparent WIF: the length+first-char heuristic accepted
  {5,K,L,U} only, but a version-188 uncompressed key starts with '7'. (F2)

Replace the heuristic with structural validation using the existing
checksum validators (F3): add util::decodeBase58Check (checksum-stripped
payload) and util::bech32Hrp (HRP of a valid Bech32 string). Transparent
keys are now accepted by decoding Base58Check and checking the payload is a
33/34-byte secret key with a DragonX SECRET_KEY version byte (188 main/
regtest, 128 testnet) — covering compressed and uncompressed, rejecting
addresses/typos by real checksum. Viewing keys are matched by the real
incoming-VK HRPs (zivks / zivktestsapling / zivkregtestsapling).

The Sweep gate and the dialog's live type indicator run off the same
predicates, so they are fixed too (F4). Messaging now names the likely
cause and appends a wrong-coin/network hint to the daemon's raw "Invalid"
error (F5).

Adds testPrivateKeyImportRecognition plus decodeBase58Check/bech32Hrp
coverage; suite green (1/1).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-08-08 22:22:29 -05:00
c61c211dfe fix(ui): add vertical padding above and below the recent-alerts popup content
The alert-history popup content sat flush against the popup's top and bottom edges. Add a
padY spacer above the header and below the content (on both the empty and populated paths).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-02 23:12:20 -05:00
16244d84a0 chore(release): bump version to 2.0.1
Full-node ObsidianDragon 2.0.0 -> 2.0.1 (single source of truth: the project() VERSION in
CMakeLists.txt). Verified the generated header renders "2.0.1 (ObsidianDragon)". The Lite
variant is versioned independently (DRAGONX_LITE_VERSION, unchanged at 1.0.0).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-02 22:53:23 -05:00
32be868dbc feat(migrate): persist the sweep opid so a mid-sweep interruption can resume (W3-3)
Migrate-to-seed submits z_mergetoaddress -> an async opid, then only persists the resolved
txid once the op completes. An app-close during Sweeping (opid submitted, txid not yet
resolved) dropped the opid and resumed at the re-sweep gate, silently losing the tx.

Now the opid is persisted and re-tracked on resume. If the daemon forgot it (restart —
its op queue is in-memory only), the existing poller flags it stale and the callback falls
back to the dismissable Sweep gate; it can never hang (a thrown RPC aborts the poll, so a
stale classification only comes from a *successful* poll that omits the opid).

- New seed_migration_sweep_opid setting; adopted atomically with clearing any prior txid in
  the SAME settings.save(), and only once the submit succeeds — so a failed "Sweep remaining"
  re-sweep keeps the already-mined first sweep's Confirming context, and txid/opid are never
  both authoritative (resume checks txid first; torn-write safe).
- Resume routing extracted to a pure, unit-tested helper
  (data/seed_migration_resume.h::decideSeedMigrationResume): txid -> Confirming; opid AND
  connected -> re-track (Sweeping); else -> the dismissable Sweep gate. The connectivity gate
  keeps a disconnected resume out of the buttonless Sweeping spinner.
- Shared makeSweepCompletionCallback(resumed): success -> Confirming; resumed-stale -> Sweep
  gate (re-fetch balance + "may have already completed" copy); fresh-fail -> Error.

Fund safety unchanged: adopt still gated on legacy balance ~0 AND sweep tx mined; legacy
wallet.dat only ever moved to a never-deleted timestamped .bak.

Reviewed in two adversarial rounds (design + implementation) per the migration-code mandate;
both safety facts (no fund loss, no hang) held, and the resume-UX traps they surfaced are
fixed. Build-clean; ctest 1/1 (adds testSeedMigrationResume). See docs/wallet-hardening.md.

*** Still requires a live mainnet interrupted-sweep run before release (human gate). ***

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-02 22:11:37 -05:00
8bb3198562 fix(diagnostics): address adversarial review of the QoL UI (popup, staleness, DPI, i18n)
Follow-up to the node-banner / staleness-badge / alert-history features — a 5-dimension
finder->verify review surfaced 4 real issues (the ImGui-stack-balance finder found none):

- Alert popup grew off the right edge: pivot (0,1) pinned the panel's LEFT edge at the
  bell, which sits near the window's right edge, so a 320px panel overflowed rightward
  (an explicit SetNextWindowPos pivot skips ImGui's on-screen clamp). Anchor the
  bottom-RIGHT corner at the bell instead (pivot (1,1) at bellMax.x) so it grows left.

- Staleness badge could flash red on reconnect: WalletState::clear() reset everything
  except the four last_*_update stamps, so the pre-outage timestamp survived and the
  badge briefly showed "Updated Nm ago" the same frame the node banner cleared. Zero the
  stamps in clear() (all readers treat 0 as "never"; app_network.cpp:1473 guards != 0).

- Banner min-height floor wasn't DPI-scaled: std::max(minH, baseH*vScale()) now uses
  minH * dpiScale() so both operands are in scaled px.

- New i18n keys weren't in res/lang/: back-filled all 16 diagnostics/QoL keys into the 8
  language files, additively (128 insertions, 0 deletions). zh/ja/ko reworded around 2
  glyphs missing from the CJK subset and hard-asserted tofu-free against the subset font.

Build-clean both variants; ctest 1/1.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-02 21:14:38 -05:00
4b3f0fa92b feat(diagnostics): persistent alert history with a status-bar bell (Foundation QoL)
Toasts fade in 1-4s, so anything that scrolled past was gone. Notifications now retains
every pushed alert in a capped (100) ring buffer with a wall-clock epoch (AlertRecord) —
separate from the 5-item live-toast deque — plus a monotonic total_pushed_ counter.

A bell in the status-bar right cluster opens an upward popup listing recent alerts
newest-first: severity icon + colour (reusing the toast palette), the message, and a
relative age (formatTimeAgoShort), with a Clear-all action. An unread dot on the bell,
coloured by the most-severe unseen alert, marks alerts that arrived since the panel was
last opened — driven by totalPushed() deltas so it survives capping/clearing.

Thread note: every push is on the UI thread (RPC results run as main-thread MainCb
callbacks), matching this class's existing lock-free model; documented as a
no-raw-worker-thread invariant.

New i18n keys (alerts_*). Build-clean; ctest 1/1 (adds testNotificationHistory: retention,
order, cap, monotonic counter, clear). Closes the QoL bundle and the Foundation tier.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-02 20:57:02 -05:00
c7c3440a7b feat(diagnostics): refresh-staleness badge on the Total Balance card (W6-2)
When the wallet is connected but the balance has quietly stopped refreshing — a busy
daemon can fail z_gettotalbalance without dropping the whole connection (only *both*
core RPCs failing 3x triggers a disconnect) — the old number sits on screen while the
node-status banner stays hidden. The Total Balance card now shows a small pill on its
status line ("Updated 2m ago", amber, escalating to red past 3 min) so the stale value
isn't silently trusted; hovering explains it and points at the node connection.

No refresh-path changes: WalletState::last_balance_update is already stamped only on a
successful fetch (network_refresh_service.cpp), so the badge reads it and computes age
against the same std::time clock via util::formatTimeAgoShort. The decision is a pure,
unit-tested helper (ui/staleness_badge.h::evaluateStalenessBadge, 45s/180s thresholds)
gated on connected so it never contradicts the banner.

Closes P2 (5/5). Build-clean; ctest 1/1 (adds testStalenessBadge).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-02 20:48:56 -05:00
e779ded2e8 feat(diagnostics): persistent node/RPC error banner at top of content (Foundation QoL)
A persistent horizontal strip now appears at the top of the content column whenever the
wallet can't reach its node — unlike the transient toasts it stays up for as long as the
fault persists, so an offline wallet is never silently mistaken for a working one.

The show/severity/action decision is a pure, unit-tested function
(ui/node_status_banner.h::evaluateNodeStatusBanner) fed a state snapshot by the new
App::renderNodeStatusBanner(). Three cases:
  - full-node offline        -> amber, "Reconnect"    (App::tryConnect)
  - embedded daemon crashed
    & auto-restart gave up    -> red,   "Restart node" (App::restartDaemon)
  - lite wallet open failed   -> red,   message-only

Suppressed during the wizard / wallet-switch / daemon-restart / screenshot-sweep / shutdown,
and while an expected startup phase (warmup / init / connect-in-progress) already owns the
screen. Banner height lives in res/themes/ui.toml (banners.node-status); colours come from the
material semantic palette; the detail text is ellipsis-clipped so it can't push the action
button off-screen. Drawn before the content edge-fade vertex capture so it stays fully opaque.

New i18n keys (node_banner_*). Build-clean both variants; ctest 1/1 (adds testNodeStatusBanner).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-02 20:21:05 -05:00
940dd21464 feat(diagnostics): add "Copy diagnostics" + "Open log folder" actions (Foundation QoL)
Settings (logging section) gains two support-friendly actions, now that the logging
foundation actually produces logs (W7-2):

- Open log folder: opens the config dir (Platform::openFolder) so users can find
  dragonx-debug.log / dragonx-crash.log.

- Copy diagnostics: copies a plaintext support snapshot to the clipboard via the new
  App::buildDiagnosticsReport() — version, build variant, platform, connection status,
  active wallet path + existence + size, encryption/lock state, sync heights, and (full-
  node) daemon status/running/crash-count/lastError, plus the log paths. No secrets.

Build-clean; ctest 1/1. Remaining QoL: persistent alert history, a daemon/RPC error
banner, and the W6-2 refresh-staleness badge. See docs/wallet-hardening.md.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-02 15:37:52 -05:00
207f9074db feat(diagnostics): make the logging + crash infrastructure actually work (W7-2, W7-3, W7-4)
The Foundation tier — answers the original "make it easier to diagnose problems" ask.

- W7-2 (keystone): the app-level Logger file sink was never initialized, so LOG/LOGF/
  VERBOSE_LOGF went nowhere and dragonx-debug.log didn't exist on Linux/macOS at all.
  main() now calls Logger::init(<config>/dragonx-debug.log) on every platform. Fixed a
  latent deadlock this exposed: init() wrote its banner via write(), which re-locks the
  non-recursive mutex_ it already holds — now written directly. On Windows the raw
  stdout/stderr freopen moved to a separate dragonx-stdout.log so the two writers don't
  contend on one file. Added testLoggerFileSink (also a deadlock guard — it would hang if
  the fix regressed).

- W7-3: no crash handler existed on Linux/macOS. Added an async-signal-safe sigaction
  handler (SIGSEGV/ABRT/BUS/FPE/ILL) that writes the signal id + a backtrace_symbols_fd
  backtrace to dragonx-crash.log, then re-raises the default disposition for a core dump —
  the POSIX counterpart of the Windows SEH filter.

- W7-4: Logger::init now rotates the log to a single .1 backup past 10 MB, so a long or
  verbose session can't grow it unbounded.

Build-clean; ctest 1/1. Remaining Foundation: the QoL bundle (mostly UI). See
docs/wallet-hardening.md.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-02 15:31:23 -05:00
05b00b158b fix(wallet): surface silent save failures + stale-state cleanups (W5-1, W5-2, W6-1, W6-3)
P2 robustness batch (localized):

- W5-1 (Med, lite): persistAfterBroadcast returned false on a persistent post-send/shield
  save failure, but both callers discarded it and it never logged — completely silent. It
  now liteLogs the failure (the spent note re-derives on the next sync, so it's a
  robustness gap, not fund loss).

- W5-2 (Med, lite): the post-sync and post-rescan save results (in the detached scan
  threads) were ignored; both now liteLog on failure. LiteDiagnostics::log is mutex-guarded,
  so it's safe from those threads.

- W6-1 (Med): WalletState::clear() didn't reset mining/pool_mining, so a wallet switch could
  briefly show the previous wallet's hashrate/blocks. Now reset in clear() (the daemon
  restarts on switch, so mining genuinely stops).

- W6-3 (Low): AddressBook::load() cleared entries_ then threw on the first non-object array
  element — discarding EVERY contact. It now guards is_object() + per-entry try/catch,
  skipping and counting malformed entries.

Build-clean; ctest 1/1. Remaining P2: W6-2 (refresh-staleness badge — needs UI, overlaps
the diagnostics Foundation bundle). See docs/wallet-hardening.md.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-02 15:22:13 -05:00
f9ddab059e fix(wallet): stamp syncedHere only after identity verified + guard the startup wallet file (W1-3)
- W1-3 (Med): updateWalletIndexForActiveWallet stamped syncedHere in the markOpened block
  at bare connect (idHash still empty), letting a freshly-restored wallet skip its needed
  rescan. syncedHere is now stamped only once the wallet's identity is verified (idHash
  non-empty), so it takes effect at the post-address-refresh index update; lastOpenedEpoch
  still records at open.

- Startup guard (the W1-1 launch counterpart): App::init now exists()-checks the recorded
  active wallet before the daemon is configured. A non-default active wallet moved/deleted
  between sessions falls back to the default wallet.dat with a warning, instead of the
  daemon silently auto-creating an empty wallet under the missing name. Runs before the PIN
  vault init so the vault is scoped to the wallet actually opened.

Completes P1-B. Remaining P1: W3-3 (sweep opid persistence) deferred for careful
adversarially-reviewed work — re-tracking a stale opid could hang the migration if the op
poller doesn't time out; the existing balance/mined gates already prevent fund loss. See
docs/wallet-hardening.md.

Build-clean; ctest 1/1.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-02 15:18:09 -05:00
de1ae736de fix(wallet): guard against opening a missing/wrong wallet file (W1-1, W1-2, W1-4)
- W1-1 (High): switchToWallet never verified the target wallet file exists before
  switching. dragonxd auto-creates a fresh empty wallet for a missing -wallet=<name>, so a
  moved/deleted wallet file silently "opened" as a brand-new empty wallet with a zero
  balance — looking exactly like fund loss. It now std::filesystem::exists-checks
  datadir/<walletFile> before switching (ahead of the daemon-stop prompt) and blocks with a
  "not found (moved or deleted?)" warning. Because the check runs regardless of how
  switchToWallet is invoked, it also closes W1-4 (the stale switcher-row TOCTOU).

- W1-2 (Med): walletOutputLooksCorrupt matched the generic "Error loading wallet" string,
  which dragonxd also prints for DB_TOO_NEW (a newer-version wallet) — so a version mismatch
  was offered a -salvagewallet repair that cannot fix it. The generic match is now excluded
  when the output also contains "newer version".

Build-clean; ctest 1/1. Remaining P1-B: W1-3 (syncedHere timing) + the startup-path
existence check. See docs/wallet-hardening.md.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-02 14:56:59 -05:00
03c1b63e03 fix(migrate): correct fund-adjacent migrate-to-seed bugs (W3-1, W3-2, W3-4)
Migrate-to-seed (legacy -> mnemonic wallet) moves real funds; three correctness fixes:

- W3-1 (High): beginAdoptSeedWallet swapped a hardcoded datadir/wallet.dat instead of the
  ACTIVE wallet file. With a non-default active wallet (e.g. wallet-2.dat) it installed the
  swept seed wallet into an unloaded wallet.dat and left the daemon reloading the emptied
  legacy wallet — swept funds only recoverable via the seed phrase. Now swaps
  datadir + "/" + getActiveWalletFile(), captured on the main thread (switching is blocked
  during migration, so no race).

- W3-2 (High): SeedWalletCreator::create() ran remove_all(<config>/seed-migrate)
  unconditionally at the start, so a prior migration that swept funds into the temp wallet
  but was abandoned/crashed before adopting would have that fund-bearing wallet destroyed.
  It now refuses (with a clear message) when DRAGONX/wallet.dat already exists — a completed
  migration removes the dir on adopt, so a leftover means an unfinished one.

- W3-4 (Med): switchToWallet blocked switching only while the migration dialog was open;
  closing it via "Later" mid-migration dropped the guard. Now also blocks while
  getSeedMigrationPending().

Build-clean; ctest 1/1. Remaining P1-A: W3-3 (persist the sweep opid). See
docs/wallet-hardening.md.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-02 14:53:58 -05:00
8c12b27c0a fix(security): don't silently leave a wallet unencrypted or unlocked (W2-2, W2-4)
P0-B encryption-integrity cluster.

W2-2: the first-run wizard's "encrypt" stored the passphrase only in memory and let the
user into the app immediately, so a quit/crash or a failed daemon connect before the
deferred encryption applied left the wallet unencrypted with NO record encryption was
ever requested — the user believing it was encrypted. A persisted encryption_pending
settings flag is now set the moment encryption is requested (never the passphrase, only
the fact). refreshWalletEncryptionState() reconciles it on every connect: wallet observed
encrypted -> clear the flag; wallet NOT encrypted while the flag is set and no deferred
encryption is pending/in-flight -> a once-per-session "your wallet is NOT encrypted — open
Settings to finish" warning (the flag stays set, so it recurs each launch until resolved).
The passphrase is deliberately never persisted to auto-complete — surfacing it is the
secure choice.

W2-4: lockWallet()'s continuation only handled success — a failed walletlock RPC silently
left the wallet UNLOCKED (an unfulfilled auto-lock). It now logs and warns once (reset on
the next successful lock) so a failing auto-lock is visible instead of leaving the wallet
exposed.

Touches settings.{h,cpp}, app_wizard.cpp, app_security.cpp, app.h. Not unit-testable at
this layer (RPC/connect-driven state). Build-clean; ctest 1/1. See docs/wallet-hardening.md.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-02 14:48:02 -05:00
c7d163f44a feat(lite): wire the create-time passphrase into the lite encrypt/unlock flow (W5-3)
The lite create/open/restore requests carried a passphrase field that the UI collected
(a labeled, masked "passphrase" input) but the backend initialize* calls never used —
so a user could believe their lite wallet was passphrase-protected at creation when it
did nothing. It now has a real meaning, wired in LiteWalletController:

- create / restore -> encryptWallet(passphrase): the backend encrypts + locks + saves
  the brand-new wallet.
- open -> unlockWallet(passphrase), but only when encryptionStatus() reports the existing
  wallet is actually encrypted + locked (no spurious unlock on an unencrypted wallet).

encryptWallet/unlockWallet take their own copy of the passphrase and wipe it; the
request copy is still wiped as before. A post-create encrypt failure is liteLog'd (the
wallet still exists, so the create is not failed).

Six existing lite-controller tests carried an incidental "hunter2" create passphrase from
when the field was dead; removed (they exercise non-encryption flows and want an
unencrypted wallet), and added testLiteWalletControllerCreateEncryptsWithPassphrase to
prove the new behavior. Completes the wallet-hardening P0-A cluster (7/7). ctest 1/1.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-02 14:39:02 -05:00
7e4822c021 fix(security): warn that the seed-backup file is unencrypted plaintext (W4-5)
The seed-phrase "Save" already wrote the file 0600 and zeroed the in-memory buffer, but
the success message was a bare "Saved to <path>" — no hint that it's a permanent
UNENCRYPTED copy of the seed at a predictable location. The message now reads
"Saved an UNENCRYPTED seed file — move it to secure offline storage and delete this
copy: <path>". English source updated; the res/lang back-fill of this changed key is
deferred to the batch i18n pass.

Also documents W5-3 (lite create-time passphrase) as a product decision rather than a
speculative change: the field is already wiped on every path (minimal security risk),
but the labeled masked "passphrase" input at lite create/open/restore is never consumed
by the backend — so either remove the dead UI or wire it into the lite encrypt flow.

Finishes the actionable part of the wallet-hardening P0-A cluster (docs/wallet-hardening.md).
Build-clean; ctest 1/1.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-02 14:25:15 -05:00
f9b622cb25 fix(security): scrub in-memory key/passphrase copies in the wallet secret paths (W4-1, W4-3, W2-3)
The wallet-hardening memzero cluster. Uses the file's established sodium_memzero
pattern (matching the existing lambda-capture scrub at app_network.cpp:2885 and the JSON
scrub at :4025) rather than a new type, since importPrivateKey/sweepPrivateKey are
fund-moving code.

- W4-1 importPrivateKey / sweepPrivateKey: the spending/viewing key was copied ≥3×
  (calling frame -> worker-lambda capture -> JSON params) and never scrubbed. Now zeroed
  on all paths: the calling-frame copy after the worker post, the lambda's captured copy
  (lambda made mutable, zeroed once the request is sent), and the request params copy.

- W4-3 exportAllKeys / backupWallet: the concatenated all-keys buffer is now zeroed after
  the consumer uses it, and the backup is written via
  Platform::writeFileAtomically(..., restrictPermissions=true) — atomic and owner-only
  (0600) — instead of a umask-default std::ofstream that left it world-readable.

- W2-3 decrypt-wallet passphrase: std::move-captured into the worker lambda (no plaintext
  copy left in the calling frame) and sodium_memzero'd right after unlockWallet, its only
  use.

Not unit-testable (no observable RPC effect — the key value sent to the daemon is
unchanged; only post-use memory zeroing is added). Build-clean; ctest 1/1. See
docs/wallet-hardening.md.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-02 14:12:44 -05:00
9204fa148a fix(security): delete the plaintext key export after decrypt-wallet import (W2-1)
Removing wallet encryption z_exportwallet'd every private key in cleartext to
<datadir>/obsidiandecryptexport<ts>, re-imported it, and never deleted it — leaving a
full plaintext dump of every key on disk permanently. The decrypt flow now scrubs
(best-effort in-place zero-overwrite) and removes that file as soon as the
z_importwallet attempt resolves, on both the success and failure paths. Recovery, if
ever needed, remains the encrypted backup (wallet.dat.encrypted.bak), never this file.

Second fix in the wallet-hardening P0-A cluster (docs/wallet-hardening.md). Not
unit-testable (fs I/O in a deep worker lambda); build-clean, ctest 1/1.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-02 14:02:45 -05:00
da0e9f5915 fix(console): redact secret-bearing commands from the console echo and history (W7-1)
The RPC console echoed and stored typed commands verbatim, so `walletpassphrase
<secret>`, `z_importkey <key>`, `encryptwallet <pass>` etc. left the secret in the
visible log AND the 100-entry recall history (copyable). Adds a pure, unit-testable
RedactConsoleCommand()/ConsoleCommandCarriesSecret() (allowlist of 13 secret-bearing
first-tokens) in console_tab_helpers; submitConsoleCommand() now echoes and stores
`> walletpassphrase ****` while still executing the real command unredacted. Bare
secret commands and non-secret commands pass through unchanged.

Output-secret commands (dumpprivkey / z_exportkey / z_exportmnemonic) — whose secret is
in the RESULT — are a separate redaction concern, tracked as a follow-up.

First fix in the wallet-hardening P0-A cluster (see docs/wallet-hardening.md). New
testConsoleSecretRedaction (11 assertions); ctest 1/1.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-02 13:57:52 -05:00
d188a08db7 test(daemon): add F1/F2 process-lifecycle integration tests; fix clobbered start error
Links the real EmbeddedDaemon into the ObsidianDragonTests target (its deps were
already present) and adds two POSIX integration tests that exercise the actual
fork/exec/waitpid fixes headlessly:

- testExecFailureReported (F2): start() against a non-executable file must fail with a
  precise "not executable or wrong architecture" reason.
- testDaemonCrashDetected (F1): a short-lived child that exits abnormally is still
  detected (crash_count_ increments) while isRunning() is hammered from the test
  thread — a regression test for the reap race.

Writing the F2 test surfaced a real bug: start()'s failure branch called
setState(State::Error, "Failed to start dragonxd process"), and setState stores the
Error message into last_error_ — clobbering the precise message startProcess() had
just set, so getLastError()/the UI only ever saw the generic string. Fixed to pass the
preserved detail to setState, so the precise reason survives and now also reaches the
state callback (crash panel / status).

ctest 1/1, green including the two new integration tests.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-02 13:11:15 -05:00
ff5f5ddf23 docs: add CHANGELOG with daemon-startup hardening release notes
Introduces CHANGELOG.md (Keep a Changelog style, Unreleased section) covering this
batch, with F8's breaking change — remote plaintext RPC now refused by default —
called out front and center along with the rpctls=1 / rpcallowplaintext=1 recovery
steps. Also records the Security / Fixed / Added entries for F1-F7. Updates the
tracking doc's status to reflect the completed release-notes + i18n back-fill and the
remaining pre-release items (F1/F2 manual repros, CJK subset-font rebuild).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-02 12:06:02 -05:00
56f9802fb9 i18n: back-fill daemon-startup hardening strings
Additively adds translations for the 6 new keys from this batch
(sb_daemon_extract_failed, sb_daemon_files_failed, loading_stall_{title,body,hint},
sb_plaintext_remote_blocked) across res/lang/*.json. es/de/fr/pt/ru get all 6; for
zh/ja/ko a string is only added when every glyph is already in the current
NotoSansCJK-Subset.ttf, since the subset can't be rebuilt here — 6 zh/ja/ko entries
whose glyphs aren't yet subsetted are left on the English fallback rather than render
as tofu. Written sorted, indent=4, ensure_ascii=False (matching add_missing_translations.py);
purely additive (42 insertions, 0 removals). The remaining 6 need a
scripts/build_cjk_subset.py font rebuild.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-02 12:05:53 -05:00
efb271cb9a fix(rpc): refuse plaintext-remote RPC by default and tighten isLocalHost
F8 (security). Two related fixes to how the wallet decides whether an RPC target is
safe to send Basic-auth credentials to:

- isLocalHost() was matching any host that merely *starts* "127." via
  rfind("127.",0)==0, so "127.evil.com" (and "127.0.0.1.attacker", "127.300.0.1",
  "1270.0.0.1") were misclassified as loopback and treated as local. It now uses a
  strict isExactIPv4Loopback() parser: exactly four 0-255 dot-separated octets with
  the first == 127. localhost / ::1 / [::1] handling is unchanged.

- A remote rpchost over plain HTTP (no rpctls=1) previously only produced a
  dismissible warning and then sent rpcuser:rpcpassword in cleartext, where a
  local-network MITM could capture them. tryConnect() now REFUSES that connection
  (clear status line + one-time notification, no creds sent) unless the user opts in
  explicitly with rpcallowplaintext=1 in DRAGONX.conf (new
  ConnectionConfig::allow_plaintext_remote, parsed in parseConfFile; policy in the
  new allowsPlaintextRemote()). Local/embedded daemons and rpctls=1 remotes are
  unaffected.

BREAKING: a wallet configured for remote plaintext RPC will stop connecting until
rpcallowplaintext=1 (or rpctls=1) is added to DRAGONX.conf. Must be called out in the
release notes. The Settings-toggle UI is deferred (the conf-key opt-in is the recovery
path; see docs/daemon-startup-hardening.md).

Adds testIsLocalHost and testAllowsPlaintextRemote to test_phase4.cpp; one i18n key
(English) added to i18n.cpp.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-02 11:40:35 -05:00
eb69e491b9 fix(startup): surface a "taking too long" notice when the daemon won't come up
F3: the daemon connect loop retried forever with only an animated spinner when the
daemon was reachable-but-never-ready (stuck in RPC warmup / -28, or an external daemon
that never finishes init) -- no error, no guidance, no escape. It now stamps
connect_stall_since_ the moment the daemon first goes "reachable but not ready" (the
warmup branch + applyDaemonInitStatus) and clears it on connect / disconnect /
warmup-complete. A pure, unit-testable util::connectHasStalled() helper (new
util/connect_stall.h, 45s default from ui.toml [screens.loading].stall-timeout-sec)
drives a "Taking longer than expected" notice in renderLoadingOverlay(): a title, a
reassuring body with elapsed seconds, and a full-node hint to Settings > Restart Daemon
or the Console. The background retry keeps running underneath, so the notice self-clears
the instant it connects. Guarded off while the daemon is in State::Error (that case is
owned by the existing crash-count hint).

The overlay is a pure draw-list layer with no interactive widgets, so this follows the
existing crash-hint idiom (guidance text, not injected buttons); the stalled state is
computed locally in the overlay, so the only new App member is connect_stall_since_.

Adds testConnectHasStalled to test_phase4.cpp and three i18n keys to i18n.cpp (English
source of truth; the res/lang/*.json back-fill is deferred to a single
add_missing_translations.py run at the end of the batch).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-02 11:33:26 -05:00
2675b8ab93 fix(startup): surface filesystem failures and verify Sapling param integrity
Three verified daemon-startup edge-case fixes centered on the config/params
filesystem path:

- F7: new non-throwing Platform::ensureDirectory(dir, outError) with one
  consistent "Cannot create <dir>: <reason>. Check permissions / free space."
  message. Replaces the unchecked/throwing create_directories sites at main.cpp
  (pre-init: log + Windows MessageBox + return 1), connection.cpp's
  autoDetectConfig (was the *throwing* overload -- could raise an uncaught
  filesystem_error through its callers; now sets the new
  ConnectionConfig::dir_error), and both app.cpp daemon-dir sites (surface via
  daemon_status_ + return false). The primary connect path (app_network.cpp)
  checks dir_error and shows it instead of mislabelling it "waiting for config".
  embedded_resources.cpp already checked its error_code, so it is left as-is.

- F6: verifySaplingParams() now hash-verifies each param against its pinned
  canonical SHA-256 (source of truth: scripts/build-lite-backend-artifact.sh)
  instead of only checking existence, so a truncated / corrupt-but-present param
  is rejected up front rather than failing later on a shielded operation. A
  <params_dir>/.sapling_verified marker keyed on size:mtime avoids re-hashing
  ~48MB on every startup. Logic extracted to the injectable, unit-testable
  verifySaplingParamsIn(dir, digests); reuses util::sha256Hex (no new hash impl).

- F5: startEmbeddedDaemon() now checks extractEmbeddedResources()'s return and
  the previously-dropped copy_file error_code in the daemon-binary fallback loop,
  aborting with a clear status (sb_daemon_extract_failed / sb_daemon_files_failed)
  instead of failing opaquely at spawn. An absent source file stays non-fatal.

Adds testPlatformEnsureDirectory and testVerifySaplingParams to test_phase4.cpp.
i18n keys added to i18n.cpp (English source of truth); the res/lang/*.json
back-fill via add_missing_translations.py is deferred to a single run at the end
of the batch. Progress tracked in docs/daemon-startup-hardening.md.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-02 11:07:32 -05:00
b3444e0a89 fix(daemon): harden startup process lifecycle (crash race, exec failure, datadir lock)
Three verified daemon-startup edge-case fixes in the embedded-daemon process
lifecycle (all in embedded_daemon.{cpp,h}):

- F1: EmbeddedDaemon::isRunning() (POSIX) now reads the atomic state_ instead of
  calling waitpid(WNOHANG) from the UI thread, which raced monitorProcess()'s own
  reap. waitpid is one-shot: whichever thread won consumed the exit status; if
  isRunning() won, the monitor never saw the crash, so crash_count_/State::Error
  and the 3-strike restart cap were silently lost. monitorProcess() is now the sole
  reaper (predicate Running || Stopping keeps stop()'s wait loops correct). Mirrors
  the existing XmrigManager::isRunning() fix.

- F2: startProcess() (POSIX) adds a close-on-exec self-pipe exec handshake. On a
  non-executable / wrong-arch / corrupt binary, execv fails in the child and the
  parent now learns synchronously (reads errno vs EOF), reaps the zombie, sets a
  precise last_error_ ("not executable or wrong architecture"), and returns false
  -- instead of reporting State::Running for a daemon that never started. Uses
  pipe()+FD_CLOEXEC (not pipe2) so the branch stays shared with macOS. Parent-side
  setpgid is now best-effort + logged.

- F4: start() gates on a lingering datadir lock after the port check. A graceful
  shutdown releases the RPC port ~90s before the datadir .lock, so a rapid
  stop->start spawned a daemon that died on the lock and, three times in ~12s,
  tripped the 3-strike crash cap before the lock cleared. start() now polls
  isDaemonProcessRunning() with a bounded ~300ms wait and bails with a distinct
  non-crash Error (no crash_count_ bump) that the connect loop retries once the
  lock clears. Isolated migrate-to-seed starts (skip_port_check_ / -datadir
  override) are exempt.

Adds the testDatadirLockGate unit test (pure evaluateDatadirLockGate matrix) to
test_phase4.cpp. Plan and progress tracked in docs/daemon-startup-hardening.md.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-02 10:32:30 -05:00
45b652f514 feat(mining): live pool fee, saved/custom pool rows, and payout-address fix
Several related mining-tab pool improvements:

- Report the default pool fee correctly: pool.dragonx.is is 1%, not 0%.
  The registry constant was hardcoded to 0. It now also fetches the live
  poolFee from the pool's /api/stats alongside hashrate (no extra
  request), so the displayed fee self-corrects and falls back to the
  compile-time value only when the fetch hasn't landed.

- Show fractional fees: new FormatFeePercent trims trailing zeros so
  whole fees read "1%" and fractional ones keep their decimals ("1.5%").

- Surface saved + custom pools in the pool list card: the list is now
  the union of the official pools, the user's saved favorites, and the
  currently-mined pool (effectivePools), each a selectable, endpoint-
  deduped row. Previously the card only showed the hardcoded knownPools().

- Fix the xmrig "user" field: the "Payout Address" field now drives the
  pool login rewards are credited to (resolveMiningUserAddress), instead
  of being written only to "pass" while "user" was auto-derived from the
  wallet's own first z-address -- which silently ignored a configured
  payout address and could route rewards to the wrong address.

Unit tests cover parsePoolFee, FormatFeePercent, effectivePools, and
resolveMiningUserAddress; full app + ObsidianDragonTests build clean.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 03:48:05 -05:00
c252e60f7f docs: add v2.0.0 and lite-v1.0.0 release notes
Release notes for ObsidianDragon v2.0.0 (full-node) and ObsidianDragonLite
v1.0.0, with verified SHA-256s for the Linux/Windows artifacts and the
osxcross-built macOS artifacts. Both tags point at this commit's parent
(fffee9f); tables cover AppImage/zip/exe/dmg/app.zip per variant.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-23 22:33:58 -05:00
fffee9f0b5 build(lite-backend): pin the SDXL backend to rustc 1.63 via rust-toolchain.toml
The pinned librustzcash / transitive crates (notably traitobject 0.1.0) rely on
pre-1.70 trait coherence and fail to compile on newer rustc (E0119), so the backend
must build with 1.63. Add a rust-toolchain.toml in the vendored backend so rustup
auto-selects 1.63 when cargo runs there — no more manual RUSTUP_TOOLCHAIN=1.63.0.
The pin is scoped to the backend tree (repo-root cargo keeps the default toolchain).
Also symlink the pin into the prepared build root so --silentdragonxlitelib-dir
builds honor it too.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-23 21:30:16 -05:00
00ffc959e5 fix(build): name macOS DMGs after the app + stop wiping other variants' artifacts
- The macOS DMG filename used a separate hardcoded DMG_BASENAME ("DragonX_Wallet"),
  so the export didn't match the .app/zip (ObsidianDragon). Derive it from
  APP_BASENAME: full-node -> ObsidianDragon-*.dmg, lite -> ObsidianDragonLite-*.dmg.
  The mounted volume + CFBundleName keep the "DragonX Wallet" display branding.
- build_release_mac did `rm -rf "$out"`, wiping all of release/mac on every build, so
  building one variant destroyed the other's artifacts. Scope the cleanup to the
  current variant's files (as the Linux/Windows release paths already do) so the
  full-node and lite releases coexist.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-23 21:24:54 -05:00
b561406c23 fix(build): make macOS release builds work natively
The --mac-release path (full-node and --lite-backend) had never run on a real
Mac and broke on several Linux-only assumptions:

- build.sh version parser used GNU sed \+ (BSD sed reads it literally), so it
  aborted before compiling — switched to POSIX [[:space:]][[:space:]]*.
- build.sh libsodium universal check used GNU grep \| — switched to grep -E.
- libwebp's cpu.cmake applies -mno-sse2 to its reference DSP files; under a
  universal build (-arch arm64;x86_64) that lands on the x86_64 slice, where it
  disables _Float16 and breaks the SDK 26 <math.h>. Added an idempotent
  FetchContent PATCH_COMMAND (cmake/patch-libwebp-simd.cmake) to neutralize it.
- The SDXL lite backend static lib needs Security + CoreFoundation frameworks on
  macOS; added them to the imported dragonx_lite_backend target for APPLE.
- Added a DRAGONX_MAC_ARCHS override plus auto-detect: with --lite-backend the
  app is built for the arch(es) the backend .a actually provides (its pinned
  ring 0.16.11 is x86_64-only), instead of failing to link a universal app.
- build-lite-backend-artifact.sh uses bash 4+ (mapfile); added a re-exec guard
  for macOS's stock bash 3.2 and added bash to setup.sh's macOS core deps.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-23 20:03:33 -05:00
5a0743a17b fix(setup): skip sudo apt when the Windows toolchain is already installed
setup.sh --win ran `sudo apt-get install` (and update-alternatives)
unconditionally, forcing the whole run under sudo even when mingw-w64 was
already present. Running setup as root makes the daemon cross-compile run as
root, leaving root-owned artifacts under external/dragonx that break `make
clean` on a later non-sudo build (stale objects relink -> mingw link failure
recurs). Gate the apt/update-alternatives block behind a presence check so
`./setup.sh --win` runs sudo-free (and the daemon build as the invoking user)
when the toolchain is already installed.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-23 17:59:05 -05:00
a7b0770ad0 feat(scripts): sign-daemon-release release command (package + sign prebuilt binaries)
Adds `sign-daemon-release.sh release <secret.key> <version>`: zips each staged
prebuilt-binaries/dragonxd-{linux,mac,win}/ into dragonx-<version>-{linux-amd64,
macos,win64}.zip, signs each (detached ed25519), and prints the SHA-256 checksum
table for the release body. Platforms with no staged daemon are skipped; warns
if the signing key doesn't match the pubkey pinned in daemon_updater.h. Keeps the
existing keygen/pubkey/sign subcommands.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-23 17:59:05 -05:00
7d8323a622 Merge branch 'chore/rename-xmrig-hac-to-drg-xmrig' into dev
Point the miner build at DragonX/drg-xmrig and rename the prebuilt staging
dir xmrig-hac -> drg-xmrig across setup.sh, build.sh, the legacy Windows
script, .gitignore and README.
2026-07-23 14:56:28 -05:00
320944fd18 chore(setup): build miner from DragonX/drg-xmrig and rename staging dir
setup.sh now clones/builds the miner from git.dragonx.is/DragonX/drg-xmrig
(was dragonx/xmrig-hac), and the prebuilt staging dir is renamed
prebuilt-binaries/xmrig-hac -> prebuilt-binaries/drg-xmrig across build.sh, the
legacy Windows build script, .gitignore and README.

Also aligns setup.sh's section-8 directory list with section 7 (it previously
used a bare "xmrig" token, creating a stray empty prebuilt-binaries/xmrig/),
and updates the xmrig_manager.cpp header comment.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-23 14:55:42 -05:00
6d5e0ac614 Merge contacts table-view footer fix into dev (follow-up to #1) 2026-07-22 23:46:55 -05:00
02554d523d fix(ui): align contacts table-view footer with cards/list views
In table view the table is inset into its glass panel by tVpad and its
outer_size is listH-2*tVpad, so the post-table cursor ended tVpad (~10px) above
where the cards/list views leave it, pulling the "N address saved" footer up.
Land the cursor at the glass-panel bottom (tpMin.y + listH) so the footer lines
up across all three view styles.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-22 23:45:14 -05:00
21da9e75fc Merge pull request 'Security audit remediations + Overview/Mining UI polish' (#1) from fix/audit-remediations-dev into dev
Reviewed-on: #1
2026-07-22 17:19:29 -05:00
c53b7f771e fix(ui): overview/mining polish + default-pool cleanup
- balance: inset the address-row favorite (star) button by the card's inner
  padding so it mirrors the left margin instead of hugging the card edge.
- mining: remove pool.dragonx.cc from the built-in default pools (pool.dragonx.is
  is now the sole default); update the pool-registry test accordingly.
- mining: middle-truncate saved pool-URL and payout-address dropdown rows (new
  shared material::TruncateToWidth helper) so a full z-address no longer runs
  under the trailing delete (X) button.
- mining: fix the thread-grid cells overflowing the card at >100% display
  scaling — the reserved Mine-button width used a raw clamp that didn't scale;
  scale it by dp so cols is estimated correctly (no-op at 100%).

Full-node build + test suite green.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-22 16:01:51 -05:00
bcee4bfe72 fix(security): apply audit remediations to dev (ported + dev-only)
Dev-branch audit (docs/_archive/security-audit-dev-2026-07-22.md) re-found the
master issues (absent on dev) plus new ones in dev-only code. Applied here;
full-node build + test suite green; the subtle fixes were adversarially re-verified.

Ported from the master remediation (adapted to dev's code):
- bootstrap: reject zip-slip / path-traversal archive members (isSafeArchivePath)
  before writing (S2-1). (dev already fail-closes on a missing checksum.)
- xmrig updater: fail closed when a signature is required but no key is pinned (F1-1).
- http_download.httpGetString: 16 MiB hard cap + MAXFILESIZE on the shared
  metadata/price fetch (F1-2 / caps the updater + exchange paths at one site).
- rpc_client: explicit SSL_VERIFYPEER/VERIFYHOST (F4-1) and a 256 MiB response
  cap in WriteCallback (F4-3).
- lite_connection_service: reject remote http:// lite servers, loopback only (L1-1).
  Loopback is matched by a strict dotted-decimal 127.0.0.0/8 check (not a
  startsWith("127.") prefix, which would wrongly accept 127.0.0.1.evil.com), with
  userinfo/fragment stripping.
- lite controller: propagate encrypt/decrypt save() failure instead of reporting
  success (F7-1).
- xmrig_manager: chmod(0600) the pool config before writing secrets (F5-2).
- app: clear the copied secret from the OS clipboard on shutdown (F3b-1).
- export_transactions: neutralize CSV/spreadsheet formula injection (F13-1).
- build pipeline: build-from-source lite backend + remove the self-attested
  CMake signature gate (F15-1); pinned+verified appimagetool (F15-3/4);
  verified Sapling params in setup.sh (F15-6); build.sh exits 0 on success.
  (F14-1 empty-quoted-arg and F8-2 NUL-termination were already fixed on dev.)

Dev-only findings:
- rpc_client.callRaw: scrub the raw buffer + parsed tree (templated scrubJsonSecrets
  for ordered_json) so console dumpprivkey/z_exportkey keys don't linger in freed
  heap (N1-1).
- seed_wallet_creator: wipe the exported mnemonic on the failure path so a discarded
  failed result never carries a live seed (W1-2).
- export_all_keys: write the plaintext key dump 0600 + atomically via
  writeFileAtomically (U1-2).
- chat_database: restrict chat_messages.sqlite and its WAL/SHM sidecars to owner-only (C3-1).

Not done (need a decision, documented in the report):
- Chat header metadata (cid/z/p) rides outside the AEAD (C1/C2) — binding it is a
  wire-protocol change requiring SilentDragonXLite interop review.
- Bootstrap lacks an offline-rooted signature (S2-2 residual) — needs signing infra.
- Console scrollback retains console-typed key-export output in plaintext (N1-1
  residual) — inherent to an echoing console; would need output redaction.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-22 12:08:24 -05:00
e1870c3b23 i18n: translate the 37 new settings tooltips into all 8 languages
Add de/es/fr/pt/ru/zh/ja/ko translations for the tt_* tooltip keys added for
the Chat & Contacts, lite Node & Security, and Debug Options controls. Written
additively (indent=4, sort_keys, ensure_ascii=False) — +37 keys per file, no
other churn. Product/technical tokens (dragonxd, RPC, HD, CPU, 0-conf,
lite-seed-backup.txt, Shift+Enter, numeric units) kept verbatim. Rebuilt the
CJK subset font for the 30 new zh/ja/ko glyphs (all now covered).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-21 22:02:19 -05:00
7cf0bb8bc7 feat(settings): add hover tooltips to the settings controls that lacked them
A per-card audit found 38 interactive controls with no tooltip. Add them, with
new tt_* i18n keys (English source; per-language JSONs fall back to English
until translated):
- Chat & Contacts (8): emoji/bubble style, accent, density, text size, poll
  rate, timestamps, enter-sends — the segmented/beginRow helpers added none.
- Node & Security (26): the whole lite wallet lifecycle / backup-keys /
  encryption panel, plus the full-node RPC-details toggle and daemon Refresh.
- Debug Options (4): the screenshot-sweep / full-UI-sweep / open-folder /
  seed-demo-chat buttons.

Theme, Wallet, Explorer, About were already fully covered.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-21 21:31:15 -05:00
541a9e1fd5 feat(settings): split Chat & Contacts into Appearance | Messaging columns
Break up the tall, left-heavy Chat & Contacts card internally: Appearance on
the left, Messaging on the right when the card is wide (fills the width, ~halves
the height). Same Indent technique as the Node & Security card; the row helpers
retarget their leftX/rowW per column so controls right-align within each. The
narrow chat-settings modal stays single-column.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-21 21:16:37 -05:00
07b8e0b2cb revert(settings): drop the page-level card masonry
Page-level two-column masonry made the cards narrow and fought their internal
layouts. Revert the whole-page band (full-width stacked cards again) and keep
the internal NODE & SECURITY two-column — the better lever is breaking up the
large cards internally, not columning the page. The Explorer row-start-X fix is
kept (correct at full width too).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-21 21:13:37 -05:00
de48414c65 feat(settings): hybrid masonry — full-width Theme + About, columns between
Per feedback, keep Theme & Language and About/Debug full width (they read
better wide), and put only Wallet / Node & Security (left) and Explorer /
Chat & Contacts (right) in the two-column band. The band captures its top
anchor below the Theme card and restores full width before About.

Also fix an Explorer bug the two-column band exposed: row 1 placed the Address
column with an absolute SameLine(pad + halfW + …) that ignores the right-column
Indent, so "Address URL" leaked into the left column. Anchor it to the row's
indent-inclusive start X instead (identical result at full width).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-21 21:01:04 -05:00
f0c681b0b5 fix(settings): lower page-masonry threshold so it engages under display scaling
The 1000-logical-px threshold never engaged at 125% OS scaling: the panel's
real-px width (~1190) was below 1000×dpiScale (~1250), so the page stayed
single-column even though the internal NODE & SECURITY two-column (760×dpiScale)
did engage. Drop to 780 logical px — still requires usable (~390 logical) columns
but engages at the widths real windows actually have under display scaling.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-21 20:50:19 -05:00
fdf2502ca8 feat(settings): two-column page masonry for the section cards (A)
Render the section cards in two columns when the page is wide (>1000px):
left = Theme, Wallet, Node & Security; right = Explorer, Chat & Contacts,
About; Debug spans full width below. Driven by shadowing `availWidth` to the
column width for the whole card region and floating the right column with
Indent() (a one-shot cursor set can't hold a column across line-advances);
each card's own responsive gates collapse their internal columns at the
narrower width. Collapses to one column below the breakpoint.

Fix the half-width overflows a parallel audit surfaced (cards previously only
ever rendered full-width):
- WALLET Tools & Actions: merge-to-address was a 3rd button on a 2-button row
  (unconditional SameLine) — flip the separators so 2-per-row pairs cleanly.
- EXPLORER: wrap the Block Explorer button to its own row when the checkboxes
  + button won't fit (measured, locale-safe).
- NODE & SECURITY: wrap "Remove Encryption" below when the encrypted row's
  three buttons + status don't fit the column.
- ABOUT: 2x2 button grid instead of 1x4 so labels don't clip.
- THEME: reserve the real gaps + custom-skin "*" marker in the theme combo
  width so Refresh doesn't spill past the edge.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-21 19:15:21 -05:00
e393b0d847 fix(settings): wrap the SECURITY row in the narrow two-column card
The encrypt controls + auto-lock combo + PIN hint are one SameLine chain that
fits at full width but overflowed the half-width NODE & SECURITY column,
spilling the "Encrypt wallet first to enable PIN" hint over the Daemon
column's buttons. When the section is narrow, break the auto-lock/PIN group
onto its own row at the section's left edge instead of continuing the line.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-21 18:45:56 -05:00
3b041f20c1 fix(settings): hold the right column with Indent, not a one-shot cursor set
The two-column NODE & SECURITY card collapsed both columns onto the left,
overlapping. A one-time SetCursorScreenPos() can't hold a column: ImGui
resets the cursor X to the window's left indent on every line-advance, so
the first widget in the right column (a Dummy spacer) bounced everything
after it back to the left margin.

Shift the whole right column with ImGui::Indent(colW+gap) instead, so each
line-start lands at the column X; set the first widget's X explicitly to
match, and Unindent before continuing the page. Left column already renders
at the shadowed half-width, so it stays in its lane.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-21 18:04:15 -05:00
77a0ad64b0 feat(settings): two-column NODE & SECURITY card (B) — fill the empty right side
The NODE & SECURITY card stacked Node / RPC / Security / Daemon-binary in one
full-width column, packing everything on the left. Split it into two columns when
the card is wide enough: Node + RPC + Security on the left, Daemon binary on the
right — which fills the previously-empty right half and roughly halves the card's
height. Falls back to a single column on narrow windows (and when there's no
daemon section). Implemented by shadowing contentW + sectionOrigin per column
(renderSecuritySection already takes an explicit width/origin), so every
sub-section lays out within its column; the card auto-sizes to the taller column.
Full-node only for now; the lite Node section stays single-column.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-21 17:15:32 -05:00
d0ca2fdf52 feat(settings): wrap crammed checkbox rows + cleanup (sweep)
Step 5. The Wallet privacy/daemon checkbox row and the Advanced-Effects checkbox
row shrank their text (SetWindowFontScale) to cram onto one line — they now wrap
to new rows at full size instead, so nothing clips on narrow windows (the lite
"Animate avatars" was being cut off). Also drop two now-unused locals left over
from the Node/Daemon rebuild. (The Explorer row, the RPC collapsible header, and
the secondary/compact effects layout are left for a later polish pass.)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-21 16:03:36 -05:00
9c07b6d33d feat(settings): converge the lite Node section styling with full-node
Step 4. Bring the Lite settings section in line with the full-node one: its
subsection headers (Backup & keys, Security, Maintenance) switch from the Body2
type style to the accent Overline used by the full-node Node/Daemon/Security
subsections, and the standalone action buttons (Open data folder, Show seed,
Show private keys, Redownload blocks) become icon ActionButtons matching the
full-node button styling. The intricate secret-reveal / encryption flow and its
label-column input layout are left untouched.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-21 15:56:23 -05:00
fedfe3d60c feat(settings): rebuild Node data-dir + Daemon binary on the new components
Step 3. Node "Data Dir" is now a clean key/value block: the directory on its own
row as a click-to-open accent link with a copy button, the wallet size below, and
the folder buttons on their own row (icon ActionButtons) — no more path
font-scaling or right-align overflow math. Daemon binary shows Installed / Bundled
as key/value rows plus a Success/Warning status chip (replacing the fragile
atom-by-atom wrapped status line); "Check for updates…" is now the accent Primary
action, and the maintenance actions are a separate row with Delete Blockchain
styled Destructive (Test/Rescan/Repair Secondary). All handlers, disabled-state
predicates and tooltips are preserved; the buttons wrap instead of overflowing.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-21 15:53:28 -05:00
95ff9ce9ae feat(settings): shared design components + Wallet button hierarchy
Step 1: promote the polished chat-settings building blocks into reusable material
components (src/ui/material/settings_controls.h) — SettingsSubheader (accent
small-caps), SettingsRow (label left / control right), SegmentedControl (iOS
track+pill), and a tiered ActionButton (Primary=accent fill, Secondary=glass,
Tertiary=ghost, Destructive=error) with an optional leading Material icon, plus a
ButtonFlow that wraps rows of buttons.

Step 2: rebuild the Wallet BACKUP & DATA button wall on them. The 9–11 identical
buttons that were font-scaled onto one row are now tier-ordered, icon-labelled,
and wrap to new rows: the emphasized actions the user flagged — Import key, Seed
phrase, Wallets…, Download Bootstrap — cluster on top as accent Primary buttons;
common actions (viewing-key import, Backup, Migrate, Setup Wizard) are Secondary;
exports are low-emphasis Tertiary. All click handlers/tooltips and the migrate
"legacy wallet" glow are preserved. Removes the SetWindowFontScale legibility hack.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-21 15:48:21 -05:00
151 changed files with 18445 additions and 3246 deletions

10
.gitignore vendored
View File

@@ -11,8 +11,8 @@ prebuilt-binaries/dragonxd-win/*
!prebuilt-binaries/dragonxd-win/.gitkeep
prebuilt-binaries/dragonxd-mac/*
!prebuilt-binaries/dragonxd-mac/.gitkeep
prebuilt-binaries/xmrig-hac/*
!prebuilt-binaries/xmrig-hac/.gitkeep
prebuilt-binaries/drg-xmrig/*
!prebuilt-binaries/drg-xmrig/.gitkeep
# External sources / toolchains (created by scripts/setup.sh)
@@ -33,7 +33,11 @@ imgui.ini
*.bak*
*.params
asmap.dat
/external/xmrig-hac
# Wallet files hold PRIVATE KEYS — never commit them
wallet.dat
wallet-*.dat
wallet.dat.*
/external/drg-xmrig
/memory
/todo.md
/.github/

58
CHANGELOG.md Normal file
View File

@@ -0,0 +1,58 @@
# Changelog
All notable user-facing changes to ObsidianDragon are documented here. The format loosely
follows [Keep a Changelog](https://keepachangelog.com/); the project uses Conventional Commits.
## [Unreleased]
### ⚠️ Breaking changes
- **Remote RPC over plain HTTP is now refused by default.** If your wallet is configured to
reach a **remote** `rpchost`/`rpcconnect` **without TLS**, it will no longer connect — it
previously sent your `rpcuser`/`rpcpassword` in cleartext (capturable by anyone on the
network path) after only a dismissible warning. To reconnect, either:
- add **`rpctls=1`** to `DRAGONX.conf` (preferred, if your daemon supports TLS), or
- add **`rpcallowplaintext=1`** to `DRAGONX.conf` to explicitly accept the plaintext link.
Local and embedded daemons (`127.0.0.0/8`, `localhost`, `::1`) are unaffected.
### Security
- Refuse remote plaintext RPC credential transmission by default (see Breaking changes above).
- Tightened localhost detection: a hostname that merely *starts* with `127.` (e.g.
`127.evil.com`) is no longer mistaken for a loopback address, so it can no longer bypass the
plaintext-RPC protection.
- Sapling parameters are now integrity-checked (SHA-256) against pinned canonical digests
before use, instead of only checking that the files exist. A truncated or corrupt parameter
file is caught up front rather than surfacing later as a confusing shielded-operation failure.
(Cached via a `size:mtime` marker so it doesn't re-hash ~48 MB on every launch.)
### Fixed
- Daemon crashes are no longer occasionally missed: a race between the UI thread and the
process monitor could consume the daemon's exit status, hiding a crash and defeating the
automatic-restart cap. The monitor is now the sole reaper.
- A daemon that fails to launch (missing execute permission, wrong architecture, corrupt
binary) now reports a precise error immediately instead of briefly showing "running" and
then a generic "exited unexpectedly (exit code 127)".
- A quick stop→start no longer triggers a restart storm: the wallet now waits briefly for a
previous daemon to release the data-directory lock and shows a clear, non-crash message
instead of exhausting the crash-restart budget.
- Failures while writing the daemon binaries or Sapling parameters (disk full, permission
denied) are now surfaced clearly up front instead of failing opaquely when the daemon later
can't start.
- Directory-creation failures on startup (read-only home, permission denied) now produce a
clear "Cannot create <dir>" message instead of a confusing downstream "config missing" /
"binary not found" error (or, in one path, an uncaught exception).
### Added
- A "Taking longer than expected" notice now appears if the daemon is reachable but hasn't
finished initializing after ~45 s (configurable via `ui.toml`), with guidance to restart the
daemon or open the Console — instead of an indefinite silent spinner. It clears itself
automatically once the daemon connects.
---
Engineering detail and the finding-by-finding rationale for this batch live in
`docs/daemon-startup-hardening.md`.

View File

@@ -15,7 +15,7 @@ if(APPLE)
endif()
project(ObsidianDragon
VERSION 2.0.0
VERSION 2.0.1
LANGUAGES C CXX
DESCRIPTION "DragonX Cryptocurrency Wallet"
)
@@ -26,7 +26,7 @@ set(DRAGONX_VERSION_SUFFIX "")
# ObsidianDragonLite is versioned INDEPENDENTLY of the full-node app above. The active variant's
# version flows to the generated header, the Windows .rc/manifest, and build.sh's release names via
# DRAGONX_APP_VERSION* (resolved in the lite/full block below).
set(DRAGONX_LITE_VERSION "1.0.0")
set(DRAGONX_LITE_VERSION "1.1.0")
set(DRAGONX_LITE_VERSION_SUFFIX "")
# C++17 standard
@@ -53,7 +53,6 @@ set_property(CACHE DRAGONX_LITE_BACKEND_LINK_MODE PROPERTY STRINGS imported)
set(DRAGONX_LITE_BACKEND_ABI "sdxl-c-v1" CACHE STRING "Expected lite backend C ABI version")
set(DRAGONX_LITE_BACKEND_SYMBOLS_FILE "" CACHE FILEPATH "Path to generated lite backend exported-symbol inventory")
set(DRAGONX_LITE_BACKEND_MANIFEST "" CACHE FILEPATH "Optional path to generated lite backend artifact manifest")
option(DRAGONX_LITE_BACKEND_REQUIRE_SIGNATURE "Require verified signature metadata in the lite backend artifact manifest" OFF)
set(DRAGONX_LITE_BACKEND_REQUIRED_SYMBOLS
litelib_wallet_exists
litelib_initialize_new
@@ -126,36 +125,24 @@ if(DRAGONX_ENABLE_LITE_BACKEND)
if(DRAGONX_LITE_BACKEND_MANIFEST AND NOT EXISTS "${DRAGONX_LITE_BACKEND_MANIFEST}")
message(FATAL_ERROR "DRAGONX_LITE_BACKEND_MANIFEST does not exist: ${DRAGONX_LITE_BACKEND_MANIFEST}")
endif()
if(DRAGONX_LITE_BACKEND_REQUIRE_SIGNATURE)
if(NOT DRAGONX_LITE_BACKEND_MANIFEST)
message(FATAL_ERROR "DRAGONX_LITE_BACKEND_REQUIRE_SIGNATURE requires DRAGONX_LITE_BACKEND_MANIFEST")
endif()
file(READ "${DRAGONX_LITE_BACKEND_MANIFEST}" DRAGONX_LITE_BACKEND_MANIFEST_JSON)
string(JSON DRAGONX_LITE_SIGNATURE_STATUS ERROR_VARIABLE DRAGONX_LITE_SIGNATURE_STATUS_ERROR GET "${DRAGONX_LITE_BACKEND_MANIFEST_JSON}" signature_verification verification_status)
if(DRAGONX_LITE_SIGNATURE_STATUS_ERROR)
message(FATAL_ERROR "DRAGONX_LITE_BACKEND_MANIFEST is missing signature verification status")
endif()
if(NOT DRAGONX_LITE_SIGNATURE_STATUS STREQUAL "verified")
message(FATAL_ERROR "DRAGONX_LITE_BACKEND_REQUIRE_SIGNATURE requires verified signature metadata")
endif()
string(JSON DRAGONX_LITE_SIGNATURE_VERIFIED_SHA ERROR_VARIABLE DRAGONX_LITE_SIGNATURE_VERIFIED_SHA_ERROR GET "${DRAGONX_LITE_BACKEND_MANIFEST_JSON}" signature_verification verified_artifact_sha256)
string(JSON DRAGONX_LITE_ARTIFACT_SHA ERROR_VARIABLE DRAGONX_LITE_ARTIFACT_SHA_ERROR GET "${DRAGONX_LITE_BACKEND_MANIFEST_JSON}" artifact sha256)
if(DRAGONX_LITE_SIGNATURE_VERIFIED_SHA_ERROR OR DRAGONX_LITE_ARTIFACT_SHA_ERROR)
message(FATAL_ERROR "DRAGONX_LITE_BACKEND_MANIFEST is missing artifact/signature SHA-256 metadata")
endif()
if(NOT DRAGONX_LITE_SIGNATURE_VERIFIED_SHA STREQUAL DRAGONX_LITE_ARTIFACT_SHA)
message(FATAL_ERROR "DRAGONX_LITE_BACKEND_MANIFEST signature metadata does not verify the artifact SHA-256")
endif()
string(JSON DRAGONX_LITE_SIGNATURE_PERFORMED ERROR_VARIABLE DRAGONX_LITE_SIGNATURE_PERFORMED_ERROR GET "${DRAGONX_LITE_BACKEND_MANIFEST_JSON}" signature_verification verification_performed)
if(DRAGONX_LITE_SIGNATURE_PERFORMED_ERROR OR NOT DRAGONX_LITE_SIGNATURE_PERFORMED)
message(FATAL_ERROR "DRAGONX_LITE_BACKEND_REQUIRE_SIGNATURE requires verification_performed=true")
endif()
endif()
# Note (F15-1): the former signature-metadata gate was removed. It trusted a
# "verification_status: verified" field that scripts/build-lite-backend-artifact.sh
# self-attested with no cryptographic check (the "verified" SHA was just the artifact's
# own SHA). The trust root is now build-from-source: that script builds the backend from
# the vendored in-tree source and refuses prebuilt artifacts, so the library linked here
# is the one built from reviewed source. The required-symbol inventory check above stays.
add_library(dragonx_lite_backend UNKNOWN IMPORTED)
set_target_properties(dragonx_lite_backend PROPERTIES
IMPORTED_LOCATION "${DRAGONX_LITE_BACKEND_LIBRARY}"
)
if(APPLE)
# The Rust backend's TLS stack (security-framework / core-foundation crates)
# references Secure Transport (SSL*) + CoreFoundation symbols. Link the frameworks
# that provide them, or the static lib leaves ~130 symbols undefined at link time.
set_property(TARGET dragonx_lite_backend APPEND PROPERTY
INTERFACE_LINK_LIBRARIES "-framework Security" "-framework CoreFoundation")
endif()
if(DRAGONX_LITE_BACKEND_INCLUDE_DIR)
if(NOT IS_DIRECTORY "${DRAGONX_LITE_BACKEND_INCLUDE_DIR}")
message(FATAL_ERROR "DRAGONX_LITE_BACKEND_INCLUDE_DIR does not exist: ${DRAGONX_LITE_BACKEND_INCLUDE_DIR}")
@@ -226,7 +213,7 @@ include(FetchContent)
FetchContent_Declare(
json
GIT_REPOSITORY https://github.com/nlohmann/json.git
GIT_TAG v3.11.3
GIT_TAG 9cca280a4d0ccf0c08f47a99aa71d1b0e52f8d03 # v3.11.3 — pinned to immutable commit (L-08); tags are mutable
GIT_SHALLOW TRUE
)
FetchContent_MakeAvailable(json)
@@ -235,7 +222,7 @@ FetchContent_MakeAvailable(json)
FetchContent_Declare(
tomlplusplus
GIT_REPOSITORY https://github.com/marzer/tomlplusplus.git
GIT_TAG v3.4.0
GIT_TAG 30172438cee64926dc41fdd9c11fb3ba5b2ba9de # v3.4.0 — pinned to immutable commit (L-08); tags are mutable
GIT_SHALLOW TRUE
)
FetchContent_MakeAvailable(tomlplusplus)
@@ -302,8 +289,17 @@ message(STATUS "Fetching libwebp (decode-only, static)...")
FetchContent_Declare(
libwebp
GIT_REPOSITORY https://github.com/webmproject/libwebp.git
GIT_TAG v1.4.0
GIT_TAG 845d5476a866141ba35ac133f856fa62f0b7445f # v1.4.0 — pinned to immutable commit (L-08); tags are mutable
GIT_SHALLOW TRUE
# libwebp's cpu.cmake applies -mno-sse2/-mno-sse4.1 to its scalar reference DSP
# files when it can't probe SSE support. Under a macOS universal build
# (-arch arm64;x86_64) that probe fails, so the flags land on the x86_64 slice,
# where -mno-sse2 disables _Float16 and breaks the SDK's <math.h>. Neutralize
# those disable flags (SSE2 is x86_64 baseline). Portable + idempotent; a no-op
# for single-arch Linux/Windows/x86_64 builds. See cmake/patch-libwebp-simd.cmake.
PATCH_COMMAND ${CMAKE_COMMAND}
-DCPU_CMAKE=<SOURCE_DIR>/cmake/cpu.cmake
-P ${CMAKE_CURRENT_SOURCE_DIR}/cmake/patch-libwebp-simd.cmake
)
set(WEBP_LINK_STATIC ON CACHE BOOL "" FORCE)
set(WEBP_BUILD_ANIM_UTILS OFF CACHE BOOL "" FORCE)
@@ -525,6 +521,8 @@ set(APP_SOURCES
src/ui/windows/settings_window.cpp
src/ui/pages/settings_page.cpp
src/ui/windows/about_dialog.cpp
src/ui/windows/faq_dialog.cpp
src/ui/windows/faq_content.cpp
src/ui/windows/key_export_dialog.cpp
src/ui/windows/transaction_details_dialog.cpp
src/ui/windows/qr_popup_dialog.cpp
@@ -548,6 +546,7 @@ set(APP_SOURCES
src/util/async_task_manager.cpp
src/util/amount_format.cpp
src/util/address_validation.cpp
src/util/seed_phrase.cpp
src/util/base64.cpp
src/util/single_instance.cpp
src/util/i18n.cpp
@@ -660,6 +659,8 @@ set(APP_HEADERS
src/ui/windows/console_tab_helpers.h
src/ui/windows/settings_window.h
src/ui/windows/about_dialog.h
src/ui/windows/faq_dialog.h
src/ui/windows/faq_content.h
src/ui/windows/key_export_dialog.h
src/ui/windows/transaction_details_dialog.h
src/ui/windows/qr_popup_dialog.h
@@ -1078,6 +1079,32 @@ install(DIRECTORY ${CMAKE_RUNTIME_OUTPUT_DIRECTORY}/res
OPTIONAL
)
# -----------------------------------------------------------------------------
# dragonx-wallet-rebuild — offline recovery helper for a BDB-inconsistent wallet.dat.
# Bundled next to the daemon; the app spawns it out-of-process. It is the ONLY thing that links
# Berkeley DB, so the AGPLv3 BDB never contaminates the GPLv3 GUI (same boundary as the daemon).
# Release builds should point DRAGONX_BDB_ROOT at the vendored static libdb (external/dragonx/depends);
# a dev build falls back to the system Berkeley DB. Skipped (with a note) if no BDB is found.
# -----------------------------------------------------------------------------
find_path(BDB_INCLUDE_DIR db.h HINTS ${DRAGONX_BDB_ROOT}/include /usr/include /usr/local/include)
find_library(BDB_LIBRARY NAMES db-6.2 db-6.0 db-5.3 db libdb
HINTS ${DRAGONX_BDB_ROOT}/lib /usr/lib /usr/local/lib /usr/lib/x86_64-linux-gnu)
if(BDB_INCLUDE_DIR AND BDB_LIBRARY)
add_executable(dragonx-wallet-rebuild tools/wallet_rebuild/main.cpp)
target_include_directories(dragonx-wallet-rebuild PRIVATE ${CMAKE_SOURCE_DIR}/src ${BDB_INCLUDE_DIR})
target_link_libraries(dragonx-wallet-rebuild PRIVATE ${BDB_LIBRARY})
if(WIN32)
target_link_libraries(dragonx-wallet-rebuild PRIVATE ws2_32) # static libdb-6.2 pulls in winsock
else()
find_package(Threads REQUIRED)
target_link_libraries(dragonx-wallet-rebuild PRIVATE Threads::Threads ${CMAKE_DL_LIBS}) # static libdb needs pthread/dl
endif()
set_target_properties(dragonx-wallet-rebuild PROPERTIES RUNTIME_OUTPUT_DIRECTORY ${CMAKE_BINARY_DIR}/bin)
message(STATUS "wallet-rebuild helper: ON (Berkeley DB ${BDB_LIBRARY})")
else()
message(STATUS "wallet-rebuild helper: OFF (no Berkeley DB found; set DRAGONX_BDB_ROOT for release builds)")
endif()
# -----------------------------------------------------------------------------
# Tests
# -----------------------------------------------------------------------------
@@ -1126,6 +1153,7 @@ if(BUILD_TESTING)
src/util/payment_uri.cpp
src/util/amount_format.cpp
src/util/address_validation.cpp
src/util/seed_phrase.cpp
src/util/i18n.cpp
src/util/text_format.cpp
src/data/wallet_state.cpp
@@ -1133,6 +1161,7 @@ if(BUILD_TESTING)
src/data/address_book.cpp
src/data/wallet_index.cpp
src/daemon/lifecycle_adapters.cpp
src/daemon/embedded_daemon.cpp
src/rpc/connection.cpp
src/config/settings.cpp
src/resources/embedded_resources.cpp
@@ -1204,5 +1233,5 @@ message(STATUS " Lite backend: ${DRAGONX_LITE_BACKEND_READY}")
message(STATUS " Lite lib: ${DRAGONX_LITE_BACKEND_LIBRARY}")
message(STATUS " Lite symbols: ${DRAGONX_LITE_BACKEND_SYMBOLS_FILE}")
message(STATUS " Lite manifest: ${DRAGONX_LITE_BACKEND_MANIFEST}")
message(STATUS " Lite signature: ${DRAGONX_LITE_BACKEND_REQUIRE_SIGNATURE}")
message(STATUS " Lite trust: built-from-source (vendored third_party/silentdragonxlite)")
message(STATUS "")

View File

@@ -81,8 +81,8 @@ Download linux and windows binaries of latest releases and place in binary direc
- prebuilt-binaries/dragonxd-win/
- prebuilt-binaries/dragonxd-mac/
**xmrig HAC fork** (https://git.dragonx.is/dragonx/xmrig-hac):
- prebuilt-binaries/xmrig-hac/
**DRG-XMRig fork** (https://git.dragonx.is/DragonX/drg-xmrig):
- prebuilt-binaries/drg-xmrig/
## Build Steps

217
build.sh
View File

@@ -131,7 +131,7 @@ fi
# truth): the full-node app uses project() VERSION + DRAGONX_VERSION_SUFFIX; ObsidianDragonLite uses
# DRAGONX_LITE_VERSION + DRAGONX_LITE_VERSION_SUFFIX.
_cml="$SCRIPT_DIR/CMakeLists.txt"
_full_ver=$(sed -n 's/^[[:space:]]*VERSION[[:space:]]\+\([0-9][0-9.]*\).*/\1/p' "$_cml" | head -1)
_full_ver=$(sed -n 's/^[[:space:]]*VERSION[[:space:]][[:space:]]*\([0-9][0-9.]*\).*/\1/p' "$_cml" | head -1)
_full_suffix=$(sed -n 's/^set(DRAGONX_VERSION_SUFFIX[[:space:]]*"\([^"]*\)").*/\1/p' "$_cml" | head -1)
_lite_ver=$(sed -n 's/^set(DRAGONX_LITE_VERSION[[:space:]]*"\([^"]*\)").*/\1/p' "$_cml" | head -1)
_lite_suffix=$(sed -n 's/^set(DRAGONX_LITE_VERSION_SUFFIX[[:space:]]*"\([^"]*\)").*/\1/p' "$_cml" | head -1)
@@ -195,6 +195,39 @@ should_bundle_full_node_assets() {
! $DO_LITE
}
# The offline wallet-rebuild helper is the ONLY thing that repairs a genuinely BDB-inconsistent
# wallet.dat — plain "Restore" just re-triggers the daemon's salvage cascade. A full-node release must
# NEVER ship without it (the in-app "Repair automatically" option silently disappears otherwise), so
# treat a missing helper as a HARD build failure instead of degrading recovery to Restore-only.
# $1 = built helper path (e.g. bin/dragonx-wallet-rebuild[.exe]); $2 = the BDB depends dir for the hint.
require_wallet_rebuild_helper() {
local helper="$1" depends="$2"
should_bundle_full_node_assets || return 0 # lite builds have no BDB wallet.dat to rebuild
if [[ ! -f "$helper" ]]; then
err "wallet-rebuild helper was NOT built: $helper"
err " → the recovery 'Repair automatically' option would be MISSING from this release."
err " Cause: the vendored Berkeley DB depends are absent, so CMake skipped the dragonx-wallet-rebuild target."
err " Fix: provide ${depends}/{lib/libdb-6.2.a,include/db.h} (same static libdb the daemon links), then rebuild."
exit 1
fi
info " wallet-rebuild helper present: $helper"
}
# Vendored Berkeley DB depends directory for the macOS wallet-rebuild helper, by TARGET arch. The daemon
# links a static libdb-6.2; the helper must match the build arch, so derive the triple from it rather than
# hardcoding one. Single-arch: x86_64 -> x86_64-apple-darwin, arm64 -> aarch64-apple-darwin. "universal"
# (or anything unexpected) falls back to the host arch — a universal .app needs a universal libdb, so if
# only a single-arch libdb is vendored, build single-arch via DRAGONX_MAC_ARCHS.
mac_bdb_dir() {
local arch="$1" triple
case "$arch" in
x86_64) triple="x86_64-apple-darwin" ;;
arm64) triple="aarch64-apple-darwin" ;;
*) [[ "$(uname -m)" == "arm64" ]] && triple="aarch64-apple-darwin" || triple="x86_64-apple-darwin" ;;
esac
printf '%s/external/dragonx/depends/%s\n' "$SCRIPT_DIR" "$triple"
}
# ── Helper: find resource files ──────────────────────────────────────────────
find_sapling_params() {
local dirs=(
@@ -286,6 +319,9 @@ bundle_linux_daemon() {
# asmap.dat
find_asmap && cp "$ASMAP_DAT" "$dest/asmap.dat" && info " Bundled asmap.dat"
# (The dragonx-wallet-rebuild recovery helper is built into bin/ by CMake and packaged explicitly
# by each release path — required via require_wallet_rebuild_helper — so it is not copied here.)
return $found
}
@@ -336,11 +372,24 @@ build_release_linux() {
mkdir -p "$bd" && cd "$bd"
# ── Compile ──────────────────────────────────────────────────────────────
# Point the wallet-rebuild helper at the vendored static Berkeley DB (same libdb the daemon links)
# so its output is a v6.2 btree the bundled dragonxd reads. Pass the paths EXPLICITLY (bypasses
# find_library + its cache); the helper target is simply not built if the depends tree is absent.
local lin_bdb="$SCRIPT_DIR/external/dragonx/depends/x86_64-unknown-linux-gnu"
local BDB_ARGS=()
if [[ -f "$lin_bdb/lib/libdb-6.2.a" && -f "$lin_bdb/include/db.h" ]]; then
BDB_ARGS=( -DBDB_INCLUDE_DIR="$lin_bdb/include" -DBDB_LIBRARY="$lin_bdb/lib/libdb-6.2.a" )
elif should_bundle_full_node_assets; then
err "Vendored Berkeley DB depends missing at $lin_bdb — the wallet-rebuild recovery helper cannot be built."
err " A full-node release must ship it; aborting rather than degrading recovery to Restore-only."
exit 1
fi
info "Configuring ..."
cmake "$SCRIPT_DIR" \
-DCMAKE_BUILD_TYPE=Release \
-DCMAKE_CXX_FLAGS_RELEASE="-O3 -DNDEBUG" \
-DDRAGONX_USE_SYSTEM_SDL3=ON \
"${BDB_ARGS[@]}" \
"${CMAKE_LITE_ARGS[@]}"
info "Building with $JOBS jobs ..."
@@ -348,8 +397,12 @@ build_release_linux() {
[[ -f "bin/${APP_BASENAME}" ]] || { err "Linux build failed"; exit 1; }
# A full-node release MUST include the recovery helper — fail loudly, never ship without it.
require_wallet_rebuild_helper "bin/dragonx-wallet-rebuild" "$lin_bdb"
info "Stripping ..."
strip "bin/${APP_BASENAME}"
[[ -f "bin/dragonx-wallet-rebuild" ]] && strip "bin/dragonx-wallet-rebuild"
info "Binary: $(du -h "bin/${APP_BASENAME}" | cut -f1)"
if should_bundle_full_node_assets; then
@@ -384,9 +437,12 @@ build_release_linux() {
[[ -f bin/asmap.dat ]] && cp bin/asmap.dat "$dist_dir/"
[[ -f bin/sapling-spend.params ]] && cp bin/sapling-spend.params "$dist_dir/"
[[ -f bin/sapling-output.params ]] && cp bin/sapling-output.params "$dist_dir/"
# Offline wallet-rebuild recovery helper — required (asserted above); ships next to the app.
cp bin/dragonx-wallet-rebuild "$dist_dir/" && chmod +x "$dist_dir/dragonx-wallet-rebuild"
info " Bundled dragonx-wallet-rebuild"
fi
# Bundle xmrig for mining support
local XMRIG_LINUX="$SCRIPT_DIR/prebuilt-binaries/xmrig-hac/xmrig"
local XMRIG_LINUX="$SCRIPT_DIR/prebuilt-binaries/drg-xmrig/xmrig"
[[ -f "$XMRIG_LINUX" ]] && { cp "$XMRIG_LINUX" "$dist_dir/"; chmod +x "$dist_dir/xmrig"; info "Bundled xmrig"; } || warn "xmrig not found — mining unavailable in zip"
cp -r bin/res "$dist_dir/" 2>/dev/null || true
@@ -417,9 +473,11 @@ build_release_linux() {
[[ -f bin/asmap.dat ]] && cp bin/asmap.dat "$APPDIR/usr/bin/"
[[ -f bin/sapling-spend.params ]] && cp bin/sapling-spend.params "$APPDIR/usr/bin/"
[[ -f bin/sapling-output.params ]] && cp bin/sapling-output.params "$APPDIR/usr/bin/"
# Offline wallet-rebuild recovery helper — required (asserted above); ships next to the app.
cp bin/dragonx-wallet-rebuild "$APPDIR/usr/bin/" && chmod +x "$APPDIR/usr/bin/dragonx-wallet-rebuild"
fi
# Bundle xmrig for mining support
local XMRIG_LINUX_AI="$SCRIPT_DIR/prebuilt-binaries/xmrig-hac/xmrig"
local XMRIG_LINUX_AI="$SCRIPT_DIR/prebuilt-binaries/drg-xmrig/xmrig"
[[ -f "$XMRIG_LINUX_AI" ]] && { cp "$XMRIG_LINUX_AI" "$APPDIR/usr/bin/"; chmod +x "$APPDIR/usr/bin/xmrig"; }
# Desktop entry
@@ -478,18 +536,28 @@ APPRUN
done
[[ -f "$bd/_deps/sdl3-build/libSDL3.so" ]] && cp "$bd/_deps/sdl3-build/libSDL3.so"* "$APPDIR/usr/lib/" 2>/dev/null || true
# appimagetool
# appimagetool — pinned to a tagged release and SHA-256 verified before we exec it.
# The old "continuous" tag is a MOVING build fetched over the network and run on the release
# builder; a compromised/MITM'd artifact would execute here. Verify, or refuse to package.
local APPIMAGETOOL_URL="https://github.com/AppImage/appimagetool/releases/download/1.9.0/appimagetool-x86_64.AppImage"
local APPIMAGETOOL_SHA256="46fdd785094c7f6e545b61afcfb0f3d98d8eab243f644b4b17698c01d06083d1"
local APPIMAGETOOL=""
if command -v appimagetool &>/dev/null; then
APPIMAGETOOL="appimagetool"
elif [[ -f "$bd/appimagetool-x86_64.AppImage" ]]; then
APPIMAGETOOL="$bd/appimagetool-x86_64.AppImage"
APPIMAGETOOL="appimagetool" # maintainer's own trusted system install
else
info "Downloading appimagetool ..."
wget -q -O "$bd/appimagetool-x86_64.AppImage" \
"https://github.com/AppImage/AppImageKit/releases/download/continuous/appimagetool-x86_64.AppImage"
chmod +x "$bd/appimagetool-x86_64.AppImage"
APPIMAGETOOL="$bd/appimagetool-x86_64.AppImage"
local at="$bd/appimagetool-x86_64.AppImage"
# Re-verify any cached copy too; a stale unverified download must not be trusted.
if [[ ! -f "$at" ]] || ! echo "${APPIMAGETOOL_SHA256} ${at}" | sha256sum -c --status; then
info "Downloading appimagetool 1.9.0 (pinned) ..."
wget -q -O "$at" "$APPIMAGETOOL_URL"
if ! echo "${APPIMAGETOOL_SHA256} ${at}" | sha256sum -c --status; then
err "appimagetool SHA-256 verification failed — refusing to use it"
rm -f "$at"
return 1
fi
chmod +x "$at"
fi
APPIMAGETOOL="$at"
fi
local ARCH
@@ -628,8 +696,32 @@ HDR
info "Lite mode: skipping embedded daemon binaries"
fi
# ── xmrig binary (from prebuilt-binaries/xmrig-hac/) ────────────────
local XMRIG_DIR="$SCRIPT_DIR/prebuilt-binaries/xmrig-hac"
# ── Wallet-rebuild recovery helper ───────────────────────────────
# Built in-tree (not a prebuilt like the daemon), so compile it standalone HERE — before the
# main app compiles embedded_resources.cpp — and INCBIN it, so a bare, self-extracting
# ObsidianDragon.exe carries the recovery tool exactly like it does the daemon.
if should_bundle_full_node_assets; then
local WBDB="$SCRIPT_DIR/external/dragonx/depends/x86_64-w64-mingw32"
if [[ -f "$WBDB/lib/libdb-6.2.a" && -f "$WBDB/include/db.h" ]]; then
info "Compiling + embedding wallet-rebuild helper ..."
x86_64-w64-mingw32-g++ -std=c++17 -O2 -static -static-libgcc -static-libstdc++ \
-I"$SCRIPT_DIR/src" -I"$WBDB/include" \
"$SCRIPT_DIR/tools/wallet_rebuild/main.cpp" \
"$WBDB/lib/libdb-6.2.a" -lws2_32 \
-o "$RES/dragonx-wallet-rebuild.exe" \
|| { err "wallet-rebuild helper failed to compile for embedding"; exit 1; }
x86_64-w64-mingw32-strip "$RES/dragonx-wallet-rebuild.exe" 2>/dev/null || true
echo -e "\n#define HAS_EMBEDDED_WALLET_REBUILD 1" >> "$GEN/embedded_data.h"
echo "INCBIN(dragonx_wallet_rebuild_exe, \"$RES/dragonx-wallet-rebuild.exe\");" >> "$GEN/embedded_data.h"
info " Embedded dragonx-wallet-rebuild.exe ($(du -h "$RES/dragonx-wallet-rebuild.exe" | cut -f1))"
else
err "Vendored mingw Berkeley DB missing at $WBDB — cannot embed the wallet-rebuild recovery helper."
exit 1
fi
fi
# ── xmrig binary (from prebuilt-binaries/drg-xmrig/) ────────────────
local XMRIG_DIR="$SCRIPT_DIR/prebuilt-binaries/drg-xmrig"
# The published DRG-XMRig archives ship the binary inside a versioned subdir, not as a flat
# xmrig.exe. Extract it from the matching win-x64 zip if it isn't already staged — otherwise
# the embed below never fires (HAS_EMBEDDED_XMRIG stays undefined) and the wallet ships with
@@ -740,11 +832,24 @@ HDR
fi
# ── CMake + build ────────────────────────────────────────────────────────
# The wallet-rebuild helper links the vendored mingw static Berkeley DB (the mingw toolchain's
# find_library is sysroot-only, so pass the depends paths EXPLICITLY to bypass the search). Only
# enabled if the depends tree is present; guarded with -DBDB_* left empty otherwise.
local win_bdb="$SCRIPT_DIR/external/dragonx/depends/x86_64-w64-mingw32"
local BDB_ARGS=()
if [[ -f "$win_bdb/lib/libdb-6.2.a" && -f "$win_bdb/include/db.h" ]]; then
BDB_ARGS=( -DBDB_INCLUDE_DIR="$win_bdb/include" -DBDB_LIBRARY="$win_bdb/lib/libdb-6.2.a" )
elif should_bundle_full_node_assets; then
err "Vendored Berkeley DB depends missing at $win_bdb — the wallet-rebuild recovery helper cannot be built."
err " A full-node release must ship it; aborting rather than degrading recovery to Restore-only."
exit 1
fi
info "Configuring (cross-compile) ..."
cmake "$SCRIPT_DIR" \
-DCMAKE_TOOLCHAIN_FILE="$bd/mingw-toolchain.cmake" \
-DCMAKE_BUILD_TYPE=Release \
-DDRAGONX_USE_SYSTEM_SDL3=OFF \
"${BDB_ARGS[@]}" \
"${FT_CMAKE_ARG[@]}" \
"${CMAKE_LITE_ARGS[@]}"
@@ -752,8 +857,16 @@ HDR
cmake --build . -j "$JOBS"
[[ -f "bin/${APP_BASENAME}.exe" ]] || { err "Windows build failed"; exit 1; }
# Strip the app exe — the Linux and macOS release paths already strip theirs, and even the Windows
# helper (dragonx-wallet-rebuild.exe) is stripped, but the main app exe was shipping unstripped
# (~5MB of symbols on the full node, ~19MB on the params-heavy lite build). Best-effort.
x86_64-w64-mingw32-strip --strip-all "bin/${APP_BASENAME}.exe" 2>/dev/null \
|| warn " strip unavailable — shipping unstripped ${APP_BASENAME}.exe"
info "Binary: $(du -h "bin/${APP_BASENAME}.exe" | cut -f1)"
# A full-node release MUST include the recovery helper — fail loudly, never ship without it.
require_wallet_rebuild_helper "bin/dragonx-wallet-rebuild.exe" "$win_bdb"
# ── Package: release/windows/ ────────────────────────────────────────────
# Remove only THIS variant's prior artifacts so full-node and lite releases coexist here.
mkdir -p "$out"
@@ -769,6 +882,9 @@ HDR
for f in dragonxd.exe dragonx-cli.exe dragonx-tx.exe; do
[[ -f "$DD/$f" ]] && cp "$DD/$f" "$dist_dir/"
done
# dragonx-wallet-rebuild helper (offline recovery for a BDB-inconsistent wallet.dat) — required.
cp "bin/dragonx-wallet-rebuild.exe" "$dist_dir/" && info " Bundled dragonx-wallet-rebuild.exe"
[[ -f "$dist_dir/dragonx-wallet-rebuild.exe" ]] || { err "Failed to bundle dragonx-wallet-rebuild.exe"; exit 1; }
# Bundle Sapling params + asmap for the zip distribution
# (The single-file exe has these embedded via INCBIN, but the zip
@@ -781,7 +897,7 @@ HDR
fi
# Bundle xmrig for mining support
local XMRIG_WIN="$SCRIPT_DIR/prebuilt-binaries/xmrig-hac/xmrig.exe"
local XMRIG_WIN="$SCRIPT_DIR/prebuilt-binaries/drg-xmrig/xmrig.exe"
[[ -f "$XMRIG_WIN" ]] && { cp "$XMRIG_WIN" "$dist_dir/"; info "Bundled xmrig.exe"; } || warn "xmrig.exe not found — mining unavailable in zip"
cp -r bin/res "$dist_dir/" 2>/dev/null || true
@@ -891,8 +1007,26 @@ build_release_mac() {
fi
info "macOS cross-compiler: $OSXCROSS_CXX (arch: $MAC_ARCH)"
else
# Native macOS: build universal binary (arm64 + x86_64)
MAC_ARCH="universal"
# Native macOS: build universal (arm64 + x86_64) by default. Override with
# DRAGONX_MAC_ARCHS (e.g. "x86_64").
MAC_ARCHS="${DRAGONX_MAC_ARCHS:-arm64;x86_64}"
# When linking the real lite backend, the app can only include architectures
# the backend static library actually provides. Its pinned ring 0.16.11 has no
# Apple-Silicon assembly, so that artifact is x86_64-only — constrain the app
# arch to the backend's (unless the user explicitly forced DRAGONX_MAC_ARCHS),
# otherwise the arm64 slice fails to link.
if $DO_LITE_BACKEND && [[ -z "${DRAGONX_MAC_ARCHS:-}" && -n "${lb_lib:-}" ]] && command -v lipo &>/dev/null; then
local _backend_archs; _backend_archs=$(lipo -archs "$lb_lib" 2>/dev/null | tr ' ' ';')
if [[ -n "$_backend_archs" && "$_backend_archs" != "$MAC_ARCHS" ]]; then
warn "Lite backend provides only [$_backend_archs] — building the app for that instead of universal."
MAC_ARCHS="$_backend_archs"
fi
fi
if [[ "$MAC_ARCHS" == *";"* || "$MAC_ARCHS" == *","* ]]; then
MAC_ARCH="universal"
else
MAC_ARCH="$MAC_ARCHS"
fi
export MACOSX_DEPLOYMENT_TARGET="11.0"
fi
@@ -964,6 +1098,12 @@ TOOLCHAIN
fi
info "Configuring (cross-compile via osxcross) ..."
# Vendored static libdb for the recovery helper (full-node only), by target arch; see mac_bdb_dir.
local mac_bdb; mac_bdb="$(mac_bdb_dir "$MAC_ARCH")"
local BDB_ARGS=()
if should_bundle_full_node_assets && [[ -f "$mac_bdb/lib/libdb-6.2.a" && -f "$mac_bdb/include/db.h" ]]; then
BDB_ARGS=( -DBDB_INCLUDE_DIR="$mac_bdb/include" -DBDB_LIBRARY="$mac_bdb/lib/libdb-6.2.a" )
fi
cmake "$SCRIPT_DIR" \
-DCMAKE_TOOLCHAIN_FILE="$bd/osxcross-toolchain.cmake" \
-DCMAKE_BUILD_TYPE=Release \
@@ -971,6 +1111,7 @@ TOOLCHAIN
-DDRAGONX_USE_SYSTEM_SDL3=OFF \
-DCMAKE_OSX_DEPLOYMENT_TARGET=11.0 \
${COMPILER_RT:+-DOSXCROSS_COMPILER_RT="$COMPILER_RT"} \
"${BDB_ARGS[@]}" \
"${CMAKE_LITE_ARGS[@]}"
else
# Build libsodium as universal if needed
@@ -980,7 +1121,7 @@ TOOLCHAIN
need_sodium=true
elif [[ -f "$SCRIPT_DIR/libs/libsodium/lib/libsodium.a" ]]; then
# Rebuild if existing lib is not universal (single-arch won't link)
if ! lipo -info "$SCRIPT_DIR/libs/libsodium/lib/libsodium.a" 2>/dev/null | grep -q "arm64.*x86_64\|x86_64.*arm64"; then
if ! lipo -info "$SCRIPT_DIR/libs/libsodium/lib/libsodium.a" 2>/dev/null | grep -Eq "arm64.*x86_64|x86_64.*arm64"; then
info "Existing libsodium is not universal — rebuilding ..."
rm -rf "$SCRIPT_DIR/libs/libsodium"
need_sodium=true
@@ -991,13 +1132,20 @@ TOOLCHAIN
"$SCRIPT_DIR/scripts/fetch-libsodium.sh"
fi
info "Configuring (native universal arm64+x86_64) ..."
info "Configuring (native macOS, arch: $MAC_ARCHS) ..."
# Point CMake at the vendored static libdb for the recovery helper (full-node only); see mac_bdb_dir.
local mac_bdb; mac_bdb="$(mac_bdb_dir "$MAC_ARCH")"
local BDB_ARGS=()
if should_bundle_full_node_assets && [[ -f "$mac_bdb/lib/libdb-6.2.a" && -f "$mac_bdb/include/db.h" ]]; then
BDB_ARGS=( -DBDB_INCLUDE_DIR="$mac_bdb/include" -DBDB_LIBRARY="$mac_bdb/lib/libdb-6.2.a" )
fi
cmake "$SCRIPT_DIR" \
-DCMAKE_BUILD_TYPE=Release \
-DCMAKE_CXX_FLAGS_RELEASE="-O3 -DNDEBUG" \
-DDRAGONX_USE_SYSTEM_SDL3=OFF \
-DCMAKE_OSX_DEPLOYMENT_TARGET=11.0 \
-DCMAKE_OSX_ARCHITECTURES="arm64;x86_64" \
-DCMAKE_OSX_ARCHITECTURES="$MAC_ARCHS" \
"${BDB_ARGS[@]}" \
"${CMAKE_LITE_ARGS[@]}"
fi
@@ -1006,6 +1154,11 @@ TOOLCHAIN
[[ -f "bin/${APP_BASENAME}" ]] || { err "macOS build failed"; exit 1; }
# A full-node release MUST include the recovery helper. macOS needs a static libdb-6.2 (Homebrew
# berkeley-db for a native build, or a vendored external/dragonx/depends/<triple>) — otherwise CMake
# skips the target and this fails loudly rather than shipping a mac release with no recovery option.
require_wallet_rebuild_helper "bin/dragonx-wallet-rebuild" "$(mac_bdb_dir "$MAC_ARCH")"
# Strip — use osxcross strip for cross-builds
if $IS_CROSS; then
local STRIP_CMD="${OSXCROSS}/target/bin/${OSXCROSS_TRIPLE}-strip"
@@ -1027,8 +1180,12 @@ TOOLCHAIN
info "Binary: $(du -h "bin/${APP_BASENAME}" | cut -f1)"
# ── Create .app bundle ───────────────────────────────────────────────────
rm -rf "$out"
mkdir -p "$out"
# Clean only THIS variant's prior artifacts so full-node and lite releases can
# coexist in release/mac/ (Linux/Windows scope their cleanup the same way). The
# "ObsidianDragon-" glob never matches "ObsidianDragonLite-" (and vice versa),
# and the ".app" names are exact.
rm -rf "$out/${APP_BASENAME}.app" "$out/${APP_BASENAME}-"*.app.zip "$out/${APP_BASENAME}-"*.dmg
local APP="$out/${APP_BASENAME}.app"
local CONTENTS="$APP/Contents"
@@ -1074,12 +1231,14 @@ TOOLCHAIN
else
warn "prebuilt-binaries/dragonxd-mac/ not found — place macOS daemon binaries there for bundling"
fi
# Offline wallet-rebuild recovery helper — required (asserted after build); next to the daemon.
cp "bin/dragonx-wallet-rebuild" "$MACOS/" && chmod +x "$MACOS/dragonx-wallet-rebuild" && info " Bundled dragonx-wallet-rebuild"
else
info "Lite mode: skipping macOS daemon and Sapling/asmap bundling"
fi
# xmrig binary (from prebuilt-binaries/xmrig-hac/)
local XMRIG_MAC="$SCRIPT_DIR/prebuilt-binaries/xmrig-hac/xmrig"
# xmrig binary (from prebuilt-binaries/drg-xmrig/)
local XMRIG_MAC="$SCRIPT_DIR/prebuilt-binaries/drg-xmrig/xmrig"
if [[ -f "$XMRIG_MAC" ]]; then
cp "$XMRIG_MAC" "$MACOS/xmrig"
chmod +x "$MACOS/xmrig"
@@ -1228,8 +1387,10 @@ PLIST
fi
# ── Create DMG ───────────────────────────────────────────────────────────
local DMG_BASENAME="DragonX_Wallet"
$DO_LITE && DMG_BASENAME="DragonX_Wallet_Lite"
# DMG filename matches the app bundle name (ObsidianDragon / ObsidianDragonLite).
# The mounted volume + CFBundleName keep the "DragonX Wallet" display branding
# (APP_DISPLAY_NAME above).
local DMG_BASENAME="${APP_BASENAME}"
local DMG_NAME="${DMG_BASENAME}-${VERSION}-macOS-${MAC_ARCH}.dmg"
if command -v create-dmg &>/dev/null; then
@@ -1311,3 +1472,9 @@ if $DO_LINUX || $DO_WIN || $DO_MAC; then
[[ -d "$SCRIPT_DIR/release/windows" ]] && echo -e " ${CYAN}windows/${NC} — .exe + .zip"
[[ -d "$SCRIPT_DIR/release/mac" ]] && echo -e " ${CYAN}mac/${NC} — .app + .dmg"
fi
# Reaching here means the build completed (real failures exit 1 at their point of failure).
# Exit 0 explicitly: the final `[[ -d release/mac ]] && echo` above returns non-zero on a
# non-mac build — and since set -e exempts the left side of an &&, that status would otherwise
# become the script's exit code and make a successful build report failure (e.g. to CI).
exit 0

View File

@@ -0,0 +1,31 @@
# patch-libwebp-simd.cmake — portable, idempotent FetchContent patch for libwebp.
#
# libwebp's cmake/cpu.cmake compiles its scalar *reference* DSP files with the
# SSE-disable flags "-mno-sse4.1;-mno-sse2" whenever it can't positively detect
# SSE support. Under a macOS *universal* build (-arch arm64;x86_64) the per-arch
# SSE flag probe fails (a flag valid for x86_64 is invalid for arm64), so those
# disable flags get applied to the x86_64 slice. clang gates the _Float16 type on
# SSE2 for x86_64, and the macOS 15+/26 SDK's <math.h> declares _Float16 math
# functions unconditionally — so any TU including <math.h> fails to compile with
# "_Float16 is not supported on this target".
#
# SSE2 is part of the x86_64 baseline ABI, so disabling it on the reference files
# is unnecessary on every platform we target. Blanking the SSE entries (indices
# must stay aligned with WEBP_SIMD_FLAGS) fixes the universal build and is a no-op
# for single-arch Linux/Windows/x86_64 builds. Idempotent: re-running is a no-op.
if(NOT DEFINED CPU_CMAKE OR NOT EXISTS "${CPU_CMAKE}")
message(FATAL_ERROR "patch-libwebp-simd: cpu.cmake not found at '${CPU_CMAKE}'")
endif()
file(READ "${CPU_CMAKE}" _contents)
string(REPLACE
"set(SIMD_DISABLE_FLAGS \"-mno-sse4.1;-mno-sse2;;-mno-dspr2;;-mno-msa\")"
"set(SIMD_DISABLE_FLAGS \";;;-mno-dspr2;;-mno-msa\")"
_patched "${_contents}")
if(_patched STREQUAL _contents)
message(STATUS "patch-libwebp-simd: no change (already patched or pattern absent)")
else()
file(WRITE "${CPU_CMAKE}" "${_patched}")
message(STATUS "patch-libwebp-simd: neutralized x86 SSE-disable flags in cpu.cmake")
endif()

View File

@@ -0,0 +1,549 @@
# Daemon Startup Hardening — Implementation Plan
Eight verified edge-case defects in how ObsidianDragon brings up (and watches) the
`dragonxd` daemon at launch. Each entry is a buildable fix: the defect (with exact
line references), the chosen approach, the call sites, a representative change, and how
to verify it.
- **Scope:** full-node startup path (`--lite` excludes the embedded daemon entirely).
- **Source:** line references are exact against branch `dev` @ `45b652f`.
- **Provenance:** findings verified by direct source read; each fix designed by an
independent agent grounded in the cited files, with a sequencing pass for ordering,
shared helpers, and merge conflicts.
**Severity:** 2 High, 6 Medium · **Effort:** ≈ 2535 engineering-hours · **7 landing steps.**
Status legend: ☐ not started · ◐ in progress · ☑ landed & verified
**Status: all 8 landed & verified** (build-clean, `ctest` green after each) across four commits on
`dev` — lifecycle cluster (F1/F2/F4), filesystem+params cluster (F7/F6/F5), F3, and F8. Six new
pure-helper unit tests added.
**Wrap-up done:** release notes added (`CHANGELOG.md`, F8 breaking change front and center); i18n
back-fill applied additively to `res/lang/*.json` (42 keys — all 6 for es/de/fr/pt/ru; 6 zh/ja/ko
entries whose glyphs aren't in the current `NotoSansCJK-Subset.ttf` were left on English fallback
rather than render as tofu).
**Still owed before release:** a **CJK subset-font rebuild** (`scripts/build_cjk_subset.py`, needs
the Noto CJK source font) to cover the 6 deferred zh/ja/ko strings. *(F1 and F2 now have headless
integration-test coverage — see the progress log — so their GUI repros are optional, not blocking.)*
---
## Recommended rollout sequence
A real dependency order, not a checklist. The daemon-lifecycle cluster lands first
because it makes the `State::Error` / `crash_count_` contract trustworthy — which the
connect-stall panel and the lock gate both build on. The filesystem cluster lands
around a single shared helper. The connectivity-breaking security flip lands last.
| Step | Finding(s) | Site | Why here | Status |
|------|-----------|------|----------|--------|
| 1 | **F1** | `embedded_daemon.cpp` · `isRunning()` | Smallest/highest-severity; establishes the reliable Error/crash-count transition steps 3 & 6 depend on. | ☑ |
| 2 | **F2** | `embedded_daemon.cpp` · `startProcess()` | Same file family, different function; test the F1+F2 pair together with `kill -SEGV` / bad-binary repros. | ☑ |
| 3 | **F4** | `embedded_daemon.cpp` · `start()` | After F1/F2 so crash-count semantics are settled; its bail deliberately stays out of the crash path. | ☑ |
| 4 | **F7** | `util/platform` · `connection.cpp` | Structural owner of the fs-error idiom + `ConnectionConfig` that F5/F6/F8 reuse. | ☑ |
| 5 | **F6 + F5** | `app.cpp` · `verifySaplingParams()` | Same `startEmbeddedDaemon` / `verifySaplingParams` block; land together. | ☑ |
| 6 | **F3** | `app.cpp` · `renderLoadingOverlay()` | After F1 — panel is guarded off during `State::Error` (owned by the crash-count hint). | ☑ |
| 7 | **F8** | `connection.cpp` · `tryConnect()` | Largest; only connectivity-breaking default flip — land last, with release notes. | ☑ |
---
## F1 — Double-`waitpid` race can swallow a daemon crash
**Severity:** High · **Effort:** S (~12h) · **Status:** ☑ landed & verified
### The defect
`EmbeddedDaemon::isRunning()` (`embedded_daemon.cpp:1136`, POSIX branch) calls
`waitpid(WNOHANG)` — from the **UI thread, nearly every frame** — racing
`monitorProcess()`'s own reap at `:1244`. `waitpid` is one-shot: if the UI thread wins,
the monitor never decodes the exit, so `crash_count_` never increments, `State::Error`
never fires, and the 3-strike auto-restart cap (`app_network.cpp:479`) is defeated. The
sibling `XmrigManager::isRunning()` (`xmrig_manager.cpp:512`) already fixed exactly this
with an atomic read.
### The fix
Make `isRunning()` read the existing `std::atomic<State> state_` (member at
`embedded_daemon.h:253`) instead of calling `waitpid`, leaving `monitorProcess()` as the
sole reaper. Predicate is `Running || Stopping``Stopping` must stay "alive" because
`stop()`'s graceful/SIGTERM wait loops poll `isRunning()` before the process has exited.
### Files touched
- `src/daemon/embedded_daemon.cpp``isRunning()`, POSIX branch (~1136)
### Core change
```cpp
bool EmbeddedDaemon::isRunning() const // POSIX branch
{
// Read the atomic state_ instead of waitpid() — monitorProcess() is the
// sole reaper. Previously both threads reaped; if the UI thread won, the
// monitor never saw the exit (crash_count_ / exit code / Error all lost).
if (process_pid_ <= 0) return false;
State s = state_.load(std::memory_order_relaxed);
// Stopping stays "alive": stop()'s wait loops poll isRunning() while
// state_ == Stopping, before the process has actually terminated.
return (s == State::Running || s == State::Stopping);
}
```
### Verification
- Manual: `kill -SEGV` the daemon 1020×; the monitor must report the exit and increment `crash_count_` every time (previously intermittent).
- Regression: a normal Settings-driven stop still escalates SIGTERM→SIGKILL (the `Stopping` predicate).
- Not unit-testable (real fork/exec/waitpid) — consistent with the no-process-spawn harness.
### Dependencies
Mirrors `XmrigManager::isRunning()`. Flags a separate latent hazard (out of scope):
`stop()`'s final blocking `waitpid` (`:1220`) can still race a mid-sleep monitor
iteration — file as its own ticket.
---
## F2 — exec-after-fork silent failure: "Running" for a daemon that never started
**Severity:** High · **Effort:** S (~23h) · **Status:** ☑ landed & verified
### The defect
In `startProcess()` (`embedded_daemon.cpp:9571061`, POSIX) the parent runs
`process_pid_ = pid; return true;` **unconditionally** after `fork()` — with no
exec-status handshake. On a non-executable / wrong-arch / corrupt binary the child's
`execv` fails and it `_exit(127)`s, but `start()` has already set `State::Running`
(`:565`). The real cause never reaches `last_error_`; it surfaces later, generically,
as "exited unexpectedly (exit code 127)".
### The fix
Add a **close-on-exec self-pipe** handshake — `pipe() + fcntl(FD_CLOEXEC)`, deliberately
**not** `pipe2()` (macOS lacks it; the POSIX branch is shared). The child writes `errno`
only on `execv` failure; a successful exec closes the write end for free. Parent reads:
EOF ⇒ success; 4 bytes ⇒ reap the zombie, set a precise `last_error_` ("not executable
or wrong architecture"), and return `false` so `start()` never reports Running. EINTR-safe
on both ends. Also comments the unchecked parent-side `setpgid` at `:1053`.
### Files touched
- `src/daemon/embedded_daemon.cpp``startProcess()` parent read path
- `src/daemon/embedded_daemon.cpp` — child `execv`-failure write (~1043)
- `src/daemon/embedded_daemon.cpp``setpgid` best-effort comment (~1053)
### Core change
```cpp
// Self-pipe exec handshake (pipe()+FD_CLOEXEC; NOT pipe2 — macOS lacks it).
int execpipe[2]; pipe(execpipe);
fcntl(execpipe[0], F_SETFD, FD_CLOEXEC);
fcntl(execpipe[1], F_SETFD, FD_CLOEXEC);
pid_t pid = fork();
if (pid == 0) { // child
close(execpipe[0]);
/* setpgid / chdir / dup2 / argv … */
execv(binary_path.c_str(), argv.data());
int e = errno; // execv failed
while (write(execpipe[1], &e, sizeof e) < 0 && errno == EINTR) {}
_exit(127);
}
close(execpipe[1]); // parent: must close or read() never EOFs
int child_errno = 0, total = 0;
for (;;) { // EOF ⇒ exec ok; 4 bytes ⇒ exec failed
ssize_t n = read(execpipe[0], (char*)&child_errno + total, sizeof(int) - total);
if (n == 0) break;
if (n < 0) { if (errno == EINTR) continue; break; }
if ((total += n) >= (int)sizeof(int)) break;
}
close(execpipe[0]);
if (total >= (int)sizeof(int)) { // exec never happened
waitpid(pid, nullptr, 0); // reap the zombie
last_error_ = "dragonxd could not be executed: " +
std::string(strerror(child_errno)) +
" — not executable or wrong architecture";
return false; // start() no longer reports Running
}
```
### Verification
- Point at a `chmod -x` / wrong-arch file → `start()` returns false immediately, precise message, no leftover zombie.
- Success path: real binary still starts with no perceptible added latency.
- Optional pure `formatExecFailureError(errno)` helper for a `test_phase4.cpp` unit test.
### Dependencies
F1 (same function family; sequence F1→F2). **Highest-risk mistake:** forgetting
`FD_CLOEXEC` makes every successful start hang the parent read forever.
---
## F4 — Stale datadir-lock start → restart storm that wedges the UI
**Severity:** Medium · **Effort:** S (~35h) · **Status:** ☑ landed & verified
### The defect
`start()` (`embedded_daemon.cpp:466`) gates only on the RPC port (`:482`), never on
`isDaemonProcessRunning()` (`:1292`). A graceful shutdown frees the port but keeps the
datadir `.lock` for up to ~90s. A rapid stop→start spawns a daemon that dies "Cannot
obtain a lock on data directory" — routed to the generic crash path. With a ~4s retry
cadence, **three lock races in ~12s exhaust the 3-strike budget** and wedge the UI long
before the lock actually clears.
### The fix
Fail-fast with a **short bounded local wait (~300ms), not a 90s block**. After the port
bail, consult `isDaemonProcessRunning()` — gated by `!skip_port_check_` and exempt when
`override_datadir_` is set, so the isolated migrate-to-seed daemon still works. A pure
`evaluateDatadirLockGate()` returns a **distinct non-crash Error** that never increments
`crash_count_`. The connect loop's own retry then absorbs the transient.
### Files touched
- `src/daemon/embedded_daemon.h` — decision struct, helper decl, poll constants
- `src/daemon/embedded_daemon.cpp``start()` gate + `evaluateDatadirLockGate()`
### Core change
```cpp
static StartLockGateDecision evaluateDatadirLockGate(
bool skipPortCheck, bool isolatedOverride, bool stillRunningAfterWait) {
if (skipPortCheck || isolatedOverride) return {true, ""}; // migrate-to-seed exempt
if (!stillRunningAfterWait) return {true, ""};
return {false, "A previous dragonxd is still shutting down and holding the "
"data directory lock. Retrying shortly…"};
}
// start() — after the isPortInUse() bail, before setState(Starting):
if (!skip_port_check_ && override_datadir_.empty()) {
bool stillLocked = false; // ~300ms bounded wait, NOT ~90s
for (int i = 0; i < kDatadirLockWaitMaxPolls; ++i) {
if (!isDaemonProcessRunning()) { stillLocked = false; break; }
stillLocked = true;
std::this_thread::sleep_for(std::chrono::milliseconds(kDatadirLockWaitPollMs));
}
auto gate = evaluateDatadirLockGate(false, false, stillLocked);
if (!gate.proceed) { setState(State::Error, gate.errorMessage); return false; }
}
```
### Verification
- Unit: `evaluateDatadirLockGate()` across the skip / isolated / still-running matrix.
- Manual: rapid restart into a lingering lock → distinct message, no crash-cap wedge.
- Migrate-to-seed second daemon still starts (isolated exemption).
### Dependencies
F1/F2 (must not touch `crash_count_`; wording must not collide with the monitor's
"exited unexpectedly"). Same TU, different function.
---
## F5 — Extraction / copy write-failures never surfaced up front
**Severity:** Medium · **Effort:** S (~23h) · **Status:** ☑ landed & verified
### The defect
`startEmbeddedDaemon()` discards `extractEmbeddedResources()`'s `bool` return
(`app.cpp:4152`) and the second copy-fallback loop drops `copy_file`'s `error_code`
entirely (`:4236`). Only Sapling params **existence** is re-checked — never the daemon
binary/CLI/tx/asmap. A disk-full or truncated `dragonxd` write falls straight through to
spawn and fails opaquely. The innermost write already returns `false`
(`embedded_resources.cpp:307`) — the signal is simply thrown away.
### The fix
Minimal, surgical wiring — no new abstraction. Capture the extraction return and, on
failure, set `daemon_status_ = TR("sb_daemon_extract_failed")` and `return false` before
spawning. In the second copy loop, check `ec` after each `copy_file`, track `copyFailed`,
and abort with a dir-parameterized `sb_daemon_files_failed`. An **absent source** stays
fine (optional files); only an actual `error_code` counts. Written so F6/F7 slot in later
without re-touching this control flow.
### Files touched
- `src/app.cpp``startEmbeddedDaemon()` extraction check (~4152)
- `src/app.cpp` — second copy-fallback loop (~42104242)
- `src/util/i18n.cpp` + `res/lang/*.json` — 2 additive keys
### Core change
```cpp
// stop discarding the extraction result (~4152)
if (!resources::extractEmbeddedResources()) {
daemon_status_ = TR("sb_daemon_extract_failed"); // disk full / permission denied
return false; // abort before spawning
}
// second copy-fallback loop — was dropping ec entirely (~4236)
bool copyFailed = false;
for (const char* name : { "asmap.dat", "dragonxd", "dragonx-cli", "dragonx-tx" }) {
fs::path dst = fs::path(daemon_dir) / name;
if (fs::exists(dst)) continue; // already present — skip
for (const auto& dir : searchDirs) {
fs::path src = fs::path(dir) / name;
if (!fs::exists(src)) continue; // absent source is OK, not a failure
fs::copy_file(src, dst, ec);
if (ec) { copyFailed = true; ec.clear(); }
break;
}
}
if (copyFailed) {
char buf[512];
snprintf(buf, sizeof buf, TR("sb_daemon_files_failed"), daemon_dir.c_str());
daemon_status_ = buf;
return false; // don't fall through to spawn
}
```
### Verification
- Unit: `extractEmbeddedResources()` returns false without embedded resources.
- Extract the copy loop into a testable helper; force one dst write to fail (dst is an existing directory).
- Manual: near-full tmpfs / read-only dir → clear status, daemon controller never constructed.
### Dependencies
Shares the `daemon_status_` surfacing convention with F6; its early-return pattern is the
template F7 matches. Open item: remove truncated dst files so a retry re-copies.
---
## F6 — Sapling params validated by existence/size only, never hashed
**Severity:** Medium · **Effort:** S (~35h) · **Status:** ☑ landed & verified
> **As-built note.** `verifySaplingParams()` now delegates to a public, injectable
> `verifySaplingParamsIn(dir, digests)` so the integrity + marker-cache logic is unit-testable
> with synthetic small files (the real 48 MB params aren't in the repo). i18n keys for F5 were
> added to `i18n.cpp` (English source of truth); the `res/lang/*.json` back-fill via
> `scripts/add_missing_translations.py` is deferred to a single run at the end of the batch,
> per the cross-cutting note. Non-English locales fall back to English until then.
### The defect
`verifySaplingParams()` (`connection.cpp:123`) only calls `fs::exists()`;
`resourceNeedsUpdate()` (`embedded_resources.cpp:250`) is size-only. On Linux (no
embedded resources) a **truncated-but-present** param passes and is handed to the daemon,
which then fails to build shielded proofs mid-operation — far from the real cause.
### The fix
Add a pinned `{ filename → size, sha256 }` table (one source of truth, cross-referenced
to `scripts/build-lite-backend-artifact.sh`) and hash-check each param after the
existence check, reusing the existing `util::sha256Hex` (no second implementation). Since
these are ~48 MB, **cache the result** via a `.sapling_verified` marker keyed on
`size:mtime` — re-hash only when the stat line changes, so startup isn't slowed.
### Files touched
- `src/rpc/connection.h``verifySaplingParams` decl
- `src/rpc/connection.cpp` — digest table, marker helpers, rewrite
### Core change
```cpp
// connection.cpp — pinned known-good digests
// (source of truth: scripts/build-lite-backend-artifact.sh ensure_sapling_params)
constexpr SaplingParamDigest kSaplingParamDigests[] = {
{ "sapling-spend.params", 47958396, "8e48ffd2…efc13" },
{ "sapling-output.params", 3592860, "2f0ebbcb…fb0e4" },
};
bool Connection::verifySaplingParams() {
// existence check (unchanged) …
// cache: skip re-hashing a ~48 MB file unless size:mtime changed
if (readMarkerMatches(marker, statLines)) return true;
for (auto& d : kSaplingParamDigests)
if (util::sha256Hex(bytes) != d.sha256) return false; // reuse existing helper
writeMarker(marker, statLines);
return true;
}
```
### Verification
- Unit: good params pass; truncated / wrong-bytes rejected; marker cache short-circuits re-hash unless size/mtime changed. Real temp-file fixtures (matches existing `sha256Hex` tests).
### Dependencies
F7 (reuse fs-error idiom; shares the `startEmbeddedDaemon`/`verifySaplingParams` block).
Third caller of the existing `util::sha256Hex`.
---
## F7 — Directory-create errors universally ignored on the daemon-env path
**Severity:** Medium · **Effort:** S (~34h) · **Status:** ☑ landed & verified
> **As-built notes.** Two deviations from the original design, both confirmed against the code:
> (1) `embedded_resources.cpp:270` already checks its `error_code` and returns `false` on failure — it was **not** a bug, so it is left untouched.
> (2) Of the four `autoDetectConfig` callers, only the primary connect path (`app_network.cpp:243`) was wired to check `dir_error`; the other three degrade gracefully on their own — `app.cpp:4306` and `app_wizard.cpp:912` are stop paths that already gate on empty creds, and `settings_page.cpp:434` is read-only display. `dir_error` is set by `autoDetectConfig`, so they can be wired later if desired.
### The defect
Five startup directory-create sites either drop the `error_code` or use the throwing
overload with no `catch`: `main.cpp:730`, `connection.cpp:216` (can throw **uncaught**
through its callers), `embedded_resources.cpp:270`, `app.cpp:4172`/`4218`. A read-only
home or permission-denied yields a confusing "conf missing" / "binary not found"
downstream — or an uncaught `filesystem_error` — instead of a clear cause.
### The fix
One shared, non-throwing `Platform::ensureDirectory(dir, outError)` in
`util/platform.{h,cpp}` that produces a single consistent message. Replace all five
sites; `autoDetectConfig()` moves off the throwing overload and sets a new
`ConnectionConfig::dir_error` that its four callers check and bail on. This is the
**structural owner** of the fs-error idiom that F5 and F6 reuse.
### Files touched
- `src/util/platform.h` / `.cpp``ensureDirectory()`
- `src/rpc/connection.h` / `.cpp``dir_error` + `autoDetectConfig`
- `main.cpp`, `app.cpp`, `app_network.cpp`, `app_wizard.cpp`, `settings_page.cpp`, `embedded_resources.cpp` — 5 sites + 4 callers
- `tests/test_phase4.cpp``TestPlatformEnsureDirectory`
### Core change
```cpp
// util/platform.cpp — one shared, non-throwing helper
bool Platform::ensureDirectory(const std::string& dir, std::string* outError) {
std::error_code ec;
if (std::filesystem::is_directory(dir, ec)) return true;
ec.clear();
std::filesystem::create_directories(dir, ec);
if (ec) {
if (outError)
*outError = "Cannot create " + dir + ": " + ec.message() +
". Check permissions / free space.";
return false;
}
return true;
}
// Replaces 5 ad-hoc sites; autoDetectConfig() now sets ConnectionConfig::dir_error,
// and its 4 callers bail on it.
```
### Verification
- Unit `TestPlatformEnsureDirectory`: existing dir → true; fresh nested → created; POSIX unwritable → false + message.
- All four `autoDetectConfig` callers tolerate `dir_error`. Pre-App-init site (main.cpp) reports via stderr / MessageBox.
### Dependencies
**Owns** `Platform::ensureDirectory` (used by F5, F6) and the `ConnectionConfig`
extension (coordinated with F8). Land before F5/F6/F8.
---
## F8 — Plaintext-remote RPC credential transmission is warn-only
**Severity:** Medium · **Effort:** M (~69h) · **Status:** ☑ landed & verified
> **⚠️ RELEASE NOTES REQUIRED — breaking default flip.** A wallet configured to talk to a
> **remote** `rpchost` over **plain HTTP** (no `rpctls=1`) will now be **refused** at connect
> time instead of warned. Affected users must add **`rpcallowplaintext=1`** to `DRAGONX.conf`
> (or switch to `rpctls=1`) to reconnect. Local/embedded daemons (`127.0.0.0/8`, `localhost`,
> `::1`) are unaffected. Call this out prominently in the release notes.
>
> **As-built note.** Shipped the security-complete core: `isLocalHost` tightened to exact
> loopback (`isExactIPv4Loopback` — `127.evil.com` no longer passes), refuse-by-default in
> `tryConnect`, and the `rpcallowplaintext` conf-key opt-in. The **Settings toggle UI was
> deferred** — the RPC section of `settings_page.cpp` is read-only display and a security
> toggle there is riskier surface; the conf-key opt-in fully covers recovery, and the refusal
> status/notification tells the user exactly what to add. The toggle can be added later
> (persist a `Settings` flag and OR it into `allowsPlaintextRemote`).
### The defect
A remote `rpchost` without `rpctls=1` sends Basic-auth `rpcuser:rpcpassword` over
cleartext HTTP. `tryConnect()` (`app_network.cpp:314`) only shows a **dismissible
warning** then proceeds — a local-network MITM sees the credentials. Compounding it,
`isLocalHost()`'s naive `rfind("127.",0)==0` misclassifies `127.evil.com` as local,
suppressing even the warning.
### The fix
Change the policy to **refuse-by-default with an explicit, persisted opt-in** — a
`rpcallowplaintext=1` conf key (for hand-editors) and a Settings toggle. Block the
connect and show a **blocking modal** explaining the risk and how to enable TLS or opt
in; localhost is unaffected. Tighten `isLocalHost()` to exact `127.x.y.z` / `::1` /
`localhost` via `isExactIPv4Loopback()`. **Back-compat:** default off ⇒ existing remote
users hit a hard stop until they opt in — **ship with prominent release notes.**
### Files touched
- `src/rpc/connection.h` / `.cpp``isLocalHost`, `allow_plaintext_remote`, `parseConfFile`
- `src/config/settings.h` / `.cpp` — persisted opt-in
- `src/app_network.cpp`, `src/app.h` — refuse + modal dispatch
- `src/ui/windows/plaintext_remote_rpc_dialog.h` — new blocking modal
- `src/ui/pages/settings_page.cpp` — toggle UI
### Core change
```cpp
// Tightened loopback test — "127.evil.com" is NOT local
bool Connection::isLocalHost(const std::string& host) {
std::string h = stripBrackets(lowercase(host));
return h == "localhost" || h == "::1" || isExactIPv4Loopback(h); // exact 127.x.y.z
}
// Refuse-by-default with an explicit, persisted opt-in
const bool plaintextRemote = rpc::Connection::usesPlaintextRemote(config);
const bool plaintextAllowed = config.allow_plaintext_remote // rpcallowplaintext=1
|| settings_.getAllowPlaintextRemoteRpc(); // Settings toggle
if (plaintextRemote && !plaintextAllowed) {
connection_status_ = TR("sb_plaintext_remote_blocked");
showPlaintextRemoteRpcDialog(config.host + ":" + config.port); // blocking modal
return; // no creds sent
}
```
### Verification
- Unit: `isLocalHost``127.evil.com` false, `127.0.0.1`/`::1`/`localhost` true; `allowsPlaintextRemote` honors conf key + settings flag.
- Manual: remote plaintext blocked; modal fires; opt-in persists across restart.
### Dependencies
F7 (second extender of `ConnectionConfig`/`parseConfFile`; land after so the struct grows
once). Wire `renderPlaintextRemoteRpcDialog` into the app modal-dispatch list.
---
## Shared helpers & coordination points
| Helper | Purpose | Used by |
|--------|---------|---------|
| `Platform::ensureDirectory()` | Single non-throwing directory-create with one consistent message; replaces five ad-hoc sites. Owned by F7. | F7, F5, F6 |
| `ConnectionConfig` extension | Coordination point, not a function: F7 adds `dir_error`, F8 adds `allow_plaintext_remote`. Land F7→F8 so it grows once per step. | F7, F8 |
| `util::sha256Hex` *(existing)* | Already-compiled, curl-free SHA-256. F6 becomes its third caller — no second hash routine. | F6 |
| `connectHasStalled()` *(new, pure)* | Stall predicate split out of the ImGui/App code for unit testing, per the `*_updater_core.cpp` precedent. | F3 |
| `evaluateDatadirLockGate()` *(new, pure)* | Lock-gate decision as `{proceed, message}` from three booleans — unit-testable without real process/fs I/O. | F4 |
## F3 — Unbounded connect spinner (deferred to step 6)
**Severity:** Medium · **Effort:** S (~35h) · **Status:** ☑ landed & verified
> **As-built note.** `renderLoadingOverlay()` is a pure draw-list overlay with **no interactive
> widgets** (the existing crash case at ~5289 already communicates via guidance *text*, relying on
> the sidebar staying reachable). So rather than inject `ActionButton`s — which would fight the
> non-interactive overlay — the stall notice follows that same idiom: a "Taking longer than
> expected" title + a reassuring body (with elapsed seconds) + a full-node-gated hint ("Open
> Settings → Restart Daemon, or check the Console"). This let me drop the planned
> `WalletState::connect_stalled` flag too: the stalled state is computed locally in the overlay
> from `connect_stall_since_`, so the only new member is `App::connect_stall_since_`.
The connect loop retries forever while `!state_.connected` (`app.cpp:1239`);
`loading_timer_` only animates the spinner. Stamp `connect_stall_since_` when
"reachable but not ready" is first seen; a pure `connectHasStalled()` helper (new
`util/connect_stall.h`, default 45s from `ui.toml`) flips `state_.connect_stalled` at
threshold, and `renderLoadingOverlay()` shows a "Taking longer than expected" panel with
Retry / Restart daemon / Open console (full-node gated). The background retry keeps
firing — recovery clears the panel automatically. Guarded off while the daemon is in
`State::Error` (owned by F1's crash-count hint). Full detail lives in the sequencing/
design record; see the shared-helper table above.
---
## Cross-cutting notes
- **One TU, three functions.** `embedded_daemon.cpp` is edited by F1 (`isRunning`),
F2 (`startProcess`) and F4 (`start`) — no literal hunk overlap, but land in order to
keep "monitorProcess is the sole reaper" coherent.
- **Connection struct grows twice.** `connection.h/.cpp` is touched by F6, F7 and F8;
F7 and F8 both extend `ConnectionConfig` and `parseConfFile` — highest collision risk.
Sequence F7→F6→F8.
- **Testability split.** The three new pure predicates all get `tests/test_phase4.cpp`
coverage. F1/F2's fork/exec/waitpid changes are **not** unit-testable — they rely on
manual `kill` / non-executable-binary repros, consistent with the no-process-spawn harness.
- **i18n is additive-only.** Add each finding's English keys to `strings_`, then run
`scripts/add_missing_translations.py` **once at the very end**
(`json.dump indent=4, sort_keys=True, ensure_ascii=False`) — never bulk-regenerate a
`res/lang/*.json`.
- **F8 is a breaking default flip.** Refuse-plaintext-by-default stops existing
remote-RPC users cold until they opt in. Lands last, gated behind a persisted opt-in,
with release notes calling out the new `rpcallowplaintext` key and the Settings toggle.
- **Latent hazard, out of scope.** F1 surfaces (but doesn't fix) a second
double-`waitpid` window between `stop()`'s final blocking reap (`:1220`) and a
mid-sleep monitor iteration — file it as its own ticket.
---
## Progress log
- **F1/F2 integration tests** — ☑ added `testExecFailureReported` (F2) and `testDaemonCrashDetected` (F1) to `test_phase4.cpp`, driving the **real** `EmbeddedDaemon` fork/exec/waitpid code headlessly (POSIX; required linking `embedded_daemon.cpp` into the test target — its deps were already there). The F1 test hammers `isRunning()` from the test thread while the child exits, so it's a genuine regression test for the reap race. **The F2 test caught a real bug:** `start()`'s failure branch overwrote `startProcess()`'s precise `last_error_` ("…not executable or wrong architecture") with a generic "Failed to start dragonxd process" (because `setState(Error, …)` stores its message into `last_error_`), so the precise reason never reached `getLastError()`/the UI — **fixed** to preserve the detail (now also surfaced via the state callback / crash panel). Build-clean; `ctest` 1/1.
- **F1** — ☑ landed: `isRunning()` (POSIX) now reads the atomic `state_` (predicate `Running || Stopping`) instead of calling `waitpid`, leaving `monitorProcess()` the sole reaper. Clean build (all targets link); `ctest` 1/1 passing. Not unit-testable — needs the manual `kill -SEGV` repro before release.
- **F2** — ☑ landed: `startProcess()` (POSIX) now creates a `FD_CLOEXEC` self-pipe before `fork()`; the child writes `errno` to it on `execv` failure, the parent reads EOF-vs-errno and, on failure, reaps the zombie + sets a precise `last_error_` ("not executable or wrong architecture") + returns `false` (so `start()` no longer reports `Running` for a daemon that never started). Parent-side `setpgid` is now best-effort with a `DEBUG_LOGF` on failure. Clean build; `ctest` 1/1 passing. Not unit-testable — needs the manual non-executable / wrong-arch-binary repro before release.
- **F8** — ☑ landed: `isLocalHost()` tightened to exact loopback via `isExactIPv4Loopback` (a `127.`-prefixed *hostname* like `127.evil.com` is no longer misclassified as local). `tryConnect()` now **refuses** a plaintext connection to a remote host instead of warn-and-proceeding — a local-network MITM can no longer capture `rpcuser:rpcpassword` — unless the user opts in with `rpcallowplaintext=1` in `DRAGONX.conf` (new `ConnectionConfig::allow_plaintext_remote` + `allowsPlaintextRemote()` policy). The refusal surfaces via status line + a one-time notification. New `testIsLocalHost` (12 assertions) + `testAllowsPlaintextRemote` (5). Clean build; `ctest` 1/1 passing. **Breaking — needs release notes; Settings-toggle UI deferred (see as-built note).**
- **F3** — ☑ landed: the connect loop now stamps `connect_stall_since_ = ImGui::GetTime()` the moment the daemon first goes "reachable but not ready" (warmup branch + `applyDaemonInitStatus`), and clears it in `onConnected` / `onDisconnected` / warmup-complete — all in `app_network.cpp`. The pure `util::connectHasStalled(stallSince, now, threshold)` helper (new `util/connect_stall.h`, default 45 s from `ui.toml`) drives a draw-list "Taking longer than expected" notice in `renderLoadingOverlay()` (title + elapsed-seconds body + full-node hint), guarded off while the daemon is in `State::Error`. Background retry continues, so the notice self-clears on connect. New `testConnectHasStalled` unit test (7 assertions). Clean build; `ctest` 1/1 passing. (Draw-list text, not buttons — see as-built note above.)
- **F6** — ☑ landed: `verifySaplingParams()` now hash-verifies each Sapling param against its pinned canonical SHA-256 (from `build-lite-backend-artifact.sh`), replacing the existence-only check, so a truncated/corrupt-but-present param is rejected instead of failing later on a shielded op. A `<params_dir>/.sapling_verified` marker keyed on `size:mtime` skips re-hashing ~48 MB on every startup. Logic extracted to the injectable `verifySaplingParamsIn(dir, digests)`; new `testVerifySaplingParams` unit test (valid / marker fast-path / wrong-hash / truncated / missing). Clean build; `ctest` 1/1 passing.
- **F5** — ☑ landed: `startEmbeddedDaemon()` now checks `extractEmbeddedResources()`'s return (abort with `sb_daemon_extract_failed` on failure) and the previously-dropped `copy_file` `error_code` in the daemon-binary fallback loop (abort with `sb_daemon_files_failed` incl. the dir), so a disk-full / truncated `dragonxd` write is surfaced up front instead of failing opaquely at spawn. An absent source file stays non-fatal. Two i18n keys added to `i18n.cpp`. Clean build; `ctest` 1/1 passing.
- **F7** — ☑ landed: new non-throwing `Platform::ensureDirectory(dir, outError)` in `util/platform.{h,cpp}` with one consistent message. Replaces the unchecked/throwing directory-create sites at `main.cpp:730` (pre-init: now logs + `MessageBoxA` on Windows + `return 1`), `connection.cpp:216` (autoDetectConfig now uses the ec overload — **no more uncaught `filesystem_error`** — and sets the new `ConnectionConfig::dir_error`), and both `app.cpp` daemon-dir sites (surface via `daemon_status_` + `return false`). Primary connect path (`app_network.cpp:243`) checks `dir_error` and bails to the status line instead of mislabelling it "waiting for config". `embedded_resources.cpp:270` left as-is (already correct). New `testPlatformEnsureDirectory` unit test (existing-dir / fresh-nested / empty / parent-is-file). Clean build; `ctest` 1/1 passing.
- **F4** — ☑ landed: `start()` now gates on a lingering datadir lock after the port bail. When `!skip_port_check_ && override_datadir_.empty()`, it polls `isDaemonProcessRunning()` with a bounded ~300 ms wait (3 × 100 ms, breaks early), then a pure header-inline `evaluateDatadirLockGate()` decides: if a sibling `dragonxd` is still alive it bails with a distinct **non-crash** `State::Error` ("…holding the data directory lock. Retrying shortly…") that never touches `crash_count_`, so the 3-strike cap can't trip; the connect loop's retry resumes once the lock clears. Isolated migrate-to-seed starts are exempt. New `testDatadirLockGate` unit test (5 assertions, proceed/bail/2× exempt) added to `test_phase4.cpp`. Clean build; `ctest` 1/1 passing.

View File

@@ -0,0 +1,61 @@
**DragonX (DRGX) Lite Desktop Wallet — v1.0.0**
ObsidianDragonLite is a lightweight companion to the ObsidianDragon full-node wallet. It skips the embedded full node entirely — **no multi-gigabyte blockchain download and near-instant startup** — by connecting to a DragonX lite-wallet (lightwalletd-style) server, while keeping the same native ImGui interface and shielded-first workflow.
This is the **first release** of the Lite variant.
---
## Why Lite?
- **No blockchain to download.** Sync in seconds instead of hours; a few MB of state instead of many GB.
- **Same wallet, lighter footprint.** The familiar ObsidianDragon UI, shielded and transparent addresses, and address book — without running a node or miner.
- **Portable.** A single self-contained binary that stores its data in its own `ObsidianDragonLite` folder, so it coexists cleanly alongside the full-node wallet.
---
## Features
- **Fast, node-free operation** — connect to a DragonX lite-wallet server and sync in seconds; minimal disk and RAM.
- **Wallet lifecycle** — create a new wallet, restore from a seed phrase, or open an existing one; wallets auto-open on startup, with a first-run welcome prompt and **guided seed backup** on creation.
- **Shielded + transparent** — send, shield, and receive DRGX; per-address balances computed from unspent notes and UTXOs.
- **Keys & seed** — export your seed phrase and keys, and import keys, from Settings.
- **Passphrase encryption** — encrypt, unlock, lock, and decrypt the wallet; you're prompted to unlock at send time and on startup when the wallet is locked.
- **Encrypted messaging (HushChat)** — built-in **Contacts** and **Chat** with a seed-derived, **SilentDragonXLite-compatible** identity and a seed-encrypted local message store, so you can message other DragonX users end-to-end.
- **Server management** — a built-in **server browser with automatic failover**; switch servers with live reconnect/recovery, and a **"Redownload blocks"** action to rescan from the server.
- **Status & diagnostics** — a **Network tab** showing connection and sync status, plus an **interactive Console** for running backend commands and copyable error output.
- **Multi-language UI** — full internationalization covering 8 languages: German, Spanish, French, Japanese, Korean, Portuguese, Russian, and Chinese.
- **Cross-platform** — native builds for Linux (AppImage + zip), Windows (portable exe + zip), and macOS (DMG + .app), x86-64.
- QR code generation for receiving addresses
- Extensive theme and appearance options
---
## How it differs from the full-node wallet
- **No embedded `dragonxd`, no mining, no local blockchain explorer.** Chain data comes from the lite-wallet server rather than being verified locally by a full node — a lighter footprint in exchange for trusting the server for chain state.
- Everything key-related stays **on your device**: seed, keys, and (when enabled) the encryption passphrase never leave the machine.
---
## Downloads
| File | SHA-256 |
|------|---------|
| ObsidianDragonLite-1.0.0.AppImage | `a1459d6081124a6b8df47aa898b60c0237875e8cbb132e4fd8ae637673055ed4` |
| ObsidianDragonLite-1.0.0-Linux-x64.zip | `573af502a6372334572e4dc88bd26e0cf36ed543b9df1e835fd8e4abd295108d` |
| ObsidianDragonLite-1.0.0.exe | `28659efb770fa573bd1b1d0fcab1c1a3f9c7e46574185c3916cc9f669684852c` |
| ObsidianDragonLite-1.0.0-Windows-x64.zip | `f6434a931e873e5936ebe249b044d6c7a413694767c667374c91ee438259b50c` |
| ObsidianDragonLite-1.0.0-macOS-x86_64.dmg | `10f976f1453e6b1978cb7a85026fd033c85a10c1e66f436475aa628379a298c7` |
| ObsidianDragonLite-1.0.0-macOS-x86_64.app.zip | `3e7259c363f7be2e9027c5282c94b742f4be280c998203752dc0a0551f6ab68b` |
## System Requirements
- **Linux:** x86-64, glibc 2.31+ (Ubuntu 20.04+, Fedora 33+, etc.)
- **Windows:** x86-64, Windows 10 or later
- **macOS:** x86-64 (Intel; runs on Apple Silicon via Rosetta 2), macOS 10.15 Catalina or later
- Network access to a DragonX lite-wallet server.
## License
Released under the **GPLv3**. See [LICENSE](https://git.dragonx.is/DragonX/ObsidianDragon/src/branch/master/LICENSE) for details.

View File

@@ -0,0 +1,86 @@
**DragonX (DRGX) Full-Node Desktop Wallet — v2.0.0**
This is ObsidianDragon, a native ImGui-based wallet for the DragonX network. It ships with an embedded full-node daemon (`dragonxd`) and an integrated CPU miner (DRG-XMRig), giving users a complete, self-contained experience on Linux, Windows, and macOS.
---
## What's New in v2.0.0
v2.0.0 is a major release. It adds **end-to-end encrypted HushChat messaging** and **BIP39 seed-phrase wallets** (with migrate-to-seed for legacy wallets), lets the wallet **update its own node and miner** with each install cryptographically verified, and ships a **security-hardening pass** across the updater, RPC, and secret-storage surfaces — plus full support for the **new multi-threaded `dragonxd`** and a large stability and UX overhaul.
### New Features
- **Encrypted messaging (HushChat)** — new **Contacts** and **Chat** tabs bring DRGX-native, end-to-end encrypted messaging. Your chat identity is derived from your wallet seed, messages are encrypted with libsodium (XChaCha20-Poly1305 / secretstream) and stored in a **seed-encrypted local database**, and the wire format is interoperable with SilentDragonX / SilentDragonXLite.
- **BIP39 seed-phrase wallets & migrate-to-seed** — new wallets are backed by a mnemonic **seed phrase** you can back up from Settings. Existing **legacy wallets can be migrated into a seed wallet** — the wallet mints a new mnemonic wallet, sweeps your funds to it, and adopts it only once the sweep is mined, keeping a timestamped `wallet.dat` backup throughout. (The bundled `dragonxd` supports the required mnemonic RPCs; older nodes degrade gracefully.)
- **In-app daemon updater** — Settings → **Node & Security → Daemon binary → "Check for updates…"** downloads the latest `dragonxd` from the project Gitea, verifies its **SHA-256 and a detached ed25519 signature** before installing, and replaces the binary **atomically while the node keeps running** (the new build takes effect on the next daemon start). A two-pane version picker lets you pin, downgrade, or install any published/pre-release build.
- **In-app miner updater** — an **"Update miner…"** action in the Mining tab fetches, verifies (SHA-256 + ed25519), and installs the latest DRG-XMRig, with the same version picker and a display of current vs. latest.
- **Daemon binary management** — the wallet no longer auto-overwrites a node you've dropped in; a dedicated Settings panel shows the managed binary and gathers all node actions (restart, rescan, repair) onto one toolbar.
- **Repair Wallet** — a one-click `-zapwallettxes=2` recovery in Settings, plus automatic wallet reconciliation after a bootstrap and runtime rescan support for pruned nodes.
- **Explorer search** — fuzzy, live (debounced) filtering of the block list by partial hash or height.
- **History sorting & fast load** — sort transactions by date or amount, a "Loading older history (N%)" indicator during the initial bulk load, and persisted history that surfaces pending sends immediately.
- **Smarter mining** — the thread benchmark now measures **sustained (thermally-throttled) hashrate** instead of an inflated initial burst, with GPU-aware idle mining and corrected idle thread scaling.
### Security & Integrity
- **Mandatory signature verification** — both the daemon and miner updaters **require** a valid ed25519 signature (checked against a key pinned in the wallet) in addition to the SHA-256; an install is refused if the signature is missing or invalid.
- **Hardened secret handling** — RPC credentials are wiped from memory after use, RPC responses are size-capped and scrubbed of secrets in logs, generated node config is written **owner-only (0600)**, and secrets copied to the clipboard **auto-clear**.
- **Safe on-disk writes** — settings, address book, and secret files are written **atomically with owner-only permissions**; the PIN vault fsyncs its secure-delete overwrite; SQLite cache growth is bounded.
- **Recipient validation** — sends validate recipient address checksums (Base58Check + Bech32) before building a transaction.
- **Path-traversal protection** — archive extraction (chain bootstrap and the updaters) rejects any entry that would escape the target directory (zip-slip), and the bootstrap fails closed on a missing checksum rather than trusting an unverified archive.
- **Robustness** — malformed RPC error JSON is guarded and sends are single-flight, preventing duplicate/ill-formed submissions.
### Improvements
- **New multi-threaded daemon support** — live Sapling note-witness rebuild progress, accurate sync-speed display, reliable rescan-completion detection, and RPC polling throttled during sync so block download isn't slowed.
- **Networking** — DragonX DNS seed nodes, `-maxconnections` passed to the daemon, and a peer count that stays current on every tab.
- **Node resilience** — non-blocking warmup so you can connect while the daemon is still initializing, a live daemon-console tail on the startup overlay, fast-failing connect probes, and mid-session disconnect detection that keeps the UI responsive (in-flight RPC calls abort cleanly on disconnect/shutdown).
- **Address list** — modernized with drag-to-transfer, labels, and view-only handling.
- **UI / DPI** — overlay dialogs scale correctly with the font/DPI setting, improved CJK font rendering, native language names in the language picker, and format-incompatible translations are rejected.
- **Settings** — an "Open data folder" button and confirmation modals for rescan and restart-daemon.
### Bug Fixes
Extensive fixes across history (shielded-tx ordering, stuck "refreshing history" banners, unconfirmed-badge stickiness), sends (correct fee passed to `z_sendmany`, fast-lane worker restart on reconnect, note-selection fee-gap workaround), rescan (accurate completion detection, no false "complete", no per-second error flood), RPC (mid-session disconnect handling, stale-refresh invalidation), and UI layout/i18n. See the commit history for the full list.
---
## Features
- **Full-node wallet** — send, receive, and verify DRGX transactions with a bundled `dragonxd` daemon; no external setup required.
- **Self-updating** — verify-and-install the latest node and miner from within the app (SHA-256 + ed25519 signature enforced).
- **Encrypted messaging** — built-in HushChat with a seed-derived identity, end-to-end encryption, and a seed-encrypted local message store, interoperable with SilentDragonX / SilentDragonXLite.
- **Seed-phrase wallets** — BIP39 mnemonic backup, and migrate-to-seed to upgrade a legacy wallet into a seed wallet.
- **Built-in CPU mining** — start/stop DRG-XMRig from the Mining tab with real-time hashrate and pool statistics, mine-when-idle support with configurable delay, and sustained-hashrate benchmarking.
- **Multi-language UI** — full internationalization covering 8 languages: German, Spanish, French, Japanese, Korean, Portuguese, Russian, and Chinese.
- **Cross-platform** — native builds for Linux (AppImage + zip), Windows (portable exe + zip), and macOS (**universal** DMG + .app); Linux/Windows x86-64, macOS Intel + Apple Silicon.
- Shielded (z-address) and transparent (t-address) send/receive
- QR code generation for receiving addresses
- Transaction history with memo support, date/amount sorting, and pending-send tracking
- Blockchain explorer with live block/transaction search
- Blockchain rescan and Sapling witness-rebuild with status-bar progress
- Repair Wallet and daemon-binary management in Settings
- Built-in console
- Extensive theme and appearance options
---
## Downloads
| File | SHA-256 |
|------|---------|
| ObsidianDragon-2.0.0.AppImage | `34c9ea57ec27bf415e59d2890d995ed9069bfce04d979d7d8d58aa18f4570aa1` |
| ObsidianDragon-2.0.0-Linux-x64.zip | `563004b4e45650dbfa9411d62fa1c59a3ca2199bd43a647b1614f2683bbdb5fa` |
| ObsidianDragon-2.0.0.exe | `56100ae59dc3c67445d015cac5412ca40465bf459ba182c5ad2477a3b95ff548` |
| ObsidianDragon-2.0.0-Windows-x64.zip | `ce9d067f36c5296288a473d3c2cceca7fcf807672a93a2084afc53c3b6e780d9` |
| ObsidianDragon-2.0.0-macOS-universal.dmg | `8a01b6c0c2b5bebd64a222ba4a5ad04a1b8851138f28458b5c7f767fbb65db66` |
| ObsidianDragon-2.0.0-macOS-universal.app.zip | `1bce1e5be15d9a2f3c6f42c95b23a0e89df142a73d65895252e4d8f50d19f541` |
## System Requirements
- **Linux:** x86-64, glibc 2.31+ (Ubuntu 20.04+, Fedora 33+, etc.)
- **Windows:** x86-64, Windows 10 or later
- **macOS:** Intel & Apple Silicon (universal binary), macOS 10.15 Catalina or later
## License
Released under the **GPLv3**. See [LICENSE](https://git.dragonx.is/DragonX/ObsidianDragon/src/branch/master/LICENSE) for details.

167
docs/wallet-hardening.md Normal file
View File

@@ -0,0 +1,167 @@
# Wallet Loading & Management — Hardening Plan
Prioritized, grouped remediation for the wallet loading/management audit (33 verified findings +
diagnosability QoL). Companion to the findings artifact. Line references are against `dev`.
- **Provenance:** 7 parallel subsystem finders, each finding adversarially verified against the
code; the 3 highest-impact confirmed findings re-checked by hand. 32 confirmed, 1 refuted
(W1-5), 1 raised (W5-3 Low→Med).
- **Severity:** 8 High · 12 Medium · 13 Low.
Status legend: ☐ not started · ◐ in progress · ☑ landed & verified
---
## Roadmap (ordered by risk; shared fixes grouped)
| Phase | Findings | Theme | Status |
|-------|----------|-------|--------|
| **P0-A** | W7-1, W2-1, W4-1, W4-3, W2-3, W4-5, W5-3 ✓ | Secret hardening (console redaction + delete-export + memzero + lite encrypt-at-create) | ☑ 7/7 |
| **P0-B** | W2-2/W4-2, W2-4 | Encryption integrity (never silently unencrypted) | ☑ |
| **P1-A** | W3-1, W3-2, W3-4, W3-3 ✓ | Migrate-to-seed correctness (fund-adjacent) | ☑ 4/4 (W3-3 pending a live-mainnet run) |
| **P1-B** | W1-1, W1-2, W1-3, W1-4 ✓ + startup guard | Missing/wrong wallet-file safety | ☑ |
| **P2** | W5-1, W5-2, W6-1, W6-3, W6-2 ✓ | Stale state & lite save-failure surfacing | ☑ 5/5 |
| **F** | W7-2, W7-3, W7-4 ✓ · QoL: copy-diag + open-log + node-error-banner + staleness-badge + alert-history ✓ | Diagnostics foundation + QoL bundle | ☑ |
---
## P0-A — Secret hardening
Shared fix: a `SecureString` RAII buffer (zeroes on destruction) retrofitted onto the un-scrubbed
key/passphrase paths, plus console redaction and deleting the plaintext export.
- **W7-1 (High)** `console_tab.cpp:1419` — RPC console echoes/stores/clipboards raw secrets. Fix: an
allowlist of secret-bearing first-tokens (`walletpassphrase`, `walletpassphrasechange`,
`encryptwallet`, `importprivkey`, `importwallet`, `z_importkey`, `z_importviewingkey`,
`signrawtransaction`, `magicrecoverkey`, lite equivalents); echo `> walletpassphrase ****` and
keep the raw text out of `command_history_`. Extract a pure `redactConsoleCommand(cmd)` helper for
unit testing. **← implementing first (self-contained + testable).**
- **W2-1 (High)** `wallet_security_workflow.cpp:66` — delete the `obsidiandecryptexport<ts>` plaintext
key dump after `z_importwallet` succeeds (overwrite-then-unlink).
- **W4-3 (High)** `app_network.cpp:4481``sodium_memzero` the concatenated all-keys string in
`exportAllKeys`; write the backup 0600. (Also unify with `ExportAllKeysDialog` — QoL.)
- **W4-1 (High)** `app_network.cpp:3801` — zero the key copies in `importPrivateKey`/`sweepPrivateKey`
(local + worker-lambda copies).
- **W2-3 (Med)** `app_security.cpp:1481` — zero the passphrase threaded through the decrypt lambda chain.
- **W4-5 (Med)** `app.cpp:3577` — the seed-backup `.txt` is a permanent predictable cleartext seed;
at minimum warn + offer to delete, ideally discourage file save in favor of the on-screen phrase.
- **W5-3 (Med)** `lite_wallet_lifecycle_service.cpp:322` — remove the dead `passphrase` field from the
lite create/open/restore requests (unused; a secret copied for nothing).
## P0-B — Encryption integrity
- **W2-2 / W4-2 (High)** `wallet_security_controller.h:89` — the wizard's deferred encryption is
in-memory only and silently lost if the daemon doesn't connect or the app quits/crashes first, so a
wallet the user believes is encrypted stays plaintext. Fix: persist a lightweight
`encryption_requested_but_incomplete` settings flag (NEVER the passphrase) when
`beginDeferredEncryption` is called; surface a persistent warning banner while it's set; clear it
only on confirmed `encryptwallet` success; on next connect, if set, re-prompt for the passphrase to
complete it.
- **W2-4 (Med)** `app_security.cpp:480``lockWallet` only sets `locked` on RPC success; log the
failure and notify (currently a silent no-op that can leave the wallet unlocked).
## P1-A — Migrate-to-seed correctness (fund-adjacent; verify carefully)
- **W3-1 (High)** `app_network.cpp:4327` — adopt hardcodes `datadir + "/wallet.dat"`; use
`settings_->getActiveWalletFile()` so migrating a non-default active wallet swaps the right file.
- **W3-2 (High)** `seed_wallet_creator.cpp:57``remove_all(<config>/seed-migrate)` unconditionally
at Phase-1 start; refuse to wipe if a temp `DRAGONX/wallet.dat` already exists (a prior un-adopted
swept wallet) and surface it, so swept funds in the temp wallet can't be destroyed by re-entry.
- **W3-4 (Med)** `app_network.cpp:1124` — block wallet switching while a migration is *pending*
(`getSeedMigrationPending()`), not only while the dialog is open.
- **W3-3 (Med)** `app_network.cpp:4231` — persist the sweep opid so an app-close mid-Sweeping can
resume/re-poll it instead of silently dropping the txid.
## P1-B — Missing/wrong wallet-file safety
- **W1-1 (High)** `app_network.cpp:1109``fs::exists()`-check the target wallet file in
`switchToWallet()` and before the first daemon launch at startup; if missing, block with an explicit
"Wallet file not found — moved or deleted?" dialog (browse / create-new) instead of letting the
daemon fabricate an empty wallet.
- **W1-3 (Med)** `app_network.cpp:1095` — defer the `syncedHere=true` stamp to the first successful
address/balance readback (idHash non-empty), not bare `onConnected()`.
- **W1-2 (Med)** `app_network.cpp:198` — split `DB_CORRUPT`-specific strings from the generic "Error
loading wallet" fallback; give `DB_TOO_NEW` its own message/action (not a salvage offer).
- **W1-4 (Low)** `wallets_dialog.h:393` — re-`fs::exists()` the in-datadir row before switching (match
the out-of-datadir path).
## P2 — State & lite persistence
- **W6-2 (Med)** `network_refresh_service.cpp:1183` — record a per-field last-success timestamp / a
"refresh failed" flag so the UI can show a staleness badge instead of last-good-as-current.
- **W5-1 / W5-2 (Med)** `lite_wallet_controller.cpp:78,603``liteLog()` the failed save and bubble a
one-shot UI warning (both call sites currently discard the bool).
- **W6-1 (Med)** `wallet_state.h:313` — reset `mining`/`pool_mining` in `clear()` (or comment why not).
- **W6-3 (Low)** `address_book.cpp:46` — per-entry try/catch: skip + count malformed entries instead
of discarding the whole list.
## F — Diagnostics foundation + QoL
Land W7-2 first — it unblocks the rest.
- **W7-2 (Med)** `logger.cpp:31` — call `Logger::instance().init(<config>/dragonx-debug.log)` early in
`main()` on all platforms; add an "Open log folder" action.
- **W7-3 (Med)** `main.cpp:144` — add a `sigaction`-based crash handler writing `dragonx-crash.log` on
POSIX (mirror the Windows SEH path).
- **W7-4 (Low)** `logger.cpp:39` — size-cap/rotate the log on `init()`.
- **QoL** — "Copy diagnostics for support" bundle; persistent alert history; daemon/RPC error banner;
refresh-staleness badge; multi-wallet diagnostic panel; refresh-diagnostics panel; structured
switch/migration audit logging; restore-from-seed entry point (W4-4, effort L).
---
## Progress log
- **Adversarial review of the 3 diagnostics UI features** — ran a 5-dimension finder → per-finding verify workflow over the node-banner + staleness-badge + alert-history commits (the hand-laid ImGui I couldn't visually verify). 4 confirmed, 1 refuted (banner title never overlaps its button — button is absolutely positioned + title is short), and the dedicated ImGui-stack-balance finder found **no** Push/Pop imbalance. Fixes landed:
- **(Med) Alert popup grew off the right edge** — pivot `(0,1)` pinned the panel's *left* edge at the bell (which sits near the window's right edge), so a 320px panel overflowed rightward (an explicit `SetNextWindowPos` pivot skips ImGui's on-screen clamp). Fixed to anchor the bottom-*right* corner at the bell (pivot `(1,1)`, at `bellMax.x`) so it grows left over the canvas.
- **(Low) Staleness badge could flash red on reconnect** — `WalletState::clear()` reset everything *except* the four `last_*_update` stamps, so after a reconnect the pre-outage timestamp survived and the badge briefly showed "Updated Nm ago" (red) on the same frame the node banner cleared — the exact contradiction the design forbids. Fixed by zeroing the four stamps in `clear()` (all readers treat 0 as "never"; verified `app_network.cpp:1473` guards on `!= 0`).
- **(Low) Banner min-height floor wasn't DPI-scaled** — `std::max(minH, baseH*vScale())` compared a raw-px floor against a scaled value; now `minH * dpiScale()`.
- **(Low) New i18n keys weren't in `res/lang/`** — back-filled all 16 diagnostics/QoL keys (this session's node_banner_*/data_stale_*/alerts_*/settings_*/tt_*) into all 8 language files, additively (128 insertions, 0 deletions). zh/ja/ko reworded around 2 glyphs missing from the CJK subset (提醒→通知; ko tooltip avoids 닐) and hard-asserted tofu-free against the subset font.
- **Foundation QoL / Persistent alert history** — ☑ landed. Toasts fade in 14s; there was no way to review what scrolled past. `Notifications` now retains every pushed alert in a capped (100) ring buffer with a wall-clock epoch (`AlertRecord`) — separate from the 5-item live-toast deque — plus a monotonic `total_pushed_` counter. A bell in the status-bar right cluster (`ICON_MD_NOTIFICATIONS`) opens an upward popup listing recent alerts newest-first with a severity icon/colour (reusing the toast palette), the message, and a relative age (`formatTimeAgoShort`), with a Clear-all action. An **unread dot** on the bell (coloured by the most-severe unseen alert) marks alerts that arrived since the panel was last opened — driven by `totalPushed()` deltas so it survives capping/clearing. Thread-safety: every push is on the UI thread (RPC results run as main-thread `MainCb`s), matching the class's existing lock-free model — documented as a no-raw-worker-thread invariant. Build-clean; `ctest` 1/1 (adds `testNotificationHistory`: retention, order, cap, monotonic counter, clear). **This closes the QoL bundle and the Foundation tier.**
- **W6-2 / Refresh-staleness badge** — ☑ landed. The Total Balance card now shows a small pill on its status line ("Updated 2m ago", amber → red past 3 min) **only when connected but the balance stopped refreshing** — a busy daemon can fail `z_gettotalbalance` without dropping the whole connection (only *both* core RPCs failing 3× triggers a disconnect), leaving stale numbers on screen while the node-status banner stays hidden. No refresh-path changes were needed: `WalletState::last_balance_update` is already stamped only on a successful fetch (`network_refresh_service.cpp:1187`), so the badge just reads it and computes age against the same `std::time` clock (`util::formatTimeAgoShort`). Decision is a pure, unit-tested helper (`ui/staleness_badge.h::evaluateStalenessBadge`, thresholds 45s/180s) gated on `connected` so it never contradicts the banner; hover shows a "may be out of date — check your node connection" tooltip. Build-clean; `ctest` 1/1 (adds `testStalenessBadge`). **This closes P2 (5/5).**
- **Foundation QoL / Persistent node-status banner** — ☑ landed. A persistent horizontal strip now sits at the top of the content column whenever the wallet can't reach its node — distinct from the transient toasts, so an offline wallet is never silently mistaken for a working one. The show/severity/action decision is a pure function (`ui/node_status_banner.h``evaluateNodeStatusBanner`, unit-tested) fed a state snapshot by `App::renderNodeStatusBanner()`. Three cases: **full-node offline** (amber, "Reconnect" → `tryConnect`), **embedded daemon crashed & auto-restart gave up** (red, "Restart node" → `restartDaemon`), **lite wallet failed to open** (red, message-only). Suppressed during the wizard / wallet-switch / daemon-restart / screenshot-sweep / shutdown, and while an expected startup phase (warmup/init/connect-in-progress) already owns the screen. Height in `res/themes/ui.toml` (`banners.node-status`); colours from the material semantic palette; detail text ellipsis-clipped so it can't shove the action button off-screen. Build-clean; `ctest` 1/1 (added `testNodeStatusBanner`). **Remaining QoL:** persistent alert history, and the W6-2 refresh-staleness badge.
- **Foundation QoL / "Copy diagnostics" + "Open log folder"** — ☑ landed: Settings (logging section) now has two actions. **Open log folder** opens the config dir (`Platform::openFolder`) so users can actually find `dragonx-debug.log`/`dragonx-crash.log`. **Copy diagnostics** copies a plaintext support snapshot to the clipboard via the new `App::buildDiagnosticsReport()` — version, build variant, platform, connection status, active wallet path + existence + size, encryption/lock state, sync heights, daemon status/running/crash-count/lastError (full-node), and the log paths. No secrets. Build-clean; `ctest` 1/1. **Remaining QoL:** persistent alert history, a daemon/RPC error banner, and the W6-2 refresh-staleness badge.
- **Foundation / W7-2 · W7-3 · W7-4 (diagnostics infrastructure)** — ☑ landed (answers the original "easier to diagnose" ask — the logging/crash foundation now actually works):
- **W7-2 (Med, keystone):** the app-level `Logger` file sink was never initialized, so `LOG`/`LOGF`/`VERBOSE_LOGF` went nowhere and `dragonx-debug.log` didn't exist on Linux/macOS at all. `main()` now calls `Logger::init(<config>/dragonx-debug.log)` on all platforms. Also fixed a **latent deadlock** this exposed: `init()` wrote its banner via `write()`, which re-locks the non-recursive `mutex_` it already holds — now written directly. On Windows the raw stdout/stderr `freopen` was moved to a separate `dragonx-stdout.log` so the two writers don't contend. New `testLoggerFileSink` (also a deadlock guard — it would hang if that regressed).
- **W7-3 (Med):** no crash handler existed on Linux/macOS. Added an **async-signal-safe** `sigaction` handler (SIGSEGV/ABRT/BUS/FPE/ILL) that writes a signal id + `backtrace_symbols_fd` backtrace to `dragonx-crash.log`, then re-raises the default disposition for a core dump — the POSIX counterpart of the Windows SEH filter.
- **W7-4 (Low):** `Logger::init` now rotates the log to a single `.1` backup when it exceeds 10 MB, so a long/verbose session can't grow it unbounded.
Build-clean; `ctest` 1/1. **Remaining Foundation:** the QoL bundle (mostly UI) — "copy diagnostics for support", an "open log folder" action, persistent alert history, a daemon/RPC error banner, and the W6-2 refresh-staleness badge.
- **P2 / W5-1 · W5-2 · W6-1 · W6-3 (localized batch)** — ☑ landed:
- **W5-1 (Med):** `persistAfterBroadcast` (lite send/shield save) returned false on a persistent save failure but both callers discarded it and it never logged — completely silent. It now `liteLog`s the failure (the note re-derives on next sync, so it's a robustness gap, not fund loss).
- **W5-2 (Med):** the post-**sync** and post-**rescan** `save` results (in the detached scan threads) were ignored; both now `liteLog` on failure (`LiteDiagnostics::log` is mutex-guarded, safe from those threads).
- **W6-1 (Med):** `WalletState::clear()` didn't reset `mining`/`pool_mining`, so a wallet switch could briefly show the previous wallet's hashrate/blocks. Now reset in `clear()` (the daemon restarts on switch, so mining genuinely stops).
- **W6-3 (Low):** `AddressBook::load()` did `entries_.clear()` then threw on the first non-object element — discarding **every** contact. Now it guards `is_object()` + per-entry try/catch, skipping and counting malformed entries.
Build-clean; `ctest` 1/1. **Remaining P2:** W6-2 (surface refresh staleness — the timestamps exist in `WalletState`; this needs the UI "updated Xs ago" badge, which overlaps the diagnostics/QoL Foundation bundle).
- **P1-B / W1-3 + startup wallet-existence guard** — ☑ landed:
- **W1-3 (Med):** `syncedHere` was stamped in the `markOpened` block at bare connect (idHash still empty), letting a freshly-restored wallet skip its needed rescan. It's now stamped only once the identity is verified (idHash non-empty), so it takes effect at the post-address-refresh index update (`updateWalletIndexForActiveWallet` after addresses load), while `lastOpenedEpoch` still records at open.
- **Startup guard (the W1-1 launch counterpart):** `App::init` now `exists()`-checks the recorded active wallet before the daemon is configured; a **non-default** active wallet that was moved/deleted between sessions falls back to the default `wallet.dat` with a warning, instead of the daemon silently auto-creating an empty wallet under the missing name. Runs before the PIN-vault init so the vault is scoped to the wallet actually opened.
Build-clean; `ctest` 1/1.
- **P1-A / W3-3 (sweep opid persistence)** — ☑ **implemented + two rounds of adversarial review** (the "live mainnet run" the migration code mandates is the remaining gate — see below). The deferral's core fear (re-tracking a stale opid hangs forever) was **refuted by the code**: the opid poller (`app.cpp:1122`) + `parseOperationStatusPoll` classify a tracked opid absent from a *successful* `z_getoperationstatus` as stale, remove it, and fire the callback `ok=false` — a thrown RPC aborts the poll so there's never a *false* stale. So re-tracking yields at worst one clean failure, never a hang.
- **What landed:** a persisted `seed_migration_sweep_opid` setting; the opid is adopted **atomically** with clearing any prior txid in the *same* `settings.save()` **only once the submit succeeds** (torn-write safe; txid always outranks opid on resume). Resume routing is a pure, unit-tested helper (`data/seed_migration_resume.h::decideSeedMigrationResume`): txid → Confirming; opid **and connected** → re-track (`Sweeping`); otherwise → the dismissable Sweep gate. The shared `makeSweepCompletionCallback(resumed)`: success → Confirming; resumed-stale → Sweep gate (re-fetch balance, honest "may have already completed" copy); fresh-fail → Error.
- **Round 1 (design review, 4 skeptics)** confirmed both safety facts (no fund loss — adopt gate + never-deleted `.bak` untouched; no hang) and caught 3 real resume-UX traps, all fixed: a missing **connectivity gate** (would trap the user in the buttonless `Sweeping` spinner while offline), a **missing balance re-fetch** on the stale fallback (permanent "Checking balance…"), and honest messaging since a daemon restart makes even a *successful* sweep read "stale".
- **Round 2 (implementation review, 3 reviewers)** caught one regression — clearing the old txid at sweep *entry* would forget an already-mined first sweep if a remainder re-sweep's submit failed; fixed by the atomic-on-success swap above. All other fixes verified present + correct.
- **⚑ Remaining gate — live mainnet run (user):** per CLAUDE.md this fund-moving path must be exercised once on mainnet before it ships. The self-verifiable parts (build, unit test, both review rounds) are green; a real interrupted-sweep resume on mainnet is the human gate I cannot perform.
- **P1-B / W1-1 (+ W1-4) · W1-2 (wallet-file safety)** — ☑ landed:
- **W1-1 (High):** `switchToWallet` never checked the target wallet file exists, so a moved/deleted file "opened" as a fresh empty wallet (dragonxd auto-creates for a missing `-wallet=`), looking exactly like fund loss. It now `std::filesystem::exists`-checks `datadir + "/" + walletFile` before switching and blocks with a "not found (moved or deleted?)" warning. Placed before the daemon-stop prompt, and — since the check runs no matter how `switchToWallet` is invoked — it also **closes W1-4** (the stale-switcher-row TOCTOU).
- **W1-2 (Med):** `walletOutputLooksCorrupt` matched the generic "Error loading wallet" string, so a `DB_TOO_NEW` (newer-version) wallet was offered a `-salvagewallet` repair that can't fix it. Now the generic match is excluded when the output also contains "newer version".
Build-clean; `ctest` 1/1. **Remaining P1-B:** W1-3 (defer the `syncedHere` stamp to a verified readback) + the startup-path existence check (`app.cpp` hands `getActiveWalletFile()` to the daemon with no `exists()` check — same silent-empty-wallet risk as W1-1 but at launch).
- **P1-A / W3-1 · W3-2 · W3-4 (migrate-to-seed correctness)** — ☑ landed (fund-adjacent — reviewed carefully):
- **W3-1 (High):** `beginAdoptSeedWallet` hardcoded `datadir + "/wallet.dat"` as the file to swap. With a non-default active wallet (e.g. `wallet-2.dat`), that installed the swept seed wallet into an unloaded `wallet.dat` and left the daemon reloading the emptied legacy — funds only recoverable via the seed phrase. Now swaps `datadir + "/" + getActiveWalletFile()` (captured on the main thread; switching is blocked during migration so it can't race).
- **W3-2 (High):** `SeedWalletCreator::create` did `remove_all(<config>/seed-migrate)` unconditionally at the start. A prior migration that swept funds into the temp wallet but was abandoned/crashed before adopting would have that fund-bearing wallet destroyed. It now refuses (with a clear message) when `DRAGONX/wallet.dat` already exists — a completed migration removes the dir on adopt, so a leftover means an unfinished one.
- **W3-4 (Med):** `switchToWallet` only blocked switching while the migration *dialog* was open; closing it via "Later" mid-migration dropped the guard. Now also blocks while `getSeedMigrationPending()`.
Build-clean; `ctest` 1/1. **Remaining P1-A:** W3-3 (persist the sweep opid so an app-close mid-sweep can resume/re-poll instead of silently dropping the txid).
- **P0-B / W2-2 (deferred encryption silently lost) + W2-4 (auto-lock silent-fail)** — ☑ landed:
- **W2-2:** the wizard's deferred encryption was stored only in memory, so a quit/crash or a failed daemon connect before it applied left the wallet unencrypted with **no record it was ever requested** — the user believing it was encrypted. Now a persisted `encryption_pending` settings flag is set the moment encryption is requested (**never the passphrase** — only the fact). `refreshWalletEncryptionState()` reconciles it on every connect: wallet observed **encrypted** → clear the flag; wallet **not** encrypted while the flag is set and no deferred encryption is pending/in-flight → a once-per-session **"your wallet is NOT encrypted — open Settings to finish"** warning (the flag stays set, so it recurs each launch until resolved). We deliberately don't persist the passphrase to auto-complete — surfacing it is the secure choice.
- **W2-4:** `lockWallet()`'s continuation only handled success — a failed `walletlock` silently left the wallet **unlocked** (an unfulfilled auto-lock). It now logs and warns once (reset on the next successful lock), so a failing auto-lock is visible instead of leaving the wallet exposed.
Touches `settings.{h,cpp}`, `app_wizard.cpp`, `app_security.cpp`, `app.h`. Not unit-testable at this layer (RPC/connect-driven state machine); build-clean, `ctest` 1/1.
- **P0-A / W5-3 (lite create-time passphrase)** — ☑ landed (chose option **(b) wire it up**). The lite create/open/restore passphrase was collected but never consumed by the backend — a "passphrase" field that did nothing. It now has a real meaning for all three operations, in `LiteWalletController`: **create/restore**`encryptWallet(passphrase)` (the backend encrypts + locks + saves the brand-new wallet); **open**`unlockWallet(passphrase)`, but only when `encryptionStatus()` reports the existing wallet is actually encrypted+locked (skips a spurious unlock otherwise). Encrypt/unlock take their own copy and wipe it; a post-create encrypt failure is `liteLog`'d (the wallet still exists — the create isn't failed). Six existing lite-controller tests carried an incidental `hunter2` create passphrase from the dead-field era; removed (they test non-encryption flows and want an unencrypted wallet), and added `testLiteWalletControllerCreateEncryptsWithPassphrase` to prove the new behavior. Build-clean; `ctest` 1/1. *(Follow-up UX polish: `settings_page` could show the passphrase field's meaning per operation — "encrypt" for create/restore vs "unlock" for open.)*
- **P0-A / W4-5 (seed-backup file)** — ☑ landed (proportionate): the seed "Save" already wrote 0600 + zeroed the in-memory buffer, but the success message was a bare "Saved to <path>". It now reads "**Saved an UNENCRYPTED seed file — move it to secure offline storage and delete this copy**: <path>", so the plaintext-on-disk risk is called out. `i18n.cpp` (English source; `res/lang` back-fill of this changed key is deferred to the batch i18n pass). A stronger fix (pre-save confirmation, or dropping the file-save in favor of on-screen + Copy) is a follow-up UX decision.
- **P0-A / W4-1 · W4-3 · W2-3 (memzero cluster)** — ☑ landed, using the file's established `sodium_memzero` pattern (matching the existing lambda-capture scrub at app_network.cpp:2885 and JSON scrub at :4025) rather than a new type, since this is fund-moving code:
- **W4-1** `importPrivateKey`/`sweepPrivateKey`: the spending/viewing key is now scrubbed on all paths — the calling-frame copy (after the worker post), the worker-lambda's captured copy (lambda made `mutable`, zeroed after the request is sent), and the JSON request `params` copy.
- **W4-3** `exportAllKeys`/`backupWallet`: the concatenated all-keys buffer is zeroed after the consumer uses it, and the backup file is now written via `Platform::writeFileAtomically(..., restrictPermissions=true)` (atomic + 0600) instead of a umask-default `ofstream`.
- **W2-3** decrypt-wallet passphrase: `std::move`-captured into the worker lambda (so no plaintext copy is left in the calling frame) and `sodium_memzero`'d right after `unlockWallet` (its only use).
Not unit-testable (the scrubbing has no observable RPC effect — the key value sent to the daemon is unchanged; only post-use memory zeroing is added). Build-clean; `ctest` 1/1 (no regression). **Remaining in P0-A:** W5-3 (remove the dead lite `passphrase` field), W4-5 (predictable plaintext seed-backup file).
- **P0-A / W2-1** — ☑ landed: the decrypt-wallet flow now scrubs (best-effort in-place zero-overwrite) and removes the plaintext key export (`obsidiandecryptexport…`) as soon as the `z_importwallet` attempt resolves — success or failure — so a full cleartext dump of every private key is no longer left on disk forever. Recovery remains the encrypted backup (`wallet.dat.encrypted.bak`). `app_security.cpp` (after the import call). Not unit-testable (fs I/O in a deep lambda); build-clean, `ctest` 1/1 (no regression).
- **P0-A / W7-1** — ☑ landed: `RedactConsoleCommand`/`ConsoleCommandCarriesSecret` in `console_tab_helpers` redact secret-bearing commands (an allowlist of 13 first-tokens: `walletpassphrase`, `encryptwallet`, `z_importkey`, …) to `> walletpassphrase ****` before they hit the console echo AND the recall history; the real command still executes unredacted. Wired into `submitConsoleCommand` (`console_tab.cpp`). New `testConsoleSecretRedaction` (11 assertions). Clean build; `ctest` 1/1. (Output-secret commands like `z_exportkey` — result redaction — remain a follow-up.)

View File

Binary file not shown.

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -700,6 +700,12 @@ status-pill-bg-alpha = { size = 30 }
status-pill-y-offset = { size = 1 }
confirmed-threshold = { size = 10 }
# Persistent node/RPC error strip at the top of the content column (see App::renderNodeStatusBanner).
# Slightly taller than the per-tab sync banner so it comfortably holds the Reconnect/Restart action.
[banners.node-status]
min-height = { size = 26.0 }
height = { size = 30.0 }
[tabs.transactions]
search-max-width = 300.0
search-width-ratio = 0.3
@@ -959,6 +965,11 @@ edition-label = { position = 120 }
link-button = { width = 100, font = "button-sm" }
close-button = { width = 120, font = "button", align = "center" }
[dialogs.faq]
width = 860.0
height = 660.0
window = { width = 860, height = 660 }
[dialogs.settings]
width = 600.0
height = 550.0
@@ -1503,6 +1514,7 @@ progress-bar = { height = 6.0, radius = 3.0 }
progress-width = { size = 260.0 }
backdrop-alpha = { opacity = 0.80 }
vertical-gap = { size = 8.0 }
stall-timeout-sec = { size = 45.0 }
# ---------------------------------------------------------------------------
# First-Run Wizard Screens

View File

@@ -1,5 +1,17 @@
#!/usr/bin/env bash
# This script uses bash 4+ features (mapfile, safe empty-array expansion under
# `set -u`). macOS ships bash 3.2, so re-exec under a newer bash when one is
# present (Homebrew), and fail with a clear message otherwise.
if [ "${BASH_VERSINFO:-0}" -lt 4 ]; then
for _newer_bash in /opt/homebrew/bin/bash /usr/local/bin/bash; do
[ -x "$_newer_bash" ] && exec "$_newer_bash" "$0" "$@"
done
echo "ERROR: build-lite-backend-artifact.sh requires bash 4+ (found ${BASH_VERSION:-unknown})." >&2
echo " On macOS: brew install bash" >&2
exit 1
fi
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
@@ -67,25 +79,9 @@ Options:
--backend-dir PATH SilentDragonXLite/lib source directory.
--silentdragonxlitelib-dir PATH Override the wrapper's silentdragonxlitelib dependency path.
--out-dir PATH Output directory for copied artifact and metadata.
--artifact PATH Inventory an existing artifact instead of building.
--no-build Do not run cargo; requires --artifact.
--reproducible Add deterministic Rust path remaps for clean builds.
--remap-path-prefix FROM=TO Extra rustc path remap used with --reproducible.
--builder NAME Redacted builder/provenance label. Default: local.
--signature-required Fail if verified signature metadata is not supplied.
--signature-file PATH Existing sidecar signature file to record.
--signature-format FORMAT Signature format: minisign, gpg, sigstore, external, or other.
--signature-verification-tool T Verification tool and version used by the release builder.
--signature-verification-command C
Verification command already run by the release builder.
--signature-key-fingerprint F Reviewed public-key fingerprint, when applicable.
--signature-certificate-identity ID
Reviewed certificate identity, when applicable.
--signature-certificate-issuer I
Reviewed certificate issuer, when applicable.
--signature-transparency-log-url URL
Transparency log entry, when applicable.
--signature-verified-sha256 SHA Artifact SHA-256 verified by the signature check.
-j, --jobs N Cargo parallel jobs.
--cargo-arg ARG Extra argument forwarded to cargo build.
-h, --help Show this help.
@@ -95,9 +91,13 @@ Outputs:
<out>/<platform>/lite-backend-symbols.txt
<out>/<platform>/lite-backend-artifact-manifest.json
The script captures symbols, checksums, and optional read-only signature
verification metadata only. It does not load the library, resolve function
pointers, call SDXL, sign, upload, or publish artifacts.
The lite backend is always built from the vendored in-tree source
(third_party/silentdragonxlite), which is the trust root. Prebuilt artifacts
and self-attested signature metadata are NOT accepted (F15-1) — the previous
scheme only recorded an unverified "verified" claim. The script captures the
freshly-built artifact's symbols and checksum, and records build provenance.
It does not load the library, resolve function pointers, call SDXL, sign,
upload, or publish artifacts.
EOF
}
@@ -166,15 +166,8 @@ while [[ $# -gt 0 ]]; do
OUT_DIR="$(absolute_path "$2")"
shift 2
;;
--artifact)
[[ $# -ge 2 ]] || die "--artifact requires a value"
ARTIFACT_PATH="$(absolute_path "$2")"
BUILD_ARTIFACT=false
shift 2
;;
--no-build)
BUILD_ARTIFACT=false
shift
--artifact|--no-build)
die "$1 was removed (F15-1): the lite backend must be built from the vendored in-tree source (third_party/silentdragonxlite); prebuilt artifacts are no longer accepted."
;;
--reproducible)
REPRODUCIBLE=true
@@ -191,54 +184,11 @@ while [[ $# -gt 0 ]]; do
BUILDER="$2"
shift 2
;;
--signature-required)
SIGNATURE_REQUIRED=true
shift
;;
--signature-file|--signature-path)
[[ $# -ge 2 ]] || die "$1 requires a value"
SIGNATURE_FILE="$(absolute_path "$2")"
shift 2
;;
--signature-format)
[[ $# -ge 2 ]] || die "--signature-format requires a value"
SIGNATURE_FORMAT="$2"
shift 2
;;
--signature-verification-tool|--signature-tool)
[[ $# -ge 2 ]] || die "$1 requires a value"
SIGNATURE_VERIFICATION_TOOL="$2"
shift 2
;;
--signature-verification-command)
[[ $# -ge 2 ]] || die "--signature-verification-command requires a value"
SIGNATURE_VERIFICATION_COMMAND="$2"
shift 2
;;
--signature-key-fingerprint)
[[ $# -ge 2 ]] || die "--signature-key-fingerprint requires a value"
SIGNATURE_KEY_FINGERPRINT="$2"
shift 2
;;
--signature-certificate-identity)
[[ $# -ge 2 ]] || die "--signature-certificate-identity requires a value"
SIGNATURE_CERTIFICATE_IDENTITY="$2"
shift 2
;;
--signature-certificate-issuer)
[[ $# -ge 2 ]] || die "--signature-certificate-issuer requires a value"
SIGNATURE_CERTIFICATE_ISSUER="$2"
shift 2
;;
--signature-transparency-log-url)
[[ $# -ge 2 ]] || die "--signature-transparency-log-url requires a value"
SIGNATURE_TRANSPARENCY_LOG_URL="$2"
shift 2
;;
--signature-verified-sha256)
[[ $# -ge 2 ]] || die "--signature-verified-sha256 requires a value"
SIGNATURE_VERIFIED_SHA256="$2"
shift 2
--signature-required|--signature-file|--signature-path|--signature-format|\
--signature-verification-tool|--signature-tool|--signature-verification-command|\
--signature-key-fingerprint|--signature-certificate-identity|--signature-certificate-issuer|\
--signature-transparency-log-url|--signature-verified-sha256)
die "signature-attestation flags were removed (F15-1): they recorded a self-attested \"verified\" claim without running any cryptographic verifier. The lite backend is built from the vendored in-tree source, which is the trust root."
;;
-j|--jobs)
[[ $# -ge 2 ]] || die "--jobs requires a value"
@@ -374,6 +324,9 @@ prepare_backend_source() {
ln -s "$BACKEND_SOURCE_DIR/src" "$prepared_root/src"
[[ -f "$BACKEND_SOURCE_DIR/Cargo.lock" ]] && ln -s "$BACKEND_SOURCE_DIR/Cargo.lock" "$prepared_root/Cargo.lock"
[[ -d "$BACKEND_SOURCE_DIR/.cargo" ]] && ln -s "$BACKEND_SOURCE_DIR/.cargo" "$prepared_root/.cargo"
# Honor the pinned Rust toolchain (rust-toolchain.toml) inside the prepared root too,
# so builds using --silentdragonxlitelib-dir still select rustc 1.63.
[[ -f "$BACKEND_SOURCE_DIR/rust-toolchain.toml" ]] && ln -s "$BACKEND_SOURCE_DIR/rust-toolchain.toml" "$prepared_root/rust-toolchain.toml"
[[ -d "$BACKEND_SOURCE_DIR/libsodium-mingw" ]] && ln -s "$BACKEND_SOURCE_DIR/libsodium-mingw" "$prepared_root/libsodium-mingw"
# Vendored crate deps (offline builds): the .cargo/config.toml's vendored-sources directory is
# "vendor" relative to the build root, so expose it inside the prepared root too.
@@ -766,6 +719,7 @@ MANIFEST_FILE="$PLATFORM_OUT_DIR/lite-backend-artifact-manifest.json"
printf ' },\n'
printf ' "provenance": {\n'
printf ' "owner_ready": true,\n'
printf ' "built_from_source": true,\n'
printf ' "metadata_provided": true,\n'
printf ' "source": '; json_escape "$BACKEND_SOURCE_DIR"; printf ',\n'
printf ' "cargo_build_source": '; json_escape "$BUILD_BACKEND_DIR"; printf ',\n'

View File

@@ -24,18 +24,27 @@ if [ ! -f "${BUILD_DIR}/bin/ObsidianDragon" ]; then
exit 1
fi
# Check for appimagetool
# Check for appimagetool — pinned to a tagged release and SHA-256 verified before we exec it.
# The old "continuous" tag is a moving, unverified network download that runs on the release
# builder; verify it or refuse to package.
APPIMAGETOOL_URL="https://github.com/AppImage/appimagetool/releases/download/1.9.0/appimagetool-x86_64.AppImage"
APPIMAGETOOL_SHA256="46fdd785094c7f6e545b61afcfb0f3d98d8eab243f644b4b17698c01d06083d1"
APPIMAGETOOL=""
if command -v appimagetool &> /dev/null; then
APPIMAGETOOL="appimagetool"
elif [ -f "${BUILD_DIR}/appimagetool-x86_64.AppImage" ]; then
APPIMAGETOOL="${BUILD_DIR}/appimagetool-x86_64.AppImage"
APPIMAGETOOL="appimagetool" # maintainer's own trusted system install
else
print_status "Downloading appimagetool..."
wget -q -O "${BUILD_DIR}/appimagetool-x86_64.AppImage" \
"https://github.com/AppImage/AppImageKit/releases/download/continuous/appimagetool-x86_64.AppImage"
chmod +x "${BUILD_DIR}/appimagetool-x86_64.AppImage"
APPIMAGETOOL="${BUILD_DIR}/appimagetool-x86_64.AppImage"
AT="${BUILD_DIR}/appimagetool-x86_64.AppImage"
if [ ! -f "$AT" ] || ! echo "${APPIMAGETOOL_SHA256} ${AT}" | sha256sum -c --status; then
print_status "Downloading appimagetool 1.9.0 (pinned)..."
wget -q -O "$AT" "$APPIMAGETOOL_URL"
if ! echo "${APPIMAGETOOL_SHA256} ${AT}" | sha256sum -c --status; then
print_error "appimagetool SHA-256 verification failed — refusing to use it"
rm -f "$AT"
exit 1
fi
chmod +x "$AT"
fi
APPIMAGETOOL="$AT"
fi
print_status "Creating AppDir structure..."

View File

@@ -256,8 +256,8 @@ HEADER_START
echo -e "${YELLOW}Note: Daemon binaries not found in prebuilt-binaries/dragonxd-win/ — wallet only${NC}"
fi
# ── xmrig binary (from prebuilt-binaries/xmrig-hac/) ────────────────
XMRIG_DIR="$SCRIPT_DIR/prebuilt-binaries/xmrig-hac"
# ── xmrig binary (from prebuilt-binaries/drg-xmrig/) ────────────────
XMRIG_DIR="$SCRIPT_DIR/prebuilt-binaries/drg-xmrig"
if [ -f "$XMRIG_DIR/xmrig.exe" ]; then
cp -f "$XMRIG_DIR/xmrig.exe" "$EMBED_RES_DIR/xmrig.exe"
echo " Staged xmrig.exe ($(du -h "$XMRIG_DIR/xmrig.exe" | cut -f1))"

View File

@@ -1,25 +1,31 @@
#!/usr/bin/env bash
# Sign dragonx full-node release archives for the wallet's in-app daemon updater (ed25519).
# Package the prebuilt dragonx full-node binaries into per-platform release archives and sign them
# for the wallet's in-app daemon updater (ed25519 over the EXACT archive bytes).
#
# The wallet verifies a detached ed25519 signature over the EXACT archive bytes against a public
# key pinned in src/util/daemon_updater.h (kDaemonSignaturePublicKeyBase64). Verification is
# MANDATORY (kDaemonRequireSignature = true): an in-app update is refused unless a valid signature
# is published. For each archive <name>.zip this produces <name>.zip.sig holding the base64 of the
# raw 64-byte ed25519 signature — upload that .sig next to the .zip as a release asset.
# The wallet verifies a detached ed25519 signature over the archive bytes against a public key
# pinned in src/util/daemon_updater.h (kDaemonSignaturePublicKeyBase64). Verification is MANDATORY
# (kDaemonRequireSignature = true): an in-app update is refused unless a valid "<archive>.sig" is
# published next to the archive. The wallet also checks each archive's SHA-256 against a markdown
# checksum table in the release body, so `release` prints that table for you to paste in.
#
# Uses OpenSSL (>= 1.1.1) only — no Python/PyNaCl needed. OpenSSL's ed25519 is PureEdDSA (RFC 8032),
# the same primitive libsodium's crypto_sign_verify_detached checks, so signatures are compatible
# (the same flow the wallet's unit tests verify for the miner updater).
# Uses OpenSSL (>= 1.1.1) only — no Python/PyNaCl. OpenSSL's ed25519 is PureEdDSA (RFC 8032), the
# same primitive libsodium's crypto_sign_verify_detached checks, so the signatures are compatible.
#
# Usage:
# scripts/sign-daemon-release.sh keygen [out-prefix] # -> <prefix>.ed25519.{key,pub.b64}
# scripts/sign-daemon-release.sh pubkey <secret.key> # print the base64 public key to pin
# scripts/sign-daemon-release.sh sign <secret.key> <file>...# -> <file>.sig per file
# scripts/sign-daemon-release.sh keygen [out-prefix] # -> <prefix>.ed25519.{key,pub.b64}
# scripts/sign-daemon-release.sh pubkey <secret.key> # print the base64 public key to pin
# scripts/sign-daemon-release.sh sign <secret.key> <file>... # sign existing files -> <file>.sig
# scripts/sign-daemon-release.sh release <secret.key> <version> [--src DIR] [--out DIR]
# # zip prebuilt-binaries/dragonxd-{linux,mac,win}/ into dragonx-<version>-{linux-amd64,macos,
# # win64}.zip, sign each, and print the SHA-256 checksum table. Platforms with no dragonxd
# # binary staged are skipped.
#
# Keep the secret key (.ed25519.key) OFFLINE. Paste the base64 public key into
# Keep the secret key (.ed25519.key) OFFLINE (mode 600). Paste the base64 public key into
# kDaemonSignaturePublicKeyBase64 in src/util/daemon_updater.h.
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
PROJECT_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"
die() { echo "error: $*" >&2; exit 1; }
command -v openssl >/dev/null || die "openssl not found (need >= 1.1.1 with ed25519)"
@@ -27,6 +33,26 @@ command -v openssl >/dev/null || die "openssl not found (need >= 1.1.1 with ed25
# ed25519 is a fixed 12-byte prefix + the 32-byte key, so the trailing 32 bytes are the raw key.
pubkey_b64() { openssl pkey -in "$1" -pubout -outform DER | tail -c 32 | openssl base64 -A; }
sha256_of() {
if command -v sha256sum >/dev/null 2>&1; then sha256sum "$1" | awk '{print $1}';
else shasum -a 256 "$1" | awk '{print $1}'; fi
}
# Detached ed25519 signature over the raw file bytes -> <file>.sig (base64 of the 64-byte sig).
sign_file() {
local key="$1" f="$2" raw
raw="$(mktemp)"
openssl pkeyutl -sign -inkey "$key" -rawin -in "$f" -out "$raw"
openssl base64 -A -in "$raw" > "$f.sig"
printf '\n' >> "$f.sig"
rm -f "$raw"
}
# platform -> (staging dir under prebuilt-binaries, release token, expected daemon binary name)
plat_dir() { case "$1" in linux) echo dragonxd-linux;; mac) echo dragonxd-mac;; win) echo dragonxd-win;; esac; }
plat_token() { case "$1" in linux) echo linux-amd64;; mac) echo macos;; win) echo win64;; esac; }
plat_daemon() { case "$1" in win) echo dragonxd.exe;; *) echo dragonxd;; esac; }
cmd="${1:-}"; shift || true
case "$cmd" in
keygen)
@@ -42,26 +68,90 @@ case "$cmd" in
echo "Pin this in src/util/daemon_updater.h (kDaemonSignaturePublicKeyBase64):"
echo " $pub"
;;
pubkey)
[ $# -ge 1 ] || die "usage: pubkey <secret.key>"
pubkey_b64 "$1"
;;
sign)
[ $# -ge 2 ] || die "usage: sign <secret.key> <file>..."
key="$1"; shift
[ -f "$key" ] || die "no such key: $key"
for f in "$@"; do
[ -f "$f" ] || die "no such file: $f"
raw="$(mktemp)"
openssl pkeyutl -sign -inkey "$key" -rawin -in "$f" -out "$raw"
openssl base64 -A -in "$raw" > "$f.sig"
printf '\n' >> "$f.sig"
rm -f "$raw"
sign_file "$key" "$f"
echo "signed: $f -> $f.sig"
done
echo "Upload each .sig as a release asset next to its archive."
;;
release)
[ $# -ge 2 ] || die "usage: release <secret.key> <version> [--src DIR] [--out DIR]"
key="$1"; version="$2"; shift 2
src="$PROJECT_ROOT/prebuilt-binaries"
out="$PROJECT_ROOT/release/daemon"
while [ $# -gt 0 ]; do
case "$1" in
--src) [ $# -ge 2 ] || die "--src needs a value"; src="$2"; shift 2 ;;
--out) [ $# -ge 2 ] || die "--out needs a value"; out="$2"; shift 2 ;;
*) die "unknown option: $1" ;;
esac
done
[ -f "$key" ] || die "no such key: $key"
[ -d "$src" ] || die "no such source dir: $src"
command -v zip >/dev/null 2>&1 || die "zip not found (install 'zip')"
mkdir -p "$out"
# Sanity: warn if this key does not match the public key pinned in the wallet (the wallet would
# then reject every signature made with it — only expected when deliberately rotating the key).
pinned="$(grep -oE '"[A-Za-z0-9+/]{43}="' "$PROJECT_ROOT/src/util/daemon_updater.h" 2>/dev/null | head -1 | tr -d '"')"
mine="$(pubkey_b64 "$key")"
if [ -n "$pinned" ] && [ "$pinned" != "$mine" ]; then
echo "WARNING: this key's public key does not match the one pinned in daemon_updater.h:" >&2
echo " signing key -> $mine" >&2
echo " pinned key -> $pinned" >&2
echo " The wallet will REJECT these signatures unless you are rotating the pinned key." >&2
echo >&2
fi
made=0
table=""
for plat in linux mac win; do
d="$src/$(plat_dir "$plat")"
daemon="$d/$(plat_daemon "$plat")"
if [ ! -f "$daemon" ]; then
echo "skip $plat: no $(plat_daemon "$plat") staged in $d" >&2
continue
fi
archive="dragonx-$version-$(plat_token "$plat").zip"
apath="$out/$archive"
rm -f "$apath"
# Zip the staged files at the archive root (binaries + sapling params + asmap), excluding
# the .gitkeep placeholder. The updater flattens paths via baseName(), so a flat zip is fine.
files=()
while IFS= read -r fn; do files+=("$fn"); done < <(cd "$d" && ls -A | grep -vx '.gitkeep')
[ "${#files[@]}" -gt 0 ] || { echo "skip $plat: nothing to package in $d" >&2; continue; }
( cd "$d" && zip -q -X "$apath" "${files[@]}" )
sign_file "$key" "$apath"
sum="$(sha256_of "$apath")"
table+="| $archive | \`$sum\` |"$'\n'
echo "packaged + signed: $apath (+ .sig) sha256=$sum"
made=$((made + 1))
done
[ "$made" -gt 0 ] || die "no platform had a staged daemon binary under $src/dragonxd-{linux,mac,win}/"
echo
echo "Checksum table (paste into the release body so the wallet can verify SHA-256):"
echo "| Archive | SHA-256 |"
echo "|---|---|"
printf '%s' "$table"
echo
echo "Upload each .zip AND its .zip.sig as release assets. Wallet enforces the ed25519 signature"
echo "(kDaemonRequireSignature=true) and the SHA-256 from the table above."
;;
*)
die "usage: $0 {keygen [prefix] | pubkey <secret.key> | sign <secret.key> <file>...}"
die "usage: $0 {keygen [prefix] | pubkey <secret.key> | sign <secret.key> <file>... | release <secret.key> <version> [--src DIR] [--out DIR]}"
;;
esac

View File

@@ -133,7 +133,7 @@ pkgs_core_arch="base-devel cmake git pkg-config
libxkbcommon wayland libsodium curl
autoconf automake libtool wget python xxd"
pkgs_core_macos="cmake python xxd"
pkgs_core_macos="bash cmake python xxd"
# Windows cross-compile (from Linux)
pkgs_win_debian="mingw-w64 zip"
@@ -284,18 +284,27 @@ fi
header "Windows Cross-Compile"
if $SETUP_WIN; then
win_pkgs="$(get_pkgs win)"
if [[ -n "$win_pkgs" ]]; then
install_pkgs "$win_pkgs" "Windows cross-compile"
fi
# Only touch apt / update-alternatives (which need sudo) when the toolchain is missing. If it is
# already installed, skip them so `./setup.sh --win` can run WITHOUT sudo — important because the
# daemon cross-compile that follows should run as the invoking user. Running the whole setup under
# sudo leaves root-owned build artifacts under external/dragonx, which then break `make clean` on
# a later non-sudo build (stale objects get relinked -> the mingw link failure recurs).
if has_cmd x86_64-w64-mingw32-g++-posix || has_cmd x86_64-w64-mingw32-g++; then
ok "Windows cross-compile toolchain already present — skipping apt install"
else
win_pkgs="$(get_pkgs win)"
if [[ -n "$win_pkgs" ]]; then
install_pkgs "$win_pkgs" "Windows cross-compile"
fi
# Set posix thread model if available
if has_cmd update-alternatives && [[ "$PKG" == "apt" ]]; then
if ! $CHECK_ONLY; then
sudo update-alternatives --set x86_64-w64-mingw32-gcc \
/usr/bin/x86_64-w64-mingw32-gcc-posix 2>/dev/null || true
sudo update-alternatives --set x86_64-w64-mingw32-g++ \
/usr/bin/x86_64-w64-mingw32-g++-posix 2>/dev/null || true
# Set posix thread model if available
if has_cmd update-alternatives && [[ "$PKG" == "apt" ]]; then
if ! $CHECK_ONLY; then
sudo update-alternatives --set x86_64-w64-mingw32-gcc \
/usr/bin/x86_64-w64-mingw32-gcc-posix 2>/dev/null || true
sudo update-alternatives --set x86_64-w64-mingw32-g++ \
/usr/bin/x86_64-w64-mingw32-g++-posix 2>/dev/null || true
fi
fi
fi
@@ -391,11 +400,26 @@ elif $SETUP_SAPLING; then
SPEND_URL="https://z.cash/downloads/sapling-spend.params"
OUTPUT_URL="https://z.cash/downloads/sapling-output.params"
# Consensus-critical MPC parameters with fixed, well-known SHA-256 (identical across every
# Zcash-family node; also pinned in scripts/build-lite-backend-artifact.sh). z.cash is
# plain HTTPS with no signature, so verify the digest and refuse a tampered/corrupt file.
SPEND_SHA256="8e48ffd23abb3a5fd9c5589204f32d9c31285a04b78096ba40a79b75677efc13"
OUTPUT_SHA256="2f0ebbcbb9bb0bcffe95a397e7eba89c29eb4dde6191c339db88570e3f3fb0e4"
curl -fSL -o "$PARAMS_DIR/sapling-spend.params" "$SPEND_URL" && \
ok "Downloaded sapling-spend.params"
curl -fSL -o "$PARAMS_DIR/sapling-output.params" "$OUTPUT_URL" && \
ok "Downloaded sapling-output.params"
if curl -fSL -o "$PARAMS_DIR/sapling-spend.params" "$SPEND_URL" \
&& echo "${SPEND_SHA256} $PARAMS_DIR/sapling-spend.params" | sha256sum -c --status; then
ok "Downloaded + verified sapling-spend.params"
else
rm -f "$PARAMS_DIR/sapling-spend.params"
err "sapling-spend.params download or SHA-256 verification failed — not installed"
fi
if curl -fSL -o "$PARAMS_DIR/sapling-output.params" "$OUTPUT_URL" \
&& echo "${OUTPUT_SHA256} $PARAMS_DIR/sapling-output.params" | sha256sum -c --status; then
ok "Downloaded + verified sapling-output.params"
else
rm -f "$PARAMS_DIR/sapling-output.params"
err "sapling-output.params download or SHA-256 verification failed — not installed"
fi
fi
else
skip "Sapling params not found (use --sapling to download, or they'll be extracted at runtime from embedded builds)"
@@ -684,11 +708,13 @@ if [[ "$STALE_DAEMON" -eq 1 ]]; then
warn " Linux: ./setup.sh · Windows: ./setup.sh --win · macOS: ./setup.sh --mac"
fi
# ── 7. xmrig-hac (mining binary) ────────────────────────────────────────────
header "xmrig-hac Mining Binary"
# ── 7. drg-xmrig (mining binary) ────────────────────────────────────────────
header "drg-xmrig Mining Binary"
XMRIG_SRC="$PROJECT_DIR/external/xmrig-hac"
XMRIG_PREBUILT="$PROJECT_DIR/prebuilt-binaries/xmrig-hac"
XMRIG_SRC="$PROJECT_DIR/external/drg-xmrig"
# Output dir bundled by build.sh (Linux zip, AppImage, Windows embed, mac .app)
# and scripts/legacy/build-windows.sh — keep this path in sync with those.
XMRIG_PREBUILT="$PROJECT_DIR/prebuilt-binaries/drg-xmrig"
# Clean previous prebuilt xmrig binaries so we always rebuild
# Only clean the binary for the platform(s) we are actually building,
@@ -700,14 +726,14 @@ if ! $CHECK_ONLY; then
fi
fi
# Helper: clone xmrig-hac if not present
# Helper: clone drg-xmrig if not present
clone_xmrig_if_needed() {
if [[ ! -d "$XMRIG_SRC" ]]; then
info "Cloning xmrig-hac..."
git clone https://git.dragonx.is/dragonx/xmrig-hac.git "$XMRIG_SRC"
info "Cloning drg-xmrig..."
git clone https://git.dragonx.is/DragonX/drg-xmrig.git "$XMRIG_SRC"
else
ok "xmrig-hac source already present"
info "Pulling latest xmrig-hac..."
ok "drg-xmrig source already present"
info "Pulling latest drg-xmrig..."
(cd "$XMRIG_SRC" && git pull --ff-only 2>/dev/null || true)
fi
}
@@ -728,15 +754,15 @@ else
rm -rf "$XMRIG_SRC/build"
# Build dependencies (libuv, hwloc, openssl)
info "Building xmrig-hac dependencies (libuv, hwloc, openssl)..."
info "Building drg-xmrig dependencies (libuv, hwloc, openssl)..."
(
cd "$XMRIG_SRC/scripts"
sh build_deps.sh
)
ok "xmrig-hac dependencies built"
ok "drg-xmrig dependencies built"
# Build xmrig
info "Building xmrig-hac (Linux)..."
info "Building drg-xmrig (Linux)..."
mkdir -p "$XMRIG_SRC/build"
(
cd "$XMRIG_SRC/build"
@@ -753,7 +779,7 @@ else
mkdir -p "$XMRIG_PREBUILT"
if [[ -f "$XMRIG_SRC/build/xmrig" ]]; then
cp "$XMRIG_SRC/build/xmrig" "$XMRIG_LINUX"
ok "xmrig (Linux) built and installed to prebuilt-binaries/xmrig-hac/"
ok "xmrig (Linux) built and installed to prebuilt-binaries/drg-xmrig/"
else
err "xmrig (Linux) build failed — binary not found"
MISSING=$((MISSING + 1))
@@ -777,7 +803,7 @@ else
# Clean previous Windows build
rm -rf "$XMRIG_SRC/build-windows"
info "Building xmrig-hac (Windows cross-compile)..."
info "Building drg-xmrig (Windows cross-compile)..."
(
cd "$XMRIG_SRC/scripts"
bash build_windows.sh
@@ -787,7 +813,7 @@ else
mkdir -p "$XMRIG_PREBUILT"
if [[ -f "$XMRIG_SRC/build-windows/xmrig.exe" ]]; then
cp "$XMRIG_SRC/build-windows/xmrig.exe" "$XMRIG_WIN"
ok "xmrig.exe (Windows) built and installed to prebuilt-binaries/xmrig-hac/"
ok "xmrig.exe (Windows) built and installed to prebuilt-binaries/drg-xmrig/"
else
err "xmrig.exe (Windows) build failed — binary not found"
MISSING=$((MISSING + 1))
@@ -797,7 +823,7 @@ fi
# ── 8. Binary directories ───────────────────────────────────────────────────
header "Binary Directories"
for platform in dragonxd-linux dragonxd-win dragonxd-mac xmrig; do
for platform in dragonxd-linux dragonxd-win dragonxd-mac drg-xmrig; do
dir="$PROJECT_DIR/prebuilt-binaries/$platform"
if [[ -d "$dir" ]]; then
# Count actual files (not .gitkeep)

File diff suppressed because it is too large Load Diff

215
src/app.h
View File

@@ -14,6 +14,7 @@
#include <unordered_map>
#include <unordered_set>
#include <deque>
#include <condition_variable>
#include <nlohmann/json_fwd.hpp>
#include "data/transaction_history_cache.h"
#include "data/address_book.h"
@@ -71,14 +72,28 @@ enum class EncryptDialogPhase {
Done // Finished — close dialog
};
// A status string written by a background/worker thread and read every frame by the UI thread. Its
// operator= locks, so all the plain `x = "..."` assignment sites stay unchanged; readers call get()
// for a consistent per-frame snapshot instead of racing a non-atomic std::string. (M-05, L-06)
class GuardedStatus {
public:
GuardedStatus() = default;
GuardedStatus& operator=(std::string v) { std::lock_guard<std::mutex> lk(m_); v_ = std::move(v); return *this; }
std::string get() const { std::lock_guard<std::mutex> lk(m_); return v_; }
private:
mutable std::mutex m_;
std::string v_;
};
/**
* @brief Main application class
*
*
* Manages application state, RPC connection, and coordinates UI rendering.
*/
class App {
public:
App();
void wipeSecrets(); // scrub all resident secret buffers; called from ~App() AND the forced-exit path (L-05)
~App();
// Non-copyable
@@ -147,6 +162,18 @@ public:
bool isLiteBuild() const { return wallet::isLiteBuild(walletCapabilities()); }
bool supportsEmbeddedDaemon() const { return wallet::supportsEmbeddedDaemon(walletCapabilities()); }
bool supportsFullNodeLifecycleActions() const { return wallet::supportsFullNodeLifecycleActions(walletCapabilities()); }
// Daemon (v1.3.0+) coinbase auto-shield status, from z_autoshieldstatus. "Not probed" / all-false on
// pre-1.3.0 daemons (no such RPC) — callers treat that as "the wallet handles auto-shield itself".
bool daemonAutoShieldProbed() const { return daemon_autoshield_probed_; }
bool daemonAutoShieldActive() const { return daemon_autoshield_active_; }
const std::string& daemonAutoShieldAddress() const { return daemon_autoshield_address_; }
const std::string& daemonAutoShieldDisabledReason() const { return daemon_autoshield_disabled_reason_; }
bool daemonAutoShieldSeedRecoverable() const { return daemon_autoshield_seed_recoverable_; }
// W7 QoL: a plaintext support-diagnostics snapshot (version, variant, daemon/RPC/wallet/log state)
// for the "Copy diagnostics" action. Contains no secrets.
std::string buildDiagnosticsReport();
bool supportsSoloMining() const { return wallet::supportsSoloMining(walletCapabilities()); }
bool supportsPoolMining() const { return wallet::supportsPoolMining(walletCapabilities()); }
bool supportsLiteBackend() const { return wallet::supportsLiteBackend(walletCapabilities()); }
@@ -156,6 +183,21 @@ public:
*/
void renderShutdownScreen();
/**
* @brief Tail the last N lines of the daemon's debug.log (best-effort, reads only the file tail).
* Fallback for the shutdown screen when we have no captured stdout — e.g. an external daemon we
* attached to rather than spawned — so the user can still see the node flushing/exiting.
*/
std::vector<std::string> tailDaemonDebugLog(int maxLines) const;
// True when the daemon's debug.log shows an in-progress Sapling witness-cache rebuild (best-effort
// heuristic). Stopping the daemon during one discards it and forces a multi-minute redo next launch.
bool daemonWitnessRebuildActive() const;
// Whether beginShutdown() should pause and confirm before stopping the daemon (rebuild in progress).
bool shouldConfirmDaemonStop() const;
// The "node is rebuilding — stop anyway / keep running / cancel" modal, rendered from render().
void renderDaemonStopConfirm();
/**
* @brief Render loading overlay in content area while daemon is starting/syncing
* @param contentH Height of the content area child window
@@ -352,6 +394,10 @@ public:
// Force refresh
void refreshNow();
// Called on window restore: drop the daemon-output backlog that accumulated while minimized (the
// per-frame update loop was paused), so a background witness rebuild that started+finished during
// the minimize isn't parsed in one batch and mistaken for a completed rescan (spurious toast).
void skipDaemonOutputBacklog();
void refreshMiningInfo();
void refreshPeerInfo();
void refreshMarketData();
@@ -389,6 +435,9 @@ public:
// each under every skin. Output: <config>/screenshots-full/<surface>/<skin>.png + an index.
void startFullUiSweep();
std::string screenshotFullDir() const;
// Debug option: restrict either sweep to just the currently-active theme instead of cycling all.
bool sweepCurrentThemeOnly() const { return sweep_current_theme_only_; }
void setSweepCurrentThemeOnly(bool v) { sweep_current_theme_only_ = v; }
bool isScreenshotSweeping() const { return screenshot_sweep_active_; }
bool wantsScreenshotThisFrame() const { return sweep_capture_this_frame_; }
const std::string& screenshotSweepPath() const { return sweep_current_path_; }
@@ -414,6 +463,7 @@ public:
return 0;
}
void showAboutDialog() { show_about_ = true; }
void showFaqDialog() { show_faq_ = true; }
// Legacy tab compat — maps int to NavPage
void setCurrentTab(int tab);
@@ -573,6 +623,9 @@ public:
// plaintext. Call pumpSecretClipboardClear() each frame to action the clear.
void copySecretToClipboard(const std::string& secret);
void pumpSecretClipboardClear();
// Immediately clear the clipboard if it still holds the armed secret (ignores the 45s timer).
// Called on app shutdown so a copied key/seed does not outlive the process in the OS clipboard.
void clearSecretClipboardIfArmed();
bool isTransactionRefreshInProgress() const {
return network_refresh_.jobInProgress(services::NetworkRefreshService::Job::Transactions);
}
@@ -708,6 +761,22 @@ private:
// wallets. In-memory only (resets on app restart).
std::map<std::string, std::int64_t> chat_seen_watermark_;
// ── Per-frame render caches (avoid O(N) recompute every frame; see the respective call sites) ──
// Chat nav-badge unread count — recomputed only when the store revision changes or after a short
// interval (mute/hide/seen changes don't bump the store). See App::chatUnreadCount().
mutable std::uint64_t chat_unread_rev_ = ~0ull; // ~0 forces the first compute
mutable int chat_unread_cached_ = 0;
mutable double chat_unread_computed_at_ = 0.0;
// Sidebar unconfirmed-tx badge — recomputed only when the tx list changes (keyed on last_tx_update +
// size), not every frame. See App::render().
std::int64_t sb_unconf_key_ts_ = -1;
std::size_t sb_unconf_key_n_ = 0;
int sb_unconf_count_ = 0;
// Daemon-memory probe is expensive (/proc scan on Linux, popen on macOS); throttle it to ~1.5s so the
// Mining tab's per-frame read doesn't hammer the OS. See App::getDaemonMemoryUsageMB().
mutable double daemon_mem_cached_mb_ = 0.0;
mutable double daemon_mem_probe_at_ = 0.0;
// ── Chat note buffer (BOTH variants) ────────────────────────────────────────────────────────
// Each chat message is a shielded tx that spends a note; its change needs a few confirmations before
// it's spendable again (lite: backend ANCHOR_OFFSET+1 = 5; full node: z_sendmany minconf = 1), so
@@ -749,6 +818,7 @@ private:
// balance poll discards per-note data). Mirrors chat_fast_scan_in_flight_.
bool chat_note_scan_in_flight_ = false;
double chat_note_scan_last_ = 0.0; // ImGui time of the last note scan (rate limit)
double chat_note_scan_ms_ = 0.0; // measured cost of the last note scan (adaptive back-off)
// Most-recent chain tip, cached across refreshes: a lite refresh model that carries spendableOutputs
// may NOT carry sync status that same cycle (tolerated partial refresh), so verifiedSelfNoteCount reads
// this cache rather than requiring the current model to have both — else the budget flickers to 0.
@@ -758,6 +828,9 @@ private:
// Coordinator helpers (both variants unless noted; see app_network.cpp).
void refreshChatNoteBudget(const wallet::LiteWalletAppRefreshModel& model); // lite: recompute caches on a fresh model
void refreshChatNoteBudgetNode(); // full node: rate-limited z_listunspent worker scan
// Feeds the SAME chat send-budget from an already-collected z_listunspent (the address refresh),
// so the dedicated scan above is skipped while the address refresh is active (dedup — see #3).
void updateChatNoteBudgetFromUnspent(const std::vector<services::NetworkRefreshService::UnspentNoteLite>& unspentNotes);
void pumpChatNoteBuffer(); // per-frame: drain the queue / build the buffer
int verifiedSelfNoteCount(const wallet::LiteWalletAppRefreshModel& model); // lite
int pipelineSelfNoteCount(const wallet::LiteWalletAppRefreshModel& model); // lite
@@ -779,6 +852,9 @@ public:
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);
// Drop the seen-watermark for a conversation (used on revive-delete so a re-imported message — even one
// whose stamped time predates the deleted thread's last message — still badges as unread).
void forgetChatConversationSeen(const std::string& cid);
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
@@ -791,12 +867,21 @@ private:
// 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();
void maybeWarnEmptyWalletWithFundedSiblings(); // full-node: empty active wallet + a funded sibling → warn once
void maybeWarnLargeWallet(); // full-node: wallet.dat past bloat threshold → one-time toast + clickable alert
void scanFundedSiblingsAsync(); // off-UI-thread probe of sibling wallet files
static void scrubAndRemoveExport(const std::string& path); // zero + delete a plaintext key export (H-02)
void sweepStaleDecryptExports(); // startup net: purge stale obsidiandecryptexport* files (H-02)
// Seed-wallet migration (Phase 1: create a new mnemonic wallet in isolation, no funds moved).
void beginCreateSeedWallet(); // starts the isolated create on a background thread
void pumpSeedMigration(); // main thread: pick up background progress/result each frame
// Phase 2: sweep all legacy funds into the new wallet, then adopt it as the primary wallet.
void refreshSeedMigrationBalance(); // query the legacy total (shown on the Sweep step)
// W3-3: the terminal callback for the sweep opid, shared by the initial submit and a resume
// re-track. `resumed` selects the failure behaviour: a fresh sweep that fails -> Error; a resumed
// opid the daemon no longer knows (stale) -> back to the dismissable Sweep gate (re-check balance).
std::function<void(bool, const std::string&)> makeSweepCompletionCallback(bool resumed);
void beginSweepToSeedWallet(); // z_mergetoaddress ["ANY_TADDR","ANY_ZADDR"] -> dest
void pollSweepStatus(); // Confirming step: poll sweep confirmations + legacy balance
void beginAdoptSeedWallet(); // stop daemon -> swap wallet.dat -> restart with -rescan
@@ -817,6 +902,8 @@ private:
void fastScanChatMemos();
bool chat_fast_scan_in_flight_ = false; // guard against overlapping fast-scan RPCs
float chat_fast_scan_accum_ = 0.0f; // seconds since the last fast-scan (dedicated ~2.5s poll)
double chat_fast_scan_last_ = 0.0; // ImGui time of the last 0-conf fast scan (adaptive back-off)
double chat_fast_scan_ms_ = 0.0; // measured cost of the last fast scan (z_listreceivedbyaddress)
bool font_rebuild_requested_ = false; // set by requestFontRebuild(); consumed in preFrame()
// Lite first-run welcome prompt: dismissed for the session once the user picks an action.
bool lite_firstrun_dismissed_ = false;
@@ -826,6 +913,16 @@ private:
bool lite_startup_lock_checked_ = false;
std::unique_ptr<daemon::DaemonController> daemon_controller_;
std::unique_ptr<daemon::XmrigManager> xmrig_manager_;
// Serialized async mining-control queue: xmrig start/stop (SIGTERM->SIGKILL->join, up to ~3s) run on
// this dedicated FIFO thread instead of the render thread, so the UI never blocks and stop/start
// ordering is preserved across the ~13 call sites. (M-03/L-06/L-08/L-09/L-13)
std::thread mining_ctl_thread_;
std::mutex mining_ctl_mutex_;
std::condition_variable mining_ctl_cv_;
std::deque<std::function<void()>> mining_ctl_queue_;
bool mining_ctl_stop_ = false;
void postMiningControl(std::function<void()> job); // enqueue a blocking xmrig op onto the FIFO thread
void stopMiningControlThread(); // signal + join the control thread (shutdown)
// Auto-balance runtime state (pool mining, full-node only). The service fetches
// pool hashrates off-thread; the RNG drives the weighted-random pick.
util::PoolStatsService pool_stats_service_;
@@ -858,8 +955,13 @@ private:
std::atomic<bool> shutting_down_{false};
std::atomic<bool> shutdown_complete_{false};
bool address_list_dirty_ = false; // P8: dedup rebuildAddressList
std::string shutdown_status_;
GuardedStatus shutdown_status_; // thread-safe: written by the shutdown thread, read by the UI (M-05)
std::thread shutdown_thread_;
// Confirm-before-stopping-daemon-mid-witness-rebuild guard (see beginShutdown / renderDaemonStopConfirm)
bool pending_shutdown_confirm_ = false; // a quit is deferred, waiting to open the confirm modal
bool daemon_stop_confirm_open_ = false; // the confirm modal is currently showing
bool shutdown_confirmed_ = false; // user chose to proceed — bypass the guard on re-entry
bool shutdown_keep_daemon_override_ = false; // user chose "keep node running" for this shutdown only
float shutdown_timer_ = 0.0f;
bool force_quit_confirm_ = false;
std::chrono::steady_clock::time_point shutdown_start_time_;
@@ -884,6 +986,49 @@ private:
// 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_;
// Block-database recovery: set when the embedded node aborts because its block DB is unreadable
// (a daemon-vs-chaindata format mismatch after an update, or a corrupt index). While set, the
// connect loop STOPS crash-restarting into the same abort and offers a one-click reindex instead.
bool block_db_reindex_available_ = false; // node needs its block DB rebuilt (gates restart loop)
bool show_block_db_reindex_confirm_ = false; // auto-shown offer dialog
// Wallet auto-recovery: the daemon moved wallet.dat to wallet.<ts>.bak and loaded a salvaged copy
// (BDB-verify failure — often a false positive from stale/cross-platform env state). We warn loudly
// so a possibly-incomplete salvaged wallet isn't mistaken for fund loss. Warned once per session.
bool wallet_auto_recovered_ = false; // a salvage happened this session
bool wallet_auto_recovered_warned_ = false; // guard: only surface it once per session
bool wallet_degraded_ = false; // v1.3.0+ opened the wallet in DEGRADED mode (no new HD keys)
bool wallet_degraded_warned_ = false; // guard: surface the degraded-mode notice once per session
bool show_wallet_recovered_dialog_ = false; // auto-shown warning dialog
// Complementary on-disk safety net for a salvage we DIDN'T witness this launch (happened on a prior
// run, or under an external daemon whose startup output we never captured): if the active wallet loads
// empty while a sibling wallet file in the datadir still holds keys, warn once so the user's funds
// (likely in a wallet.<ts>.bak) aren't mistaken for loss. See maybeWarnEmptyWalletWithFundedSiblings().
struct FundedSibling { std::string fileName; int transparentKeys = 0; int shieldedKeys = 0; };
bool show_empty_wallet_warning_ = false; // auto-shown warning modal
bool empty_wallet_warn_checked_ = false; // evaluated this wallet-open already (reset in onConnected)
bool empty_wallet_scan_in_flight_ = false; // a sibling scan is running (main-thread only)
bool empty_wallet_has_salvage_bak_ = false; // modal variant: a funded salvage .bak → offer Restore
std::vector<FundedSibling> empty_wallet_funded_siblings_; // scan result (main-thread only)
// The recovery dialog is the ONE authoritative surface: it stays open through the async rebuild/
// restore, driven Offer → Working → Done/Failed (pumpWalletRestore sets the outcome). Presentation
// only — the fund-safety file ops in rebuildWalletDatabase()/restoreOriginalWallet() are unchanged.
enum class RecoveryPhase { Offer, Working, Done, Failed };
RecoveryPhase recovery_phase_ = RecoveryPhase::Offer;
int recovery_outcome_sev_ = 0; // 0 ok / 1 warn / 2 error, set at Done/Failed
std::string recovery_outcome_msg_; // honest result string for the Done/Failed body
bool recovery_last_action_rebuild_ = false; // which handler ran (for "try the other option")
// After a successful repair the daemon restarts with a full rescan — minutes long, and it won't
// answer RPC yet. This makes the loading overlay show a calm "finishing your wallet repair" screen
// (instead of the generic "daemon stuck / RPC timeout / restart daemon" text) and suppresses the
// daemon-crash toast. Set on repair success; cleared on connect (onConnected).
bool post_recovery_rescan_ = false;
double post_recovery_rescan_since_ = 0.0; // stamped on first overlay frame (ImGui::GetTime)
// "Restore original wallet" background op: worker sets these under the mutex, pumpWalletRestore()
// (main thread) shows the result. 0 = success, 1 = warning, 2 = error.
std::mutex wallet_restore_mutex_;
bool wallet_restore_done_ = false;
int wallet_restore_severity_ = 0;
std::string wallet_restore_msg_;
// 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.
@@ -910,6 +1055,7 @@ private:
bool show_demo_window_ = false;
bool show_settings_ = false;
bool show_about_ = false;
bool show_faq_ = false;
bool show_import_key_ = false;
bool show_export_key_ = false;
bool show_backup_ = false;
@@ -923,6 +1069,7 @@ private:
bool seed_backup_loading_ = false;
bool seed_backup_no_mnemonic_ = false;
bool seed_backup_reminder_in_flight_ = false; // guards the one-time backup nudge probe
bool large_wallet_checked_ = false; // gate: stat wallet.dat for the bloat nudge once per launch
// Cached mnemonic status of the current wallet, driving the Migrate-to-seed button glow. Probed
// once per connect (probeWalletSeedStatus, via exportSeedPhrase); NoMnemonic = a legacy wallet a
@@ -1016,10 +1163,30 @@ private:
bool daemon_start_error_shown_ = false;
int daemon_last_seen_crashes_ = 0; // surface each new embedded-daemon crash reason once
bool refresh_policy_syncing_ = false; // whether the sync-throttle refresh profile is active
// Sync-settle hysteresis + adaptive balance-poll throttle. Balance polling (z_gettotalbalance) is
// O(mapWallet) and holds the daemon's cs_main, which starves block connection on a large shielded
// wallet — so we keep the low-impact profile briefly after catching up, and back the balance poll
// off in proportion to its own measured cost. See effectivelySyncing() / balanceRefreshDue().
bool was_core_syncing_ = false; // previous Core-refresh sync state, to detect the caught-up edge
std::time_t sync_settle_until_ = 0; // hold the sync-throttle until this wall-clock time (0 = not settling)
double last_balance_scan_ms_ = 0.0; // measured cost of the last z_gettotalbalance scan
bool force_balance_refresh_ = false; // a wallet mutation forces the next balance poll through the throttle
// Same adaptive back-off applied to the other two O(mapWallet) scans that hold the daemon's cs_main:
// the address scan (z_listunspent) and the history scan (z_listreceivedbyaddress). Without this, a
// large shielded wallet re-scans them every tab cadence (~seconds each), saturating cs_main and
// starving block connection near the tip (where effectivelySyncing() reads false). See
// addressRefreshDue() / txRefreshDue(); only the routine periodic poll is throttled — explicit
// refreshes (tab switch, dirty set, in-flight send) call the refresh directly and bypass this.
double last_address_scan_ms_ = 0.0; // measured cost of the last address scan
double last_tx_scan_ms_ = 0.0; // measured cost of the last history scan
// Auto-clear for secrets copied to the clipboard. Only a hash of the copied secret is kept.
std::uint64_t clipboard_secret_hash_ = 0;
double clipboard_clear_deadline_ = 0.0;
float loading_timer_ = 0.0f; // spinner animation for loading overlay
double connect_stall_since_ = 0.0; // ImGui::GetTime() when the daemon first went "reachable but not ready"; 0 = not stalling (see util/connect_stall.h)
bool encryption_incomplete_warned_ = false; // W2-2: once-per-session guard for the "encryption didn't complete" warning
bool lock_failure_warned_ = false; // W2-4: guard so a repeatedly-failing auto-lock warns once, not every retry
std::uint64_t alerts_seen_total_ = 0; // Notifications::totalPushed() at last alert-panel open; drives the bell's unread dot
// Current page (sidebar navigation)
ui::NavPage current_page_ = ui::NavPage::Overview;
@@ -1029,6 +1196,7 @@ private:
// Debug screenshot sweep state.
bool screenshot_sweep_active_ = false;
bool sweep_current_theme_only_ = false; // Debug Options: sweep only the active theme
bool sweep_capture_this_frame_ = false;
int sweep_skin_idx_ = 0;
int sweep_settle_frames_ = 0; // frames to let a new skin/surface settle before capture
@@ -1122,6 +1290,16 @@ private:
// Auto-shield guard (prevents concurrent auto-shield operations)
std::atomic<bool> auto_shield_pending_{false};
// v1.3.0+ daemons auto-shield coinbase themselves; probe z_autoshieldstatus once per connection and
// defer the wallet's own client-side auto-shield when the daemon is doing it (otherwise both race for
// the same coinbase UTXOs and split funds across different z-addresses). Fail-closed: a pre-1.3.0
// daemon lacks the RPC → active stays false → the wallet keeps shielding client-side (no regression).
bool daemon_autoshield_probed_ = false;
bool daemon_autoshield_active_ = false;
std::atomic<bool> daemon_autoshield_probe_inflight_{false};
std::string daemon_autoshield_address_; // z_autoshieldstatus fields (O1); empty on old daemons
std::string daemon_autoshield_disabled_reason_; // daemon's reason auto-shield is off (e.g. seed not recoverable)
bool daemon_autoshield_seed_recoverable_ = false;
// P4: Incremental transaction cache
int last_tx_block_height_ = -1; // block height at last full tx fetch
@@ -1160,6 +1338,11 @@ private:
// the per-second mining/rescan-status pollers are suppressed (the daemon holds cs_main for
// the whole scan and would block them); completion is signalled by the rescan RPC callback.
bool runtime_rescan_active_ = false;
// True only for a rescan the WALLET/USER initiated (the Rescan button, a -rescan/salvage/zap/reindex
// restart, key import, seed migration) — not an autonomous background witness rebuild the daemon does
// on its own. Gates the "Blockchain rescan complete" toast so background rebuilds don't fire it;
// cleared when the toast is shown.
std::atomic<bool> user_initiated_rescan_{false}; // atomic: some rescan triggers run on worker threads
// Set when a bootstrap completes; consumed once the daemon is connected to auto-run a rescan
// that reconciles the preserved wallet.dat against the freshly-imported chain.
bool post_bootstrap_rescan_pending_ = false;
@@ -1218,8 +1401,8 @@ private:
services::WalletSecurityWorkflow wallet_security_workflow_;
// Wizard: stopping an external daemon before bootstrap
bool wizard_stopping_external_ = false;
std::string wizard_stop_status_;
std::atomic<bool> wizard_stopping_external_{false}; // written by the stop worker, read by the UI (L-06)
GuardedStatus wizard_stop_status_; // thread-safe: written by the stop worker, read by the UI (L-06)
// PIN vault
std::unique_ptr<util::SecureVault> vault_;
@@ -1285,6 +1468,13 @@ private:
// Private methods - rendering
void renderStatusBar();
// Persistent node/RPC error strip at the top of the content column when the wallet can't
// reach its node (or the embedded daemon gave up crashing). Decision logic is the pure
// evaluateNodeStatusBanner() in ui/node_status_banner.h; this draws it and wires the action.
void renderNodeStatusBanner();
// Body of the status-bar alert-history popup: recent alerts (incl. ones whose toast faded),
// newest first, with severity icon + relative age + a Clear action. See src/ui/notifications.h.
void renderAlertHistoryPanel();
void renderLiteFirstRunPrompt(); // lite-only welcome modal when no wallet exists yet
void renderLiteUnlockPrompt(); // lite-only send-time unlock modal
void renderImportKeyDialog();
@@ -1302,6 +1492,16 @@ private:
void renderPinDialogs();
void renderAntivirusHelpDialog();
void renderSwitchStopDaemonDialog(); // confirm before stopping an adopted node to switch wallets
void renderBlockDbReindexDialog(); // offer to rebuild an unreadable block database (-reindex)
void reindexBlockDatabase(); // restart the daemon with -reindex to rebuild the block DB
void renderWalletRecoveredDialog(); // warn that the node auto-recovered/salvaged wallet.dat
void renderEmptyWalletWarningDialog();// warn that the active wallet is empty while a sibling holds funds
void detectWalletAutoRecovery(); // scan daemon output for a salvage; fire the warning once/session
void detectWalletDegraded(); // scan daemon output for a DEGRADED-mode open; warn once/session
void restoreOriginalWallet(); // swap the wallet.<ts>.bak back over the salvaged copy + restart
void pumpWalletRestore(); // main-thread: surface the restore/rebuild op's result
void rebuildWalletDatabase(); // rebuild a BDB-inconsistent wallet into a loadable one (helper)
bool walletRebuildAvailable() const; // the dragonx-wallet-rebuild helper is present
void processDeferredEncryption();
// Private methods - connection
@@ -1332,6 +1532,13 @@ private:
void refreshPrice();
void refreshWalletEncryptionState();
void applyRefreshPolicy(ui::NavPage page);
bool effectivelySyncing() const; // syncing, or within the post-sync settle window (hysteresis)
bool balanceRefreshDue() const; // adaptive: enough time elapsed given the last balance-scan cost?
bool addressRefreshDue() const; // same adaptive back-off for the address scan (z_listunspent)
bool txRefreshDue() const; // same adaptive back-off for the history scan (z_listreceivedbyaddress)
// Shared duty-cycle rule: a scan may resume only once (lastScanMs / kScanDutyCycle) has elapsed since
// lastUpdate, so any single O(mapWallet) scan can occupy at most ~kScanDutyCycle of wall-clock.
bool scanRefreshDue(std::int64_t lastUpdate, double lastScanMs) const;
bool currentPageNeedsWalletDataRefresh() const;
bool shouldRunWalletTransactionRefresh() const;
bool shouldRefreshTransactions() const;

File diff suppressed because it is too large Load Diff

View File

@@ -33,6 +33,10 @@
#include <ctime>
#include <cstdint>
#include <filesystem>
#include <fstream>
#include <vector>
#include <utility>
#include <sodium.h>
#include <functional>
#include <memory>
#include <utility>
@@ -234,6 +238,41 @@ private:
// daemon off the main thread (to avoid stalling the UI), or ask the user to
// restart an external daemon. Shared by encryptWalletWithPassphrase() and
// processDeferredEncryption(); must be called on the main thread.
// Zero (overwrite) then delete a plaintext key export so a full cleartext dump of every private key is
// never left readable on disk. Idempotent + error-tolerant (safe on a missing/locked file). (H-02)
void App::scrubAndRemoveExport(const std::string& path)
{
if (path.empty()) return;
std::error_code ec;
const auto sz = std::filesystem::file_size(path, ec);
if (!ec && sz > 0) {
std::fstream scrub(path, std::ios::binary | std::ios::in | std::ios::out);
if (scrub) {
const std::vector<char> zeros(static_cast<size_t>(sz), 0);
scrub.write(zeros.data(), static_cast<std::streamsize>(sz));
scrub.flush();
}
}
std::filesystem::remove(path, ec);
}
// Startup net for H-02: a crash/kill/early-return between exporting the cleartext keys and scrubbing them
// could leave an obsidiandecryptexport* file behind. Purge any found in the data dir on launch.
void App::sweepStaleDecryptExports()
{
std::error_code ec;
const std::string dir = util::Platform::getDragonXDataDir();
std::filesystem::directory_iterator it(dir, ec), end;
for (; it != end; it.increment(ec)) {
if (ec) break;
const std::string name = it->path().filename().string();
if (name.rfind("obsidiandecryptexport", 0) == 0) {
scrubAndRemoveExport(it->path().string());
DEBUG_LOGF("[decrypt] swept stale plaintext key export: %s\n", name.c_str());
}
}
}
void App::restartDaemonAfterEncryption(const char* taskName, bool announceRestartStatus) {
if (isUsingEmbeddedDaemon()) {
if (announceRestartStatus) {
@@ -261,14 +300,14 @@ void App::restartDaemonAfterEncryption(const char* taskName, bool announceRestar
});
} else {
ui::Notifications::instance().warning(
"Please restart your daemon for encryption to take effect.");
TR("sec_restart_daemon_for_encryption"));
}
}
void App::encryptWalletWithPassphrase(const std::string& passphrase) {
if (!rpc_ || !rpc_->isConnected()) return;
encrypt_in_progress_ = true;
encrypt_status_ = "Encrypting wallet...";
encrypt_status_ = TR("sec_encrypting_wallet");
if (worker_) {
worker_->post([this, passphrase]() mutable -> rpc::RPCWorker::MainCb {
@@ -278,7 +317,7 @@ void App::encryptWalletWithPassphrase(const std::string& passphrase) {
if (result.encrypted) {
return [this]() {
encrypt_in_progress_ = false;
encrypt_status_ = "Wallet encrypted. Restarting daemon...";
encrypt_status_ = TR("sec_wallet_encrypted_restarting_daemon");
DEBUG_LOGF("[App] Wallet encrypted — restarting daemon\n");
// Immediately update local encryption state so the
@@ -297,7 +336,7 @@ void App::encryptWalletWithPassphrase(const std::string& passphrase) {
}
ui::Notifications::instance().info(
"Wallet encrypted successfully", 5.0f);
TR("sec_wallet_encrypted_successfully"), 5.0f);
// The daemon shuts itself down after encryptwallet.
// Update connection_status_ so the loading overlay
@@ -309,11 +348,11 @@ void App::encryptWalletWithPassphrase(const std::string& passphrase) {
std::string err = result.error;
return [this, err]() {
encrypt_in_progress_ = false;
encrypt_status_ = "Encryption failed: " + err;
encrypt_status_ = std::string(TR("sec_encryption_failed_prefix")) + err;
DEBUG_LOGF("[App] encryptwallet failed: %s\n", err.c_str());
ui::Notifications::instance().error(
"Encryption failed: " + err);
std::string(TR("sec_encryption_failed_prefix")) + err);
// Return to passphrase entry on failure
if (show_encrypt_dialog_ &&
@@ -354,7 +393,7 @@ void App::processDeferredEncryption() {
std::string pin = std::move(deferredEncryption.pin);
encrypt_in_progress_ = true;
encrypt_status_ = "Encrypting wallet...";
encrypt_status_ = TR("sec_encrypting_wallet");
if (worker_) {
worker_->post([this, request = services::WalletSecurityController::DeferredEncryptionSnapshot{std::move(passphrase), std::move(pin)}]() mutable -> rpc::RPCWorker::MainCb {
@@ -374,13 +413,13 @@ void App::processDeferredEncryption() {
if (result.pinStored) {
settings_->setPinEnabled(true);
settings_->save();
ui::Notifications::instance().info("Wallet encrypted & PIN set", 5.0f);
ui::Notifications::instance().info(TR("sec_wallet_encrypted_and_pin_set"), 5.0f);
} else {
ui::Notifications::instance().warning(
"Wallet encrypted but PIN vault failed");
TR("sec_wallet_encrypted_but_pin_vault_failed"));
}
} else {
ui::Notifications::instance().info("Wallet encrypted successfully", 5.0f);
ui::Notifications::instance().info(TR("sec_wallet_encrypted_successfully"), 5.0f);
}
wallet_security_.clearDeferredEncryption();
@@ -393,9 +432,9 @@ void App::processDeferredEncryption() {
std::string err = result.error;
return [this, err]() {
encrypt_in_progress_ = false;
encrypt_status_ = "Encryption failed: " + err;
encrypt_status_ = std::string(TR("sec_encryption_failed_prefix")) + err;
DEBUG_LOGF("[App] Deferred encryptwallet failed: %s\n", err.c_str());
ui::Notifications::instance().error("Encryption failed: " + err);
ui::Notifications::instance().error(std::string(TR("sec_encryption_failed_prefix")) + err);
wallet_security_.clearDeferredEncryption();
};
}
@@ -483,7 +522,17 @@ void App::lockWallet() {
state_.locked = true;
state_.unlocked_until = 0;
resetTransactionHistoryCacheSession();
lock_failure_warned_ = false;
DEBUG_LOGF("[App] Wallet locked\n");
} else {
// The walletlock RPC failed — the wallet is still UNLOCKED. Surface it (once) rather
// than silently leaving an auto-lock unfulfilled and the wallet exposed (W2-4).
DEBUG_LOGF("[App] walletlock failed — wallet remains unlocked\n");
if (!lock_failure_warned_) {
lock_failure_warned_ = true;
ui::Notifications::instance().warning(
TR("sec_couldnt_lock_wallet"), 12.0f);
}
}
};
});
@@ -492,7 +541,7 @@ void App::lockWallet() {
void App::changePassphrase(const std::string& oldPass, const std::string& newPass) {
if (!rpc_ || !rpc_->isConnected() || !worker_) return;
encrypt_in_progress_ = true;
encrypt_status_ = "Changing passphrase...";
encrypt_status_ = TR("sec_changing_passphrase");
auto* w = (fast_worker_ && fast_worker_->isRunning()) ? fast_worker_.get() : worker_.get();
auto* r = (fast_rpc_ && fast_rpc_->isConnected()) ? fast_rpc_.get() : rpc_.get();
@@ -525,9 +574,9 @@ void App::changePassphrase(const std::string& oldPass, const std::string& newPas
memset(change_confirm_buf_, 0, sizeof(change_confirm_buf_));
unlockTransactionHistoryCacheWithPassphrase(newPass);
storeTransactionHistoryCacheIfAvailable();
ui::Notifications::instance().info("Passphrase changed successfully");
ui::Notifications::instance().info(TR("sec_passphrase_changed_successfully"));
} else {
encrypt_status_ = "Failed: " + err_msg;
encrypt_status_ = std::string(TR("sec_failed_prefix")) + err_msg;
}
util::SecureVault::secureZero(newPass.data(), newPass.size());
};
@@ -560,6 +609,12 @@ void App::refreshWalletEncryptionState() {
state_.unlocked_until = until;
state_.locked = (until == 0);
state_.encryption_state_known = true;
// Wallet is encrypted — any pending deferred-encryption request has now been
// satisfied (however it completed). Clear the persisted flag (W2-2).
if (settings_ && settings_->getEncryptionPending()) {
settings_->setEncryptionPending(false);
settings_->save();
}
if (state_.locked) {
resetTransactionHistoryCacheSession();
} else if (state_.transactions.empty()) {
@@ -572,6 +627,18 @@ void App::refreshWalletEncryptionState() {
state_.locked = false;
state_.unlocked_until = 0;
state_.encryption_state_known = true;
// W2-2: encryption was requested (persisted flag) but the wallet is NOT encrypted,
// and no deferred encryption is pending/in-flight — it was lost to a quit/crash or a
// failed connect before it applied. Warn (once/session) instead of silently leaving
// an unencrypted wallet the user believes is protected. The flag stays set until the
// wallet is actually encrypted, so the warning recurs each launch until resolved.
if (settings_ && settings_->getEncryptionPending() &&
!wallet_security_.hasDeferredEncryption() && !encrypt_in_progress_ &&
!encryption_incomplete_warned_) {
encryption_incomplete_warned_ = true;
ui::Notifications::instance().warning(
TR("sec_encryption_did_not_complete"), 30.0f);
}
if (state_.transactions.empty()) {
loadTransactionHistoryCacheIfAvailable();
} else {
@@ -691,6 +758,10 @@ void App::checkIdleMining() {
// Resolve auto values: active defaults to half, idle defaults to all
if (activeThreads <= 0) activeThreads = std::max(1, maxThreads / 2);
if (idleThreads <= 0) idleThreads = maxThreads;
// Clamp to [1, logical cores] before these reach setgenerate / startPoolMining — a settings field
// could otherwise carry an arbitrary count straight past every bound. (M-06)
activeThreads = std::clamp(activeThreads, 1, maxThreads);
idleThreads = std::clamp(idleThreads, 1, maxThreads);
if (systemIdle) {
// System is idle — scale up to idle thread count
@@ -879,7 +950,7 @@ void App::renderLockScreen() {
ImU32 textCol = ui::material::OnSurface();
{
const char* title = "Wallet Locked";
const char* title = TR("sec_wallet_locked_title");
ImVec2 ts = titleFont->CalcTextSizeA(titleFont->LegacySize, FLT_MAX, 0, title);
dl->AddText(titleFont, titleFont->LegacySize,
ImVec2(cardX + (cardW - ts.x) * 0.5f, cy), textCol, title);
@@ -892,7 +963,7 @@ void App::renderLockScreen() {
if (lock_lockout_timer_ < 0) lock_lockout_timer_ = 0;
char msg[128];
snprintf(msg, sizeof(msg), "Too many attempts. Wait %.0f seconds...", lock_lockout_timer_);
snprintf(msg, sizeof(msg), TR("sec_too_many_attempts_wait"), lock_lockout_timer_);
ImVec2 ms = captionFont->CalcTextSizeA(captionFont->LegacySize, FLT_MAX, 0, msg);
dl->AddText(captionFont, captionFont->LegacySize,
ImVec2(cardX + (cardW - ms.x) * 0.5f, cy), ui::material::Warning(), msg);
@@ -905,10 +976,10 @@ void App::renderLockScreen() {
// Mode toggle (PIN / Passphrase) — only show if PIN vault exists
if (hasPinVault) {
const char* modeIcon = lock_use_pin_ ? ICON_MD_DIALPAD : ICON_MD_PASSWORD;
const char* modeText = lock_use_pin_ ? " PIN" : " Passphrase";
const char* modeText = lock_use_pin_ ? " PIN" : TR("sec_mode_passphrase");
const char* switchLabel = lock_use_pin_
? "Use passphrase instead"
: "Use PIN instead";
? TR("sec_use_passphrase_instead")
: TR("sec_use_pin_instead");
// Current mode indicator — icon with icon font, text with caption font
ImFont* iconFont = ui::material::Type().iconSmall();
@@ -1010,7 +1081,7 @@ void App::renderLockScreen() {
if (lock_unlock_in_progress_) {
// Animated spinner dots
char msg[64];
snprintf(msg, sizeof(msg), "Unlocking%s", ui::material::LoadingDots());
snprintf(msg, sizeof(msg), TR("sec_unlocking_fmt"), ui::material::LoadingDots());
ImVec2 ms = captionFont->CalcTextSizeA(captionFont->LegacySize, FLT_MAX, 0, msg);
dl->AddText(captionFont, captionFont->LegacySize,
ImVec2(cardX + (cardW - ms.x) * 0.5f, cy),
@@ -1034,7 +1105,7 @@ void App::renderLockScreen() {
ImGui::PushStyleColor(ImGuiCol_Text, ImGui::ColorConvertU32ToFloat4(ui::material::OnPrimary()));
ImGui::PushStyleVar(ImGuiStyleVar_FrameRounding, 8.0f * dp);
ImGui::BeginDisabled(!canSubmit);
bool btnClicked = ui::material::TactileButton("Unlock", ImVec2(unlockW, unlockH));
bool btnClicked = ui::material::TactileButton(TR("sec_unlock_button"), ImVec2(unlockW, unlockH));
ImGui::EndDisabled();
ImGui::PopStyleVar();
ImGui::PopStyleColor(3);
@@ -1088,7 +1159,7 @@ void App::renderLockScreen() {
r->call("walletpassphrase", {passphrase, timeout});
rpcOk = true;
} else {
rpcErr = "Not connected to daemon";
rpcErr = TR("sec_not_connected_to_daemon");
}
} catch (const std::exception& e) {
rpcErr = e.what();
@@ -1104,7 +1175,7 @@ void App::renderLockScreen() {
// so route through applyUnlockFailure so the lockout curve applies
// (this path previously bumped the counter but skipped the lockout math).
return [this, rpcErr, passphrase = std::move(passphrase)]() mutable {
applyUnlockFailure("Unlock failed: " + rpcErr);
applyUnlockFailure(std::string(TR("sec_unlock_failed_prefix")) + rpcErr);
util::SecureVault::secureZero(passphrase.data(), passphrase.size());
};
}
@@ -1193,7 +1264,7 @@ void App::renderEncryptWalletDialog() {
else if (tier == 1) { strengthLabel = TR("wiz_strength_fair"); strengthCol = ImVec4(1,0.7f,0.3f,1); strengthPct = 0.5f; }
float barW = ImGui::GetContentRegionAvail().x;
float barH = 4.0f;
float barH = 4.0f * ui::Layout::dpiScale();
ImVec2 p = ImGui::GetCursorScreenPos();
ImDrawList* dl = ImGui::GetWindowDrawList();
dl->AddRectFilled(p, ImVec2(p.x + barW, p.y + barH),
@@ -1243,7 +1314,7 @@ void App::renderEncryptWalletDialog() {
// Indeterminate progress bar
{
float barW = ImGui::GetContentRegionAvail().x;
float barH = 6.0f;
float barH = 6.0f * ui::Layout::dpiScale();
ImVec2 p = ImGui::GetCursorScreenPos();
ImDrawList* dl = ImGui::GetWindowDrawList();
dl->AddRectFilled(p, ImVec2(p.x + barW, p.y + barH),
@@ -1311,9 +1382,13 @@ void App::renderEncryptWalletDialog() {
enc_dlg_pin_status_.clear();
std::string savedPass = enc_dlg_saved_passphrase_;
if (worker_ && vault_) {
worker_->post([this, pinStr, savedPass]() -> rpc::RPCWorker::MainCb {
worker_->post([this, pinStr, savedPass]() mutable -> rpc::RPCWorker::MainCb {
// Argon2id runs here (worker thread)
bool ok = vault_->store(pinStr, savedPass);
// Scrub the captured PIN + passphrase copies (they live in the worker's task
// queue until this runs); the source member is scrubbed in the MainCb. (L-03)
if (!savedPass.empty()) util::SecureVault::secureZero(&savedPass[0], savedPass.size());
if (!pinStr.empty()) util::SecureVault::secureZero(&pinStr[0], pinStr.size());
return [this, ok]() {
if (ok) {
settings_->setPinEnabled(true);
@@ -1374,6 +1449,11 @@ void App::renderEncryptWalletDialog() {
ov.cardWidth = 460.0f; ov.idSuffix = "changepass";
if (BeginOverlayDialog(ov)) {
// Same fund-loss consequence as Encrypt/Remove Encryption if the new
// passphrase is lost — reuse their warning string/header for consistency.
DialogWarningHeader(TR("wiz_encrypt_warning"));
ImGui::Spacing();
ImGui::TextUnformatted(TR("change_pass_current"));
ImGui::PushItemWidth(-1);
ImGui::InputText("##chg_old", change_old_pass_buf_, sizeof(change_old_pass_buf_),
@@ -1400,12 +1480,22 @@ void App::renderEncryptWalletDialog() {
bool valid = strlen(change_old_pass_buf_) > 0 &&
strlen(change_new_pass_buf_) >= 8 &&
strcmp(change_new_pass_buf_, change_confirm_buf_) == 0;
// Two-button footer (primary + Cancel) to match the encrypt/decrypt siblings.
float btnW = (ImGui::GetContentRegionAvail().x - ImGui::GetStyle().ItemSpacing.x) * 0.5f;
ImGui::BeginDisabled(!valid || encrypt_in_progress_);
if (ui::material::TactileButton(TR("change_pass_title"), ImVec2(-1, 40))) {
if (ui::material::TactileButton(TR("change_pass_title"), ImVec2(btnW, 40))) {
changePassphrase(std::string(change_old_pass_buf_),
std::string(change_new_pass_buf_));
}
ImGui::EndDisabled();
ImGui::SameLine();
// Cancel does what Esc/close does — dismiss without applying. Buffers are wiped by
// the !show_change_passphrase_ cleanup block below.
if (ui::material::TactileButton(TR("cancel"), ImVec2(btnW, 40))) {
show_change_passphrase_ = false;
}
EndOverlayDialog();
}
@@ -1478,15 +1568,17 @@ void App::renderDecryptWalletDialog() {
// Run entire decrypt flow on worker thread
if (worker_) {
worker_->post([this, passphrase]() -> rpc::RPCWorker::MainCb {
worker_->post([this, passphrase = std::move(passphrase)]() mutable -> rpc::RPCWorker::MainCb {
WalletSecurityDecryptRpcAdapter decryptRpc(rpc_.get(),
[this](rpc::RPCClient& client, const char* context) {
return sendStopCommandSafely(client, context);
});
auto unlock = services::WalletSecurityWorkflowExecutor::unlockWallet(passphrase, decryptRpc);
// Scrub the passphrase — unlock is its only use in this flow.
if (!passphrase.empty()) sodium_memzero(&passphrase[0], passphrase.size());
if (!unlock.ok) {
return [this]() {
wallet_security_workflow_.failEntry("Incorrect passphrase");
wallet_security_workflow_.failEntry(TR("sec_incorrect_passphrase_decrypt"));
};
}
@@ -1553,6 +1645,11 @@ void App::renderDecryptWalletDialog() {
std::chrono::steady_clock::now());
auto restartAndImport = [this, exportPath](const util::AsyncTaskManager::Token& token) {
// Scrub + delete the plaintext key export (obsidiandecryptexport…) on EVERY exit path —
// success, a restart-failure early return, or an exception. A full cleartext dump of all
// private keys must never outlive this step. The startup sweep is a further net for a
// crash/kill mid-flight. (H-02)
struct ExportScrub { std::string p; ~ExportScrub() { App::scrubAndRemoveExport(p); } } exportScrub{exportPath};
WalletSecurityDaemonAdapter daemonAdapter(*this, token);
WalletSecurityDecryptRpcAdapter decryptRpc(rpc_.get(),
[this](rpc::RPCClient& client, const char* context) {
@@ -1597,7 +1694,7 @@ void App::renderDecryptWalletDialog() {
});
ui::Notifications::instance().info(
"Importing keys & rescanning blockchain — wallet is usable while this runs",
TR("sec_importing_keys_rescanning"),
8.0f);
};
});
@@ -1606,6 +1703,8 @@ void App::renderDecryptWalletDialog() {
WalletSecurityImportRpcAdapter importAdapter(rpc_.get(), saved_config_);
auto importResult = services::WalletSecurityWorkflowExecutor::importWallet(
importAdapter, exportPath);
// (exportScrub scrubs + deletes the plaintext key export on scope exit — H-02)
if (!importResult.ok) {
std::string err = importResult.error;
if (worker_) {
@@ -1614,7 +1713,7 @@ void App::renderDecryptWalletDialog() {
wallet_security_workflow_.finishImport();
ui::Notifications::instance().error(
err +
"\nEncrypted backup: wallet.dat.encrypted.bak",
TR("sec_encrypted_backup_suffix"),
12.0f);
};
});
@@ -1640,7 +1739,7 @@ void App::renderDecryptWalletDialog() {
refreshPeerInfo();
ui::Notifications::instance().success(
"Wallet decrypted successfully! All keys imported.",
TR("sec_wallet_decrypted_all_keys_imported"),
8.0f);
DEBUG_LOGF("[App] Wallet decrypted successfully\n");
};
@@ -1730,7 +1829,7 @@ void App::renderDecryptWalletDialog() {
// Indeterminate progress bar
{
float barW = ImGui::GetContentRegionAvail().x;
float barH = 6.0f;
float barH = 6.0f * ui::Layout::dpiScale();
ImVec2 p = ImGui::GetCursorScreenPos();
ImDrawList* dl = ImGui::GetWindowDrawList();
dl->AddRectFilled(p, ImVec2(p.x + barW, p.y + barH),
@@ -1762,7 +1861,7 @@ void App::renderDecryptWalletDialog() {
int tMins = (int)(totalElapsed / 60);
int tSecs = (int)(totalElapsed % 60);
ImGui::Spacing();
ImGui::TextDisabled("Total elapsed: %dm %02ds", tMins, tSecs);
ImGui::TextDisabled(TR("sec_total_elapsed_fmt"), tMins, tSecs);
}
// ---- Phase 2: Success ----
@@ -1860,10 +1959,12 @@ void App::renderPinDialogs() {
util::SecureVault::isValidPin(pinStr) &&
strcmp(pin_buf_, pin_confirm_buf_) == 0;
// Two-button footer (primary + Cancel) to match the encrypt/decrypt siblings.
float btnW = (ImGui::GetContentRegionAvail().x - ImGui::GetStyle().ItemSpacing.x) * 0.5f;
ImGui::BeginDisabled(!valid || pin_in_progress_);
if (ui::material::TactileButton(TR("settings_set_pin"), ImVec2(-1, 40))) {
if (ui::material::TactileButton(TR("settings_set_pin"), ImVec2(btnW, 40))) {
pin_in_progress_ = true;
pin_status_ = "Verifying passphrase...";
pin_status_ = TR("sec_verifying_passphrase");
// Verify passphrase + store vault on worker thread to avoid
// blocking the UI with Argon2id key derivation.
@@ -1874,20 +1975,25 @@ void App::renderPinDialogs() {
memset(pin_confirm_buf_, 0, sizeof(pin_confirm_buf_));
if (rpc_ && rpc_->isConnected() && worker_) {
worker_->post([this, passphrase, pin]() -> rpc::RPCWorker::MainCb {
worker_->post([this, passphrase, pin]() mutable -> rpc::RPCWorker::MainCb {
// Verify passphrase via RPC (worker thread)
try {
rpc::RPCClient::TraceScope trace("Security / PIN setup");
rpc_->call("walletpassphrase", {passphrase, 5});
} catch (const std::exception& e) {
if (!passphrase.empty()) util::SecureVault::secureZero(&passphrase[0], passphrase.size());
if (!pin.empty()) util::SecureVault::secureZero(&pin[0], pin.size());
return [this]() {
pin_status_ = "Incorrect passphrase";
pin_status_ = TR("sec_incorrect_passphrase_pin_setup");
pin_in_progress_ = false;
};
}
// Passphrase correct — store in vault (Argon2id, worker thread)
bool storeOk = vault_ && vault_->store(pin, passphrase);
// Captured passphrase + PIN are no longer needed — scrub the worker-queue copies. (M-01)
if (!passphrase.empty()) util::SecureVault::secureZero(&passphrase[0], passphrase.size());
if (!pin.empty()) util::SecureVault::secureZero(&pin[0], pin.size());
// Lock wallet back
try {
@@ -1902,19 +2008,26 @@ void App::renderPinDialogs() {
pin_status_.clear();
pin_in_progress_ = false;
show_pin_setup_ = false;
ui::Notifications::instance().info("PIN set successfully");
ui::Notifications::instance().info(TR("sec_pin_set_successfully"));
} else {
pin_status_ = "Failed to create vault";
pin_status_ = TR("sec_failed_to_create_vault");
pin_in_progress_ = false;
}
};
});
} else {
pin_status_ = "Not connected to daemon";
pin_status_ = TR("sec_not_connected_to_daemon_pin");
pin_in_progress_ = false;
}
}
ImGui::EndDisabled();
ImGui::SameLine();
// Cancel does what Esc/close does — dismiss without applying. Buffers are wiped by
// the !show_pin_setup_ cleanup block below.
if (ui::material::TactileButton(TR("cancel"), ImVec2(btnW, 40))) {
show_pin_setup_ = false;
}
EndOverlayDialog();
}
// Wipe the passphrase/PIN buffers if the dialog was dismissed (X / Esc /
@@ -1968,7 +2081,7 @@ void App::renderPinDialogs() {
ImGui::BeginDisabled(!valid || pin_in_progress_);
if (ui::material::TactileButton(TR("settings_change_pin"), ImVec2(-1, 40))) {
pin_in_progress_ = true;
pin_status_ = "Changing PIN...";
pin_status_ = TR("sec_changing_pin");
std::string oldPin(pin_old_buf_);
std::string newPinCopy = newPin;
memset(pin_old_buf_, 0, sizeof(pin_old_buf_));
@@ -1984,15 +2097,15 @@ void App::renderPinDialogs() {
pin_status_.clear();
pin_in_progress_ = false;
show_pin_change_ = false;
ui::Notifications::instance().info("PIN changed successfully");
ui::Notifications::instance().info(TR("sec_pin_changed_successfully"));
} else {
pin_status_ = "Incorrect current PIN";
pin_status_ = TR("sec_incorrect_current_pin");
pin_in_progress_ = false;
}
};
});
} else {
pin_status_ = "Internal error";
pin_status_ = TR("sec_internal_error_change_pin");
pin_in_progress_ = false;
}
}
@@ -2033,7 +2146,7 @@ void App::renderPinDialogs() {
ImGui::BeginDisabled(!valid || pin_in_progress_);
if (ui::material::TactileButton(TR("settings_remove_pin"), ImVec2(-1, 40))) {
pin_in_progress_ = true;
pin_status_ = "Verifying PIN...";
pin_status_ = TR("sec_verifying_pin");
std::string oldPin(pin_old_buf_);
memset(pin_old_buf_, 0, sizeof(pin_old_buf_));
@@ -2053,15 +2166,15 @@ void App::renderPinDialogs() {
pin_status_.clear();
pin_in_progress_ = false;
show_pin_remove_ = false;
ui::Notifications::instance().info("PIN removed");
ui::Notifications::instance().info(TR("sec_pin_removed"));
} else {
pin_status_ = "Incorrect PIN";
pin_status_ = TR("sec_incorrect_pin_remove");
pin_in_progress_ = false;
}
};
});
} else {
pin_status_ = "Internal error";
pin_status_ = TR("sec_internal_error_remove_pin");
pin_in_progress_ = false;
}
}

View File

@@ -170,10 +170,10 @@ void App::installDemoWalletData()
}
auto zaddr = [](const char* a, double bal, const char* label) {
AddressInfo i; i.address = a; i.balance = bal; i.type = "shielded"; i.label = label; return i;
AddressInfo i; i.address = a; i.balance = bal; i.spendableBalance = bal; i.type = "shielded"; i.label = label; return i;
};
auto taddr = [](const char* a, double bal, const char* label) {
AddressInfo i; i.address = a; i.balance = bal; i.type = "transparent"; i.label = label; return i;
AddressInfo i; i.address = a; i.balance = bal; i.spendableBalance = bal; i.type = "transparent"; i.label = label; return i;
};
state_.z_addresses = {
zaddr("zs1demoprimaryshieldedaddressforuisweep000000000000000000000000000", 12.0, "Savings"),
@@ -310,6 +310,8 @@ void App::buildSweepCatalog()
[](App& a) { ui::BootstrapDownloadDialog::show(&a); }, [](App&) { ui::BootstrapDownloadDialog::hide(); });
add("modal-backup", ui::NavPage::Overview,
[](App& a) { a.show_backup_ = true; }, [](App& a) { a.show_backup_ = false; a.backup_status_.clear(); });
add("modal-faq", ui::NavPage::Overview,
[](App& a) { a.show_faq_ = true; }, [](App& a) { a.show_faq_ = false; });
// Encrypt-wallet dialog — the redesigned passphrase-entry phase (never fires the async encrypt).
add("modal-encrypt", ui::NavPage::Settings,
[](App& a) { a.encrypt_dialog_phase_ = EncryptDialogPhase::PassphraseEntry; a.show_encrypt_dialog_ = true; },
@@ -704,9 +706,26 @@ void App::startSweepImpl(bool full)
if (sk.valid) sweep_skins_.push_back(sk.id);
if (sweep_skins_.empty()) return;
// Debug Options "Current theme only": sweep just the active skin instead of cycling every theme.
if (sweep_current_theme_only_)
sweep_skins_.assign(1, ui::schema::SkinManager::instance().activeSkinId());
// DEV/TEST hook (dormant unless the env is set): DRAGONX_SWEEP_ONLY="send,receive" restricts the
// sweep to the named surfaces and the dark skin, so a slow large-window run captures just the tab
// under review instead of all surfaces x every skin.
const char* sweepOnly = std::getenv("DRAGONX_SWEEP_ONLY");
std::string sweepOnlyStr = sweepOnly ? sweepOnly : "";
if (!sweepOnlyStr.empty()) sweep_skins_.assign(1, std::string("dark"));
sweep_full_ = full;
if (full) { capture_mode_ = true; installDemoWalletData(); }
buildSweepCatalog();
if (!sweepOnlyStr.empty()) {
std::vector<SweepTarget> keep;
for (const auto& t : sweep_targets_)
if (sweepOnlyStr.find(t.name) != std::string::npos) keep.push_back(t);
sweep_targets_.swap(keep);
}
if (sweep_targets_.empty()) { if (full) { clearDemoWalletData(); capture_mode_ = false; sweep_full_ = false; } return; }
sweep_dir_ = full ? screenshotFullDir() : screenshotDir();

View File

@@ -177,8 +177,36 @@ void App::renderFirstRunWizard() {
// DPI scale factor — multiply all pixel constants by dp
const float dp = ui::Layout::dpiScale();
// Vertical scroll: the wizard cards are hand-drawn at absolute Y offsets and grow ~1.5x with the
// font-scale setting, so at high scale the focused card's primary button (Continue / Encrypt & Continue
// / Skip) can fall below the fixed window. Offset the whole layout by a wheel-driven scroll, clamped to
// last frame's measured content height, so every control stays reachable. The window keeps
// NoScrollWithMouse, so ImGui doesn't consume the wheel — we read the raw delta and apply our own offset.
static float s_wizScroll = 0.0f, s_wizContentH = 0.0f;
if (ImGui::IsWindowAppearing()) s_wizScroll = 0.0f;
const float wizMaxScroll = std::max(0.0f, s_wizContentH - winSize.y);
// Don't steal the wheel from an open combo popup (e.g. the 9-item Language dropdown, which is a
// scrollable popup): NoPopupHierarchy stops the popup counting as hovering the wizard, and the
// IsPopupOpen guard ensures no wheel is consumed for the whole wizard while any popup is showing.
const bool wizPopupOpen = ImGui::IsPopupOpen("", ImGuiPopupFlags_AnyPopupId | ImGuiPopupFlags_AnyPopupLevel);
if (wizMaxScroll > 0.0f && !wizPopupOpen &&
ImGui::IsWindowHovered(ImGuiHoveredFlags_ChildWindows | ImGuiHoveredFlags_NoPopupHierarchy)) {
float wheel = ImGui::GetIO().MouseWheel;
if (wheel != 0.0f) s_wizScroll -= wheel * 60.0f * dp;
}
s_wizScroll = std::max(0.0f, std::min(s_wizScroll, wizMaxScroll));
const float scrollY = s_wizScroll;
// --- Header: Logo + Welcome ---
float headerCy = winPos.y + 20.0f * dp;
// Vertically center the content when it fits (mirrors the horizontal centering below): on a tall
// monitor top-anchoring leaves a large void under the cards. Using last frame's measured block
// height, when the content fits inside the window (and we're NOT overflowing, so this doesn't
// fight the scroll), push everything down by half the leftover space. No-op once content
// fills/exceeds the window (s_wizContentH >= winSize.y ⇒ wizMaxScroll > 0 ⇒ vCenter skipped).
float vCenter = 0.0f;
if (wizMaxScroll == 0.0f && s_wizContentH > 0.0f && s_wizContentH < winSize.y)
vCenter = std::max(0.0f, (winSize.y - s_wizContentH) * 0.5f);
float headerCy = winPos.y - scrollY + 20.0f * dp + vCenter;
float logoSize = S.drawElement("screens.first-run", "logo").sizeOr(56.0f);
if (logo_tex_ != 0) {
float aspect = (logo_h_ > 0) ? (float)logo_w_ / (float)logo_h_ : 1.0f;
@@ -266,29 +294,43 @@ void App::renderFirstRunWizard() {
{
int state = cardState(0);
bool isFocused = (state == 1);
bool isCollapsed = (state == 2); // Completed: minimize to a compact pill (mirrors Card 1)
float cx = leftX + cardPad;
float cy = card0Top + cardPad;
float contentW = colW - 2 * cardPad;
// Step indicator
{
// Step indicator + title (inline when collapsed)
if (isCollapsed) {
// Compact single-line: check icon + "Step 1" + "Appearance"
float iconW = iconFont->CalcTextSizeA(iconFont->LegacySize, FLT_MAX, 0, stepIcon(state)).x;
dl->AddText(iconFont, iconFont->LegacySize, ImVec2(cx, cy), dimCol, stepIcon(state));
dl->AddText(captionFont, captionFont->LegacySize, ImVec2(cx + iconW + 4.0f * dp, cy), dimCol, TR("wiz_step1"));
cy += captionFont->LegacySize + 6.0f * dp;
}
float labelX = cx + iconW + 4.0f * dp;
dl->AddText(captionFont, captionFont->LegacySize, ImVec2(labelX, cy), dimCol, TR("wiz_step1"));
float step1W = captionFont->CalcTextSizeA(captionFont->LegacySize, FLT_MAX, 0, TR("wiz_step1")).x;
float titleX = labelX + step1W + 12.0f * dp;
dl->AddText(bodyFont, bodyFont->LegacySize, ImVec2(titleX, cy), dimCol, TR("wiz_appearance"));
cy += captionFont->LegacySize + 4.0f * dp;
} else {
// Step indicator
{
float iconW = iconFont->CalcTextSizeA(iconFont->LegacySize, FLT_MAX, 0, stepIcon(state)).x;
dl->AddText(iconFont, iconFont->LegacySize, ImVec2(cx, cy), dimCol, stepIcon(state));
dl->AddText(captionFont, captionFont->LegacySize, ImVec2(cx + iconW + 4.0f * dp, cy), dimCol, TR("wiz_step1"));
cy += captionFont->LegacySize + 6.0f * dp;
}
// Title
{
const char* t = TR("wiz_appearance");
dl->AddText(titleFont, titleFont->LegacySize, ImVec2(cx, cy), textCol, t);
cy += titleFont->LegacySize + 10.0f * dp;
}
// Title
{
const char* t = TR("wiz_appearance");
dl->AddText(titleFont, titleFont->LegacySize, ImVec2(cx, cy), textCol, t);
cy += titleFont->LegacySize + 10.0f * dp;
}
// Separator
dl->AddLine(ImVec2(cx, cy), ImVec2(cx + contentW, cy),
(textCol & 0x00FFFFFF) | IM_COL32(0,0,0,40), 1.0f * dp);
cy += 14.0f * dp;
// Separator
dl->AddLine(ImVec2(cx, cy), ImVec2(cx + contentW, cy),
(textCol & 0x00FFFFFF) | IM_COL32(0,0,0,40), 1.0f * dp);
cy += 14.0f * dp;
}
float& wiz_blur_amount = wizardUi.blur_amount;
bool& wiz_theme_effects = wizardUi.theme_effects;
@@ -324,6 +366,9 @@ void App::renderFirstRunWizard() {
wiz_appearance_init = true;
}
// Controls: rendered for the focused and upcoming states so content is visible under
// the dim overlay; skipped entirely once completed so the card shrinks to a compact pill.
if (!isCollapsed) {
// Render controls always so content is visible under the dim
// overlay when not focused; disable interaction when not active.
ImGui::BeginDisabled(!isFocused);
@@ -640,13 +685,21 @@ void App::renderFirstRunWizard() {
cy += btnH;
}
cy += cardPad;
// Lock card height to the tallest content ever seen
float& card0MaxH = wizardUi.card0_max_h;
card0MaxH = std::max(card0MaxH, cy - card0Top);
card0Bot = card0Top + card0MaxH;
} // if (!isCollapsed)
// Card 0 finalization deferred until after cards 1+2 are sized
cy += cardPad;
// Lock card height to the tallest content ever seen (but not when collapsed)
float& card0MaxH = wizardUi.card0_max_h;
if (isCollapsed) {
// Completed: finalize immediately as a compact pill (do not stretch to the
// right column height, and skip the deferred stretch below).
card0Bot = card0Top + (cy - card0Top);
finalizeCard(leftX, colW, card0Top, card0Bot, state);
} else {
card0MaxH = std::max(card0MaxH, cy - card0Top);
card0Bot = card0Top + card0MaxH;
// Card 0 finalization deferred until after cards 1+2 are sized
}
}
@@ -889,8 +942,9 @@ void App::renderFirstRunWizard() {
}
if (wizard_stopping_external_) {
const std::string ws = wizard_stop_status_.get();
dl->AddText(captionFont, captionFont->LegacySize, ImVec2(cx, cy), dimCol,
wizard_stop_status_.c_str());
ws.c_str());
cy += captionFont->LegacySize + 8.0f * dp;
} else {
float stopW = 150.0f * dp;
@@ -1338,6 +1392,10 @@ void App::renderFirstRunWizard() {
wallet_security_.beginDeferredEncryption(
std::string(encrypt_pass_buf_),
(pinEntered && pinOk) ? pinStr : std::string());
// Persist that encryption was requested (never the passphrase) so a quit/crash or
// failed daemon connect before it applies isn't silent — reconciled on the next
// connect in refreshWalletEncryptionState (W2-2). Saved with the wizard state below.
settings_->setEncryptionPending(true);
// Clear sensitive buffers
memset(encrypt_pass_buf_, 0, sizeof(encrypt_pass_buf_));
@@ -1372,6 +1430,13 @@ void App::renderFirstRunWizard() {
encrypt_status_ = TR("wiz_skip_confirm");
} else {
s_skipEncConfirm = false;
// Skipping leaves the wallet UNENCRYPTED — wipe the passphrase/PIN the user may have
// typed so it doesn't linger in these process-lifetime buffers (only the Encrypt
// path cleared them before). (L-07)
memset(encrypt_pass_buf_, 0, sizeof(encrypt_pass_buf_));
memset(encrypt_confirm_buf_, 0, sizeof(encrypt_confirm_buf_));
memset(wizard_pin_buf_, 0, sizeof(wizard_pin_buf_));
memset(wizard_pin_confirm_buf_, 0, sizeof(wizard_pin_confirm_buf_));
wizard_phase_ = WizardPhase::Done;
settings_->setWizardCompleted(true);
settings_->save();
@@ -1407,7 +1472,9 @@ void App::renderFirstRunWizard() {
}
// --- Deferred Card 0 finalization: match right column total height ---
{
// Only for the focused/upcoming Appearance card; a completed one was already finalized
// above as a compact pill and must not be re-stretched.
if (cardState(0) != 2) {
float rightColBot = card2Bot;
if (rightColBot > card0Bot) card0Bot = rightColBot;
finalizeCard(leftX, colW, card0Top, card0Bot, cardState(0));
@@ -1416,6 +1483,24 @@ void App::renderFirstRunWizard() {
// Merge channels: backgrounds → content → overlays
dl->ChannelsMerge();
// Measure this frame's content height (feeds next frame's scroll clamp) and, when it overflows the
// window, draw a slim scroll indicator so the off-screen content is discoverable.
{
float contentBottom = std::max(card0Bot, std::max(card1Bot, card2Bot));
// Subtract vCenter back out: everything below the header was shifted down by it, so the raw
// span includes it. We want s_wizContentH to be the true (un-centered) content height, or the
// vertical-centering above would feed on itself and oscillate frame-to-frame.
s_wizContentH = (contentBottom - winPos.y + scrollY - vCenter) + 24.0f * dp;
if (wizMaxScroll > 0.0f && s_wizContentH > 0.0f) {
float trackH = winSize.y - 8.0f * dp;
float thumbH = std::min(trackH, std::max(32.0f * dp, trackH * (winSize.y / s_wizContentH)));
float thumbY = winPos.y + 4.0f * dp + (trackH - thumbH) * (scrollY / wizMaxScroll);
float barX = winPos.x + winSize.x - 6.0f * dp;
dl->AddRectFilled(ImVec2(barX, thumbY), ImVec2(barX + 3.0f * dp, thumbY + thumbH),
ui::material::WithAlpha(ui::material::OnSurface(), 55), 1.5f * dp);
}
}
ImGui::End();
}

View File

@@ -89,6 +89,7 @@ bool ChatDatabase::unlockWithSecret(const std::string& secret)
lock();
return false;
}
loadTombstones();
return true;
}
@@ -97,11 +98,13 @@ void ChatDatabase::lock()
sodium_memzero(key_.data(), key_.size());
key_ready_ = false;
wallet_tag_.clear();
tombstones_.clear();
}
bool ChatDatabase::append(const ChatMessage& message)
{
if (!key_ready_ || !ensureOpen()) return false;
if (isTombstoned(message)) return false; // locally deleted — don't re-persist on a chain re-scan
std::vector<unsigned char> nonce;
std::vector<unsigned char> cipher;
@@ -193,13 +196,79 @@ std::vector<ChatMessage> ChatDatabase::load()
void ChatDatabase::clearWallet()
{
if (wallet_tag_.empty() || !ensureOpen()) return;
for (const char* sql : {"DELETE FROM chat_messages WHERE wallet_tag = ?",
"DELETE FROM chat_deleted WHERE wallet_tag = ?"}) {
sqlite3_stmt* stmt = nullptr;
if (sqlite3_prepare_v2(db_, sql, -1, &stmt, nullptr) != SQLITE_OK) continue;
sqlite3_bind_text(stmt, 1, wallet_tag_.c_str(), -1, SQLITE_TRANSIENT);
sqlite3_step(stmt);
sqlite3_finalize(stmt);
}
tombstones_.clear();
}
bool ChatDatabase::deleteMessages(const std::vector<ChatMessage>& messages, bool tombstone)
{
if (!key_ready_ || !ensureOpen()) return false;
if (messages.empty()) return true;
if (!exec("BEGIN")) return false;
bool ok = true;
for (const auto& m : messages) {
const std::string dedup = dedupHash(m.txid, m.payload_position);
sqlite3_stmt* del = nullptr;
if (sqlite3_prepare_v2(db_, "DELETE FROM chat_messages WHERE wallet_tag = ? AND dedup_hash = ?",
-1, &del, nullptr) == SQLITE_OK) {
sqlite3_bind_text(del, 1, wallet_tag_.c_str(), -1, SQLITE_TRANSIENT);
sqlite3_bind_text(del, 2, dedup.c_str(), -1, SQLITE_TRANSIENT);
if (sqlite3_step(del) != SQLITE_DONE) ok = false;
sqlite3_finalize(del);
} else {
ok = false;
}
if (tombstone) {
sqlite3_stmt* ins = nullptr;
if (sqlite3_prepare_v2(db_,
"INSERT OR IGNORE INTO chat_deleted (wallet_tag, dedup_hash) VALUES (?, ?)",
-1, &ins, nullptr) == SQLITE_OK) {
sqlite3_bind_text(ins, 1, wallet_tag_.c_str(), -1, SQLITE_TRANSIENT);
sqlite3_bind_text(ins, 2, dedup.c_str(), -1, SQLITE_TRANSIENT);
if (sqlite3_step(ins) != SQLITE_DONE) ok = false;
sqlite3_finalize(ins);
} else {
ok = false;
}
}
}
if (!exec(ok ? "COMMIT" : "ROLLBACK")) ok = false;
// Only reflect the tombstones in the in-memory cache once they are durably committed.
if (ok && tombstone)
for (const auto& m : messages) tombstones_.insert(dedupHash(m.txid, m.payload_position));
return ok;
}
bool ChatDatabase::isTombstoned(const ChatMessage& message) const
{
if (!key_ready_ || tombstones_.empty()) return false;
return tombstones_.count(dedupHash(message.txid, message.payload_position)) > 0;
}
void ChatDatabase::loadTombstones()
{
tombstones_.clear();
if (!key_ready_ || !ensureOpen()) return;
sqlite3_stmt* stmt = nullptr;
if (sqlite3_prepare_v2(db_, "DELETE FROM chat_messages WHERE wallet_tag = ?", -1, &stmt, nullptr)
!= SQLITE_OK) {
if (sqlite3_prepare_v2(db_, "SELECT dedup_hash FROM chat_deleted WHERE wallet_tag = ?",
-1, &stmt, nullptr) != SQLITE_OK) {
return;
}
sqlite3_bind_text(stmt, 1, wallet_tag_.c_str(), -1, SQLITE_TRANSIENT);
sqlite3_step(stmt);
while (sqlite3_step(stmt) == SQLITE_ROW) {
const auto* h = reinterpret_cast<const char*>(sqlite3_column_text(stmt, 0));
if (h) tombstones_.insert(h);
}
sqlite3_finalize(stmt);
}
@@ -228,6 +297,17 @@ bool ChatDatabase::ensureOpen()
exec("PRAGMA journal_mode=WAL");
exec("PRAGMA synchronous=NORMAL");
// C3-1: restrict the chat DB and its WAL/SHM sidecars to owner-only. sqlite creates them with
// umask-derived permissions (often world/group-readable); they hold per-row nonces + AEAD
// ciphertext of the user's messages. Best-effort (errors swallowed; a no-op-ish on Windows).
{
std::error_code perr;
const auto ownerOnly = std::filesystem::perms::owner_read | std::filesystem::perms::owner_write;
std::filesystem::permissions(database_path_, ownerOnly, std::filesystem::perm_options::replace, perr);
std::filesystem::permissions(database_path_ + "-wal", ownerOnly, std::filesystem::perm_options::replace, perr);
std::filesystem::permissions(database_path_ + "-shm", ownerOnly, std::filesystem::perm_options::replace, perr);
}
if (!createSchema()) {
close();
return false;
@@ -249,11 +329,18 @@ bool ChatDatabase::exec(const char* sql)
bool ChatDatabase::createSchema()
{
return exec("CREATE TABLE IF NOT EXISTS chat_messages ("
if (!exec("CREATE TABLE IF NOT EXISTS chat_messages ("
"wallet_tag TEXT NOT NULL, "
"dedup_hash TEXT NOT NULL, "
"nonce BLOB NOT NULL, "
"payload BLOB NOT NULL, "
"PRIMARY KEY (wallet_tag, dedup_hash))"))
return false;
// Tombstones for locally-deleted messages (dedup_hash only — the same keyed, non-revealing hash the
// message rows use). A chain re-scan checks this so a deleted message never re-imports.
return exec("CREATE TABLE IF NOT EXISTS chat_deleted ("
"wallet_tag TEXT NOT NULL, "
"dedup_hash TEXT NOT NULL, "
"nonce BLOB NOT NULL, "
"payload BLOB NOT NULL, "
"PRIMARY KEY (wallet_tag, dedup_hash))");
}

View File

@@ -16,6 +16,7 @@
#include <array>
#include <cstddef>
#include <string>
#include <unordered_set>
#include <vector>
struct sqlite3;
@@ -54,8 +55,20 @@ public:
void clearWallet(); // delete the unlocked wallet's rows
// Per-conversation local delete. Removes the given messages' rows; when `tombstone` is true it also
// records their (txid,position) dedup keys so a chain re-scan never re-imports them — this backs the
// "delete, but a NEW message revives the thread" path. With `tombstone` false the rows are simply
// removed (used by "delete & block", where a settings-level cid block suppresses re-import until the
// user unblocks, at which point the history re-imports from chain). Atomic; no-op while locked.
bool deleteMessages(const std::vector<ChatMessage>& messages, bool tombstone);
// True if this message's (txid,position) was locally deleted with a tombstone. Checked against an
// in-memory cache loaded on unlock — O(1), no SQL. False while locked.
bool isTombstoned(const ChatMessage& message) const;
private:
bool ensureOpen();
void loadTombstones(); // populate tombstones_ from chat_deleted for the unlocked wallet
bool exec(const char* sql);
bool createSchema();
std::string dedupHash(const std::string& txid, std::size_t position) const;
@@ -74,6 +87,7 @@ private:
std::array<unsigned char, 32> key_{}; // AEAD storage key (seed-derived)
std::string wallet_tag_; // seed-derived row partition (a keyed hash, hex)
bool key_ready_ = false;
std::unordered_set<std::string> tombstones_; // dedup_hash cache of locally-deleted messages
};
} // namespace dragonx::chat

View File

@@ -28,6 +28,10 @@ int ChatService::ingest(const std::vector<HushChatTransactionMetadata>& metadata
std::int64_t fallbackTimestamp,
std::vector<std::string>* newIncomingCids) {
if (!has_identity_) return 0;
// Persistence is attached but not unlocked (e.g. the DB failed to open with the seed): we can't
// consult tombstones, so ingesting now would resurface locally-deleted messages into the store.
// Skip until the DB is usable — chat is degraded anyway without its store.
if (db_ && !db_->hasKey()) return 0;
const std::string myPubKey = chatIdentityPublicKeyHex(identity_);
@@ -62,6 +66,13 @@ int ChatService::ingest(const std::vector<HushChatTransactionMetadata>& metadata
}
message.payload_position = meta.payload_position;
// Suppress locally-removed conversations before the (relatively costly) decrypt. A blocked cid
// is dropped outright (old + future messages) until unblocked; a tombstoned (txid,position) was
// deleted with "revive on new message", so only that exact message is skipped — a new message
// in the same conversation has a different txid and flows through normally.
if (blocked_pred_ && blocked_pred_(message.conversation_id)) continue;
if (db_ && db_->isTombstoned(message)) continue;
if (meta.type == HushChatHeaderType::ContactRequest) {
message.kind = ChatMessageKind::ContactRequest;
message.body = meta.payload_memo; // plaintext request text
@@ -92,10 +103,28 @@ int ChatService::ingest(const std::vector<HushChatTransactionMetadata>& metadata
void ChatService::loadFromDatabase() {
if (!db_) return;
for (const auto& message : db_->load()) {
// Never surface a blocked conversation, even if a prior "delete & block" failed to remove its
// rows (defense-in-depth): the ingest guard already drops live scans, this covers the reload path.
if (blocked_pred_ && blocked_pred_(message.conversation_id)) continue;
store_.append(message);
}
}
bool ChatService::deleteConversation(const std::string& conversationId, bool block) {
// Delete the persisted rows FIRST and only mutate the in-memory view if that succeeds. Doing it the
// other way round means a failed DB write (disk full / locked) would empty the store while the rows
// survive — and on the next reload the conversation silently reappears with no tombstone.
// Revive-on-new-message => tombstone the removed rows so a re-scan won't re-import them.
// Block => remove the rows without a tombstone; the caller's blocked predicate suppresses re-import
// until unblocked, at which point the conversation re-imports from chain.
if (db_) {
std::vector<ChatMessage> msgs = store_.conversation(conversationId); // snapshot (copy)
if (!db_->deleteMessages(msgs, /*tombstone=*/!block)) return false;
}
store_.eraseConversation(conversationId);
return true;
}
std::string ChatService::identityPublicKeyHex() const {
if (!has_identity_) return {};
return chatIdentityPublicKeyHex(identity_);

View File

@@ -11,6 +11,7 @@
#include "chat_store.h"
#include <cstdint>
#include <functional>
#include <string>
#include <unordered_map>
#include <vector>
@@ -55,6 +56,19 @@ public:
// the seed-derived key) into the in-memory store. No-op without an unlocked database.
void loadFromDatabase();
// Set a predicate returning true for a BLOCKED conversation id. ingest() drops those messages
// entirely (they are never stored or persisted) until the predicate stops returning true — this is
// how "delete & block" suppresses old and future messages. Typically wired to Settings::isChatBlocked.
void setBlockedPredicate(std::function<bool(const std::string&)> pred) { blocked_pred_ = std::move(pred); }
// Locally delete a conversation: remove its messages from the store and the database. When `block`
// is false, the removed messages are tombstoned so a chain re-scan won't re-import them, but a
// genuinely NEW message (new txid) revives the thread. When `block` is true, the rows are removed
// WITHOUT a tombstone (the caller also records the cid as blocked via the predicate above); unblocking
// later lets the conversation re-import from chain. Returns false (leaving the store untouched) if the
// persisted rows couldn't be removed, so the caller can avoid a store/DB divergence.
bool deleteConversation(const std::string& conversationId, bool block);
// --- Outgoing (compose) ---
// My chat public key (hex), or "" without an identity — goes in an outgoing header's "p".
std::string identityPublicKeyHex() const;
@@ -90,6 +104,7 @@ private:
bool has_identity_ = false;
ChatStore store_;
ChatDatabase* db_ = nullptr; // optional; not owned
std::function<bool(const std::string&)> blocked_pred_; // true => cid is blocked (drop its messages)
};
} // namespace dragonx::chat

View File

@@ -13,6 +13,7 @@ std::string ChatStore::dedupKey(const ChatMessage& message) {
bool ChatStore::append(const ChatMessage& message) {
if (!seen_.insert(dedupKey(message)).second) return false;
messages_.push_back(message);
++revision_;
return true;
}
@@ -38,12 +39,24 @@ const ChatMessage* ChatStore::updateDelivery(const std::string& txid, ChatDelive
for (auto& message : messages_) {
if (message.txid == txid) {
message.delivery = delivery;
++revision_;
return &message;
}
}
return nullptr;
}
int ChatStore::countUnread(const std::function<bool(const std::string&)>& excluded,
const std::function<std::int64_t(const std::string&)>& seenFor) const {
int unread = 0;
for (const auto& m : messages_) {
if (m.direction != ChatDirection::Incoming) continue;
if (excluded && excluded(m.conversation_id)) continue;
if (m.timestamp > seenFor(m.conversation_id)) ++unread;
}
return unread;
}
std::vector<std::string> ChatStore::conversationIds() const {
std::vector<std::string> ids;
std::unordered_set<std::string> seenIds;
@@ -53,9 +66,27 @@ std::vector<std::string> ChatStore::conversationIds() const {
return ids;
}
std::vector<ChatMessage> ChatStore::eraseConversation(const std::string& conversationId) {
std::vector<ChatMessage> removed;
std::vector<ChatMessage> kept;
kept.reserve(messages_.size());
for (auto& m : messages_) {
if (m.conversation_id == conversationId) {
seen_.erase(dedupKey(m));
removed.push_back(std::move(m));
} else {
kept.push_back(std::move(m));
}
}
messages_.swap(kept);
if (!removed.empty()) ++revision_;
return removed;
}
void ChatStore::clear() {
messages_.clear();
seen_.clear();
++revision_;
}
} // namespace dragonx::chat

View File

@@ -5,6 +5,8 @@
#include "chat_message.h"
#include <cstdint>
#include <functional>
#include <string>
#include <unordered_set>
#include <vector>
@@ -38,15 +40,32 @@ public:
return out;
}
// Remove every message in a conversation from the in-memory view and return the removed messages
// (so the caller can delete/tombstone their persisted rows). Re-appends are prevented by the ingest
// guard (blocked-cid predicate / DB tombstone), not here.
std::vector<ChatMessage> eraseConversation(const std::string& conversationId);
std::size_t size() const { return messages_.size(); }
bool empty() const { return messages_.empty(); }
void clear();
// Monotonic counter bumped on every mutation (append / updateDelivery change / eraseConversation /
// clear). Callers memoize expensive per-frame reads (conversation-list build, unread count) against it
// so they only rebuild when the store actually changed.
std::uint64_t revision() const { return revision_; }
// Count unread incoming messages in a SINGLE pass over the store: an incoming message counts when its
// cid is not `excluded` and its timestamp is newer than `seenFor(cid)`. Avoids the per-conversation
// copy+sort that conversation() does — the count doesn't need ordering.
int countUnread(const std::function<bool(const std::string&)>& excluded,
const std::function<std::int64_t(const std::string&)>& seenFor) const;
private:
static std::string dedupKey(const ChatMessage& message);
std::vector<ChatMessage> messages_;
std::unordered_set<std::string> seen_;
std::uint64_t revision_ = 0;
};
} // namespace dragonx::chat

View File

@@ -108,6 +108,20 @@ void loadClamped(const json& j, const char* key, T& field, T lo, T hi)
} // namespace
std::vector<Settings::LiteServerPreference> Settings::defaultLiteServers()
{
return {
{"https://lite.dragonx.is", "DragonX Lite", true},
{"https://lite1.dragonx.is", "DragonX Lite 1", true},
{"https://lite2.dragonx.is", "DragonX Lite 2", true},
{"https://lite3.dragonx.is", "DragonX Lite 3", true},
{"https://lite4.dragonx.is", "DragonX Lite 4", true},
{"https://lite5.dragonx.is", "DragonX Lite 5", true},
{"https://lite6.dragonx.is", "DragonX Lite 6", true},
{"https://lite7.dragonx.is", "DragonX Lite 7", true}
};
}
std::string Settings::getDefaultPath()
{
// Single per-platform, per-variant config dir (util::Platform::getConfigDir handles the
@@ -157,6 +171,13 @@ bool Settings::load(const std::string& path)
for (const auto& c : j["hidden_chat_cids"])
if (c.is_string()) hidden_chat_cids_.push_back(c.get<std::string>());
}
if (j.contains("blocked_chat_convs") && j["blocked_chat_convs"].is_array()) {
blocked_chat_convs_.clear();
for (const auto& c : j["blocked_chat_convs"])
if (c.is_object() && c.contains("cid") && c["cid"].is_string())
blocked_chat_convs_.push_back({c["cid"].get<std::string>(),
c.value("name", std::string())});
}
// Chat-tab customization (re-clamped through the setters so hand-edited JSON stays in range).
loadScalar(j, "chat_emoji_color", chat_emoji_color_);
loadScalar(j, "chat_poll_rate_sec", chat_poll_rate_sec_); setChatPollRateSec(chat_poll_rate_sec_);
@@ -204,6 +225,7 @@ bool Settings::load(const std::string& path)
loadScalar(j, "console_text_color", console_text_color_);
loadScalar(j, "console_zoom", console_zoom_);
if (!(console_zoom_ >= 0.25f && console_zoom_ <= 4.0f)) console_zoom_ = 1.0f; // guard bad/NaN
loadScalar(j, "console_auto_focus", console_auto_focus_);
if (j.contains("hidden_addresses") && j["hidden_addresses"].is_array()) {
hidden_addresses_.clear();
for (const auto& a : j["hidden_addresses"])
@@ -231,18 +253,28 @@ bool Settings::load(const std::string& path)
}
loadScalar(j, "wizard_completed", wizard_completed_);
loadScalar(j, "seed_backup_reminded", seed_backup_reminded_);
loadScalar(j, "large_wallet_warned", large_wallet_warned_);
if (j.contains("empty_wallet_warning_acked") && j["empty_wallet_warning_acked"].is_array()) {
empty_wallet_warning_acked_.clear();
for (const auto& w : j["empty_wallet_warning_acked"])
if (w.is_string()) empty_wallet_warning_acked_.insert(w.get<std::string>());
}
loadScalar(j, "encryption_pending", encryption_pending_);
loadScalar(j, "daemon_update_prompted_size", daemon_update_prompted_size_);
loadScalar(j, "active_wallet_file", active_wallet_file_);
loadScalar(j, "seed_migration_pending", seed_migration_pending_);
loadScalar(j, "seed_migration_dest", seed_migration_dest_);
loadScalar(j, "seed_migration_temp_dir", seed_migration_temp_dir_);
loadScalar(j, "seed_migration_sweep_txid", seed_migration_sweep_txid_);
loadScalar(j, "seed_migration_sweep_opid", seed_migration_sweep_opid_);
loadScalar(j, "auto_lock_timeout", auto_lock_timeout_);
loadScalar(j, "unlock_duration", unlock_duration_);
loadScalar(j, "pin_enabled", pin_enabled_);
loadScalar(j, "keep_daemon_running", keep_daemon_running_);
loadScalar(j, "stop_external_daemon", stop_external_daemon_);
loadScalar(j, "max_connections", max_connections_);
loadScalar(j, "stratum_host_enabled", stratum_host_enabled_);
loadScalar(j, "stratum_allowip", stratum_allowip_);
if (j.contains("lite_wallet") && j["lite_wallet"].is_object()) {
const auto& lite = j["lite_wallet"];
if (lite.contains("server_selection_mode")) {
@@ -289,6 +321,16 @@ bool Settings::load(const std::string& path)
}
lite_servers_.push_back(preference);
}
// Surface newly-shipped default servers (e.g. lite6/lite7) on an existing
// install whose saved list predates them. Servers are hidden (see
// lite_hidden_servers_), never deleted, so appending a missing default won't
// resurrect one the user intentionally removed.
for (const auto& def : defaultLiteServers()) {
bool present = false;
for (const auto& s : lite_servers_)
if (s.url == def.url) { present = true; break; }
if (!present) lite_servers_.push_back(def);
}
}
if (lite.contains("rollout_override") && lite["rollout_override"].is_string()) {
const auto v = lite["rollout_override"].get<std::string>();
@@ -450,6 +492,13 @@ bool Settings::save(const std::string& path)
j["hidden_chat_cids"] = json::array();
for (const auto& c : hidden_chat_cids_)
j["hidden_chat_cids"].push_back(c);
j["blocked_chat_convs"] = json::array();
for (const auto& b : blocked_chat_convs_) {
json o;
o["cid"] = b.cid;
o["name"] = b.name;
j["blocked_chat_convs"].push_back(o);
}
j["chat_emoji_color"] = chat_emoji_color_;
j["chat_poll_rate_sec"] = chat_poll_rate_sec_;
j["chat_bubble_style"] = chat_bubble_style_;
@@ -476,6 +525,7 @@ bool Settings::save(const std::string& path)
j["console_line_accents"] = console_line_accents_;
j["console_text_color"] = console_text_color_;
j["console_zoom"] = console_zoom_;
j["console_auto_focus"] = console_auto_focus_;
j["hidden_addresses"] = json::array();
for (const auto& addr : hidden_addresses_)
j["hidden_addresses"].push_back(addr);
@@ -497,18 +547,26 @@ bool Settings::save(const std::string& path)
}
j["wizard_completed"] = wizard_completed_;
j["seed_backup_reminded"] = seed_backup_reminded_;
j["large_wallet_warned"] = large_wallet_warned_;
j["empty_wallet_warning_acked"] = json::array();
for (const auto& w : empty_wallet_warning_acked_)
j["empty_wallet_warning_acked"].push_back(w);
j["encryption_pending"] = encryption_pending_;
j["daemon_update_prompted_size"] = daemon_update_prompted_size_;
j["active_wallet_file"] = active_wallet_file_;
j["seed_migration_pending"] = seed_migration_pending_;
j["seed_migration_dest"] = seed_migration_dest_;
j["seed_migration_temp_dir"] = seed_migration_temp_dir_;
j["seed_migration_sweep_txid"] = seed_migration_sweep_txid_;
j["seed_migration_sweep_opid"] = seed_migration_sweep_opid_;
j["auto_lock_timeout"] = auto_lock_timeout_;
j["unlock_duration"] = unlock_duration_;
j["pin_enabled"] = pin_enabled_;
j["keep_daemon_running"] = keep_daemon_running_;
j["stop_external_daemon"] = stop_external_daemon_;
j["max_connections"] = max_connections_;
j["stratum_host_enabled"] = stratum_host_enabled_;
j["stratum_allowip"] = stratum_allowip_;
{
json lite = json::object();
lite["server_selection_mode"] = liteServerSelectionPreferenceModeName(lite_server_selection_mode_);

View File

@@ -144,6 +144,26 @@ public:
hidden_chat_cids_.end());
}
// Blocked chat conversations (by cid). Unlike hide (reversible, keeps messages), block DELETES the
// local history AND suppresses every message for the cid — old and future — until you unblock, at
// which point the conversation re-imports from the chain. The last-known peer name is kept so the
// "Blocked" list can label the entry (its messages are gone from the local store).
struct BlockedChatConv { std::string cid; std::string name; };
bool isChatBlocked(const std::string& cid) const {
for (const auto& b : blocked_chat_convs_) if (b.cid == cid) return true;
return false;
}
void setChatBlocked(const std::string& cid, const std::string& name, bool blocked) {
const bool already = isChatBlocked(cid);
if (blocked && !already) blocked_chat_convs_.push_back({cid, name});
else if (!blocked && already)
blocked_chat_convs_.erase(
std::remove_if(blocked_chat_convs_.begin(), blocked_chat_convs_.end(),
[&](const BlockedChatConv& b) { return b.cid == cid; }),
blocked_chat_convs_.end());
}
const std::vector<BlockedChatConv>& blockedChatConversations() const { return blocked_chat_convs_; }
// ── Chat-tab customization (chat settings modal + Settings → Chat & Contacts) ──────
bool getChatEmojiColor() const { return chat_emoji_color_; }
void setChatEmojiColor(bool v) { chat_emoji_color_ = v; }
@@ -256,6 +276,9 @@ public:
void setConsoleTextColor(bool v) { console_text_color_ = v; }
float getConsoleZoom() const { return console_zoom_; }
void setConsoleZoom(float v) { console_zoom_ = v; }
// Auto-place the text cursor in the command box when the Console tab is opened.
bool getConsoleAutoFocus() const { return console_auto_focus_; }
void setConsoleAutoFocus(bool v) { console_auto_focus_ = v; }
// Hidden addresses (addresses hidden from the UI by the user)
const std::set<std::string>& getHiddenAddresses() const { return hidden_addresses_; }
@@ -327,6 +350,24 @@ public:
bool getSeedBackupReminded() const { return seed_backup_reminded_; }
void setSeedBackupReminded(bool v) { seed_backup_reminded_ = v; }
// One-time nudge when wallet.dat grows past the bloat threshold (re-armed if it shrinks back).
bool getLargeWalletWarned() const { return large_wallet_warned_; }
void setLargeWalletWarned(bool v) { large_wallet_warned_ = v; }
// Wallet filenames for which the one-time "this wallet is empty but a sibling holds funds"
// warning has been dismissed. Keyed per active wallet file so switching to a different empty
// wallet can warn again (see App::maybeWarnEmptyWalletWithFundedSiblings).
bool isEmptyWalletWarnAcked(const std::string& walletFile) const {
return empty_wallet_warning_acked_.count(walletFile) > 0;
}
void ackEmptyWalletWarn(const std::string& walletFile) { empty_wallet_warning_acked_.insert(walletFile); }
// Persisted the moment deferred (wizard) encryption is requested; cleared only once the wallet is
// observed to be actually encrypted. Lets a quit/crash/failed-connect before it applies be detected
// and surfaced (W2-2). NEVER stores the passphrase — only the fact that encryption was requested.
bool getEncryptionPending() const { return encryption_pending_; }
void setEncryptionPending(bool v) { encryption_pending_ = v; }
// Bundled-daemon size we last prompted to install (see App::renderDaemonUpdatePrompt). Lets the
// "a newer node is bundled — update?" prompt fire once per wallet version, never re-nagging.
long long getDaemonUpdatePromptedSize() const { return daemon_update_prompted_size_; }
@@ -350,6 +391,11 @@ public:
// migration is past the sweep, so a resume goes to the confirm/adopt stage (not sweep again).
std::string getSeedMigrationSweepTxid() const { return seed_migration_sweep_txid_; }
void setSeedMigrationSweepTxid(const std::string& v) { seed_migration_sweep_txid_ = v; }
// W3-3: the async sweep operation id, persisted while the sweep is in flight (before it resolves
// to a txid). Lets a resume re-poll a mid-sweep interruption instead of dropping the txid. Cleared
// in the same write that persists the txid, so the txid always outranks it (see [[decideSeedMigrationResume]]).
std::string getSeedMigrationSweepOpid() const { return seed_migration_sweep_opid_; }
void setSeedMigrationSweepOpid(const std::string& v) { seed_migration_sweep_opid_ = v; }
// Security — auto-lock timeout (seconds; 0 = disabled)
int getAutoLockTimeout() const { return auto_lock_timeout_; }
@@ -374,6 +420,11 @@ public:
// Daemon — maximum peer connections (0 = daemon default)
int getMaxConnections() const { return max_connections_; }
void setMaxConnections(int v) { max_connections_ = std::max(0, v); }
// Host a RandomX stratum pool from the node (v1.3.0+ daemons). Empty allow-IP = loopback only (safe).
bool getStratumHost() const { return stratum_host_enabled_; }
void setStratumHost(bool v) { stratum_host_enabled_ = v; }
const std::string& getStratumAllowIp() const { return stratum_allowip_; }
void setStratumAllowIp(const std::string& v) { stratum_allowip_ = v; }
// Lite wallet server selection
LiteServerSelectionPreferenceMode getLiteServerSelectionMode() const { return lite_server_selection_mode_; }
@@ -388,6 +439,9 @@ public:
void setLitePersistSelectedServer(bool persist) { lite_persist_selected_server_ = persist; }
const std::vector<LiteServerPreference>& getLiteServers() const { return lite_servers_; }
void setLiteServers(const std::vector<LiteServerPreference>& servers) { lite_servers_ = servers; }
// Servers shipped as defaults. Also merged into an existing install's saved list on load
// (see Settings::load), so upgrades surface newly-added servers (e.g. lite6/lite7).
static std::vector<LiteServerPreference> defaultLiteServers();
// User-defined portfolio entries (Market tab). "All funds" is implicit, not stored here.
const std::vector<PortfolioEntry>& getPortfolioEntries() const { return portfolio_entries_; }
@@ -528,6 +582,7 @@ private:
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
std::vector<BlockedChatConv> blocked_chat_convs_; // blocked chat conversations (cid + last-known name)
// Chat-tab customization (chat settings modal + Settings → Chat & Contacts).
bool chat_emoji_color_ = true; // true = color (needs FreeType; falls back to mono if absent), false = monochrome
float chat_poll_rate_sec_ = 2.5f; // 0-conf chat fast-scan cadence (full node)
@@ -569,23 +624,30 @@ private:
bool console_line_accents_ = true; // left color accent bars in console output
bool console_text_color_ = true; // per-channel text coloring in console output
float console_zoom_ = 1.0f; // console output font zoom factor
bool console_auto_focus_ = false; // focus the command input when the Console tab is opened (opt-in)
std::set<std::string> hidden_addresses_;
std::set<std::string> favorite_addresses_;
std::map<std::string, AddressMeta> address_meta_;
bool wizard_completed_ = false;
bool seed_backup_reminded_ = false;
bool large_wallet_warned_ = false;
std::set<std::string> empty_wallet_warning_acked_; // wallet files whose empty-wallet warning was dismissed
bool encryption_pending_ = false;
long long daemon_update_prompted_size_ = 0; // bundled daemon size last offered via the update prompt
std::string active_wallet_file_ = "wallet.dat"; // -wallet=<name> the daemon loads (multi-wallet)
bool seed_migration_pending_ = false;
std::string seed_migration_dest_;
std::string seed_migration_temp_dir_;
std::string seed_migration_sweep_txid_;
std::string seed_migration_sweep_opid_;
int auto_lock_timeout_ = 900; // 15 minutes
int unlock_duration_ = 600; // 10 minutes
bool pin_enabled_ = false;
bool keep_daemon_running_ = false;
bool stop_external_daemon_ = false;
int max_connections_ = 0; // 0 = daemon default
bool stratum_host_enabled_ = false; // host a RandomX stratum pool from the node (v1.3.0+ daemons)
std::string stratum_allowip_; // -stratumallowip filter (empty = daemon default: loopback only)
// Lite wallet server preferences. These are user/server settings only;
// wallet secrets, wallet files, and lifecycle state are never stored here.
@@ -596,14 +658,7 @@ private:
bool lite_persist_selected_server_ = true;
std::string lite_rollout_override_ = "auto"; // auto|force_on|force_off
std::string lite_install_id_; // random local-only id; rollout-bucket source
std::vector<LiteServerPreference> lite_servers_ = {
{"https://lite.dragonx.is", "DragonX Lite", true},
{"https://lite1.dragonx.is", "DragonX Lite 1", true},
{"https://lite2.dragonx.is", "DragonX Lite 2", true},
{"https://lite3.dragonx.is", "DragonX Lite 3", true},
{"https://lite4.dragonx.is", "DragonX Lite 4", true},
{"https://lite5.dragonx.is", "DragonX Lite 5", true}
};
std::vector<LiteServerPreference> lite_servers_ = defaultLiteServers();
std::set<std::string> lite_hidden_servers_; // server URLs hidden from the Network tab
std::vector<PortfolioEntry> portfolio_entries_; // Market tab custom portfolio groups

View File

@@ -26,6 +26,7 @@ void DaemonController::syncSettings(const config::Settings* settings)
if (!settings) return;
daemon_->setDebugCategories(settings->getDebugCategories());
daemon_->setMaxConnections(settings->getMaxConnections());
daemon_->setStratumHosting(settings->getStratumHost(), settings->getStratumAllowIp());
std::string walletFile = settings->getActiveWalletFile();
// The Wallets dialog opens an out-of-datadir wallet by linking it into the datadir under a
@@ -71,9 +72,11 @@ DaemonController::State DaemonController::state() const
return daemon_->getState();
}
const std::string& DaemonController::lastError() const
std::string DaemonController::lastError() const
{
return daemon_->getLastError();
// By value — getLastError() now returns a mutex-locked COPY, so forwarding it by reference would
// dangle (bind a reference to that temporary). (M-04 follow-through)
return daemon_ ? daemon_->getLastError() : std::string();
}
int DaemonController::crashCount() const
@@ -126,6 +129,11 @@ void DaemonController::setSalvageOnNextStart(bool enabled)
daemon_->setSalvageOnNextStart(enabled);
}
void DaemonController::setReindexOnNextStart(bool enabled)
{
daemon_->setReindexOnNextStart(enabled);
}
bool DaemonController::zapOnNextStart() const
{
return daemon_->zapOnNextStart();

View File

@@ -95,7 +95,7 @@ public:
bool externalDaemonDetected() const;
void clearExternalDaemonDetected();
State state() const;
const std::string& lastError() const;
std::string lastError() const; // by value: EmbeddedDaemon::getLastError() returns a locked copy (M-04)
int crashCount() const;
int lastBlockHeight() const;
double memoryUsageMB() const;
@@ -108,6 +108,7 @@ public:
void setZapOnNextStart(bool enabled);
bool zapOnNextStart() const;
void setSalvageOnNextStart(bool enabled);
void setReindexOnNextStart(bool enabled); // -reindex: rebuild the block DB from raw blocks on next start
static ShutdownDecision evaluateShutdownPolicy(bool hasDaemon,
bool externalDaemonDetected,

View File

@@ -0,0 +1,107 @@
// DragonX Wallet - ImGui Edition
// Copyright 2024-2026 The Hush Developers
// Released under the GPLv3
//
// daemon_startup_diagnosis.h — pure classifiers over a crashed daemon's captured console output,
// so the app can offer a targeted one-click fix instead of a bare "daemon crashed" / silent no-funds.
#pragma once
#include <string>
#include <utility>
#include <vector>
namespace dragonx {
namespace daemon {
// True when dragonxd aborted because its BLOCK DATABASE could not be loaded — either a
// daemon-vs-chaindata serialization-format mismatch after a daemon update (the deterministic
// "non-canonical optional discriminant" → "Error loading block database" → "Aborted block database
// rebuild. Exiting." sequence) or a genuinely corrupt/incomplete block index. In BOTH cases the fix
// is the same: `-reindex` rebuilds the index + chainstate from the intact raw blocks (blk*.dat).
// This is what otherwise silently presents as a wallet with zero balance — the node never starts.
inline bool blockDbOutputLooksBroken(const std::string& out)
{
return out.find("Error loading block database") != std::string::npos
|| out.find("non-canonical optional discriminant") != std::string::npos
|| out.find("Aborted block database rebuild") != std::string::npos
|| out.find("LoadBlockIndex()") != std::string::npos; // "... : failed to read value"
}
// True when dragonxd AUTO-RECOVERED the wallet on startup: on any BDB-verify failure it moves the
// original wallet.dat to "wallet.{timestamp}.bak", salvages readable keys into a fresh wallet.dat, and
// keeps running — no flag required (CWallet::Verify → CDBEnv::Verify(walletFile, CWalletDB::Recover)).
// The salvage can be incomplete (or a false positive from stale/cross-platform BDB env state), so the
// node silently comes up on a possibly-empty wallet — which reads as fund loss unless we surface it.
inline bool walletAutoRecovered(const std::string& out)
{
// Cover BOTH salvage outcomes. A successful salvage prints the "data salvaged"/"saved as wallet.<ts>.bak"
// warning; a FAILED one (e.g. an inconsistent-but-readable file where aggressive salvage finds no
// records) prints "salvage failed"/"found no records". In every case CWalletDB::Recover first logs
// "Renamed <wallet> to wallet.<ts>.bak" and CDBEnv::Salvage logs its own banner — those two fire the
// instant a salvage begins, before the daemon may abort, so they're the earliest reliable signal.
return out.find("CDBEnv::Salvage") != std::string::npos // salvage is running
|| out.find("wallet.dat corrupt, data salvaged") != std::string::npos // RECOVER_OK
|| out.find("Original wallet.dat saved as wallet.") != std::string::npos
|| out.find("wallet.dat corrupt, salvage failed") != std::string::npos // RECOVER_FAIL
|| out.find("found no records in wallet") != std::string::npos // aggressive salvage empty
|| (out.find("Renamed ") != std::string::npos && out.find(" to wallet.") != std::string::npos
&& out.find(".bak") != std::string::npos); // Recover moved wallet.dat aside
}
// True when dragonxd (v1.3.0+) opened the wallet in DEGRADED mode: a wallet.dat that lost its hdchain
// record (e.g. an old `-salvagewallet` output) now OPENS — existing keys stay intact and spendable —
// instead of aborting, but the daemon can no longer derive NEW HD keys, so z_getnewaddress /
// z_shieldcoinbase / a t->z z_sendmany fail with "HD seed not found". The only signal is a startup log
// line; pre-1.3.0 daemons never emit it, so this classifier is naturally a no-op against them.
inline bool walletOpenedDegraded(const std::string& out)
{
return out.find("Wallet opened in DEGRADED mode") != std::string::npos;
}
// If `name` is a daemon salvage backup "wallet.<unixtime>.bak", return its timestamp; else -1.
inline long long parseWalletSalvageBakTs(const std::string& name)
{
if (name.rfind("wallet.", 0) != 0) return -1; // must start "wallet."
if (name.size() < 12 || name.compare(name.size() - 4, 4, ".bak") != 0) return -1; // ...and end ".bak"
const std::string mid = name.substr(7, name.size() - 7 - 4); // digits between the dots
if (mid.empty() || mid.size() > 18) return -1;
for (char c : mid) if (c < '0' || c > '9') return -1;
long long ts = 0;
for (char c : mid) ts = ts * 10 + (c - '0');
return ts;
}
// Most RECENT salvage backup (highest timestamp). Pure, testable.
inline std::string newestWalletSalvageBak(const std::vector<std::string>& filenames)
{
long long best = -1;
std::string bestName;
for (const auto& f : filenames) {
const long long ts = parseWalletSalvageBakTs(f);
if (ts > best) { best = ts; bestName = f; }
}
return bestName;
}
// LARGEST salvage backup, from (filename, fileSize) pairs — the least-salvaged one, i.e. the original.
// This is what "Restore original wallet" should use: a salvage CASCADE shrinks the wallet each round, so
// the newest .bak is the WORST and the largest is the pristine pre-salvage original (an emptied salvage
// is tiny; a real wallet is large). Ties break toward the newest timestamp. Returns "" if none present.
inline std::string largestWalletSalvageBak(const std::vector<std::pair<std::string, unsigned long long>>& files)
{
std::string bestName;
unsigned long long bestSize = 0;
long long bestTs = -1;
for (const auto& fp : files) {
const long long ts = parseWalletSalvageBakTs(fp.first);
if (ts < 0) continue;
if (fp.second > bestSize || (fp.second == bestSize && ts > bestTs)) {
bestSize = fp.second; bestTs = ts; bestName = fp.first;
}
}
return bestName;
}
} // namespace daemon
} // namespace dragonx

View File

@@ -205,11 +205,13 @@ std::vector<std::string> EmbeddedDaemon::getChainParams()
"-ac_reward=300000000",
"-ac_blocktime=36",
"-ac_private=1",
"-addnode=node.dragonx.is",
// Seeds: seed.dragonx.is is a round-robin A record over the live seed set (self-updates without
// a wallet release), with node1/node5 as static fallbacks — mirrors the daemon's own vSeeds.
// Plain -addnode hostname resolution works on EVERY daemon version, and is load-bearing for
// pre-1.3.0 daemons whose built-in peer discovery was broken (they rely on these to find peers).
"-addnode=seed.dragonx.is",
"-addnode=node1.dragonx.is",
"-addnode=node2.dragonx.is",
"-addnode=node3.dragonx.is",
"-addnode=node4.dragonx.is",
"-addnode=node5.dragonx.is",
"-experimentalfeatures",
"-developerencryptwallet",
// Create fresh wallets from a BIP39 mnemonic so their 24-word phrase can be
@@ -224,12 +226,11 @@ std::vector<std::string> EmbeddedDaemon::getChainParams()
void EmbeddedDaemon::setState(State s, const std::string& message)
{
state_ = s;
if (!message.empty()) {
if (s == State::Error) {
last_error_ = message;
}
if (!message.empty() && s == State::Error) {
std::lock_guard<std::mutex> lk(error_mutex_); // dedicated mutex — never taken with output_mutex_ held
last_error_ = message;
}
if (state_callback_) {
state_callback_(s, message);
}
@@ -488,6 +489,34 @@ bool EmbeddedDaemon::start(const std::string& binary_path)
return false;
}
external_daemon_detected_ = false;
// A previous dragonxd can release the RPC port well before it releases the datadir
// .lock — a graceful shutdown can take up to ~90s (see isDaemonProcessRunning). Starting
// into a still-held lock spawns a process that dies instantly with "Cannot obtain a lock
// on data directory"; the crash monitor reports that generically and, three times in
// ~12s, that is enough to trip the 3-strike restart cap before the lock's ~90s life
// elapses. Gate on the process actually still being alive, with a SHORT bounded wait
// (not the full ~90s — start() runs on the UI thread). Isolated starts (migrate-to-seed:
// skip_port_check_ / -datadir override) are exempt; they run their own datadir+port.
{
constexpr int kDatadirLockWaitPollMs = 100;
constexpr int kDatadirLockWaitMaxPolls = 3; // ~300ms total, breaks early on exit
bool stillRunning = false;
if (!skip_port_check_ && override_datadir_.empty()) {
stillRunning = isDaemonProcessRunning();
for (int i = 0; stillRunning && i < kDatadirLockWaitMaxPolls; ++i) {
std::this_thread::sleep_for(std::chrono::milliseconds(kDatadirLockWaitPollMs));
stillRunning = isDaemonProcessRunning();
}
}
const StartLockGateDecision gate =
evaluateDatadirLockGate(skip_port_check_, !override_datadir_.empty(), stillRunning);
if (!gate.proceed) {
VERBOSE_LOGF("[INFO] %s\n", gate.errorMessage);
setState(State::Error, gate.errorMessage);
return false;
}
}
setState(State::Starting, "Looking for dragonxd binary...");
@@ -516,6 +545,15 @@ bool EmbeddedDaemon::start(const std::string& binary_path)
args.push_back("-maxconnections=" + std::to_string(max_connections_));
}
// Host a RandomX stratum pool from this node (-stratum). Only v1.3.0+ daemons implement it; older
// ones ignore the unknown flag (no fatal arg check), and the Settings toggle is gated on daemon
// version, so this is only enabled against a daemon that supports it. Without -stratumallowip the
// daemon serves loopback only (safe default); a subnet opens it to that LAN.
if (stratum_enabled_) {
args.push_back("-stratum");
if (!stratum_allowip_.empty()) args.push_back("-stratumallowip=" + stratum_allowip_);
}
// Active wallet file (multi-wallet). The daemon loads <datadir>/<name>. Only pass it for a
// non-default name so the common case's command line is unchanged; skip during an isolated
// start (seed migration manages its own throwaway wallet).
@@ -543,6 +581,14 @@ bool EmbeddedDaemon::start(const std::string& binary_path)
args.push_back("-rescan");
}
// -reindex rebuilds the block index + chainstate from the raw blocks (fixes an unreadable/format-
// mismatched block DB). It's about the CHAIN, not the wallet, so it's independent of the wallet-repair
// chain above (and implies its own wallet rescan). One-shot, consumed here.
if (reindex_on_next_start_.exchange(false)) {
DEBUG_LOGF("[INFO] Adding -reindex flag to rebuild the block database from raw blocks\n");
args.push_back("-reindex");
}
// One-shot isolated-datadir override (migrate-to-seed flow): run this start against a
// throwaway datadir, plus any extra args (e.g. -connect=0). Consumed here so later starts
// revert to the normal datadir. The datadir's basename MUST be the assetchain name (DRAGONX)
@@ -557,8 +603,14 @@ bool EmbeddedDaemon::start(const std::string& binary_path)
override_extra_args_.clear();
if (!startProcess(daemon_path, args)) {
DEBUG_LOGF("[ERROR] Failed to start dragonxd process: %s\\n", last_error_.c_str());
setState(State::Error, "Failed to start dragonxd process");
// startProcess() sets a precise last_error_ (e.g. "dragonxd could not be executed:
// ... not executable or wrong architecture"). Surface THAT via setState — which also
// stores the Error message into last_error_ — instead of clobbering it with a generic
// string that would then be all getLastError()/the UI ever sees.
std::string detail = last_error_.empty() ? std::string("Failed to start dragonxd process")
: last_error_;
DEBUG_LOGF("[ERROR] %s\n", detail.c_str());
setState(State::Error, detail);
return false;
}
@@ -579,12 +631,28 @@ bool EmbeddedDaemon::start(const std::string& binary_path)
// Forward declaration — defined after startProcess
static DWORD findProcessByName(const char* name);
// Quote a single argument per the CommandLineToArgvW rules (MSDN) so a value containing a space or a
// quote is delivered as ONE argv token to the daemon instead of splitting/corrupting argv (L-02).
static std::string quoteWinArg(const std::string& arg) {
if (!arg.empty() && arg.find_first_of(" \t\n\v\"") == std::string::npos) return arg;
std::string out = "\"";
for (size_t i = 0; ; ++i) {
size_t nbs = 0;
while (i < arg.size() && arg[i] == '\\') { ++nbs; ++i; }
if (i == arg.size()) { out.append(nbs * 2, '\\'); break; }
if (arg[i] == '"') { out.append(nbs * 2 + 1, '\\'); out.push_back('"'); }
else { out.append(nbs, '\\'); out.push_back(arg[i]); }
}
out.push_back('"');
return out;
}
bool EmbeddedDaemon::startProcess(const std::string& binary_path, const std::vector<std::string>& args)
{
// Build command line
// Build command line (binary path always quoted; each arg quoted/escaped per Windows rules — L-02)
std::string cmd = "\"" + binary_path + "\"";
for (const auto& arg : args) {
cmd += " " + arg;
cmd += " " + quoteWinArg(arg);
}
DEBUG_LOGF("[INFO] Starting daemon: %s\n", cmd.c_str());
@@ -632,7 +700,10 @@ bool EmbeddedDaemon::startProcess(const std::string& binary_path, const std::vec
debug_log_path_.c_str(), debug_log_offset_);
}
// Launch daemon with CREATE_NEW_CONSOLE (hidden via SW_HIDE).
// Launch daemon windowless. Use CREATE_NO_WINDOW (NOT CREATE_NEW_CONSOLE): CREATE_NEW_CONSOLE
// allocates a console window that briefly flashes on screen before SW_HIDE can hide it, which is
// visible as a console-window flash on wallet launch. CREATE_NO_WINDOW gives the console child no
// window at all (same approach as the xmrig launcher). The daemon logs to debug.log, not a console.
// The daemon binary must NOT be in the data directory (%APPDATA%\Hush\DRAGONX)
// — it must be in <exe_dir>/dragonx/ to avoid conflicts with lock files and data.
STARTUPINFOA si;
@@ -642,7 +713,7 @@ bool EmbeddedDaemon::startProcess(const std::string& binary_path, const std::vec
si.dwFlags = STARTF_USESHOWWINDOW;
si.wShowWindow = SW_HIDE;
ZeroMemory(&pi, sizeof(pi));
char* cmd_line = _strdup(cmd.c_str());
BOOL success = CreateProcessA(
NULL,
@@ -650,7 +721,7 @@ bool EmbeddedDaemon::startProcess(const std::string& binary_path, const std::vec
NULL,
NULL,
FALSE,
CREATE_NEW_CONSOLE,
CREATE_NO_WINDOW,
NULL,
work_dir.c_str(),
&si,
@@ -745,11 +816,21 @@ void EmbeddedDaemon::drainOutput()
}
size_t currentSize = static_cast<size_t>(fileSize.QuadPart);
// Truncation / rotation detection. dragonxd shrinks debug.log on startup (ShrinkDebugFile keeps
// only the tail once it has grown large), so the file can become SMALLER than where we left off.
// Our offset was set to the PRE-shrink size at spawn, so without this reset it stays stranded ahead
// of the freshly-truncated file and we read NOTHING for the whole session — no block height (status
// bar shows "Block: 0") and, worse, no witness-rebuild progress, since the "Setting Initial Sapling
// Witness …" lines the warmup progress bar parses only exist in debug.log on Windows. On a shrink,
// restart from the new beginning; the parser is monotonic and converges as it reaches current output.
if (currentSize < debug_log_offset_) {
debug_log_offset_ = 0;
}
if (currentSize <= debug_log_offset_) {
CloseHandle(hFile);
return; // No new data
}
// Seek to where we left off
LARGE_INTEGER seekPos;
seekPos.QuadPart = static_cast<LONGLONG>(debug_log_offset_);
@@ -962,18 +1043,38 @@ bool EmbeddedDaemon::startProcess(const std::string& binary_path, const std::vec
last_error_ = "Failed to create pipe: " + std::string(strerror(errno));
return false;
}
// Self-pipe used purely as an exec-success/failure handshake, separate from
// the stdout pipe above. Both ends are close-on-exec, so a successful execv()
// closes the write end for free (parent reads EOF); on execv() failure the
// child writes errno here, so the parent learns synchronously instead of
// reporting State::Running for a child that never became dragonxd. We use
// pipe()+FD_CLOEXEC (not pipe2) because this POSIX branch is shared with
// macOS, which has no pipe2().
int execpipe[2];
if (pipe(execpipe) == -1) {
last_error_ = "Failed to create exec-status pipe: " + std::string(strerror(errno));
close(pipefd[0]);
close(pipefd[1]);
return false;
}
fcntl(execpipe[0], F_SETFD, FD_CLOEXEC);
fcntl(execpipe[1], F_SETFD, FD_CLOEXEC);
pid_t pid = fork();
if (pid == -1) {
last_error_ = "Fork failed: " + std::string(strerror(errno));
close(pipefd[0]);
close(pipefd[1]);
close(execpipe[0]);
close(execpipe[1]);
return false;
}
if (pid == 0) {
// Child process
close(pipefd[0]); // Close read end
close(pipefd[0]); // Close read end of the stdout pipe
close(execpipe[0]); // Child only writes the exec-status pipe
// Put child in its own process group so we can kill the entire
// group later (including dragonxd spawned by a wrapper script).
@@ -1040,22 +1141,61 @@ bool EmbeddedDaemon::startProcess(const std::string& binary_path, const std::vec
execv(binary_path.c_str(), argv.data());
}
// If we get here, exec failed
fprintf(stderr, "execv failed: %s\n", strerror(errno));
// If we get here, execv() failed — the child never became dragonxd.
// Capture errno before fprintf/strerror can clobber it, report it to
// the parent over the exec-status pipe (EINTR-safe), then exit.
int exec_errno = errno;
fprintf(stderr, "execv failed: %s\n", strerror(exec_errno));
ssize_t w;
do {
w = write(execpipe[1], &exec_errno, sizeof(exec_errno));
} while (w < 0 && errno == EINTR);
_exit(127);
}
// Parent process
close(pipefd[1]); // Close write end
close(pipefd[1]); // Close our copy of the stdout write end
close(execpipe[1]); // Must close our copy, or the read() below never sees EOF
// Exec-status handshake: EOF => execv() succeeded (its write end was closed
// on exec); a full sizeof(int) => execv() failed and the child sent errno.
int child_errno = 0;
size_t got = 0;
char* ep = reinterpret_cast<char*>(&child_errno);
for (;;) {
ssize_t n = read(execpipe[0], ep + got, sizeof(child_errno) - got);
if (n == 0) break; // EOF: exec succeeded
if (n < 0) { if (errno == EINTR) continue; break; } // other error: assume success
got += static_cast<size_t>(n);
if (got >= sizeof(child_errno)) break; // full errno: exec failed
}
close(execpipe[0]);
if (got >= sizeof(child_errno)) {
// execv() never replaced the child; it fprintf'd and _exit(127)'d. Reap
// the already-dead zombie here — monitorProcess() is only started after
// this function returns true, so there is no competing reaper.
close(pipefd[0]);
int status;
waitpid(pid, &status, 0);
last_error_ = "dragonxd could not be executed: " + std::string(strerror(child_errno)) +
" — not executable or wrong architecture";
return false;
}
stdout_fd_ = pipefd[0];
// Also set process group from parent side (race with child's setpgid)
setpgid(pid, pid);
// Best-effort: the child already calls setpgid(0, 0); this parent-side call
// just closes the fork/exec race window. A failure here is not fatal to
// startup, so we log rather than abort.
if (setpgid(pid, pid) != 0) {
DEBUG_LOGF("[WARN] setpgid(%d) from parent failed: %s\n", (int)pid, strerror(errno));
}
// Set non-blocking
int flags = fcntl(stdout_fd_, F_GETFL, 0);
fcntl(stdout_fd_, F_SETFL, flags | O_NONBLOCK);
process_pid_ = pid;
return true;
}
@@ -1135,17 +1275,21 @@ double EmbeddedDaemon::getMemoryUsageMB() const
bool EmbeddedDaemon::isRunning() const
{
// Read the atomic state_ instead of calling waitpid() here. monitorProcess()
// is the sole thread allowed to waitpid() process_pid_ during normal operation.
// Calling waitpid() from this method too (as it used to, and this is invoked
// from the UI thread nearly every frame) meant whichever thread reaped the
// child's exit first consumed the status; if isRunning() won that race,
// monitorProcess() never saw the exit, so crash_count_ / the decoded exit
// code / the State::Error transition were all silently lost. Mirrors the
// fix already in XmrigManager::isRunning().
if (process_pid_ <= 0) return false;
int status;
pid_t result = waitpid(process_pid_, &status, WNOHANG);
if (result == 0) {
// Still running
return true;
}
return false;
const State s = state_.load(std::memory_order_relaxed);
// State::Stopping is included: stop()'s graceful/SIGTERM wait loops poll
// isRunning() while state_ == Stopping — before the process has actually
// terminated — and must keep seeing "alive" to wait/escalate correctly.
return (s == State::Running || s == State::Stopping);
}
void EmbeddedDaemon::drainOutput()

View File

@@ -79,7 +79,9 @@ public:
/**
* @brief Get last error message
*/
const std::string& getLastError() const { return last_error_; }
// Copy under lock: last_error_ is written from the monitor thread (setState on an unexpected exit)
// while the UI thread reads it — a reference would be a torn-read / use-after-free race (M-04).
std::string getLastError() const { std::lock_guard<std::mutex> lk(error_mutex_); return last_error_; }
/**
* @brief Get dragonxd process output (thread-safe copy)
@@ -180,6 +182,7 @@ public:
* @brief Set maximum peer connections (0 = use daemon default)
*/
void setMaxConnections(int v) { max_connections_ = v; }
void setStratumHosting(bool enabled, const std::string& allowIp) { stratum_enabled_ = enabled; stratum_allowip_ = allowIp; }
/**
* @brief Request a blockchain rescan on the next daemon start
@@ -206,6 +209,13 @@ public:
void setSalvageOnNextStart(bool v) { salvage_on_next_start_ = v; }
bool salvageOnNextStart() const { return salvage_on_next_start_.load(); }
// -reindex: rebuild the block index + chainstate from the raw blocks (blk*.dat) on startup. One-shot,
// consumed on the next start. Offered when the node aborts on an unreadable block database (a
// daemon-vs-chaindata format mismatch after an update, or a corrupt index). It implies a wallet
// rescan, so it's the block-DB analogue of -salvagewallet and coexists with the wallet-repair flags.
void setReindexOnNextStart(bool v) { reindex_on_next_start_ = v; }
bool reindexOnNextStart() const { return reindex_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
@@ -235,6 +245,32 @@ public:
*/
static bool isDaemonProcessRunning();
/** Decision returned by evaluateDatadirLockGate(): whether start() may spawn now. */
struct StartLockGateDecision {
bool proceed = true; // false => bail before spawning
const char* errorMessage = ""; // set (a string literal) when proceed == false
};
/**
* @brief Pure decision for start(): bail because a previous dragonxd still holds the
* shared datadir lock? Isolated instances (skip_port_check_ / an active -datadir
* override) are exempt — they run their own throwaway datadir+port and can coexist
* with the main daemon. Does no process/fs I/O itself (the caller does the probing),
* so it is directly unit-testable; defined inline so tests need only this header.
*/
static StartLockGateDecision evaluateDatadirLockGate(bool skipPortCheck,
bool isolatedOverride,
bool stillRunningAfterWait)
{
if (skipPortCheck || isolatedOverride) return {true, ""};
if (stillRunningAfterWait) {
return {false,
"A previous dragonxd is still shutting down and holding the data "
"directory lock. Retrying shortly…"};
}
return {true, ""};
}
/** @brief Is an arbitrary TCP port currently in use on localhost? (used to pick a free port) */
static bool tcpPortInUse(int port);
@@ -253,6 +289,7 @@ private:
std::atomic<State> state_{State::Stopped};
std::atomic<bool> external_daemon_detected_{false};
std::string last_error_;
mutable std::mutex error_mutex_; // protects last_error_ (written by main + monitor threads)
mutable std::mutex output_mutex_; // protects process_output_
std::string process_output_;
StateCallback state_callback_;
@@ -275,11 +312,14 @@ private:
std::atomic<bool> should_stop_{false};
std::set<std::string> debug_categories_;
int max_connections_ = 0; // 0 = daemon default
bool stratum_enabled_ = false; // -stratum: host a RandomX pool (v1.3.0+; older daemons ignore it)
std::string stratum_allowip_; // -stratumallowip subnet (empty = daemon default: loopback only)
std::string wallet_file_; // -wallet=<name> for the active wallet; empty/"wallet.dat" = default
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::atomic<bool> reindex_on_next_start_{false}; // -reindex flag for next start (rebuild block DB)
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

@@ -54,6 +54,16 @@ SeedWalletResult SeedWalletCreator::create(bool keepDatadir,
// RPC port. So the wallet lives in <base>/DRAGONX; `base` is the migration root we clean up.
const std::string base = util::Platform::getConfigDir() + "/seed-migrate";
const std::string dataDir = base + "/DRAGONX";
// W3-2: never blindly wipe a pre-existing temp seed wallet. A prior migration that swept funds into
// it but was abandoned or crashed before adopting would otherwise have its (fund-bearing) wallet
// destroyed here. A completed migration removes this dir on adopt, so a leftover means an unfinished
// one — refuse and point the user at it rather than silently destroying it.
if (fs::exists(dataDir + "/wallet.dat")) {
r.error = "A previous seed migration looks unfinished — its temporary wallet is still at\n" + base +
"\nResume or cancel it first. If you are certain its funds are already in your main "
"wallet, delete that folder and try again.";
return r;
}
fs::remove_all(base, ec);
fs::create_directories(dataDir, ec);
if (ec) { r.error = "Could not create the temporary wallet directory."; return r; }
@@ -136,6 +146,14 @@ SeedWalletResult SeedWalletCreator::create(bool keepDatadir,
}
}
// W1-2: never hand back a live seed on a failure path. If the mnemonic was exported but a
// later step failed (empty address, or z_getnewaddress threw), the caller discards this
// result without wiping it, which would leave the seed resident. Success keeps it deliberately.
if (!r.ok && !r.seedPhrase.empty()) {
sodium_memzero(&r.seedPhrase[0], r.seedPhrase.size());
r.seedPhrase.clear();
}
// 7. Stop the isolated node (graceful; it flushes its tiny empty chain quickly).
cli.disconnect();
temp.stop(20000);

View File

@@ -2,7 +2,7 @@
// Copyright 2024-2026 The Hush Developers
// Released under the GPLv3
//
// xmrig_manager.cpp — Pool mining process management via xmrig-hac.
// xmrig_manager.cpp — Pool mining process management via drg-xmrig.
// Spawns xmrig, monitors via HTTP API, tracks hashrate and shares.
#include "xmrig_manager.h"
@@ -23,6 +23,7 @@
#include <curl/curl.h>
#include "../util/logger.h"
#include "../util/platform.h"
#include "../util/pool_registry.h"
#ifdef _WIN32
@@ -89,8 +90,32 @@ static std::string getConfigDir() {
// libcurl write callback
static size_t curlWriteCb(void* ptr, size_t sz, size_t n, void* userdata) {
auto* s = static_cast<std::string*>(userdata);
s->append(static_cast<char*>(ptr), sz * n);
return sz * n;
const size_t add = sz * n;
// Stats JSON (local xmrig HTTP API + pool API) is tiny; refuse an unbounded body from a hostile or
// MITM'd endpoint so it can't grow this string until OOM. Returning < add aborts the transfer. (L-02)
constexpr size_t kMaxStatsBytes = 1u << 20; // 1 MiB
if (s->size() + add > kMaxStatsBytes) return 0;
s->append(static_cast<char*>(ptr), add);
return add;
}
// True if `host` (already stripped of scheme+port) is a loopback/private/link-local/single-label target
// that a public mining pool would never be — used to refuse a background stats GET to it (M-09).
static bool hostLooksInternal(const std::string& host) {
if (host.empty() || host == "localhost") return true;
if (host.rfind("127.", 0) == 0 || host.rfind("10.", 0) == 0 ||
host.rfind("192.168.", 0) == 0 || host.rfind("169.254.", 0) == 0) return true;
if (host.rfind("172.", 0) == 0) { // 172.16.0.0 - 172.31.255.255
const int second = std::atoi(host.c_str() + 4);
if (second >= 16 && second <= 31) return true;
}
if (host.find(':') != std::string::npos) { // IPv6 literal: loopback / ULA / link-local
if (host == "::1" || host.rfind("fc", 0) == 0 || host.rfind("fd", 0) == 0 ||
host.rfind("fe80", 0) == 0) return true;
}
if (host.size() >= 6 && host.compare(host.size() - 6, 6, ".local") == 0) return true;
if (host.find('.') == std::string::npos) return true; // bare single-label name = LAN/hosts, not a pool
return false;
}
// ============================================================================
@@ -100,9 +125,14 @@ static size_t curlWriteCb(void* ptr, size_t sz, size_t n, void* userdata) {
XmrigManager::XmrigManager() = default;
XmrigManager::~XmrigManager() {
should_stop_ = true;
if (isRunning()) {
stop(3000);
}
// Join a monitor thread left joinable by an unexpected xmrig exit (State::Error, so isRunning() is
// false and stop() above was skipped) — std::thread's destructor would otherwise std::terminate(). (M-04)
if (monitor_thread_.joinable())
monitor_thread_.join();
}
// ============================================================================
@@ -116,32 +146,18 @@ std::string XmrigManager::findXmrigBinary() {
return path;
}
// Fallback: system PATH
// Fallback: system PATH — windowless so it never flashes a console.
#ifdef _WIN32
FILE* f = _popen("where xmrig.exe 2>nul", "r");
std::string out = util::Platform::runHiddenCapture("where xmrig.exe");
#else
FILE* f = popen("which xmrig 2>/dev/null", "r");
#endif
if (f) {
char line[512];
if (fgets(line, sizeof(line), f)) {
std::string s(line);
while (!s.empty() && (s.back() == '\n' || s.back() == '\r'))
s.pop_back();
if (!s.empty() && fs::exists(s)) {
#ifdef _WIN32
_pclose(f);
#else
pclose(f);
#endif
return s;
}
}
#ifdef _WIN32
_pclose(f);
#else
pclose(f);
std::string out = util::Platform::runHiddenCapture("which xmrig");
#endif
{
std::string s = out;
const auto nl = s.find_first_of("\r\n"); // first line only
if (nl != std::string::npos) s.erase(nl);
while (!s.empty() && (s.back() == ' ' || s.back() == '\t')) s.pop_back();
if (!s.empty() && fs::exists(s)) return s;
}
return {};
@@ -208,22 +224,43 @@ bool XmrigManager::generateConfig(const Config& cfg, const std::string& outPath)
try {
fs::create_directories(fs::path(outPath).parent_path());
std::ofstream ofs(outPath);
if (!ofs.is_open()) {
last_error_ = "Cannot write xmrig config: " + outPath;
const std::string dumped = j.dump(4);
#ifndef _WIN32
// Create the config 0600 AT CREATION (open with mode) so the API token + wallet address are never
// in a world/group-readable file — even for a local attacker who opened it in the old
// create-then-chmod window and held the fd open across the chmod. (L-01)
int fd = ::open(outPath.c_str(), O_WRONLY | O_CREAT | O_TRUNC, 0600);
if (fd < 0) {
setLastError("Cannot write xmrig config: " + outPath);
DEBUG_LOGF("[ERROR] XmrigManager: %s\n", last_error_.c_str());
return false;
}
ofs << j.dump(4);
ofs.close();
#ifndef _WIN32
// 0600 permissions — only owner can read/write
chmod(outPath.c_str(), 0600);
#endif
size_t off = 0;
bool wrote = true;
while (off < dumped.size()) {
ssize_t nw = ::write(fd, dumped.data() + off, dumped.size() - off);
if (nw <= 0) { wrote = false; break; }
off += static_cast<size_t>(nw);
}
::close(fd);
if (!wrote) {
setLastError("Cannot write xmrig config: " + outPath);
return false;
}
return true;
#else
std::ofstream ofs(outPath, std::ios::trunc);
if (!ofs.is_open()) {
setLastError("Cannot write xmrig config: " + outPath);
DEBUG_LOGF("[ERROR] XmrigManager: %s\n", last_error_.c_str());
return false;
}
ofs << dumped;
ofs.close();
return true;
#endif
} catch (const std::exception& e) {
last_error_ = std::string("Config write error: ") + e.what();
setLastError(std::string("Config write error: ") + e.what());
DEBUG_LOGF("[ERROR] XmrigManager: %s\n", last_error_.c_str());
return false;
}
@@ -235,19 +272,22 @@ bool XmrigManager::generateConfig(const Config& cfg, const std::string& outPath)
bool XmrigManager::start(const Config& cfg) {
if (state_ == State::Running || state_ == State::Starting) {
last_error_ = "Already running";
setLastError("Already running");
DEBUG_LOGF("[WARN] XmrigManager: %s\n", last_error_.c_str());
return false;
}
state_ = State::Starting;
should_stop_ = false;
last_error_.clear();
setLastError(std::string());
{
std::lock_guard<std::mutex> lk(output_mutex_);
process_output_.clear();
}
stats_ = PoolStats{};
{
std::lock_guard<std::mutex> lk(stats_mutex_);
stats_ = PoolStats{};
}
// Extract pool hostname for stats API queries
{
@@ -264,7 +304,7 @@ bool XmrigManager::start(const Config& cfg) {
// Find binary
std::string binary = findXmrigBinary();
if (binary.empty()) {
last_error_ = "xmrig binary not found";
setLastError("xmrig binary not found");
state_ = State::Error;
DEBUG_LOGF("[ERROR] XmrigManager: xmrig binary not found\n");
return false;
@@ -292,7 +332,11 @@ bool XmrigManager::start(const Config& cfg) {
return false;
}
// Start monitor thread
// Join a prior monitor thread before move-assigning: if xmrig exited unexpectedly, monitorProcess set
// State::Error and returned, leaving monitor_thread_ joinable — move-assigning over a joinable
// std::thread calls std::terminate() and aborts the whole wallet. (M-04)
if (monitor_thread_.joinable())
monitor_thread_.join();
monitor_thread_ = std::thread(&XmrigManager::monitorProcess, this);
state_ = State::Running;
DEBUG_LOGF("[INFO] XmrigManager: started\n");
@@ -367,7 +411,7 @@ bool XmrigManager::startProcess(const std::string& xmrigPath, const std::string&
HANDLE hRead = nullptr, hWrite = nullptr;
if (!CreatePipe(&hRead, &hWrite, &sa, 0)) {
last_error_ = "CreatePipe failed";
setLastError("CreatePipe failed");
DEBUG_LOGF("[ERROR] XmrigManager: %s\n", last_error_.c_str());
return false;
}
@@ -399,7 +443,7 @@ bool XmrigManager::startProcess(const std::string& xmrigPath, const std::string&
char errBuf[256];
FormatMessageA(FORMAT_MESSAGE_FROM_SYSTEM | FORMAT_MESSAGE_IGNORE_INSERTS,
NULL, err, 0, errBuf, sizeof(errBuf), NULL);
last_error_ = "CreateProcess failed for xmrig (error " + std::to_string(err) + "): " + errBuf;
setLastError("CreateProcess failed for xmrig (error " + std::to_string(err) + "): " + errBuf);
DEBUG_LOGF("[ERROR] XmrigManager: %s\nCommand: %s\n", last_error_.c_str(), cmdLine.c_str());
return false;
}
@@ -450,14 +494,14 @@ void XmrigManager::drainOutput() {
bool XmrigManager::startProcess(const std::string& xmrigPath, const std::string& cfgPath, int threads) {
int pipefd[2];
if (pipe(pipefd) != 0) {
last_error_ = "pipe() failed";
setLastError("pipe() failed");
DEBUG_LOGF("[ERROR] XmrigManager: %s\n", last_error_.c_str());
return false;
}
pid_t pid = fork();
if (pid < 0) {
last_error_ = "fork() failed";
setLastError("fork() failed");
DEBUG_LOGF("[ERROR] XmrigManager: %s\n", last_error_.c_str());
close(pipefd[0]);
close(pipefd[1]);
@@ -628,7 +672,7 @@ void XmrigManager::monitorProcess() {
if (GetExitCodeProcess(process_handle_, &exitCode) && exitCode != STILL_ACTIVE) {
DEBUG_LOGF("[ERROR] XmrigManager: process exited (code %lu)\n", exitCode);
state_ = State::Error;
last_error_ = "xmrig process exited unexpectedly";
setLastError("xmrig process exited unexpectedly");
break;
}
}
@@ -639,7 +683,7 @@ void XmrigManager::monitorProcess() {
if (ret == process_pid_ || ret < 0) {
DEBUG_LOGF("[ERROR] XmrigManager: process exited (waitpid=%d)\n", ret);
state_ = State::Error;
last_error_ = "xmrig process exited unexpectedly";
setLastError("xmrig process exited unexpectedly");
break;
}
}
@@ -779,6 +823,11 @@ void XmrigManager::fetchPoolApiStats() {
// own API shape (pool.dragonx.is = custom /api/stats; pool.dragonx.cc = Miningcore
// /api/pools); unknown/custom hosts fall back to the .is convention.
const util::KnownPool* known = util::findKnownPoolByUrl(pool_host_);
// SSRF guard: for an UNKNOWN (user-typed) pool host, don't let the wallet issue a background GET to a
// loopback/private/link-local/single-label target — those aren't public mining pools, and a
// paste-a-pool-config lure could otherwise point us at an internal host. Known pools use their trusted
// registry statsUrl and are exempt. (M-09)
if (!known && hostLooksInternal(pool_host_)) return;
const std::string url = known ? known->statsUrl
: ("https://" + pool_host_ + "/api/stats");
@@ -857,25 +906,18 @@ void XmrigManager::startVersionDetection()
std::thread([]() {
const std::string bin = findXmrigBinary();
std::string ver;
if (!bin.empty()) {
const std::string cmd = "\"" + bin + "\" --version 2>&1";
#ifdef _WIN32
FILE* fp = _popen(cmd.c_str(), "r");
#else
FILE* fp = popen(cmd.c_str(), "r");
#endif
if (fp) {
std::string out;
char buf[256];
size_t n;
while ((n = fread(buf, 1, sizeof(buf), fp)) > 0) out.append(buf, n);
#ifdef _WIN32
_pclose(fp);
#else
pclose(fp);
#endif
ver = parseMinerVersion(out);
}
// Don't hand a path containing shell/cmd metacharacters to popen()'s shell — bin is normally an
// app-controlled path, but this closes command injection if it ever isn't. (M-10)
// Reject only chars that stay shell-special INSIDE the double-quotes we wrap bin in ("\"" + bin + "\"")
// on cmd.exe or /bin/sh. Parens are inert when quoted, so they're excluded — otherwise common Windows
// paths like "C:\Program Files (x86)\..." would be rejected and version detection would silently fail. (M-10)
const bool binShellSafe =
!bin.empty() && bin.find_first_of("\"'`$;&|<>^%\n\r") == std::string::npos;
if (binShellSafe) {
// Windowless capture (mergeStderr: xmrig may print --version to stderr) — never flashes.
const std::string cmd = "\"" + bin + "\" --version";
const std::string out = util::Platform::runHiddenCapture(cmd, /*mergeStderr=*/true);
if (!out.empty()) ver = parseMinerVersion(out);
}
std::lock_guard<std::mutex> lk(g_installed_ver_mutex);
g_installed_ver = ver;

View File

@@ -86,8 +86,10 @@ public:
bool isRunning() const;
State getState() const { return state_.load(std::memory_order_relaxed); }
const PoolStats& getStats() const { return stats_; }
const std::string& getLastError() const { return last_error_; }
// Return COPIES under lock: stats_ and last_error_ are mutated by the monitor thread while the UI
// thread reads them, so handing out a reference is a torn-read / use-after-free race (M-03, M-04).
PoolStats getStats() const { std::lock_guard<std::mutex> lk(stats_mutex_); return stats_; }
std::string getLastError() const { std::lock_guard<std::mutex> lk(error_mutex_); return last_error_; }
/// Thread count requested at start() — available immediately, unlike
/// PoolStats::threads_active which requires an API response.
@@ -156,11 +158,14 @@ private:
void monitorProcess();
void drainOutput();
void appendOutput(const char* data, size_t len);
// Set last_error_ under error_mutex_ (writers run on both the main thread and the monitor thread).
void setLastError(std::string e) { std::lock_guard<std::mutex> lk(error_mutex_); last_error_ = std::move(e); }
void fetchStatsHttp(); // Blocking HTTP call — runs on monitor thread only
void fetchPoolApiStats(); // Fetch pool-side stats (hashrate) from pool HTTP API
std::atomic<State> state_{State::Stopped};
std::string last_error_;
mutable std::mutex error_mutex_; // guards last_error_ (written by main + monitor threads)
mutable std::mutex output_mutex_;
std::string process_output_;

View File

@@ -46,25 +46,31 @@ bool AddressBook::load()
entries_.clear();
if (j.contains("entries") && j["entries"].is_array()) {
size_t skipped = 0;
for (const auto& entry : j["entries"]) {
AddressBookEntry e;
e.label = entry.value("label", "");
e.address = entry.value("address", "");
e.notes = entry.value("notes", "");
// Legacy entries (no "scope") migrate to "global" so nothing disappears when
// multi-wallet scoping lands — a contact you already had stays visible everywhere.
e.scope = entry.value("scope", "global");
e.avatar = entry.value("avatar", "");
if (!e.address.empty()) {
entries_.push_back(e);
}
// W6-3: skip (and count) a malformed element rather than letting one bad entry throw and
// abort the whole load — which would discard EVERY contact (entries_ was already cleared).
if (!entry.is_object()) { ++skipped; continue; }
try {
AddressBookEntry e;
e.label = entry.value("label", "");
e.address = entry.value("address", "");
e.notes = entry.value("notes", "");
// Legacy entries (no "scope") migrate to "global" so nothing disappears when
// multi-wallet scoping lands — a contact you already had stays visible everywhere.
e.scope = entry.value("scope", "global");
e.avatar = entry.value("avatar", "");
if (!e.address.empty()) entries_.push_back(e);
} catch (const std::exception&) { ++skipped; }
}
if (skipped > 0)
DEBUG_LOGF("Address book: skipped %zu malformed entr%s\n", skipped, skipped == 1 ? "y" : "ies");
}
DEBUG_LOGF("Address book loaded: %zu entries\n", entries_.size());
++revision_;
return true;
} catch (const std::exception& e) {
DEBUG_LOGF("Error loading address book: %s\n", e.what());
return false;
@@ -116,6 +122,7 @@ bool AddressBook::addEntry(const AddressBookEntry& entry)
}
entries_.push_back(entry);
++revision_;
return save();
}
@@ -131,6 +138,7 @@ bool AddressBook::updateEntry(size_t index, const AddressBookEntry& entry)
}
entries_[index] = entry;
++revision_;
return save();
}
@@ -141,6 +149,7 @@ bool AddressBook::removeEntry(size_t index)
}
entries_.erase(entries_.begin() + index);
++revision_;
return save();
}
@@ -154,7 +163,7 @@ int AddressBook::reattachLegacyScopes(const std::string& scopeId)
e.scope = scopeId;
++rescoped;
}
if (rescoped > 0) save();
if (rescoped > 0) { ++revision_; save(); }
return rescoped;
}

View File

@@ -4,6 +4,7 @@
#pragma once
#include <cstdint>
#include <string>
#include <vector>
@@ -121,11 +122,18 @@ public:
*/
size_t size() const { return entries_.size(); }
/**
* @brief Monotonic counter bumped on every content change (add/update/remove/load/sweep). Consumers
* (e.g. the chat conversation-list memo) key their caches off this so an IN-PLACE edit — a rename or
* address change that keeps size() constant — still invalidates them.
*/
std::uint64_t revision() const { return revision_; }
/**
* @brief UI-sweep ONLY: replace the in-memory entries WITHOUT persisting to disk, so the sweep can
* seed demo contacts and restore the real book without a disk write. Do not use outside the sweep.
*/
void sweepSetEntries(std::vector<AddressBookEntry> e) { entries_ = std::move(e); }
void sweepSetEntries(std::vector<AddressBookEntry> e) { entries_ = std::move(e); ++revision_; }
/**
* @brief Check if empty
@@ -134,6 +142,7 @@ public:
private:
std::vector<AddressBookEntry> entries_;
std::uint64_t revision_ = 0;
std::string file_path_;
};

View File

@@ -0,0 +1,37 @@
#pragma once
#include <string>
// Pure routing decision for resuming a pending migrate-to-seed flow (finding W3-3). Kept free of
// App/UI/RPC state so the highest-risk branch — where a reopened migration lands — is unit-testable
// and reviewable in isolation. App::showSeedMigrationDialog feeds it the persisted state + live
// connectivity and switches on the result. See src/app_network.cpp.
namespace dragonx {
enum class MigrationResume {
Intro, // no pending migration → start fresh at the intro
Confirming, // a sweep txid is persisted → resume at the confirm/adopt gate (re-derived from chain)
RetrackOpid, // a sweep opid (but no txid yet) is persisted AND we're connected → re-poll it
SweepGate, // otherwise → the dismissable Sweep step (reload balance, offer re-sweep)
};
// Decide where reopening the migration dialog lands.
//
// Invariant: the txid outranks the opid — once a sweep resolves to a txid the opid is cleared in the
// same settings write, so a persisted txid always means "past the sweep". A persisted opid is only
// re-tracked when connected, because the buttonless "Sweeping" spinner relies on the opid poller
// (which needs an RPC connection) to ever exit; disconnected, we fall back to the dismissable Sweep
// gate (which reloads the balance and, if the earlier sweep already drained it, short-circuits to
// adopt) — never trapping the user.
inline MigrationResume decideSeedMigrationResume(bool pending,
bool haveDest,
const std::string& sweepTxid,
const std::string& sweepOpid,
bool connected) {
if (!pending || !haveDest) return MigrationResume::Intro;
if (!sweepTxid.empty()) return MigrationResume::Confirming;
if (!sweepOpid.empty() && connected) return MigrationResume::RetrackOpid;
return MigrationResume::SweepGate;
}
} // namespace dragonx

View File

@@ -19,12 +19,13 @@ std::vector<size_t> sortedSpendableAddressIndices(const std::vector<AddressInfo>
for (size_t i = 0; i < addresses.size(); ++i) {
const auto& address = addresses[i];
if (!address.isSpendable()) continue;
if (requirePositiveBalance && address.balance <= 0.0) continue;
// Rank/filter by the CONFIRMED balance — an address holding only 0-conf change can't be sent from.
if (requirePositiveBalance && address.spendableBalance <= 0.0) continue;
indices.push_back(i);
}
std::sort(indices.begin(), indices.end(), [&](size_t lhs, size_t rhs) {
return addresses[lhs].balance > addresses[rhs].balance;
return addresses[lhs].spendableBalance > addresses[rhs].spendableBalance;
});
return indices;
}
@@ -34,8 +35,8 @@ int bestSpendableAddressIndex(const std::vector<AddressInfo>& addresses)
int bestIndex = -1;
double bestBalance = 0.0;
for (size_t i = 0; i < addresses.size(); ++i) {
if (addresses[i].isSpendable() && addresses[i].balance > bestBalance) {
bestBalance = addresses[i].balance;
if (addresses[i].isSpendable() && addresses[i].spendableBalance > bestBalance) {
bestBalance = addresses[i].spendableBalance;
bestIndex = static_cast<int>(i);
}
}

View File

@@ -21,13 +21,17 @@ namespace dragonx {
*/
struct AddressInfo {
std::string address;
double balance = 0.0;
double balance = 0.0; // DISPLAY total incl. pending 0-conf change (minconf=0)
std::string type; // "shielded" or "transparent"
bool has_spending_key = true; // false for view-only (imported via z_importviewingkey)
// For display
std::string label;
// CONFIRMED balance (minconf>=1) — what z_sendmany can actually spend now. Kept last so positional
// brace-init of the leading fields (used in tests) still compiles.
double spendableBalance = 0.0;
// Derived
bool isZAddr() const { return !address.empty() && address[0] == 'z'; }
bool isShielded() const { return type == "shielded"; }
@@ -252,12 +256,18 @@ struct WalletState {
// Sync status
SyncInfo sync;
// Balances (named to match UI usage)
double privateBalance = 0.0; // shielded balance
// Balances (named to match UI usage). These are the DISPLAY totals — minconf=0, so they include the
// user's own pending change and don't crater during an unconfirmed send.
double privateBalance = 0.0; // shielded balance (display, incl. pending change)
double transparentBalance = 0.0;
double totalBalance = 0.0;
double unconfirmedBalance = 0.0;
double unconfirmedBalance = 0.0; // = totalBalance - spendableTotalBalance (the pending portion)
// CONFIRMED / spendable totals (minconf>=1) — what can actually be sent right now. z_sendmany runs at
// minconf=1, so the Send form / Max / spend validation must size off these, never the display totals.
double spendablePrivateBalance = 0.0;
double spendableTransparentBalance = 0.0;
double spendableTotalBalance = 0.0;
// Aliases for backward compatibility
double& shielded_balance = privateBalance;
double& transparent_balance = transparentBalance;
@@ -302,6 +312,7 @@ struct WalletState {
// Timestamps for refresh logic
int64_t last_balance_update = 0;
int64_t last_address_update = 0; // set when an address-list refresh applies; 0 = never loaded yet
int64_t last_tx_update = 0;
int64_t last_peer_update = 0;
int64_t last_mining_update = 0;
@@ -325,6 +336,7 @@ struct WalletState {
sync = SyncInfo{};
privateBalance = transparentBalance = totalBalance = 0.0;
unconfirmedBalance = 0.0;
spendablePrivateBalance = spendableTransparentBalance = spendableTotalBalance = 0.0;
encrypted = false;
locked = false;
unlocked_until = 0;
@@ -335,6 +347,15 @@ struct WalletState {
transactions.clear();
peers.clear();
bannedPeers.clear();
// W6-1: reset node-level mining state too — the daemon restarts on a wallet switch (mining
// stops), so leaving the previous wallet's hashrate/blocks would show stale mining stats.
mining = MiningInfo{};
pool_mining = PoolMiningState{};
// After a disconnect / wallet switch nothing is freshly known, so drop the "last successful
// refresh" stamps. Otherwise the pre-teardown time survives and, on reconnect, the staleness
// badge (and any "updated X ago" reader) briefly reports it as current until the first refresh
// re-stamps it. All readers treat 0 as "never" (formatTimeAgoShort/timeAgo return "").
last_balance_update = last_address_update = last_tx_update = last_peer_update = last_mining_update = 0;
}
// Rebuild combined addresses list from z/t lists

View File

@@ -721,20 +721,105 @@ static void handleDisplayScaleChange(SDL_Window* window, float newScale,
}
}
#if !defined(_WIN32)
#include <csignal>
#include <cstring>
#include <unistd.h>
#include <fcntl.h>
#if defined(__has_include)
# if __has_include(<execinfo.h>)
# include <execinfo.h>
# define DRAGONX_HAVE_BACKTRACE 1
# endif
#endif
// Absolute path to the crash log, filled at install time so the async-signal handler needs no
// allocation. (POSIX counterpart of the Windows SEH CrashHandler above — W7-3.)
static char g_crashLogPath[1024] = {0};
// Async-signal-safe crash handler: only open()/write()/backtrace_symbols_fd()/raise() are used —
// no stdio, std::filesystem or malloc (all unsafe inside a signal handler).
static void PosixCrashHandler(int sig)
{
int fd = g_crashLogPath[0] ? open(g_crashLogPath, O_WRONLY | O_CREAT | O_APPEND, 0600) : -1;
if (fd >= 0) {
auto put = [fd](const char* s) { ssize_t n = write(fd, s, std::strlen(s)); (void)n; };
put("\n=== CRASH: signal ");
char num[16]; int i = 0, v = sig; // signal number -> decimal, no stdio
if (v == 0) { num[i++] = '0'; }
else { char tmp[16]; int t = 0; while (v > 0) { tmp[t++] = char('0' + v % 10); v /= 10; }
while (t > 0) num[i++] = tmp[--t]; }
num[i] = '\n';
ssize_t nn = write(fd, num, i + 1); (void)nn;
#ifdef DRAGONX_HAVE_BACKTRACE
void* frames[64];
int nframes = backtrace(frames, 64);
backtrace_symbols_fd(frames, nframes, fd); // async-signal-safe
#endif
put("=== END CRASH ===\n");
close(fd);
}
// Restore the default disposition and re-raise so we still get a core dump / normal termination.
signal(sig, SIG_DFL);
raise(sig);
}
static void installPosixCrashHandler(const std::string& crashLogPath)
{
std::snprintf(g_crashLogPath, sizeof(g_crashLogPath), "%s", crashLogPath.c_str());
struct sigaction sa;
std::memset(&sa, 0, sizeof(sa));
sa.sa_handler = PosixCrashHandler;
sigemptyset(&sa.sa_mask);
sa.sa_flags = 0;
for (int sig : {SIGSEGV, SIGABRT, SIGBUS, SIGFPE, SIGILL}) {
sigaction(sig, &sa, nullptr);
}
}
#endif // !_WIN32
int main(int argc, char* argv[])
{
// Ensure ObsidianDragon config directory exists early (before any file I/O)
{
std::string odDir = dragonx::util::Platform::getObsidianDragonDir();
std::error_code ec;
std::filesystem::create_directories(odDir, ec);
std::string odErr;
if (!dragonx::util::Platform::ensureDirectory(odDir, &odErr)) {
// Pre-App-init: nothing (ini, logs, config) can persist if this fails, and the
// Windows log redirect below isn't set up yet — report loudly before any setup.
std::fprintf(stderr, "%s\n", odErr.c_str());
#ifdef _WIN32
MessageBoxA(nullptr, odErr.c_str(), DRAGONX_APP_NAME, MB_OK | MB_ICONERROR);
#endif
return 1;
}
}
#ifdef _WIN32
// Redirect stdout/stderr to a log file so diagnostic output is visible
// even when built as a GUI app (WIN32_EXECUTABLE hides the console).
// W7-2: initialize the app-level Logger's file sink on ALL platforms so LOG/LOGF/VERBOSE_LOGF are
// actually persisted to dragonx-debug.log. Previously init() was never called, so on Linux/macOS the
// file never existed at all (the Windows-only stdout freopen below is a separate mechanism).
{
std::string logPath = (std::filesystem::path(dragonx::util::Platform::getObsidianDragonDir()) / "dragonx-debug.log").string();
const std::string logPath =
(std::filesystem::path(dragonx::util::Platform::getObsidianDragonDir()) / "dragonx-debug.log").string();
dragonx::util::Logger::instance().init(logPath);
}
#if !defined(_WIN32)
// W7-3: install the POSIX crash handler (the Windows SEH filter is installed below). A segfault or
// abort now leaves a backtrace in dragonx-crash.log instead of vanishing silently on Linux/macOS.
{
const std::string crashPath =
(std::filesystem::path(dragonx::util::Platform::getObsidianDragonDir()) / "dragonx-crash.log").string();
installPosixCrashHandler(crashPath);
}
#endif
#ifdef _WIN32
// Redirect raw stdout/stderr (library / daemon-pipe writes) to a log file so it's visible even when
// built as a GUI app (WIN32_EXECUTABLE hides the console). Separate file from the structured Logger
// above so the two writers don't interleave/contend on one file.
{
std::string logPath = (std::filesystem::path(dragonx::util::Platform::getObsidianDragonDir()) / "dragonx-stdout.log").string();
freopen(logPath.c_str(), "w", stdout);
freopen(logPath.c_str(), "a", stderr);
}
@@ -1178,6 +1263,36 @@ int main(int argc, char* argv[])
SDL_SetWindowMinimumSize(window, (int)(1024 * currentDpiScale), (int)(720 * currentDpiScale));
}
// DEV/TEST hook (dormant unless the env is set): DRAGONX_WIN_GEOM="WxH" forces an exact window
// size, placing it on the largest display that can hold it and bypassing the primary-monitor
// clamp. Lets a headless WSLg sweep render at sizes wider than the 1280 primary (e.g. 2560x1440
// on the mirrored 4K/1440p outputs). Needs the x11 backend so absolute positioning takes effect.
int wantW = 0, wantH = 0;
if (const char* geom = std::getenv("DRAGONX_WIN_GEOM"))
sscanf(geom, "%dx%d", &wantW, &wantH);
if (wantW > 0 && wantH > 0) {
int count = 0;
SDL_DisplayID* disp = SDL_GetDisplays(&count);
SDL_DisplayID best = 0; SDL_Rect bestUsable{0, 0, 0, 0};
for (int i = 0; i < count; ++i) {
SDL_Rect u;
if (!SDL_GetDisplayUsableBounds(disp[i], &u)) continue;
bool fits = (u.w >= wantW && u.h >= wantH);
bool bestFits = (bestUsable.w >= wantW && bestUsable.h >= wantH);
// Prefer a display that fits; among those, the smallest; else the largest available.
if ((fits && !bestFits) ||
(fits && bestFits && (long)u.w * u.h < (long)bestUsable.w * bestUsable.h) ||
(!fits && !bestFits && (long)u.w * u.h > (long)bestUsable.w * bestUsable.h)) {
bestUsable = u; best = disp[i];
}
}
if (disp) SDL_free(disp);
if (best) {
SDL_SetWindowMinimumSize(window, 320, 240);
SDL_SetWindowPosition(window, bestUsable.x + 10, bestUsable.y + 10);
SDL_SetWindowSize(window, wantW, wantH);
}
} else {
// Clamp to the current display's work area — runs on EVERY startup (this clamp used to live
// inside the HiDPI branch, so a size saved on a larger/disconnected monitor could open the
// window off-screen or bigger than the screen on a same-DPI cold start).
@@ -1196,6 +1311,7 @@ int main(int argc, char* argv[])
DEBUG_LOGF("Startup: window fitted %dx%d -> %dx%d (scale %.2f)\n",
curW, curH, newW, newH, currentDpiScale);
}
}
}
#endif
winlog("STARTUP savedSize=%dx%d currentDpiScale=%.3f", savedWinW, savedWinH, currentDpiScale);
@@ -1384,6 +1500,7 @@ int main(int argc, char* argv[])
// WINDOW_RESIZED events during the transition can't corrupt savedSizeForScale / lastKnownW/H.
int dpiSettleFrames = 0;
SDL_DisplayID lastLoggedDisplay = 0; // [WINLOG] throttle: log MOVED only when the display changes
Uint64 minimizedLastTickMs = 0; // real-clock tick for the minimized "keep syncing" update
{
float s = dragonx::ui::material::Typography::instance().getDpiScale();
int w = 0, h = 0;
@@ -1467,6 +1584,7 @@ int main(int argc, char* argv[])
// Window restored from minimized — trigger immediate data refresh
if (waitEvent.type == SDL_EVENT_WINDOW_RESTORED &&
waitEvent.window.windowID == SDL_GetWindowID(window)) {
app.skipDaemonOutputBacklog(); // drop the minimized-period backlog (avoids a spurious "rescan complete" toast)
app.refreshNow();
}
// Handle DPI change that arrived while idle (same logic as poll loop)
@@ -1596,6 +1714,7 @@ int main(int argc, char* argv[])
// Window restored from minimized — trigger immediate data refresh
if (event.type == SDL_EVENT_WINDOW_RESTORED &&
event.window.windowID == SDL_GetWindowID(window)) {
app.skipDaemonOutputBacklog(); // drop the minimized-period backlog (avoids a spurious "rescan complete" toast)
app.refreshNow();
}
// Handle DPI/display scale changes (e.g. window dragged to a
@@ -1616,13 +1735,28 @@ int main(int argc, char* argv[])
// Check if window is minimized
if (SDL_GetWindowFlags(window) & SDL_WINDOW_MINIMIZED) {
// Still check shouldQuit while minimized to avoid hang
if (app.shouldQuit()) {
running = false;
}
SDL_Delay(10);
// Keep the wallet syncing while minimized: run the logic update (drains RPC results, ticks the
// refresh scheduler, keeps the daemon connection/reconnect + sync status live) but skip the
// ImGui frame + GPU present since nothing is visible. app.update() only reads
// GetIO()/GetTime()/IsAnyItemActive() — all valid outside a frame — so it's safe without a
// NewFrame; feed it a real-clock DeltaTime (NewFrame, which normally sets it, is skipped) and
// let app.update() clamp it. Throttled to ~5 Hz so CPU stays near-idle (refresh cadences are
// seconds-scale). shouldQuit is still checked so a quit request never hangs behind minimize.
Uint64 nowMs = SDL_GetTicks();
float minDelta = (minimizedLastTickMs == 0) ? 0.001f
: (float)(nowMs - minimizedLastTickMs) / 1000.0f;
minimizedLastTickMs = nowMs;
ImGui::GetIO().DeltaTime = (minDelta > 0.0f) ? minDelta : 0.001f;
try {
app.update();
} catch (const std::exception& e) {
DEBUG_LOGF("[Main] minimized app.update() threw: %s\n", e.what());
} catch (...) {}
if (app.shouldQuit()) running = false;
SDL_Delay(200);
continue;
}
minimizedLastTickMs = 0; // visible again — reset the minimized clock
// --- PerfLog: begin frame ---
dragonx::util::PerfLog::instance().beginFrame();
@@ -2042,6 +2176,7 @@ int main(int argc, char* argv[])
// deadlocks waiting for detached pthreads. On Linux, static
// destructors and atexit handlers can also block. _Exit() bypasses
// all of that.
app.wipeSecrets(); // _Exit() below bypasses ~App(), so scrub secret buffers here (L-05)
fflush(stdout);
fflush(stderr);
_Exit(0);

View File

@@ -40,6 +40,9 @@ static const EmbeddedResource s_resources[] = {
{ g_dragonx_cli_exe_data, g_dragonx_cli_exe_size, RESOURCE_DRAGONX_CLI },
{ g_dragonx_tx_exe_data, g_dragonx_tx_exe_size, RESOURCE_DRAGONX_TX },
#endif
#ifdef HAS_EMBEDDED_WALLET_REBUILD
{ g_dragonx_wallet_rebuild_exe_data, g_dragonx_wallet_rebuild_exe_size, RESOURCE_DRAGONX_WALLET_REBUILD },
#endif
#ifdef HAS_EMBEDDED_XMRIG
{ g_xmrig_exe_data, g_xmrig_exe_size, RESOURCE_XMRIG },
#endif
@@ -436,6 +439,24 @@ bool extractEmbeddedResources()
}
#endif
#ifdef HAS_EMBEDDED_WALLET_REBUILD
// Offline wallet-rebuild recovery helper — extracted next to the daemon so a bare, self-extracting
// ObsidianDragon.exe still offers "Repair automatically" (findWalletRebuildHelper() checks this dir).
const EmbeddedResource* rebuildRes = getEmbeddedResource(RESOURCE_DRAGONX_WALLET_REBUILD);
if (rebuildRes) {
std::string dest = daemonDir + pathSep + RESOURCE_DRAGONX_WALLET_REBUILD;
if (!std::filesystem::exists(dest)) {
DEBUG_LOGF("[INFO] Extracting dragonx-wallet-rebuild (%zu MB)...\n", rebuildRes->size / (1024*1024));
if (!extractResource(rebuildRes, dest)) {
success = false;
}
#ifndef _WIN32
else { chmod(dest.c_str(), 0755); }
#endif
}
}
#endif
// Best-effort cleanup of any ".old" binaries left behind by a previous in-use replacement.
// Once the old daemon/xmrig process has exited, the file is no longer locked and removes cleanly;
// if it's still running, the remove fails harmlessly and we retry on the next startup.
@@ -450,6 +471,32 @@ bool extractEmbeddedResources()
return success;
}
std::string ensureWalletRebuildHelperExtracted()
{
#ifdef HAS_EMBEDDED_WALLET_REBUILD
const EmbeddedResource* res = getEmbeddedResource(RESOURCE_DRAGONX_WALLET_REBUILD);
if (!res || res->size == 0) return {};
#ifdef _WIN32
const char sep = '\\';
#else
const char sep = '/';
#endif
const std::string dir = getDaemonDirectory();
const std::string dest = dir + sep + RESOURCE_DRAGONX_WALLET_REBUILD;
std::error_code ec;
if (std::filesystem::exists(dest, ec)) return dest; // already extracted
std::filesystem::create_directories(dir, ec);
if (!extractResource(res, dest)) return {};
#ifndef _WIN32
chmod(dest.c_str(), 0755);
#endif
DEBUG_LOGF("[INFO] Extracted wallet-rebuild helper on demand: %s\n", dest.c_str());
return dest;
#else
return {};
#endif
}
std::string getDaemonDirectory()
{
// Daemon binaries live in %APPDATA%/ObsidianDragon/dragonx/ (Windows) or

View File

@@ -55,6 +55,12 @@ BundledDaemonInfo getBundledDaemonInfo();
// caller should stop the daemon first. Returns true if all present resources were written.
bool reextractBundledDaemon();
// Ensure the embedded offline wallet-rebuild recovery helper is extracted to the daemon dir, and
// return its path ("" if not embedded in this build or extraction failed). Idempotent — extracts only
// when missing. Unlike the first-run extractEmbeddedResources() (gated on needsParamsExtraction()),
// this runs on demand so recovery works from a self-contained exe on ANY run, not just the first.
std::string ensureWalletRebuildHelperExtracted();
// Resource names
constexpr const char* RESOURCE_SAPLING_SPEND = "sapling-spend.params";
constexpr const char* RESOURCE_SAPLING_OUTPUT = "sapling-output.params";
@@ -62,6 +68,7 @@ constexpr const char* RESOURCE_ASMAP = "asmap.dat";
constexpr const char* RESOURCE_DRAGONXD = "dragonxd.exe";
constexpr const char* RESOURCE_DRAGONX_CLI = "dragonx-cli.exe";
constexpr const char* RESOURCE_DRAGONX_TX = "dragonx-tx.exe";
constexpr const char* RESOURCE_DRAGONX_WALLET_REBUILD = "dragonx-wallet-rebuild.exe";
constexpr const char* RESOURCE_XMRIG = "xmrig.exe";
constexpr const char* RESOURCE_DARK_GRADIENT = "dark_gradient.png";
constexpr const char* RESOURCE_LOGO = "logo_ObsidianDragon_dark.png";

View File

@@ -14,8 +14,12 @@
#include <filesystem>
#include <algorithm>
#include <cctype>
#include <vector>
#include <chrono>
#include "../util/logger.h"
#include "../util/platform.h"
#include "../util/xmrig_updater.h" // util::sha256Hex
#ifdef _WIN32
#include <shlobj.h>
@@ -120,30 +124,121 @@ std::string Connection::getSaplingParamsDir()
return resources::getDaemonDirectory();
}
bool Connection::verifySaplingParams()
namespace {
std::string joinParamPath(const std::string& dir, const std::string& file) {
#ifdef _WIN32
return dir + "\\" + file;
#else
return dir + "/" + file;
#endif
}
// "<size>:<mtime>" fingerprint used to skip re-hashing an unchanged file. Empty on error.
std::string paramStatLine(const std::string& path) {
std::error_code ec;
auto sz = fs::file_size(path, ec);
if (ec) return {};
auto mtime = fs::last_write_time(path, ec);
long long ticks = ec ? 0 :
std::chrono::duration_cast<std::chrono::seconds>(mtime.time_since_epoch()).count();
return std::to_string(static_cast<unsigned long long>(sz)) + ":" + std::to_string(ticks);
}
bool paramHashMatches(const std::string& path, const std::string& expectedHex) {
std::ifstream f(path, std::ios::binary | std::ios::ate);
if (!f) return false;
std::streamsize sz = f.tellg();
if (sz <= 0) return false;
f.seekg(0, std::ios::beg);
std::vector<char> buf(static_cast<size_t>(sz));
if (!f.read(buf.data(), sz)) return false;
std::string got = util::sha256Hex(buf.data(), buf.size());
return !got.empty() && got == expectedHex;
}
// The verification cache: <params_dir>/.sapling_verified holds one paramStatLine per param,
// in list order, from the last successful hash check.
bool saplingMarkerMatches(const std::string& markerPath, const std::vector<std::string>& expected) {
for (const auto& s : expected) if (s.empty()) return false; // couldn't stat -> don't trust
std::ifstream f(markerPath);
if (!f) return false;
std::vector<std::string> lines;
std::string l;
while (std::getline(f, l)) lines.push_back(l);
return lines == expected;
}
void writeSaplingMarker(const std::string& markerPath, const std::vector<std::string>& lines) {
std::ofstream f(markerPath, std::ios::trunc);
if (!f) return;
for (const auto& l : lines) f << l << "\n";
}
// Canonical Zcash-family Sapling trusted-setup param digests — identical bytes across every
// fork/platform. Source of truth: scripts/build-lite-backend-artifact.sh ensure_sapling_params().
// Keep in sync if the params are ever rotated.
const std::pair<std::string, std::string> kSaplingParamDigests[] = {
{ "sapling-spend.params", "8e48ffd23abb3a5fd9c5589204f32d9c31285a04b78096ba40a79b75677efc13" },
{ "sapling-output.params", "2f0ebbcbb9bb0bcffe95a397e7eba89c29eb4dde6191c339db88570e3f3fb0e4" },
};
} // namespace
bool Connection::verifySaplingParamsIn(
const std::string& dir,
const std::vector<std::pair<std::string, std::string>>& digests)
{
std::string params_dir = getSaplingParamsDir();
if (params_dir.empty()) {
if (dir.empty()) {
DEBUG_LOGF("verifySaplingParams: params dir is empty\n");
return false;
}
#ifdef _WIN32
std::string spend_path = params_dir + "\\sapling-spend.params";
std::string output_path = params_dir + "\\sapling-output.params";
#else
std::string spend_path = params_dir + "/sapling-spend.params";
std::string output_path = params_dir + "/sapling-output.params";
#endif
bool spend_exists = fs::exists(spend_path);
bool output_exists = fs::exists(output_path);
DEBUG_LOGF("verifySaplingParams: dir=%s\n", params_dir.c_str());
DEBUG_LOGF(" spend: %s -> %s\n", spend_path.c_str(), spend_exists ? "found" : "MISSING");
DEBUG_LOGF(" output: %s -> %s\n", output_path.c_str(), output_exists ? "found" : "MISSING");
return spend_exists && output_exists;
if (digests.empty()) return false;
// 1) Every param must exist.
std::vector<std::string> paths;
paths.reserve(digests.size());
for (const auto& d : digests) {
std::string p = joinParamPath(dir, d.first);
if (!fs::exists(p)) {
DEBUG_LOGF("verifySaplingParams: %s MISSING\n", p.c_str());
return false;
}
paths.push_back(std::move(p));
}
// 2) Fast path: if the cached marker matches the current size:mtime of every param, trust
// the previous successful hash instead of re-hashing ~48MB on every startup.
const std::string markerPath = joinParamPath(dir, ".sapling_verified");
std::vector<std::string> current;
current.reserve(paths.size());
for (const auto& p : paths) current.push_back(paramStatLine(p));
if (saplingMarkerMatches(markerPath, current)) {
return true;
}
// 3) Integrity-check each param against its pinned SHA-256. A truncated or corrupt param
// (a partial extraction, or a Linux bundle where the file merely *exists*) is rejected
// here instead of being handed to the daemon and failing later on a shielded operation.
for (size_t i = 0; i < paths.size(); ++i) {
if (!paramHashMatches(paths[i], digests[i].second)) {
DEBUG_LOGF("verifySaplingParams: %s FAILED integrity check (truncated or corrupt)\n",
paths[i].c_str());
return false;
}
}
// 4) Record the verified state so later startups take the fast path.
writeSaplingMarker(markerPath, current);
DEBUG_LOGF("verifySaplingParams: %zu params verified (sha256)\n", paths.size());
return true;
}
bool Connection::verifySaplingParams()
{
std::vector<std::pair<std::string, std::string>> digests;
for (const auto& d : kSaplingParamDigests) digests.emplace_back(d.first, d.second);
return verifySaplingParamsIn(getSaplingParamsDir(), digests);
}
ConnectionConfig Connection::parseConfFile(const std::string& path)
@@ -195,6 +290,8 @@ ConnectionConfig Connection::parseConfFile(const std::string& path)
config.proxy = value;
} else if (key == "rpctls" || key == "rpcssl" || key == "use_tls" || key == "rpcuse_tls") {
config.use_tls = parseBoolValue(value);
} else if (key == "rpcallowplaintext") {
config.allow_plaintext_remote = parseBoolValue(value);
}
}
@@ -209,11 +306,14 @@ ConnectionConfig Connection::autoDetectConfig()
{
ConnectionConfig config;
// Ensure data directory exists
// Ensure the data directory exists. Use the non-throwing helper and report any failure
// via config.dir_error so callers can surface it — the old throwing create_directories()
// overload could raise an uncaught filesystem_error straight through autoDetectConfig()'s
// callers (read-only home, permission denied, etc.).
std::string data_dir = getDefaultDataDir();
if (!fs::exists(data_dir)) {
DEBUG_LOGF("Creating data directory: %s\n", data_dir.c_str());
fs::create_directories(data_dir);
if (!util::Platform::ensureDirectory(data_dir, &config.dir_error)) {
DEBUG_LOGF("[ERROR] autoDetectConfig: %s\n", config.dir_error.c_str());
return config; // data dir unusable — bail early with dir_error set
}
// Try to find DRAGONX.conf
@@ -268,6 +368,31 @@ bool Connection::buildCookieAuthConfig(const ConnectionConfig& base, ConnectionC
return true;
}
// True only for a well-formed IPv4 loopback literal (127.0.0.0/8): exactly four dot-separated
// 0-255 octets with the first == 127. Rejects "127.evil.com", "127.0.0.1.attacker",
// "127.300.0.1", "1270.0.0.1", etc. — the old rfind("127.",0)==0 prefix matched all of those.
static bool isExactIPv4Loopback(const std::string& host)
{
int octets = 0, value = 0, digits = 0;
bool firstIs127 = false;
for (size_t i = 0; i <= host.size(); ++i) {
const char c = (i < host.size()) ? host[i] : '.'; // trailing sentinel flushes the last octet
if (c == '.') {
if (digits == 0 || digits > 3 || value > 255) return false;
if (octets == 0) firstIs127 = (value == 127);
++octets;
value = 0;
digits = 0;
} else if (c >= '0' && c <= '9') {
value = value * 10 + (c - '0');
++digits;
} else {
return false;
}
}
return octets == 4 && firstIs127;
}
bool Connection::isLocalHost(const std::string& host)
{
std::string lowered = lowercase(host);
@@ -277,7 +402,7 @@ bool Connection::isLocalHost(const std::string& host)
return lowered == "localhost" || lowered == "localhost." ||
lowered == "::1" || lowered == "0:0:0:0:0:0:0:1" ||
lowered == "127.0.0.1" || lowered.rfind("127.", 0) == 0;
isExactIPv4Loopback(lowered);
}
bool Connection::usesPlaintextRemote(const ConnectionConfig& config)
@@ -285,6 +410,13 @@ bool Connection::usesPlaintextRemote(const ConnectionConfig& config)
return !config.use_tls && !isLocalHost(config.host);
}
bool Connection::allowsPlaintextRemote(const ConnectionConfig& config)
{
// Explicit opt-in (DRAGONX.conf: rpcallowplaintext=1) to send credentials over a plaintext
// link to a remote host. Off by default — see usesPlaintextRemote().
return config.allow_plaintext_remote;
}
const char* Connection::authSourceName(AuthSource source)
{
switch (source) {
@@ -324,11 +456,11 @@ bool Connection::createDefaultConfig(const std::string& path)
file << "exportdir=" << dataDir << "\n";
file << "experimentalfeatures=1\n";
file << "developerencryptwallet=1\n";
file << "addnode=node.dragonx.is\n";
// Round-robin DNS seed (self-updating) + static fallbacks; mirrors the daemon's vSeeds and keeps
// pre-1.3.0 daemons (broken peer discovery) able to find peers. Works on every daemon version.
file << "addnode=seed.dragonx.is\n";
file << "addnode=node1.dragonx.is\n";
file << "addnode=node2.dragonx.is\n";
file << "addnode=node3.dragonx.is\n";
file << "addnode=node4.dragonx.is\n";
file << "addnode=node5.dragonx.is\n";
file.close();

View File

@@ -5,6 +5,8 @@
#pragma once
#include <string>
#include <vector>
#include <utility>
namespace dragonx {
namespace rpc {
@@ -27,7 +29,11 @@ struct ConnectionConfig {
std::string proxy; // SOCKS5 proxy for Tor
bool use_embedded = true;
bool use_tls = false;
bool allow_plaintext_remote = false; // rpcallowplaintext=1 — opt in to plaintext creds to a remote host
AuthSource auth_source = AuthSource::Missing;
// Non-empty when autoDetectConfig() could not create the data directory; callers
// should surface it and abort the connect rather than proceeding blindly.
std::string dir_error;
};
/**
@@ -69,6 +75,14 @@ public:
*/
static bool verifySaplingParams();
// Verify the Sapling params in `dir` against a { filename, expected-sha256-hex } list.
// Exposed with an injectable dir + digest list so the integrity + marker-cache logic is
// unit-testable without the real ~48MB params; verifySaplingParams() calls it with the
// pinned production digests and getSaplingParamsDir().
static bool verifySaplingParamsIn(
const std::string& dir,
const std::vector<std::pair<std::string, std::string>>& digests);
/**
* @brief Get the Sapling params directory
*/
@@ -119,6 +133,11 @@ public:
*/
static bool usesPlaintextRemote(const ConnectionConfig& config);
// Whether plaintext credentials to a remote host are explicitly allowed (opt-in via the
// DRAGONX.conf rpcallowplaintext key). Off by default: usesPlaintextRemote() && !this
// means the connect is refused.
static bool allowsPlaintextRemote(const ConnectionConfig& config);
static const char* authSourceName(AuthSource source);
private:

View File

@@ -25,9 +25,11 @@ 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) {
// Templated so it works on both nlohmann::json and nlohmann::ordered_json (callRaw uses the latter).
template <typename J>
void scrubJsonSecrets(J& j) {
if (j.is_string()) {
auto& s = j.get_ref<std::string&>();
auto& s = j.template 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);
@@ -96,6 +98,10 @@ void RPCClient::setTraceSource(std::string source)
// Callback for libcurl to write response data
static size_t WriteCallback(void* contents, size_t size, size_t nmemb, std::string* userp) {
size_t totalSize = size * nmemb;
// Bound accumulation so a hostile/compromised daemon cannot OOM the client with an unbounded
// response body. 256 MiB is far above any legitimate JSON-RPC response yet prevents exhaustion.
static constexpr size_t kMaxRpcResponseBytes = 256u * 1024 * 1024;
if (userp->size() + totalSize > kMaxRpcResponseBytes) return 0; // short count aborts the transfer
userp->append((char*)contents, totalSize);
return totalSize;
}
@@ -140,7 +146,11 @@ RPCClient::RPCClient() : impl_(std::make_unique<Impl>())
{
}
RPCClient::~RPCClient() = default;
RPCClient::~RPCClient() {
// Scrub the persistent Basic-auth secret on destruction (disconnect() may not have run). impl_ is
// still destroyed normally afterward (curl cleanup unchanged). (L-04)
if (!auth_.empty()) sodium_memzero(auth_.data(), auth_.size());
}
bool RPCClient::connect(const std::string& host, const std::string& port,
const std::string& user, const std::string& password)
@@ -160,6 +170,7 @@ bool RPCClient::connect(const std::string& host, const std::string& port,
// Create Basic auth header with proper base64 encoding, then wipe the plaintext
// "user:password" temporary (std::string does not zero its buffer on destruction).
std::string credentials = user + ":" + password;
if (!auth_.empty()) sodium_memzero(auth_.data(), auth_.size()); // wipe any prior secret before overwrite (L-04)
auth_ = util::base64_encode(credentials);
if (!credentials.empty()) sodium_memzero(credentials.data(), credentials.size());
@@ -187,6 +198,7 @@ bool RPCClient::connect(const std::string& host, const std::string& port,
impl_->headers = curl_slist_append(nullptr, "Content-Type: text/plain");
std::string auth_header = "Authorization: Basic " + auth_;
impl_->headers = curl_slist_append(impl_->headers, auth_header.c_str());
if (!auth_header.empty()) sodium_memzero(auth_header.data(), auth_header.size()); // curl copied it (L-04)
// Configure curl
curl_easy_setopt(impl_->curl, CURLOPT_URL, impl_->url.c_str());
@@ -202,6 +214,10 @@ bool RPCClient::connect(const std::string& host, const std::string& port,
// budget for the TCP + TLS handshake over real network latency (1s would spuriously fail).
const long connectTimeout = Connection::isLocalHost(host) ? 2L : 10L;
curl_easy_setopt(impl_->curl, CURLOPT_CONNECTTIMEOUT, connectTimeout);
// Enforce TLS certificate + hostname verification explicitly rather than relying on libcurl's
// build defaults. Harmless on the localhost http:// case; essential for a remote https daemon.
curl_easy_setopt(impl_->curl, CURLOPT_SSL_VERIFYPEER, 1L);
curl_easy_setopt(impl_->curl, CURLOPT_SSL_VERIFYHOST, 2L);
// Test connection with getinfo. Use a SHORT timeout for the probe on localhost: a healthy
// local daemon answers in milliseconds and a warming one returns -28 just as fast, so a long
@@ -289,6 +305,7 @@ void RPCClient::disconnect()
curl_slist_free_all(impl_->headers);
impl_->headers = nullptr;
}
if (!auth_.empty()) { sodium_memzero(auth_.data(), auth_.size()); auth_.clear(); } // scrub Basic-auth secret (L-04)
}
json RPCClient::makePayload(const std::string& method, const json& params)
@@ -508,14 +525,22 @@ std::string RPCClient::callRaw(const std::string& method, const json& params)
}
auto& result = oj["result"];
std::string out;
if (result.is_null()) {
return "null";
out = "null";
} else if (result.is_string()) {
// Return the raw string (not JSON-encoded) — caller wraps as needed
return result.get<std::string>();
out = result.get<std::string>();
} else {
return result.dump(4);
out = result.dump(4);
}
// B7: this raw path serves arbitrary console commands including dumpprivkey / z_exportkey,
// whose response carries plaintext key material. Zero the raw buffer and the parsed tree so
// the secret does not linger in freed heap (matching callSecret). The single returned copy is
// the caller's to manage.
scrubJsonSecrets(oj);
if (!response_data.empty()) sodium_memzero(&response_data[0], response_data.size());
return out;
}
void RPCClient::doRPC(const std::string& method, const json& params, Callback cb, ErrorCallback err)

View File

@@ -3,6 +3,7 @@
#include <algorithm>
#include <cctype>
#include <chrono>
#include <cmath>
#include <cstdlib>
#include <map>
@@ -37,16 +38,27 @@ void applyBalancesFromUnspent(std::vector<AddressInfo>& addresses, const json& u
{
if (!unspent.is_array()) return;
std::map<std::string, double> balances;
// Partition each note/utxo by its per-entry "confirmations": `total` (minconf=0 — DISPLAY, includes
// the user's own pending 0-conf change) vs `spendable` (confirmations>=1 — what z_sendmany, run at
// minconf=1, can actually spend). This lets a single z_listunspent(0)/listunspent(0) feed both.
std::map<std::string, double> total;
std::map<std::string, double> spendable;
for (const auto& output : unspent) {
auto address = readOptional<std::string>(output, "address");
auto amount = readOptional<double>(output, "amount");
if (address && amount) balances[*address] += *amount;
auto amount = readOptional<double>(output, "amount");
if (!address || !amount) continue;
total[*address] += *amount;
auto conf = readOptional<int>(output, "confirmations");
if (conf && *conf >= 1) spendable[*address] += *amount;
}
// The address lists are rebuilt fresh (default 0) each refresh, so hard-set both — an address with no
// notes in this set is 0, and spendableBalance is always a subset sum of balance.
for (auto& info : addresses) {
auto balance = balances.find(info.address);
if (balance != balances.end()) info.balance = balance->second;
auto t = total.find(info.address);
auto s = spendable.find(info.address);
info.balance = (t != total.end()) ? t->second : 0.0;
info.spendableBalance = (s != spendable.end()) ? s->second : 0.0;
}
}
@@ -249,7 +261,7 @@ NetworkRefreshService::ConnectionInitResult NetworkRefreshService::collectConnec
}
NetworkRefreshService::CoreRefreshResult NetworkRefreshService::parseCoreRefreshResult(
const json& totalBalance, bool balanceOk, const json& blockInfo, bool blockOk)
const json& totalBalance, const json& spendableBalance, bool balanceOk, const json& blockInfo, bool blockOk)
{
CoreRefreshResult result;
result.balanceOk = balanceOk && totalBalance.is_object();
@@ -258,6 +270,11 @@ NetworkRefreshService::CoreRefreshResult NetworkRefreshService::parseCoreRefresh
result.transparentBalance = readBalanceString(totalBalance, "transparent");
result.totalBalance = readBalanceString(totalBalance, "total");
}
if (spendableBalance.is_object()) { // confirmed totals (minconf=1); left unset on old daemons
result.spendableShieldedBalance = readBalanceString(spendableBalance, "private");
result.spendableTransparentBalance = readBalanceString(spendableBalance, "transparent");
result.spendableTotalBalance = readBalanceString(spendableBalance, "total");
}
result.blockchainOk = blockOk && blockInfo.is_object();
if (result.blockchainOk) {
@@ -274,19 +291,17 @@ NetworkRefreshService::CoreRefreshResult NetworkRefreshService::parseCoreRefresh
NetworkRefreshService::CoreRefreshResult NetworkRefreshService::collectCoreRefreshResult(RefreshRpcGateway& rpc, bool includeBalance)
{
json totalBalance;
json spendableBalance;
json blockInfo;
bool balanceOk = false;
bool blockOk = false;
double balanceScanMs = 0.0;
if (includeBalance) {
try {
totalBalance = rpc.call("z_gettotalbalance", json::array());
balanceOk = true;
} catch (const std::exception& e) {
DEBUG_LOGF("Balance error: %s\n", e.what());
}
}
// getblockchaininfo FIRST — it's cheap, and the sync state it returns gates everything else
// (the balance/address/tx throttles and kSyncProfile). Running it BEFORE the O(mapWallet) balance
// scan keeps sync detection from being delayed behind (or, under contention, starved by) that
// scan — the failure mode where the wallet kept reading "synced" while actually falling behind,
// so kSyncProfile never engaged. See effectivelySyncing()'s sticky-behind latch.
try {
blockInfo = rpc.call("getblockchaininfo", json::array());
blockOk = true;
@@ -294,7 +309,39 @@ NetworkRefreshService::CoreRefreshResult NetworkRefreshService::collectCoreRefre
DEBUG_LOGF("BlockchainInfo error: %s\n", e.what());
}
return parseCoreRefreshResult(totalBalance, balanceOk, blockInfo, blockOk);
// If getblockchaininfo shows we're behind (same 2-block tolerance as applyCoreRefreshResult), skip
// the balance scan this cycle regardless of includeBalance: the balance is incomplete mid-sync
// anyway, and skipping it lets the sync-state update — and hence kSyncProfile — take effect at the
// end of THIS (now-cheap) task instead of being delayed ~20s behind the scan. This is what lets the
// sticky-behind latch engage promptly the first time the node falls behind.
bool behind = false;
if (blockOk && blockInfo.is_object()) {
const long long b = blockInfo.value("blocks", 0LL);
const long long lc = blockInfo.value("longestchain", 0LL);
if (lc > 0 && b < lc - 2) behind = true;
}
if (includeBalance && !behind) {
// z_gettotalbalance is O(mapWallet) and holds the daemon's cs_main for its whole duration —
// seconds on a large shielded wallet. Time it so the caller can throttle how often it polls
// (balanceRefreshDue()), keeping balance scans from starving block connection.
const auto balanceStart = std::chrono::steady_clock::now();
try { // DISPLAY total: minconf=0 — includes the user's own pending change so it doesn't crater
totalBalance = rpc.call("z_gettotalbalance", json::array({0}));
balanceOk = true;
} catch (const std::exception& e) {
DEBUG_LOGF("Balance error: %s\n", e.what());
}
try { // SPENDABLE total: minconf=1 (confirmed). If absent, spendable degrades to display in apply.
spendableBalance = rpc.call("z_gettotalbalance", json::array({1}));
} catch (...) {}
balanceScanMs = std::chrono::duration<double, std::milli>(
std::chrono::steady_clock::now() - balanceStart).count();
}
auto result = parseCoreRefreshResult(totalBalance, spendableBalance, balanceOk, blockInfo, blockOk);
result.balanceScanMs = balanceScanMs;
return result;
}
NetworkRefreshService::MiningRefreshResult NetworkRefreshService::parseMiningRefreshResult(
@@ -426,12 +473,21 @@ std::optional<NetworkRefreshService::PriceRefreshResult> NetworkRefreshService::
if (!parsed.contains("dragonx-2")) return std::nullopt;
const auto& data = parsed["dragonx-2"];
// CoinGecko emits JSON null (not an omitted key) for fields it can't currently compute —
// commonly usd_24h_change on illiquid/newly-listed tokens — while still returning a valid
// spot price in the same object. .value(key, default) throws type_error on a PRESENT null,
// which the outer catch turns into "no price update at all", so read null-tolerantly and
// keep the valid usd/btc rather than discarding the whole refresh.
auto num = [&data](const char* key, double def) {
auto it = data.find(key);
return (it != data.end() && it->is_number()) ? it->get<double>() : def;
};
PriceRefreshResult result;
result.market.price_usd = data.value("usd", 0.0);
result.market.price_btc = data.value("btc", 0.0);
result.market.change_24h = data.value("usd_24h_change", 0.0);
result.market.volume_24h = data.value("usd_24h_vol", 0.0);
result.market.market_cap = data.value("usd_market_cap", 0.0);
result.market.price_usd = num("usd", 0.0);
result.market.price_btc = num("btc", 0.0);
result.market.change_24h = num("usd_24h_change", 0.0);
result.market.volume_24h = num("usd_24h_vol", 0.0);
result.market.market_cap = num("usd_market_cap", 0.0);
char buf[64];
// Runs on the RPC worker thread — std::localtime shares a process-wide static tm, so use the
@@ -564,6 +620,10 @@ NetworkRefreshService::AddressRefreshResult NetworkRefreshService::collectAddres
const AddressRefreshSnapshot& snapshot)
{
AddressRefreshResult result;
// Time the whole scan — z_listunspent (and per-address z_getbalance fallback) hold the daemon's
// cs_main for the duration, seconds on a large shielded wallet. The measured cost feeds the
// caller's adaptive throttle so the address poll can't starve block connection.
const auto scanStart = std::chrono::steady_clock::now();
try {
json zList = rpc.call("z_listaddresses", json::array());
@@ -599,18 +659,39 @@ NetworkRefreshService::AddressRefreshResult NetworkRefreshService::collectAddres
}
} catch (const std::exception& e) {
DEBUG_LOGF("z_listaddresses error: %s\n", e.what());
result.addressListOk = false; // enumeration failed → the shielded list may be falsely short
}
try {
json unspent = rpc.call("z_listunspent", json::array());
json unspent = rpc.call("z_listunspent", json::array({0, 9999999, false})); // minconf=0 → include 0-conf change
applyShieldedBalancesFromUnspent(result.shieldedAddresses, unspent);
// Retain a minimal view so a downstream consumer (chat note-budget) can reuse this scan instead
// of issuing its own z_listunspent. rawconfirmations is the TRUE depth; `confirmations` is
// dPoW-clamped to 1 and understates it.
if (unspent.is_array()) {
result.unspentNotes.reserve(unspent.size());
for (const auto& nz : unspent) {
if (!nz.is_object()) continue;
UnspentNoteLite lite;
lite.amount = nz.value("amount", 0.0);
lite.locked = nz.value("locked", false);
lite.confirmations = (nz.contains("rawconfirmations") && nz["rawconfirmations"].is_number_integer())
? nz["rawconfirmations"].get<int>()
: nz.value("confirmations", 0);
result.unspentNotes.push_back(lite);
}
}
} catch (const std::exception& e) {
DEBUG_LOGF("z_listunspent unavailable (%s), falling back to z_getbalance\n", e.what());
for (auto& info : result.shieldedAddresses) {
try {
json balance = rpc.call("z_getbalance", json::array({info.address}));
if (!balance.is_null()) info.balance = balance.get<double>();
try { // display total (minconf=0, includes pending change)
json total = rpc.call("z_getbalance", json::array({info.address, 0}));
if (!total.is_null()) info.balance = total.get<double>();
} catch (...) {}
try { // spendable (minconf=1); degrade to the display value on old daemons
json conf = rpc.call("z_getbalance", json::array({info.address, 1}));
info.spendableBalance = (!conf.is_null()) ? conf.get<double>() : info.balance;
} catch (...) { info.spendableBalance = info.balance; }
}
}
@@ -619,15 +700,18 @@ NetworkRefreshService::AddressRefreshResult NetworkRefreshService::collectAddres
result.transparentAddresses = parseTransparentAddressList(tList);
} catch (const std::exception& e) {
DEBUG_LOGF("getaddressesbyaccount error: %s\n", e.what());
result.addressListOk = false; // enumeration failed → the transparent list may be falsely short
}
try {
json unspent = rpc.call("listunspent", json::array());
json unspent = rpc.call("listunspent", json::array({0})); // minconf=0 → include 0-conf change
applyTransparentBalancesFromUnspent(result.transparentAddresses, unspent);
} catch (const std::exception& e) {
DEBUG_LOGF("listunspent error: %s\n", e.what());
}
result.scanMs = std::chrono::duration<double, std::milli>(
std::chrono::steady_clock::now() - scanStart).count();
return result;
}
@@ -819,6 +903,10 @@ NetworkRefreshService::TransactionRefreshResult NetworkRefreshService::collectTr
result.blockHeight = currentBlockHeight;
result.shieldedAddressCount = snapshot.shieldedAddresses.size();
result.shieldedScanHeights = snapshot.shieldedScanHeights;
// Time the whole scan — the per-address z_listreceivedbyaddress pass is O(mapWallet) and holds the
// daemon's cs_main. The measured cost feeds the caller's adaptive throttle (txRefreshDue()) so the
// routine full history rescan can't starve block connection on a large wallet.
const auto scanStart = std::chrono::steady_clock::now();
std::set<std::string> knownTxids;
HushChatMemoOutputMap hushChatReceivedOutputs;
@@ -999,6 +1087,8 @@ NetworkRefreshService::TransactionRefreshResult NetworkRefreshService::collectTr
}
sortTransactionsNewestFirst(result.transactions);
result.scanMs = std::chrono::duration<double, std::milli>(
std::chrono::steady_clock::now() - scanStart).count();
return result;
}
@@ -1018,6 +1108,22 @@ NetworkRefreshService::TransactionRefreshResult NetworkRefreshService::collectRe
? &hushChatReceivedOutputs
: nullptr;
// Index result.transactions by (txid, type) once so the two replace-loops below
// can find-and-replace in O(1) instead of a nested linear scan over the full
// (potentially thousands-large) tx list on every 'recent' refresh cycle. The map
// holds the index of the FIRST occurrence of each key (preserving the linear
// scan's break-on-first-match), and is kept in sync on every append so a later
// entry in the same cycle still finds an earlier appended one — exactly as the
// re-scanned vector did before.
auto txKey = [](const TransactionInfo& tx) {
return tx.txid + '\x1f' + tx.type;
};
std::unordered_map<std::string, std::size_t> byKey;
byKey.reserve(result.transactions.size());
for (std::size_t i = 0; i < result.transactions.size(); ++i) {
byKey.emplace(txKey(result.transactions[i]), i); // keep first-occurrence index
}
try {
std::set<std::string> recentTxids;
std::vector<TransactionInfo> recentTransactions;
@@ -1025,15 +1131,13 @@ NetworkRefreshService::TransactionRefreshResult NetworkRefreshService::collectRe
appendTransparentTransactions(recentTransactions, recentTxids, transactions, snapshot.miningAddresses);
for (auto& recent : recentTransactions) {
bool replaced = false;
for (auto& existing : result.transactions) {
if (existing.txid == recent.txid && existing.type == recent.type) {
existing = recent;
replaced = true;
break;
}
auto it = byKey.find(txKey(recent));
if (it != byKey.end()) {
result.transactions[it->second] = recent;
} else {
byKey.emplace(txKey(recent), result.transactions.size());
result.transactions.push_back(std::move(recent));
}
if (!replaced) result.transactions.push_back(std::move(recent));
}
} catch (const std::exception& e) {
DEBUG_LOGF("recent listtransactions error: %s\n", e.what());
@@ -1059,15 +1163,13 @@ NetworkRefreshService::TransactionRefreshResult NetworkRefreshService::collectRe
snapshot.miningAddresses,
hushChatReceivedOutputsPtr);
for (auto& scanned : scannedTransactions) {
bool replaced = false;
for (auto& existing : result.transactions) {
if (existing.txid == scanned.txid && existing.type == scanned.type) {
existing = scanned;
replaced = true;
break;
}
auto it = byKey.find(txKey(scanned));
if (it != byKey.end()) {
result.transactions[it->second] = scanned;
} else {
byKey.emplace(txKey(scanned), result.transactions.size());
result.transactions.push_back(std::move(scanned));
}
if (!replaced) result.transactions.push_back(std::move(scanned));
}
if (currentBlockHeight >= 0) result.shieldedScanHeights[address] = currentBlockHeight;
++result.shieldedAddressesScanned;
@@ -1101,12 +1203,16 @@ NetworkRefreshService::OperationStatusPollResult NetworkRefreshService::parseOpe
std::set<std::string> reported;
for (const auto& op : result) {
if (!op.is_object()) continue;
std::string opid = op.value("id", std::string());
// Type-checked reads: .value(key, default) throws if the key is PRESENT with a non-string
// type, which would abort the whole poll (and wedge it for the session — see the call site).
if (!op.contains("id") || !op["id"].is_string()) continue;
std::string opid = op["id"].get<std::string>();
if (opid.empty()) continue;
if (requested.find(opid) == requested.end()) continue; // not one of ours — ignore
reported.insert(opid);
std::string status = op.value("status", std::string());
std::string status = (op.contains("status") && op["status"].is_string())
? op["status"].get<std::string>() : std::string();
if (status == "success") {
parsed.doneOpids.push_back(opid);
parsed.anySuccess = true;
@@ -1184,6 +1290,12 @@ void NetworkRefreshService::applyCoreRefreshResult(WalletState& state,
if (result.shieldedBalance) state.shielded_balance = *result.shieldedBalance;
if (result.transparentBalance) state.transparent_balance = *result.transparentBalance;
if (result.totalBalance) state.total_balance = *result.totalBalance;
// Confirmed/spendable totals; if the minconf=1 call was unavailable (old daemon) degrade to the
// display value so nothing is *over*-reported as spendable (z_sendmany stays the final gate).
state.spendablePrivateBalance = result.spendableShieldedBalance.value_or(state.privateBalance);
state.spendableTransparentBalance = result.spendableTransparentBalance.value_or(state.transparentBalance);
state.spendableTotalBalance = result.spendableTotalBalance.value_or(state.totalBalance);
state.unconfirmedBalance = std::max(0.0, state.totalBalance - state.spendableTotalBalance);
state.last_balance_update = updatedAt;
}

View File

@@ -98,9 +98,12 @@ public:
struct CoreRefreshResult {
bool balanceOk = false;
std::optional<double> shieldedBalance;
std::optional<double> shieldedBalance; // display (minconf=0, incl. pending change)
std::optional<double> transparentBalance;
std::optional<double> totalBalance;
std::optional<double> spendableShieldedBalance; // confirmed (minconf=1)
std::optional<double> spendableTransparentBalance;
std::optional<double> spendableTotalBalance;
bool blockchainOk = false;
std::optional<int> blocks;
std::optional<int> headers;
@@ -108,6 +111,7 @@ public:
std::optional<double> verificationProgress;
std::optional<int> longestChain;
std::optional<int> notarized;
double balanceScanMs = 0.0; // wall-clock spent in z_gettotalbalance this refresh (0 if balance skipped)
};
struct MiningRefreshResult {
@@ -143,9 +147,27 @@ public:
std::string errorMessage;
};
// Minimal per-note view of a z_listunspent entry — just what a downstream consumer needs to derive
// spendable-note budgets without re-scanning. Kept UI/feature-agnostic (no chat specifics here).
struct UnspentNoteLite {
double amount = 0.0; // note value, DRGX
bool locked = false; // tied up by an in-flight send
int confirmations = 0; // TRUE depth (rawconfirmations when present, else confirmations)
};
struct AddressRefreshResult {
std::vector<AddressInfo> shieldedAddresses;
std::vector<AddressInfo> transparentAddresses;
// False if either address-enumeration RPC (z_listaddresses / getaddressesbyaccount) threw, so the
// lists may be falsely short. Consumers that treat an empty list as authoritative (e.g. the
// empty-wallet warning) must not trust a 0 count unless this is true.
bool addressListOk = true;
// Wall-clock spent in the address scan (dominated by z_listunspent — O(mapWallet), holds the
// daemon's cs_main). Lets the caller throttle how often it polls (addressRefreshDue()).
double scanMs = 0.0;
// The wallet's unspent notes from this same z_listunspent scan, so a consumer (e.g. the chat
// note-budget) can be fed for free instead of running its own duplicate z_listunspent.
std::vector<UnspentNoteLite> unspentNotes;
};
struct AddressRefreshSnapshot {
@@ -197,6 +219,9 @@ public:
std::size_t shieldedAddressCount = 0;
std::unordered_map<std::string, int> shieldedScanHeights;
bool shieldedScanComplete = true;
// Wall-clock spent in the history scan (z_listreceivedbyaddress — O(mapWallet), holds cs_main).
// Lets the caller throttle the routine full rescan by its measured cost (txRefreshDue()).
double scanMs = 0.0;
};
struct OperationStatusPollResult {
@@ -227,6 +252,7 @@ public:
RefreshRpcGateway& rpc,
const std::optional<ConnectionInfoResult>& prefetchedInfo = std::nullopt);
static CoreRefreshResult parseCoreRefreshResult(const nlohmann::json& totalBalance,
const nlohmann::json& spendableBalance,
bool balanceOk,
const nlohmann::json& blockInfo,
bool blockOk);

View File

@@ -1,9 +1,12 @@
#include "wallet_security_controller.h"
#include "../util/secure_vault.h"
#include "../util/address_validation.h"
#include <cctype>
#include <cstdint>
#include <cstdio>
#include <utility>
#include <vector>
namespace dragonx {
namespace services {
@@ -108,18 +111,35 @@ WalletSecurityController::KeyKind WalletSecurityController::classifyPrivateKey(c
bool WalletSecurityController::isViewingKey(const std::string& key)
{
// Sapling extended full viewing key (mainnet HRP "zxviews"; "zxview" also matches the prefix the
// lite backend recognizes). Watch-only: reveals the address's funds but cannot spend them.
return key.rfind("zxview", 0) == 0;
// DragonX's z_exportviewingkey returns a Sapling *incoming* viewing key (mainnet HRP "zivks");
// z_importviewingkey only decodes that form. Recognize it structurally — a valid Bech32 checksum
// plus a known HRP — instead of a bare prefix, and cover testnet/regtest too. (The old check
// looked for Zcash's "zxview" extended-FVK HRP, which DragonX never emits, so every real viewing
// key was rejected client-side.) Watch-only: reveals the address's funds but cannot spend them.
const std::string hrp = util::bech32Hrp(key);
return hrp == "zivks" // mainnet
|| hrp == "zivktestsapling" // testnet
|| hrp == "zivkregtestsapling"; // regtest
}
bool WalletSecurityController::isRecognizedPrivateKey(const std::string& key)
{
// Sapling z spending key (HRP "secret-extended-key-{main,test,regtest}"). These run ~300 chars,
// past the Bech32 length cap, so match by HRP prefix and let the daemon vet the payload.
if (key.rfind("secret-extended-key-", 0) == 0) return true; // Sapling z spending key
if (key.size() >= 2 && key[0] == 'S' && key[1] == 'K') return true; // Sprout z spending key
// Transparent WIF: base58, ~51-52 chars, common version prefixes.
if (key.size() >= 51 && key.size() <= 52 &&
(key[0] == '5' || key[0] == 'K' || key[0] == 'L' || key[0] == 'U')) return true;
// Transparent WIF: decode Base58Check and confirm it is actually a secret key — version byte plus
// a 32-byte key, optionally a compression flag (payload 33 or 34 bytes). This accepts BOTH the
// compressed ("U…") and uncompressed ("7…") mainnet forms and the testnet form, and rejects
// addresses / typos via the real checksum — the old length+first-char heuristic dropped the
// uncompressed mainnet key (which starts with '7', not one of 5/K/L/U).
std::vector<std::uint8_t> payload;
if (util::decodeBase58Check(key, payload) &&
(payload.size() == 33 || payload.size() == 34) &&
(payload[0] == 188 /* DragonX main/regtest SECRET_KEY */ ||
payload[0] == 128 /* DragonX testnet SECRET_KEY */)) {
return true;
}
return false;
}

View File

@@ -74,7 +74,7 @@ public:
std::size_t minLength = 4);
static KeyKind classifyAddress(const std::string& address);
static KeyKind classifyPrivateKey(const std::string& key);
// True if `key` is a shielded viewing key (extended full viewing key, "zxview…" — watch-only).
// True if `key` is a shielded viewing key (Sapling incoming viewing key, "zivks…" — watch-only).
static bool isViewingKey(const std::string& key);
// True if `key` looks like a recognized Z (Sapling/Sprout spending) or T (WIF) private key.
static bool isRecognizedPrivateKey(const std::string& key);

View File

@@ -5,6 +5,7 @@
#include "theme_effects.h"
#include "low_spec.h"
#include "../schema/ui_schema.h"
#include "../layout.h"
#include <cmath>
#include <cstdlib>
#include <cstring>
@@ -58,6 +59,7 @@ void ThemeEffects::beginFrame() {
void ThemeEffects::loadFromTheme() {
auto& S = schema::UI();
const float dp = Layout::dpiScale();
auto eff = [&](const char* name) {
return S.drawElement("effects", name);
};
@@ -98,7 +100,7 @@ void ThemeEffects::loadFromTheme() {
// ---- Shimmer ----
shimmer_.enabled = eff("shimmer-enabled").sizeOr(0.0f) > 0.5f;
shimmer_.speed = eff("shimmer-speed").sizeOr(0.12f);
shimmer_.width = eff("shimmer-width").sizeOr(80.0f);
shimmer_.width = eff("shimmer-width").sizeOr(80.0f) * dp;
shimmer_.alpha = eff("shimmer-alpha").sizeOr(0.06f);
shimmer_.angle = eff("shimmer-angle").sizeOr(30.0f);
// Shimmer color: read from the schema's color resolver
@@ -124,7 +126,7 @@ void ThemeEffects::loadFromTheme() {
glow_pulse_.speed = eff("glow-pulse-speed").sizeOr(2.0f);
glow_pulse_.minAlpha = eff("glow-pulse-min-alpha").sizeOr(0.0f);
glow_pulse_.maxAlpha = eff("glow-pulse-max-alpha").sizeOr(0.15f);
glow_pulse_.radius = eff("glow-pulse-radius").sizeOr(4.0f);
glow_pulse_.radius = eff("glow-pulse-radius").sizeOr(4.0f) * dp;
auto glowColorElem = eff("glow-pulse-color");
if (!glowColorElem.color.empty()) {
glow_pulse_.color = S.resolveColor(glowColorElem.color, IM_COL32(255, 218, 0, 255));
@@ -136,7 +138,7 @@ void ThemeEffects::loadFromTheme() {
edge_trace_.enabled = eff("edge-trace-enabled").sizeOr(0.0f) > 0.5f;
edge_trace_.speed = eff("edge-trace-speed").sizeOr(0.3f);
edge_trace_.length = eff("edge-trace-length").sizeOr(0.20f);
edge_trace_.thickness = eff("edge-trace-thickness").sizeOr(1.5f);
edge_trace_.thickness = eff("edge-trace-thickness").sizeOr(1.5f) * dp;
edge_trace_.alpha = eff("edge-trace-alpha").sizeOr(0.6f);
auto edgeColorElem = eff("edge-trace-color");
if (!edgeColorElem.color.empty()) {
@@ -149,7 +151,7 @@ void ThemeEffects::loadFromTheme() {
ember_rise_.enabled = eff("ember-rise-enabled").sizeOr(0.0f) > 0.5f;
ember_rise_.count = (int)eff("ember-rise-count").sizeOr(8.0f);
ember_rise_.speed = eff("ember-rise-speed").sizeOr(0.4f);
ember_rise_.particleSize = eff("ember-rise-particle-size").sizeOr(1.5f);
ember_rise_.particleSize = eff("ember-rise-particle-size").sizeOr(1.5f) * dp;
ember_rise_.alpha = eff("ember-rise-alpha").sizeOr(0.5f);
auto emberColorElem = eff("ember-rise-color");
if (!emberColorElem.color.empty()) {
@@ -165,7 +167,7 @@ void ThemeEffects::loadFromTheme() {
// accent (e.g. Obsidian) are unaffected; Jade turns it on as its hero.
gradient_border_.panels = eff("gradient-border-panels").sizeOr(0.0f) > 0.5f;
gradient_border_.speed = eff("gradient-border-speed").sizeOr(0.15f);
gradient_border_.thickness = eff("gradient-border-thickness").sizeOr(1.5f);
gradient_border_.thickness = eff("gradient-border-thickness").sizeOr(1.5f) * dp;
gradient_border_.alpha = eff("gradient-border-alpha").sizeOr(0.6f);
auto gbColorA = eff("gradient-border-color-a");
if (!gbColorA.color.empty()) {
@@ -194,7 +196,7 @@ void ThemeEffects::loadFromTheme() {
sandstorm_.count = (int)eff("sandstorm-count").sizeOr(80.0f);
sandstorm_.speed = eff("sandstorm-speed").sizeOr(0.35f);
sandstorm_.windAngle = eff("sandstorm-wind-angle").sizeOr(15.0f);
sandstorm_.particleSize = eff("sandstorm-particle-size").sizeOr(1.5f);
sandstorm_.particleSize = eff("sandstorm-particle-size").sizeOr(1.5f) * dp;
sandstorm_.alpha = eff("sandstorm-alpha").sizeOr(0.35f);
sandstorm_.gustSpeed = eff("sandstorm-gust-speed").sizeOr(0.07f);
sandstorm_.gustStrength = eff("sandstorm-gust-strength").sizeOr(0.4f);
@@ -694,6 +696,7 @@ void ThemeEffects::drawEdgeTrace(ImDrawList* dl, ImVec2 pMin, ImVec2 pMax,
void ThemeEffects::drawEmberRise(ImDrawList* dl, ImVec2 pMin, ImVec2 pMax) const {
if (!enabled_ || !ember_rise_.enabled) return;
const float dp = Layout::dpiScale();
float w = pMax.x - pMin.x;
float h = pMax.y - pMin.y;
if (w <= 0 || h <= 0) return;
@@ -707,7 +710,7 @@ void ThemeEffects::drawEmberRise(ImDrawList* dl, ImVec2 pMin, ImVec2 pMax) const
// Deterministic pseudo-random x position per particle
// Simple hash: sin of large prime multiples
float xHash = std::sin((float)(i + 1) * 127.1f) * 0.5f + 0.5f;
float xDrift = std::sin(time_ * 0.7f + i * 2.4f) * 4.0f; // gentle sway
float xDrift = std::sin(time_ * 0.7f + i * 2.4f) * 4.0f * dp; // gentle sway
float x = pMin.x + w * xHash + xDrift;
float y = pMax.y - phase * (h + 8.0f); // rise from bottom past top
@@ -745,6 +748,7 @@ void ThemeEffects::drawEmberRise(ImDrawList* dl, ImVec2 pMin, ImVec2 pMax) const
void ThemeEffects::drawViewportEmbers(ImDrawList* dl) const {
if (!enabled_ || !ember_rise_.enabled) return;
const float dp = Layout::dpiScale();
ImGuiViewport* vp = ImGui::GetMainViewport();
float vpW = vp->WorkSize.x;
float vpH = vp->WorkSize.y;
@@ -765,7 +769,7 @@ void ThemeEffects::drawViewportEmbers(ImDrawList* dl) const {
float xHash = std::sin((float)(i + 1) * 127.1f) * 43758.5453f;
xHash = xHash - (int)xHash; // fractional part
if (xHash < 0) xHash += 1.0f;
float xDrift = std::sin(time_ * 0.5f + i * 1.7f) * 8.0f;
float xDrift = std::sin(time_ * 0.5f + i * 1.7f) * 8.0f * dp;
float x = vpX + vpW * xHash + xDrift;
float y = vpY + vpH * (1.0f - phase); // rise from bottom to top

View File

@@ -173,6 +173,24 @@ inline float kSidePanelMinWidth() { return schema::UI().drawElement("panels",
inline float kSidePanelMaxWidth() { return schema::UI().drawElement("panels", "side-panel").getFloat("max-width", 450.0f) * dpiScale(); }
inline float kSidePanelWidthRatio() { return schema::UI().drawElement("panels", "side-panel").getFloat("width-ratio", 0.4f); }
// Overall content-column cap: the max width a tab's content occupies before it is centered in wider
// windows. <= 0 disables the cap so tab content fills ALL available horizontal width (the default —
// requested so large windows don't leave a big empty gutter on the right). Set a positive
// ui.toml [layout] content-max-width to re-enable a centered readable column.
inline float kContentMaxWidth() { return schema::UI().drawElement("layout", "content-max-width").sizeOr(0.0f) * dpiScale(); }
// Shared compose-card envelope for the Send + Receive tabs (and any tab wanting the same box): fill the
// available column up to the content-max-width cap, then center the leftover as margin. Both tabs MUST
// derive their card width/offset from this so the two envelopes stay byte-for-byte identical — they
// previously drifted (Send capped at 760dp, Receive at 860dp), so the Send card rendered narrower than
// Receive on any window wider than ~860dp. Returns {width, offsetX} in the same units as availW.
struct CardBox { float width; float offsetX; };
inline CardBox mainComposeCardBox(float availW) {
float cap = kContentMaxWidth();
float w = (cap > 0.0f) ? std::min(availW, cap) : availW; // cap <= 0 -> fill full width
return CardBox{ w, std::max(0.0f, (availW - w) * 0.5f) };
}
inline float kTableMinHeight() { return schema::UI().drawElement("panels", "table").getFloat("min-height", 150.0f) * dpiScale(); }
inline float kTableHeightRatio() { return schema::UI().drawElement("panels", "table").getFloat("height-ratio", 0.45f); }

View File

@@ -116,6 +116,26 @@ inline ImVec4 WarningVec4() { return ImGui::ColorConvertU32ToFloat4(Warni
// Convenience Functions for Common Patterns
// ============================================================================
/**
* @brief Theme-aware translucent overlay for tracks / hover fills / dividers.
*
* A raw white overlay (IM_COL32(255,255,255,a)) reads on dark skins but vanishes
* on light/pastel skins (white-on-white). This picks a dark overlay on light
* themes and a white overlay on dark themes so the alpha reads either way.
* (Self-contained luminance check so colors.h stays free of draw_helpers.h.)
*
* @param alpha 0-255 opacity of the overlay
*/
inline ImU32 SurfaceOverlay(int alpha)
{
ImU32 bg = Background();
float r = ((bg >> IM_COL32_R_SHIFT) & 0xFF) / 255.0f;
float g = ((bg >> IM_COL32_G_SHIFT) & 0xFF) / 255.0f;
float b = ((bg >> IM_COL32_B_SHIFT) & 0xFF) / 255.0f;
bool light = (0.299f * r + 0.587f * g + 0.114f * b) > 0.5f;
return light ? IM_COL32(0, 0, 0, alpha) : IM_COL32(255, 255, 255, alpha);
}
/**
* @brief Get color with applied state overlay
*

View File

@@ -57,6 +57,21 @@ inline ImU32 ReadableError() {
return IM_COL32(r, g, b, (e >> IM_COL32_A_SHIFT) & 0xFF);
}
// Middle-ellipsis truncation ("front...back", roughly equal halves) so `text` fits within
// maxWidth pixels when drawn with `font` at `fontSize`. Returns `text` unchanged if it already
// fits (or maxWidth is non-positive). Display-only — never mutate the underlying value with this.
inline std::string TruncateToWidth(const std::string& text, ImFont* font, float fontSize, float maxWidth) {
if (text.empty() || !font || maxWidth <= 0.0f) return text;
if (font->CalcTextSizeA(fontSize, FLT_MAX, 0.0f, text.c_str()).x <= maxWidth) return text;
const int n = static_cast<int>(text.size());
for (int f = n / 2; f >= 3; --f) {
const int b = (f - 2 > 3) ? (f - 2) : 3; // keep the two halves roughly equal
std::string t = text.substr(0, f) + "..." + text.substr(n - b);
if (font->CalcTextSizeA(fontSize, FLT_MAX, 0.0f, t.c_str()).x <= maxWidth) return t;
}
return n > 6 ? (text.substr(0, 3) + "..." + text.substr(n - 3)) : text;
}
// Animated "loading" ellipsis: "", ".", "..", "..." cycling on a ~3Hz phase.
inline const char* LoadingDots() {
int n = ((int)(ImGui::GetTime() * 3.0f)) % 4;
@@ -64,6 +79,63 @@ inline const char* LoadingDots() {
return kDots[n];
}
// ── Centered empty state ─────────────────────────────────────────────────
// A big muted icon + title + optional wrapped hint, centered on BOTH axes within
// GetContentRegionAvail(). Mirrors chat_tab's centeredEmptyState so list-empty states
// read the same across tabs. Call at the start of the region you want it centered in
// (e.g. right after a BeginChild / a leading Dummy). Font metrics use the live font
// scale (LegacySize * FontScaleMain) and PushFont draws at that same scale, so this is
// crisp at HiDPI / font_scale 1.5 without any manual dpiScale multiply on the metrics.
inline void DrawEmptyState(const char* iconGlyph, const char* title, const char* hint = nullptr)
{
auto scaled = [](ImFont* f) { return f->LegacySize * ImGui::GetStyle().FontScaleMain; };
const ImVec2 avail = ImGui::GetContentRegionAvail();
const ImVec2 origin = ImGui::GetCursorPos();
ImFont* iconF = Type().iconXL();
ImFont* titleF = Type().subtitle1();
ImFont* hintF = Type().body2();
const float dp = Layout::dpiScale();
const float gap = 8.0f * dp;
const float wrap = std::min(avail.x - 40.0f * dp, 360.0f * dp);
const float iconSz = iconF ? scaled(iconF) : 40.0f;
const float iconH = (iconF && iconGlyph) ? iconF->CalcTextSizeA(iconSz, FLT_MAX, 0.0f, iconGlyph).y : 0.0f;
const float titleH = titleF->CalcTextSizeA(scaled(titleF), FLT_MAX, 0.0f, title).y;
const float hintH = hint ? hintF->CalcTextSizeA(scaled(hintF), wrap, wrap, hint).y : 0.0f;
const float totalH = iconH + (iconH > 0.0f ? gap : 0.0f) + titleH + (hint ? gap + hintH : 0.0f);
float y = origin.y + std::max(0.0f, (avail.y - totalH) * 0.5f);
if (iconF && iconGlyph && iconGlyph[0]) {
const float iw = iconF->CalcTextSizeA(iconSz, FLT_MAX, 0.0f, iconGlyph).x;
ImGui::SetCursorPos(ImVec2(origin.x + (avail.x - iw) * 0.5f, y));
ImGui::PushFont(iconF);
ImGui::PushStyleColor(ImGuiCol_Text, WithAlpha(OnSurface(), 70));
ImGui::TextUnformatted(iconGlyph);
ImGui::PopStyleColor();
ImGui::PopFont();
y += iconH + gap;
}
{
const float tw = titleF->CalcTextSizeA(scaled(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, 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, WithAlpha(OnSurface(), 120));
ImGui::PushTextWrapPos(ImGui::GetCursorPos().x + wrap);
ImGui::TextUnformatted(hint);
ImGui::PopTextWrapPos();
ImGui::PopStyleColor();
ImGui::PopFont();
}
}
// ============================================================================
// Text Drop Shadow
// ============================================================================
@@ -472,11 +544,16 @@ inline bool TactileButton(const char* label, const ImVec2& size = ImVec2(0, 0),
ImVec2 bMin = ImGui::GetItemRectMin();
ImVec2 bMax = ImGui::GetItemRectMax();
// For icon fonts, manually draw centered icon after getting button rect
// For icon fonts, manually draw centered icon after getting button rect. Measure/draw only the
// VISIBLE label (up to the "##id" separator): CalcTextSizeA/AddText don't strip "##" the way
// ImGui's own text render does, so an id suffix like "##pickContact" would inflate textSz and
// shove the glyph left off-center (and try to draw the notdef id chars).
if (isIconFont && size.x > 0 && size.y > 0) {
ImVec2 textSz = useFont->CalcTextSizeA(useFont->LegacySize, FLT_MAX, 0, label);
const char* labelEnd = label;
while (*labelEnd && !(labelEnd[0] == '#' && labelEnd[1] == '#')) ++labelEnd;
ImVec2 textSz = useFont->CalcTextSizeA(useFont->LegacySize, FLT_MAX, 0, label, labelEnd);
ImVec2 textPos(bMin.x + (size.x - textSz.x) * 0.5f, bMin.y + (size.y - textSz.y) * 0.5f);
dl->AddText(useFont, useFont->LegacySize, textPos, ImGui::GetColorU32(ImGuiCol_Text), label);
dl->AddText(useFont, useFont->LegacySize, textPos, ImGui::GetColorU32(ImGuiCol_Text), label, labelEnd);
}
float rounding = ImGui::GetStyle().FrameRounding;
@@ -848,7 +925,7 @@ inline void DrawStatCard(ImDrawList* dl,
// Draw a full-height rounded rect with card rounding (left corners)
// and clip to stripe width so the shape follows the corner radius.
if ((card.accentCol & IM_COL32_A_MASK) != 0) {
float stripeW = 4.0f;
float stripeW = 4.0f * Layout::dpiScale();
dl->PushClipRect(cMin, ImVec2(cMin.x + stripeW, cMax.y), true);
dl->AddRectFilled(cMin, cMax, card.accentCol, rnd,
ImDrawFlags_RoundCornersLeft);
@@ -1205,7 +1282,8 @@ inline bool DrawDialogTitleBar(const char* title, bool* p_open, ImU32 accent_col
ImDrawList* dl = ImGui::GetWindowDrawList();
ImVec2 winPos = ImGui::GetWindowPos();
float winWidth = ImGui::GetWindowWidth();
float barHeight = 36.0f;
const float dp = Layout::dpiScale();
float barHeight = 36.0f * dp;
// Get accent color from theme if not provided
if (!accent_col) {
@@ -1229,15 +1307,15 @@ inline bool DrawDialogTitleBar(const char* title, bool* p_open, ImU32 accent_col
ImFont* titleFont = Type().subtitle1();
ImGui::PushFont(titleFont);
ImVec2 titleSize = ImGui::CalcTextSize(title);
float titleX = barMin.x + 16.0f;
float titleX = barMin.x + 16.0f * dp;
float titleY = barMin.y + (barHeight - titleSize.y) * 0.5f;
DrawTextShadow(dl, ImVec2(titleX, titleY), OnSurface(), title);
ImGui::PopFont();
// Close button (X) on right side
if (p_open) {
float btnSize = 24.0f;
float btnX = barMax.x - btnSize - 12.0f;
float btnSize = 24.0f * dp;
float btnX = barMax.x - btnSize - 12.0f * dp;
float btnY = barMin.y + (barHeight - btnSize) * 0.5f;
ImVec2 btnMin(btnX, btnY);
ImVec2 btnMax(btnX + btnSize, btnY + btnSize);
@@ -1250,7 +1328,7 @@ inline bool DrawDialogTitleBar(const char* title, bool* p_open, ImU32 accent_col
// Button background on hover
if (hovered) {
dl->AddRectFilled(btnMin, btnMax, IM_COL32(255, 255, 255, held ? 40 : 25), 4.0f);
dl->AddRectFilled(btnMin, btnMax, IM_COL32(255, 255, 255, held ? 40 : 25), 4.0f * dp);
}
// Draw X icon
@@ -1271,7 +1349,7 @@ inline bool DrawDialogTitleBar(const char* title, bool* p_open, ImU32 accent_col
}
// Reserve space for title bar so content starts below it
ImGui::SetCursorPosY(ImGui::GetCursorPosY() + barHeight + 8.0f);
ImGui::SetCursorPosY(ImGui::GetCursorPosY() + barHeight + 8.0f * dp);
return closeClicked;
}
@@ -1388,6 +1466,7 @@ struct OverlayCardState {
int stableCount = 0; // consecutive frames the height held steady (within 1px)
int appearFrames = 0; // frames since (re)appearing while still hidden — a safety cap
bool shown = false; // revealed (centered) at least once this open; don't re-hide after
bool overflow = false; // content once exceeded the viewport → clamp to viewport + scroll (sticky/open)
};
inline std::unordered_map<std::string, OverlayCardState> g_overlayCardHeights;
inline std::string g_overlayCurrentKey;
@@ -1513,6 +1592,7 @@ inline bool BeginOverlayDialog(const OverlayDialogSpec& spec)
float cardX = vp_pos.x + (vp_size.x - cardWidth) * 0.5f;
float cardY, cardBottomY;
bool hideForMeasure = false; // true on an auto-height dialog's first (unmeasured) frame
bool autoOverflow = false; // auto-height content taller than the viewport → clamp + scroll
const bool fixedHeight = (spec.cardHeight > 0.0f);
if (fixedHeight) {
float cardH = std::min(spec.cardHeight * dp, vp_size.y - 32.0f);
@@ -1522,9 +1602,16 @@ inline bool BeginOverlayDialog(const OverlayDialogSpec& spec)
} else {
g_overlayCurrentKey = childId;
OverlayCardState& cs = g_overlayCardHeights[childId];
if (scrimAppearing) { cs.shown = false; cs.stableCount = 0; cs.appearFrames = 0; }
if (scrimAppearing) { cs.shown = false; cs.stableCount = 0; cs.appearFrames = 0; cs.overflow = false; }
if (!cs.shown) cs.appearFrames++;
const float measuredH = cs.height;
const float maxCardH = vp_size.y - 32.0f;
// Once the measured content is taller than the viewport, lock the card to the viewport height and
// let its content child scroll (autoOverflow) so the footer/actions stay reachable. Sticky for this
// open: clamping makes next frame's measured height the clamped value, so re-deciding from it would
// oscillate — decide once and hold until the dialog re-opens.
if (measuredH > maxCardH) cs.overflow = true;
autoOverflow = cs.overflow;
// Reveal once the measured height has settled (auto-resize converges in ~2 frames) or it's
// already been shown this open (don't re-hide on a mid-dialog content change); a frame cap
// guarantees a pathological ever-changing height can't hide the dialog forever.
@@ -1532,11 +1619,18 @@ inline bool BeginOverlayDialog(const OverlayDialogSpec& spec)
(cs.shown || cs.stableCount >= 1 || cs.appearFrames >= 8);
if (ready) {
cs.shown = true;
// Center the measured content; if it's taller than the window, anchor at the top margin.
cardY = (measuredH < vp_size.y - 32.0f)
? vp_pos.y + (vp_size.y - measuredH) * 0.5f
: vp_pos.y + 16.0f;
cardBottomY = cardY + measuredH;
if (autoOverflow) {
// Taller than the screen: top-anchor at the 16px margin, clamp to the viewport; the
// content child (below) becomes the scroll region so the footer/actions stay reachable.
cardY = vp_pos.y + 16.0f;
cardBottomY = cardY + maxCardH;
} else {
// Center the measured content; if it's taller than the window, anchor at the top margin.
cardY = (measuredH < maxCardH)
? vp_pos.y + (vp_size.y - measuredH) * 0.5f
: vp_pos.y + 16.0f;
cardBottomY = cardY + measuredH;
}
} else {
// Still settling: lay the content out (so the auto-height child gets measured) but keep
// the card hidden (hideForMeasure below) so it never flashes off-center — it appears,
@@ -1552,7 +1646,10 @@ inline bool BeginOverlayDialog(const OverlayDialogSpec& spec)
// the measuring frame (its geometry is a placeholder; the whole card is hidden until centered).
if (!floating && !hideForMeasure) {
GlassPanelSpec cardGlass;
cardGlass.rounding = 16.0f; cardGlass.fillAlpha = 35; cardGlass.borderAlpha = 50; cardGlass.borderWidth = 1.0f;
// Fill/border alpha govern every overlay dialog's card boundary — kept well above the default
// glass panel so the card reads as a distinct surface over busy backdrops (tx lists, mining
// tiles, chat) while staying translucent rather than opaque.
cardGlass.rounding = 16.0f * dp; cardGlass.fillAlpha = 60; cardGlass.borderAlpha = 90; cardGlass.borderWidth = 1.0f;
DrawGlassPanel(dl, cardMin, cardMax, cardGlass);
}
@@ -1566,17 +1663,24 @@ inline bool BeginOverlayDialog(const OverlayDialogSpec& spec)
// Content child.
ImGui::SetCursorScreenPos(ImVec2(cardX, cardY));
ImGui::PushStyleVar(ImGuiStyleVar_ChildRounding, floating ? 20.0f : 16.0f);
ImGui::PushStyleVar(ImGuiStyleVar_WindowPadding, floating ? ImVec2(28, 20) : ImVec2(28, 24));
ImGui::PushStyleVar(ImGuiStyleVar_ChildRounding, floating ? 20.0f * dp : 16.0f * dp);
ImGui::PushStyleVar(ImGuiStyleVar_WindowPadding, floating ? ImVec2(28 * dp, 20 * dp) : ImVec2(28 * dp, 24 * dp));
ImGui::PushStyleColor(ImGuiCol_ChildBg, ImVec4(0, 0, 0, 0)); // transparent (glass/blur behind)
ImGuiChildFlags cflags = ImGuiChildFlags_AlwaysUseWindowPadding | (fixedHeight ? 0 : ImGuiChildFlags_AutoResizeY);
// A card with a known height is a fixed frame (fixed-height dialogs, and auto-height dialogs whose
// content overflowed the viewport); otherwise the child auto-resizes to its content.
const bool clampedCard = fixedHeight || autoOverflow;
ImGuiChildFlags cflags = ImGuiChildFlags_AlwaysUseWindowPadding | (clampedCard ? 0 : ImGuiChildFlags_AutoResizeY);
// NoScrollWithMouse (not just NoScrollbar): a modal is a fixed frame — the wheel must never drift
// the WHOLE card. If content marginally overflows a fixed card, the wheel would otherwise scroll
// the entire dialog (title + footer and all). Inner scroll regions (lists, notes) still scroll on
// their own; auto-height cards resize to content so they never overflow anyway.
// their own; auto-height cards resize to content so they normally never overflow — EXCEPT when the
// content is taller than the viewport (autoOverflow), where the card itself IS the scroll region.
ImGuiWindowFlags childScroll = autoOverflow
? ImGuiWindowFlags_None
: (ImGuiWindowFlags_NoScrollbar | ImGuiWindowFlags_NoScrollWithMouse);
bool childVisible = ImGui::BeginChild(childId.c_str(),
ImVec2(cardWidth, fixedHeight ? (cardBottomY - cardY) : 0.0f),
cflags, ImGuiWindowFlags_NoScrollbar | ImGuiWindowFlags_NoScrollWithMouse);
ImVec2(cardWidth, clampedCard ? (cardBottomY - cardY) : 0.0f),
cflags, childScroll);
// Floating (portfolio-style) cards: the padding applies to this content child only, so pop it
// now (nested children mustn't inherit it), and center button labels. Net style-var count stays
// at 2 (ChildRounding + ButtonTextAlign) so EndOverlayDialog's PopStyleVar(2) is unchanged.
@@ -1699,7 +1803,7 @@ inline void DialogWarningHeader(const char* warningLabel, const ImVec4& col = Wa
inline void DialogConfirmFooter(const char* cancelId, const char* confirmLabel,
bool danger, bool& outCancel, bool& outConfirm)
{
float btnH = schema::UI().drawElement("components.overlay-dialog", "confirm-btn-height").sizeOr(40.0f);
float btnH = schema::UI().drawElement("components.overlay-dialog", "confirm-btn-height").sizeOr(40.0f) * Layout::dpiScale();
float btnW = (ImGui::GetContentRegionAvail().x - ImGui::GetStyle().ItemSpacing.x) * 0.5f;
if (ImGui::Button(cancelId, ImVec2(btnW, btnH))) {
outCancel = true;

View File

@@ -0,0 +1,195 @@
// DragonX Wallet - ImGui Edition
// Copyright 2024-2026 The Hush Developers
// Released under the GPLv3
//
// Settings design-system controls — the polished chat-settings look (accent subsection headers,
// labeled rows with right-aligned controls, iOS-style segmented controls) promoted to reusable
// components, plus a tiered ActionButton (Primary/Secondary/Tertiary/Destructive) with optional
// leading Material icon and a ButtonFlow that wraps rows of buttons instead of shrinking them.
#pragma once
#include "draw_helpers.h" // colors, type, layout, icons, WithAlpha, ScaleAlpha, DrawButtonGlassOverlay
#include "imgui.h"
#include <algorithm>
namespace dragonx {
namespace ui {
namespace material {
// Accent small-caps subsection header ("KEYS & BACKUP", "APPEARANCE"…) — the chat-settings section() look.
inline void SettingsSubheader(const char* text) {
const float dp = Layout::dpiScale();
ImGui::Dummy(ImVec2(0.0f, 8.0f * dp));
ImGui::PushFont(Type().caption());
ImGui::PushStyleColor(ImGuiCol_Text, WithAlpha(Primary(), 235));
ImGui::TextUnformatted(text);
ImGui::PopStyleColor();
ImGui::PopFont();
ImGui::Dummy(ImVec2(0.0f, 2.0f * dp));
}
// A labeled settings row: label left, control right-aligned in a fixed column. Construct once per card
// section with the content width (0 = auto), then call .label(text) before drawing each control (leaves
// the cursor at the control origin and sets the next item width to the control column).
struct SettingsRow {
float leftX, rowW, ctrlW, rowGap;
explicit SettingsRow(float contentWidth, float controlWidth = 250.0f) {
const float dp = Layout::dpiScale();
leftX = ImGui::GetCursorPosX();
rowW = (contentWidth > 0.0f) ? contentWidth : ImGui::GetContentRegionAvail().x;
ctrlW = controlWidth * dp;
rowGap = 5.0f * dp;
}
void label(const char* text) {
ImGui::Dummy(ImVec2(0.0f, rowGap));
ImGui::AlignTextToFramePadding();
ImGui::TextUnformatted(text);
ImGui::SameLine();
ImGui::SetCursorPosX(std::max(ImGui::GetCursorPosX(), leftX + std::max(0.0f, rowW - ctrlW)));
ImGui::SetNextItemWidth(ctrlW);
}
};
// iOS-style segmented control (rounded track + inset pill on the selection). Draws its own labeled row
// and returns the (possibly changed) index.
inline int SegmentedControl(SettingsRow& row, const char* label, const char* const* items, int count, int value) {
const float dp = Layout::dpiScale();
row.label(label);
const ImVec2 origin = ImGui::GetCursorScreenPos();
const float h = ImGui::GetFrameHeight();
const float seg = row.ctrlW / static_cast<float>(count);
const float round = 7.0f * dp;
ImDrawList* dl = ImGui::GetWindowDrawList();
dl->AddRectFilled(origin, ImVec2(origin.x + row.ctrlW, origin.y + h), WithAlpha(OnSurface(), 20), round);
int result = value;
ImGui::PushID(label);
for (int i = 0; i < count; ++i) {
ImGui::PushID(i);
const ImVec2 mn(origin.x + i * seg, origin.y), mx(origin.x + (i + 1) * seg, origin.y + h);
ImGui::SetCursorScreenPos(mn);
if (ImGui::InvisibleButton("##s", ImVec2(seg, h))) result = i;
const bool hov = ImGui::IsItemHovered();
if (hov) ImGui::SetMouseCursor(ImGuiMouseCursor_Hand);
const bool sel = (value == i);
if (sel) {
const float in = 2.0f * dp;
dl->AddRectFilled(ImVec2(mn.x + in, mn.y + in), ImVec2(mx.x - in, mx.y - in),
WithAlpha(Primary(), 210), std::max(1.0f, round - in));
} else if (hov) {
dl->AddRectFilled(mn, mx, WithAlpha(OnSurface(), 26), round);
}
const ImVec2 ts = ImGui::CalcTextSize(items[i]);
const float lpad = 4.0f * dp;
const float tx = (ts.x <= seg - 2.0f * lpad) ? (seg - ts.x) * 0.5f : lpad;
ImGui::PushClipRect(mn, mx, true);
dl->AddText(ImVec2(mn.x + tx, mn.y + (h - ts.y) * 0.5f),
sel ? IM_COL32(255, 255, 255, 236) : OnSurfaceMedium(), items[i]);
ImGui::PopClipRect();
ImGui::PopID();
}
ImGui::PopID();
ImGui::SetCursorScreenPos(origin);
ImGui::Dummy(ImVec2(row.ctrlW, h)); // reserve the control's rect for layout flow
return result;
}
// ── Tiered action buttons ───────────────────────────────────────────────────
// Primary = filled accent (the one main action of a group)
// Secondary = glass (default — common actions)
// Tertiary = ghost / low-emphasis (rarely used)
// Destructive = error-tinted outline (delete / reset)
enum class ActionTier { Primary, Secondary, Tertiary, Destructive };
// The width an ActionButton will occupy (for ButtonFlow / manual layout).
inline float ActionButtonWidth(const char* label, const char* icon, float minWidth = 0.0f) {
ImFont* lf = Type().button();
ImFont* icf = Type().iconSmall();
const float dp = Layout::dpiScale();
const float padX = 9.0f * dp, gap = 6.0f * dp; // mockup .btn padding: 9px horizontal
const float labelW = lf->CalcTextSizeA(lf->LegacySize, FLT_MAX, 0, label).x;
const float iconW = (icon && icon[0] && icf) ? icf->CalcTextSizeA(icf->LegacySize, FLT_MAX, 0, icon).x : 0.0f;
const float w = padX * 2.0f + iconW + (iconW > 0.0f ? gap : 0.0f) + labelW;
return std::max(w, minWidth);
}
// A tiered action button with an optional leading Material icon (ICON_MD_* or nullptr). Auto-sizes to
// its content (>= minWidth). Respects BeginDisabled() (dims via the style alpha, not clickable).
inline bool ActionButton(const char* id, const char* label, const char* icon, ActionTier tier, float minWidth = 0.0f) {
ImFont* lf = Type().button();
ImFont* icf = Type().iconSmall();
const float dp = Layout::dpiScale();
const float gap = 6.0f * dp;
const float h = ImGui::GetFrameHeight();
const bool hasIcon = icon && icon[0] && icf;
const float labelW = lf->CalcTextSizeA(lf->LegacySize, FLT_MAX, 0, label).x;
const float iconW = hasIcon ? icf->CalcTextSizeA(icf->LegacySize, FLT_MAX, 0, icon).x : 0.0f;
const float w = ActionButtonWidth(label, icon, minWidth);
const ImVec2 pos = ImGui::GetCursorScreenPos();
const bool pressed = ImGui::InvisibleButton(id, ImVec2(w, h));
const bool hov = ImGui::IsItemHovered(), act = ImGui::IsItemActive();
if (hov) ImGui::SetMouseCursor(ImGuiMouseCursor_Hand);
ImDrawList* dl = ImGui::GetWindowDrawList();
const ImVec2 pMax(pos.x + w, pos.y + h);
const float round = 7.0f * dp; // mockup .btn radius: 7px (softer than the global 4px frame)
const float a = ImGui::GetStyle().Alpha; // BeginDisabled() lowers this
ImU32 bg = 0, border = 0, fg = OnSurface();
bool glass = false;
switch (tier) {
case ActionTier::Primary:
// Mockup .btn.acc: a dark accent-tinted chip with accent TEXT — not a bright filled button.
bg = WithAlpha(Primary(), hov ? 52 : 38);
border = WithAlpha(Primary(), hov ? 150 : 110);
fg = Primary();
break;
case ActionTier::Secondary:
bg = WithAlpha(OnSurface(), hov ? 30 : 20);
border = WithAlpha(OnSurface(), 48);
glass = true;
fg = OnSurface();
break;
case ActionTier::Tertiary:
bg = hov ? WithAlpha(OnSurface(), 18) : 0;
fg = OnSurfaceMedium();
break;
case ActionTier::Destructive:
bg = hov ? WithAlpha(Error(), 32) : WithAlpha(Error(), 12);
border = WithAlpha(Error(), 90);
fg = Error();
break;
}
if (bg) dl->AddRectFilled(pos, pMax, ScaleAlpha(bg, a), round);
if (border) dl->AddRect(pos, pMax, ScaleAlpha(border, a), round, 0, 1.0f);
if (glass) DrawButtonGlassOverlay(dl, pos, pMax, round, act, hov);
fg = ScaleAlpha(fg, a);
const float contentW = iconW + (iconW > 0.0f ? gap : 0.0f) + labelW;
float cx = pos.x + (w - contentW) * 0.5f;
if (hasIcon) {
dl->AddText(icf, icf->LegacySize, ImVec2(cx, pos.y + (h - icf->LegacySize) * 0.5f), fg, icon);
cx += iconW + gap;
}
dl->AddText(lf, lf->LegacySize, ImVec2(cx, pos.y + (h - lf->LegacySize) * 0.5f), fg, label);
return pressed;
}
// Places ActionButtons left→right, wrapping to a new row when the next one won't fit (instead of the
// old font-scale-to-fit). Call next(width) before each ActionButton.
struct ButtonFlow {
float availW, gap; float x = 0.0f; bool firstOnRow = true;
explicit ButtonFlow(float availWidth, float gapPx = 8.0f) : availW(availWidth) {
gap = gapPx * Layout::dpiScale();
}
void next(float w) {
if (firstOnRow) { firstOnRow = false; x = w; return; }
if (x + gap + w <= availW) { ImGui::SameLine(0, gap); x += gap + w; }
else { x = w; } // natural newline wraps to the next row
}
};
} // namespace material
} // namespace ui
} // namespace dragonx

111
src/ui/node_status_banner.h Normal file
View File

@@ -0,0 +1,111 @@
#pragma once
#include <string>
// Persistent node-connectivity banner shown at the top of the content column when the wallet
// cannot reach its node. Distinct from the transient toast notifications: it stays visible for
// as long as the fault persists, so an offline wallet is never silently mistaken for a working
// one. The decision (whether to show, how severe, which action) is a pure function of a state
// snapshot so it can be unit-tested; App::renderNodeStatusBanner() feeds it the live state and
// draws the strip. See src/app.cpp.
namespace dragonx::ui {
// Visual weight. Warning (amber) = recoverable / a reconnect is offered; Error (red) = a hard
// fault the user must act on (the daemon gave up crashing, or a lite wallet failed to open).
enum class NodeBannerSeverity {
Warning,
Error,
};
// What the banner's action button does. App maps this to the concrete call.
enum class NodeBannerAction {
None, // no button — nothing the user can usefully do from here
Reconnect, // full node: re-run the RPC connect state machine (App::tryConnect)
RestartNode, // full node: the embedded daemon crashed & auto-restart gave up (App::restartDaemon)
};
// Why the banner is up. App maps this to a translated headline; `detail` carries the live,
// already-human-readable status text (connection_status_ / daemon lastError / lite open error).
enum class NodeBannerReason {
None,
FullNodeOffline, // a reachable node was lost, or never came up; reconnect offered
DaemonCrashed, // the embedded daemon crashed repeatedly and auto-restart stopped
LiteOpenFailed, // lite build: the wallet failed to open
};
struct NodeBannerState {
bool show = false;
NodeBannerSeverity severity = NodeBannerSeverity::Warning;
NodeBannerReason reason = NodeBannerReason::None;
NodeBannerAction action = NodeBannerAction::None;
std::string detail; // passthrough status/error text (may be empty)
};
// Snapshot of the connection state the banner reads. Plain values so the decision is testable
// without an App instance.
struct NodeBannerInputs {
bool lite = false; // lite build (no embedded daemon / RPC)
bool connected = false; // state_.connected — the master "online" flag
bool warming_up = false; // daemon reachable, RPC warmup (code -28)
bool daemon_initializing = false; // daemon launching / block index loading
bool connection_in_progress = false; // a connect attempt is actively running
// Full-node embedded-daemon crash signal.
bool using_embedded_daemon = false;
bool has_daemon_controller = false;
bool daemon_running = false;
int daemon_crash_count = 0;
std::string connection_status; // human-readable status line (already translated)
std::string daemon_last_error; // DaemonController::lastError() (may be empty)
std::string lite_open_error; // lite: last wallet-open failure reason
};
// Auto-restart give-up threshold — mirrors the crash cap in app_network.cpp's connect loop.
inline constexpr int kNodeBannerCrashGiveUpCount = 3;
inline NodeBannerState evaluateNodeStatusBanner(const NodeBannerInputs& in) {
NodeBannerState s;
if (in.lite) {
// Lite has no daemon/RPC; "online" == wallet open. Only a genuine open failure is a
// fault worth a persistent banner (a not-yet-created wallet is handled by the normal
// "No wallet open" prompt, and leaves lite_open_error empty).
if (!in.connected && !in.lite_open_error.empty()) {
s.show = true;
s.severity = NodeBannerSeverity::Error;
s.reason = NodeBannerReason::LiteOpenFailed;
s.action = NodeBannerAction::None;
s.detail = in.lite_open_error;
}
return s;
}
// Full node. Connected, or in an expected startup phase → the loading/warmup overlay owns
// the screen, so no banner. An active connect attempt likewise shows progress, not an
// error — don't flicker a banner over it.
if (in.connected) return s;
if (in.warming_up || in.daemon_initializing) return s;
if (in.connection_in_progress) return s;
// Genuinely offline. Distinguish "the embedded daemon crashed and we stopped retrying" (a
// hard fault needing a manual restart) from an ordinary lost/failed connection (retryable).
if (in.using_embedded_daemon && in.has_daemon_controller && !in.daemon_running &&
in.daemon_crash_count >= kNodeBannerCrashGiveUpCount) {
s.show = true;
s.severity = NodeBannerSeverity::Error;
s.reason = NodeBannerReason::DaemonCrashed;
s.action = NodeBannerAction::RestartNode;
s.detail = !in.daemon_last_error.empty() ? in.daemon_last_error : in.connection_status;
return s;
}
s.show = true;
s.severity = NodeBannerSeverity::Warning;
s.reason = NodeBannerReason::FullNodeOffline;
s.action = NodeBannerAction::Reconnect;
s.detail = in.connection_status;
return s;
}
} // namespace dragonx::ui

View File

@@ -32,19 +32,22 @@ void Notifications::render()
return v >= 0 ? v : fb;
};
// Status bar geometry
float sbHeight = S.window("components.status-bar").height;
if (sbHeight <= 0.0f) sbHeight = 30.0f;
// Status bar geometry. These are logical-px schema values; the icon/text drawn into the pill
// are DPI-baked, so scale the box by dpiScale to match the (also DPI-scaled) rendered status bar
// and keep the icon/text inside the pill at HiDPI.
const float dp = Layout::dpiScale();
float sbHeight = S.window("components.status-bar").height * dp;
if (sbHeight <= 0.0f) sbHeight = 30.0f * dp;
ImGuiViewport* viewport = ImGui::GetMainViewport();
float viewBottom = viewport->WorkPos.y + viewport->WorkSize.y;
float viewCenterX = viewport->WorkPos.x + viewport->WorkSize.x * 0.5f;
// Toast pill sizing — fit inside status bar with margin
float pillMarginY = nde("pill-margin-y", 3.0f);
float pillMarginY = nde("pill-margin-y", 3.0f) * dp;
float pillHeight = sbHeight - pillMarginY * 2.0f;
float pillPadX = nde("padding-x", 12.0f);
float pillRounding = nde("pill-rounding", 12.0f);
float pillPadX = nde("padding-x", 12.0f) * dp;
float pillRounding = nde("pill-rounding", 12.0f) * dp;
// Get accent color based on type — resolved from theme palette
ImVec4 accent_color, text_color;
@@ -89,7 +92,7 @@ void Notifications::render()
ImFont* textFont = material::Type().caption();
ImFont* iconFont = material::Type().iconSmall();
float iconW = iconFont ? iconFont->CalcTextSizeA(iconFont->LegacySize, FLT_MAX, 0.0f, icon).x : 0.0f;
float iconGap = 4.0f;
float iconGap = 4.0f * dp;
float msgW = textFont ? textFont->CalcTextSizeA(textFont->LegacySize, FLT_MAX, 0.0f, notif.message.c_str()).x : 100.0f;
float pillWidth = pillPadX + iconW + iconGap + msgW + pillPadX;
// Clamp to reasonable bounds
@@ -122,7 +125,7 @@ void Notifications::render()
// Progress bar at bottom of pill (accent-colored), clipped to pill rounded
// corners. Draw a full-pill-size rounded rect and clip it to just the
// bottom-left progress strip so both bottom corners are respected.
float progH = nde("progress-bar-height", 2.0f);
float progH = nde("progress-bar-height", 2.0f) * dp;
float progW = pillWidth * (1.0f - progress);
if (progW > 0.0f) {
ImVec2 clipMin(pillX, pMax.y - progH);

View File

@@ -9,6 +9,8 @@
#include <chrono>
#include <functional>
#include <cstdio>
#include <cstdint>
#include <ctime>
#include "../util/logger.h"
#include "schema/ui_schema.h"
@@ -22,6 +24,17 @@ enum class NotificationType {
Error
};
// A retained alert for the persistent history panel. Unlike a live Notification (which fades and is
// erased within seconds), this keeps a wall-clock epoch so its age can be shown as "3m ago" long
// after the toast is gone. See App::renderAlertHistoryPanel.
struct AlertRecord {
std::string message;
NotificationType type;
std::int64_t epoch; // std::time(nullptr) at push — wall-clock, for relative-age display
std::function<void()> onClick; // optional: makes this bell-panel entry actionable
std::string actionHint; // optional: accent link label rendered for the action
};
struct Notification {
std::string message;
NotificationType type;
@@ -81,23 +94,43 @@ public:
if (duration < 0.0f) duration = schemaDuration("duration-error", 4.0f);
push(message, NotificationType::Error, duration);
}
// An actionable alert: a normal toast PLUS a clickable entry in the bell/alert-history panel.
// onClick fires when the user clicks the accent `actionHint` link in that panel.
void action(const std::string& message, NotificationType type, std::function<void()> onClick,
const std::string& actionHint, float duration = -1.0f) {
if (duration < 0.0f) duration = schemaDuration("duration-warning", 3.5f);
push(message, type, duration, std::move(onClick), actionHint);
}
void push(const std::string& message, NotificationType type, float duration = 5.0f) {
void push(const std::string& message, NotificationType type, float duration = 5.0f,
std::function<void()> onClick = nullptr, const std::string& actionHint = "") {
notifications_.emplace_back(message, type, duration);
// Retain a copy in the persistent history (the toast above will fade in seconds; this
// survives so the user can review what happened). Thread note: every push is on the UI
// thread (RPC results run as main-thread MainCb callbacks), so this container needs no lock,
// consistent with the rest of this class. Do NOT push from a raw worker thread.
history_.push_back(AlertRecord{message, type, static_cast<std::int64_t>(std::time(nullptr)),
std::move(onClick), actionHint});
++total_pushed_;
while (history_.size() > kMaxHistory) {
history_.pop_front();
}
// Log errors and warnings (debug-only output)
if (type == NotificationType::Error) {
DEBUG_LOGF("[ERROR] Notification: %s\n", message.c_str());
} else if (type == NotificationType::Warning) {
DEBUG_LOGF("[WARN] Notification: %s\n", message.c_str());
}
// Forward errors and warnings to console callback
if (console_callback_ && (type == NotificationType::Error || type == NotificationType::Warning)) {
const char* prefix = (type == NotificationType::Error) ? "[ERROR] " : "[WARN] ";
console_callback_(prefix + message, type == NotificationType::Error);
}
// Limit max notifications
while (notifications_.size() > max_notifications_) {
notifications_.pop_front();
@@ -122,21 +155,34 @@ public:
void clear() {
notifications_.clear();
}
void setMaxNotifications(size_t max) {
max_notifications_ = max;
}
// ── Persistent alert history (for the status-bar bell panel) ──
/// Retained alerts, oldest first (capped at kMaxHistory; the toast deque is separate).
const std::deque<AlertRecord>& history() const { return history_; }
bool hasHistory() const { return !history_.empty(); }
void clearHistory() { history_.clear(); }
/// Monotonic count of every alert ever pushed this session — survives capping/clearing, so it is
/// the correct basis for an "unseen since last opened" count (deque size is not).
std::uint64_t totalPushed() const { return total_pushed_; }
private:
Notifications() = default;
~Notifications() = default;
Notifications(const Notifications&) = delete;
Notifications& operator=(const Notifications&) = delete;
std::deque<Notification> notifications_;
size_t max_notifications_ = 5;
std::function<void(const std::string&, bool)> console_callback_;
std::deque<AlertRecord> history_;
std::uint64_t total_pushed_ = 0;
static constexpr size_t kMaxHistory = 100;
static float schemaDuration(const char* key, float fallback) {
float v = schema::UI().drawElement("components.notifications", key).size;
return v > 0.0f ? v : fallback;

File diff suppressed because it is too large Load Diff

View File

@@ -219,7 +219,7 @@ inline void DrawGlassCutout(ImDrawList* dl, ImVec2 mn, ImVec2 mx,
float lineW = s_cc.lineW;
// --- Outer glow pass: wider, softer dark edge on top-left ---
float glowExpand = s_cc.glowExpand;
float glowExpand = s_cc.glowExpand * Layout::dpiScale();
int glowA = (int)s_cc.glowAlpha;
float glowLineW = s_cc.glowLineW;
{
@@ -289,6 +289,7 @@ inline void DrawGlassBevelButton(ImDrawList* dl, ImVec2 mn, ImVec2 mx,
// inner pass gives crisp bevel edge. All use AddRect with rounding so
// every layer follows the rounded corners perfectly — no clip rects needed.
{
const float dp = Layout::dpiScale();
float cx = (mn.x + mx.x) * 0.5f;
float cy = (mn.y + mx.y) * 0.5f;
@@ -304,7 +305,7 @@ inline void DrawGlassBevelButton(ImDrawList* dl, ImVec2 mn, ImVec2 mx,
struct BevelPass { float expand; float lineW; float fadeStart; float fadeEnd; };
BevelPass passes[] = {
{ 0.5f, 0.75f, 0.30f, 0.55f }, // Outer glow (thin)
{ 0.5f * dp, 0.75f, 0.30f, 0.55f }, // Outer glow (thin)
{ 0.0f, 0.75f, 0.38f, 0.58f }, // Inner crisp bevel
};
@@ -387,7 +388,7 @@ inline void DrawGlassBevelButton(ImDrawList* dl, ImVec2 mn, ImVec2 mx,
}
}
if (depth > s_ic.threshold) {
float baseInset = s_ic.inset;
float baseInset = s_ic.inset * Layout::dpiScale();
int shadowMax = (int)(s_ic.maxAlpha * depth);
float fadeRatio = s_ic.fadeRatio;
float bW = mx.x - mn.x - baseInset * 2.0f;
@@ -487,7 +488,7 @@ inline bool RenderSidebar(NavPage& current, float sidebarWidth, float contentHei
float fixedH = stripH; // collapse strip
for (int i = 0; i < (int)NavPage::Count_; ++i)
if (IsNavPageVisible(kNavItems[i].page) && kNavItems[i].section_label && showLabels)
fixedH += olFsz + 2.0f + sectionLabelPadBot; // section label + pad below
fixedH += olFsz + 2.0f * dp + sectionLabelPadBot; // section label + pad below
fixedH += bottomPadding + stripH; // exit area
float baseFlexH = baseNavGap;
@@ -525,7 +526,7 @@ inline bool RenderSidebar(NavPage& current, float sidebarWidth, float contentHei
if (showLabels) {
curY += sectionGap;
if (nSectionLabels < 4) sectionLabelY[nSectionLabels++] = curY;
curY += olFsz + 2.0f + sectionLabelPadBot;
curY += olFsz + 2.0f * dp + sectionLabelPadBot;
} else {
curY += sectionGap * 0.4f;
if (nSeparators < 4) separatorY[nSeparators++] = curY;
@@ -538,11 +539,6 @@ inline bool RenderSidebar(NavPage& current, float sidebarWidth, float contentHei
float exitRelY = curY + bottomPadding;
float panelH = exitRelY + stripH;
// Vertical centering — offset so panel is centered in the child window
float centerOffset = std::max(glassMarginY, (contentHeight - panelH) * 0.5f);
if (centerOffset + panelH > contentHeight)
centerOffset = std::max(0.0f, contentHeight - panelH);
// ===================================================================
// PASS 2: Render using computed positions
// ===================================================================
@@ -552,6 +548,13 @@ inline bool RenderSidebar(NavPage& current, float sidebarWidth, float contentHei
ImDrawList* dl = ImGui::GetWindowDrawList();
ImVec2 wp = ImGui::GetWindowPos();
// Vertical centering — center the panel within the child. app.cpp sizes the child (contentHeight)
// to the visible area (child top -> status-bar top) using window-local geometry, so this yields
// equal top/bottom gaps at any height on every platform, no viewport dependency.
float centerOffset = std::max(glassMarginY, (contentHeight - panelH) * 0.5f);
if (centerOffset + panelH > contentHeight)
centerOffset = std::max(0.0f, contentHeight - panelH);
float panelLeft = wp.x + glassMarginL;
float panelRight = wp.x + sidebarWidth - glassMarginR;
float panelTopY = wp.y + centerOffset;
@@ -651,7 +654,7 @@ inline bool RenderSidebar(NavPage& current, float sidebarWidth, float contentHei
fx.drawShimmer(dl, indMin, indMax, btnRnd);
fx.drawGradientBorderShift(dl, indMin, indMax, btnRnd);
}
DrawGlassCutout(dl, indMin, indMax, btnRnd, 1.5f);
DrawGlassCutout(dl, indMin, indMax, btnRnd, 1.5f * dp);
DrawGlassBevelButton(dl, indMin, indMax, btnRnd, btnDepth, 18);
buttonRects.push_back({indMin, indMax, btnRnd});
}
@@ -676,10 +679,31 @@ inline bool RenderSidebar(NavPage& current, float sidebarWidth, float contentHei
ImU32 textCol = selected ? Primary() : (pageNeedsUnlock ? OnSurfaceDisabled() : OnSurfaceMedium());
if (showLabels) {
// The badge is a fixed top-right corner overlay, so it must NOT move
// the icon+label — otherwise the text jumps sideways the moment a live
// count toggles the badge on/off. Reserve clearance from whether the
// page CAN show a badge (constant per item), never from the current
// count, and keep the icon+label centered in the FULL button width so
// the text position and size stay identical with or without a badge.
bool itemBadgeCapable =
item.page == NavPage::History ||
item.page == NavPage::Mining ||
item.page == NavPage::Chat;
float badgeReserve = 0.0f;
if (itemBadgeCapable) {
bool dotOnlyReserve = (item.page == NavPage::Mining);
float badgeRReserve = dotOnlyReserve ? badgeRadiusDot : badgeRadiusNumber;
float badgeInsetXReserve = sde("badge-inset-x", 6.0f);
badgeReserve = badgeRReserve * 2.0f + badgeInsetXReserve;
}
ImFont* font = selected ? Type().subtitle2() : Type().body2();
float lblFsz = ScaledFontSize(font);
float btnW = indMax.x - indMin.x;
float maxLabelW = btnW - iconS * 2.0f - iconLabelGap - Layout::spacingXs() * 2;
// Clearance is symmetric (2x) because the group stays centered in the
// full width: reserving on both sides keeps the label's right edge clear
// of the right-side corner badge without shifting the center off-axis.
float maxLabelW = btnW - iconS * 2.0f - iconLabelGap - Layout::spacingXs() * 2 - badgeReserve * 2.0f;
ImVec2 labelSz = font->CalcTextSizeA(lblFsz, 1000.0f, 0.0f, NavLabel(item));
if (labelSz.x > maxLabelW && maxLabelW > 0) {
lblFsz *= maxLabelW / labelSz.x;
@@ -714,16 +738,16 @@ inline bool RenderSidebar(NavPage& current, float sidebarWidth, float contentHei
badgeCol = Warning(); badgeTextCol = OnWarning();
} else if (item.page == NavPage::Mining && status.miningActive) {
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) {
float badgeR = dotOnly ? badgeRadiusDot : badgeRadiusNumber;
float bx = indMax.x - badgeR - 6.0f;
float by = indMin.y + badgeR + 5.0f;
float badgeInsetX = sde("badge-inset-x", 6.0f);
float badgeInsetY = sde("badge-inset-y", 5.0f);
float bx = indMax.x - badgeR - badgeInsetX;
float by = indMin.y + badgeR + badgeInsetY;
dl->AddCircleFilled(ImVec2(bx, by), badgeR, badgeCol);
if (!dotOnly && showLabels) {
char buf[16];

48
src/ui/staleness_badge.h Normal file
View File

@@ -0,0 +1,48 @@
#pragma once
#include <cstdint>
// Refresh-staleness badge (finding W6-2). The wallet stamps WalletState::last_balance_update only on
// a *successful* balance fetch (see services/network_refresh_service.cpp), so a busy daemon that fails
// z_gettotalbalance without dropping the whole connection leaves the old balance on screen with a
// frozen timestamp — and the node-status banner (which only fires on a full disconnect) stays hidden.
// This badge is the surface that reflects that "connected but the number may be out of date" state.
//
// The decision is a pure function of (last-success timestamp, now, connected) so it is unit-testable;
// balance_tab.cpp draws the pill. Both use the same std::time(nullptr) wall-clock the refresh path
// stamps with, so age = now - last_update is consistent.
namespace dragonx::ui {
enum class StalenessSeverity {
Warning, // amber — noticeably behind
Error, // red — very stale, something is likely wrong
};
struct StalenessBadge {
bool show = false;
StalenessSeverity severity = StalenessSeverity::Warning;
std::int64_t seconds_old = 0;
};
// Balance refreshes every ~2s on the Overview profile (and ~10s while syncing), so tens of seconds
// with no successful update means refreshes are failing, not merely slow.
inline constexpr std::int64_t kStaleAfterSeconds = 45;
inline constexpr std::int64_t kVeryStaleAfterSeconds = 180;
inline StalenessBadge evaluateStalenessBadge(std::int64_t last_update, std::int64_t now, bool connected) {
StalenessBadge b;
// Offline is the node-status banner's job; don't double up. A zero stamp means "never updated
// this session" (fresh start) or "reset on disconnect" — nothing to be stale about yet.
if (!connected || last_update <= 0) return b;
std::int64_t age = now - last_update;
if (age < 0) age = 0; // clock skew guard
if (age < kStaleAfterSeconds) return b;
b.show = true;
b.seconds_old = age;
b.severity = (age >= kVeryStaleAfterSeconds) ? StalenessSeverity::Error : StalenessSeverity::Warning;
return b;
}
} // namespace dragonx::ui

View File

@@ -125,29 +125,26 @@ void RenderAboutDialog(App* app, bool* p_open)
ImGui::Spacing();
ImGui::TextWrapped("%s", TR("about_license_text"));
ImGui::Spacing();
ImGui::Separator();
ImGui::Spacing();
// Links
if (material::StyledButton(TR("about_website"), ImVec2(linkW, 0), S.resolveFont(linkBtn.font))) {
// Links — 3-button action row, centered via the shared footer helper (draws its own
// Spacing/Separator/Spacing above the row, replacing the hand-rolled divider block).
const float linksTotalW = linkW * 3.0f + ImGui::GetStyle().ItemSpacing.x * 2.0f;
material::BeginOverlayDialogFooter(linksTotalW);
if (material::TactileButton(TR("about_website"), ImVec2(linkW, 0), S.resolveFont(linkBtn.font))) {
util::Platform::openUrl("https://dragonx.is");
}
ImGui::SameLine();
if (material::StyledButton(TR("about_github"), ImVec2(linkW, 0), S.resolveFont(linkBtn.font))) {
if (material::TactileButton(TR("about_github"), ImVec2(linkW, 0), S.resolveFont(linkBtn.font))) {
util::Platform::openUrl("https://git.dragonx.is/dragonx/ObsidianDragon");
}
ImGui::SameLine();
if (material::StyledButton(TR("about_block_explorer"), ImVec2(linkW, 0), S.resolveFont(linkBtn.font))) {
if (material::TactileButton(TR("about_block_explorer"), ImVec2(linkW, 0), S.resolveFont(linkBtn.font))) {
util::Platform::openUrl("https://explorer.dragonx.is");
}
ImGui::Spacing();
// Close button
float button_width = closeW;
ImGui::SetCursorPosX((ImGui::GetWindowWidth() - button_width) * 0.5f);
if (material::StyledButton(TR("close"), ImVec2(button_width, 0), S.resolveFont(closeBtn.font))) {
// Close button — lone dismiss action, centered via the shared footer helper (no extra
// divider above it, so it sits directly under the links row).
material::BeginOverlayDialogFooter(closeW, false);
if (material::TactileButton(TR("close"), ImVec2(closeW, 0), S.resolveFont(closeBtn.font))) {
*p_open = false;
}

View File

@@ -138,8 +138,13 @@ public:
const float controlsTopY = std::max(gridStartY + cellSz * 2.0f, buttonY - preButtonReserve);
const float gridMaxH = std::max(cellSz * 2.0f, controlsTopY - gridStartY);
ImGui::PushStyleColor(ImGuiCol_ChildBg, IM_COL32(0, 0, 0, 0));
ImGui::PushStyleVar(ImGuiStyleVar_ScrollbarSize, 11.0f * dp);
ImGui::PushStyleVar(ImGuiStyleVar_ScrollbarRounding, 5.5f * dp);
// Scrollbar visible (not NoScrollbar) — the icon set exceeds the fixed-height
// grid, so a real scrollbar is the discoverable way to reach the rest.
ImGui::BeginChild("##IconGrid", ImVec2(avail, gridMaxH), ImGuiChildFlags_None,
ImGuiWindowFlags_NoScrollbar);
ImGuiWindowFlags_NoScrollWithMouse);
ApplySmoothScroll();
ImDrawList* dl = ImGui::GetWindowDrawList();
@@ -185,6 +190,7 @@ public:
}
ImGui::EndChild();
ImGui::PopStyleVar(2); // ScrollbarSize + ScrollbarRounding
ImGui::PopStyleColor();
if (ImGui::GetCursorPosY() < controlsTopY) {

View File

@@ -93,7 +93,7 @@ public:
// Arrow
{
float arrowCX = ImGui::GetContentRegionAvail().x * 0.5f;
ImGui::SetCursorPosX(arrowCX - 8.0f);
ImGui::SetCursorPosX(arrowCX - 8.0f * dp);
ImFont* iconFont = Type().iconMed();
float fsz = ScaledFontSize(iconFont);
ImVec2 pos = ImGui::GetCursorScreenPos();
@@ -169,9 +169,9 @@ public:
}
if (amountValid && newFromBal < 1e-9) {
ImGui::Spacing();
ImGui::PushStyleColor(ImGuiCol_Text, ImGui::ColorConvertU32ToFloat4(Warning()));
ImGui::TextWrapped("%s", TR("sends_full_balance_warning"));
ImGui::PopStyleColor();
// Full-balance send: same warning-icon treatment as the de-shielding header above,
// so this stakes-bearing line reads as distinct from the neutral preview text.
DialogWarningHeader(TR("sends_full_balance_warning"));
}
// Buttons
@@ -180,16 +180,14 @@ public:
const char* sendingLabel = TR("sending");
ImFont* buttonFont = Type().button();
float buttonFontSize = ScaledFontSize(buttonFont);
float minBtnW = 120.0f * dp;
float confirmMinW = 160.0f * dp;
float buttonPadW = ImGui::GetStyle().FramePadding.x * 2.0f + 24.0f * dp;
float cancelW = std::max(minBtnW,
buttonFont->CalcTextSizeA(buttonFontSize, 1000.0f, 0.0f, cancelLabel).x + buttonPadW);
// Both footer buttons share one width (equal-width primary/Close pair), sized to fit the
// widest label — the "Sending…" swap label included — so nothing clips.
float confirmTextW = std::max(
buttonFont->CalcTextSizeA(buttonFontSize, 1000.0f, 0.0f, confirmLabel).x,
buttonFont->CalcTextSizeA(buttonFontSize, 1000.0f, 0.0f, sendingLabel).x);
float confirmW = std::max(confirmMinW, confirmTextW + buttonPadW);
float totalW = cancelW + confirmW + Layout::spacingMd();
float btnW = std::max(confirmMinW, confirmTextW + buttonPadW);
float footerH = ImGui::GetFrameHeight() + ImGui::GetStyle().ItemSpacing.y * 3.0f; // footer divider removed
ImGuiViewport* vp = ImGui::GetMainViewport();
float cardBottomY = vp->Pos.y + vp->Size.y * 0.85f;
@@ -201,19 +199,18 @@ public:
ImGui::Spacing();
}
ImGui::Spacing();
// Standardized primary + Close footer (centered, no divider). The primary is the
// Confirm/"Sending…" action; the Close button dismisses the dialog.
bool outConfirm = false;
bool outClose = false;
DialogActionFooter(s_sending ? sendingLabel : confirmLabel,
amountValid && !s_sending,
cancelLabel, outConfirm, outClose, btnW);
float rowStartX = ImGui::GetCursorPosX();
float contentW = ImGui::GetContentRegionAvail().x;
ImGui::SetCursorPosX(rowStartX + std::max(0.0f, (contentW - totalW) * 0.5f));
if (TactileButton(cancelLabel, ImVec2(cancelW, 0), buttonFont)) {
if (outClose) {
s_open = false;
}
ImGui::SameLine(0, Layout::spacingMd());
ImGui::BeginDisabled(!amountValid || s_sending);
if (TactileButton(s_sending ? sendingLabel : confirmLabel, ImVec2(confirmW, 0), buttonFont)) {
if (outConfirm) {
s_sending = true;
s_app->sendTransaction(s_info.fromAddr, s_info.toAddr,
amount, s_fee, "",
@@ -231,7 +228,6 @@ public:
// state, and when the async callback sets s_resultMsg the in-dialog result screen shows
// (with its own Close button). Previously closing here made that result screen dead code.
}
ImGui::EndDisabled();
EndOverlayDialog();
}
@@ -316,7 +312,8 @@ private:
ImGui::Spacing();
ImGui::Spacing();
float btnW = 120.0f;
const float dp = Layout::dpiScale();
float btnW = 120.0f * dp;
ImGui::SetCursorPosX((ImGui::GetContentRegionAvail().x - btnW) * 0.5f);
if (TactileButton(TR("close"), ImVec2(btnW, 0))) {
s_open = false;

View File

@@ -70,6 +70,7 @@ AddressRowLayout ComputeAddressRowLayout(float rowX,
float spacingSm,
float spacingXs)
{
(void)spacingXs; // trailing button now insets by rowPadLeft (mirrors the left margin)
AddressRowLayout layout;
layout.contentStartX = rowX + rowPadLeft;
layout.contentStartY = rowY + spacingMd;
@@ -77,7 +78,9 @@ AddressRowLayout ComputeAddressRowLayout(float rowX,
const float buttonY = rowY + (rowHeight - layout.buttonSize) * 0.5f;
const float rightEdge = rowX + rowWidth;
const float favoriteX = rightEdge - layout.buttonSize - spacingXs;
// Inset the trailing (favorite/star) button by the card's inner padding — the same margin the
// left content uses (rowPadLeft) — so it mirrors the left edge instead of hugging the card edge.
const float favoriteX = rightEdge - layout.buttonSize - rowPadLeft;
const float visibilityX = favoriteX - spacingSm - layout.buttonSize;
layout.favoriteButton = {favoriteX, buttonY, layout.buttonSize, layout.buttonSize};

View File

@@ -116,7 +116,7 @@ void RenderCompactHero(App* app, ImDrawList* dl, float availW, float hs, float v
// Render the shared address list section (used by all layouts)
void RenderSharedAddressList(App* app, float listH, float availW,
float glassRound, float hs, float vs) {
float glassRound, float hs, float vs, float reserveBelow) {
using namespace material;
const auto& S = schema::UISchema::instance();
const float dp = Layout::dpiScale();
@@ -188,8 +188,8 @@ void RenderSharedAddressList(App* app, float listH, float availW,
}
}
float buttonWidth = (addrBtn.width > 0) ? addrBtn.width : 140.0f;
float spacing = (addrBtn.gap > 0) ? addrBtn.gap : 8.0f;
float buttonWidth = ((addrBtn.width > 0) ? addrBtn.width : 140.0f) * dp;
float spacing = ((addrBtn.gap > 0) ? addrBtn.gap : 8.0f) * dp;
float totalButtonsWidth = buttonWidth * 2 + spacing;
float kMinButtonsPosition = std::max(S.drawElement("tabs.balance", "min-buttons-position").size,
S.drawElement("tabs.balance", "buttons-position").size * hs);
@@ -225,6 +225,14 @@ void RenderSharedAddressList(App* app, float listH, float availW,
// ---- Glass panel container ----
float addrListH = listH;
// Cap the card to the space that actually remains here (measured AFTER the title + toolbar are laid
// out, so no chrome modelling is needed) minus what the caller reserves for the section below it
// (recent-tx). Without this, a fixed dp-scaled listH grows ~1.5x at high font scale and evicts the
// Recent Transactions list off the bottom of the fixed, non-scrolling tab host.
if (reserveBelow > 0.0f) {
float maxH = ImGui::GetContentRegionAvail().y - reserveBelow;
if (maxH < addrListH) addrListH = maxH;
}
if (addrListH < 40.0f * dp) addrListH = 40.0f * dp;
ImDrawList* dlPanel = ImGui::GetWindowDrawList();
@@ -256,7 +264,7 @@ void RenderSharedAddressList(App* app, float listH, float availW,
} else if (rows.empty()) {
float cw = ImGui::GetContentRegionAvail().x;
float ch = ImGui::GetContentRegionAvail().y;
if (ch < 60) ch = 60;
if (ch < 60.0f * dp) ch = 60.0f * dp;
const char* emptyMsg = addr_search[0] ? TR("no_addresses_match") : TR("no_addresses_yet");
ImVec2 msgSz = ImGui::CalcTextSize(emptyMsg);
ImGui::SetCursorPosX((cw - msgSz.x) * 0.5f);
@@ -324,12 +332,12 @@ void RenderSharedAddressList(App* app, float listH, float availW,
s_dragIdx < (int)rows.size()) {
const auto& srcRow = rows[s_dragIdx];
const auto& dstRow = rows[s_dropTargetIdx];
if (srcRow.info->balance > 1e-9) {
if (srcRow.info->spendableBalance > 1e-9) { // only offer a transfer of CONFIRMED funds
AddressTransferDialog::TransferInfo ti;
ti.fromAddr = srcRow.info->address;
ti.toAddr = dstRow.info->address;
ti.fromBalance = srcRow.info->balance;
ti.toBalance = dstRow.info->balance;
ti.fromBalance = srcRow.info->spendableBalance; // spend cap — z_sendmany runs at minconf=1
ti.toBalance = dstRow.info->balance; // destination display only
ti.fromIsZ = srcRow.isZ;
ti.toIsZ = dstRow.isZ;
AddressTransferDialog::show(app, ti);
@@ -466,7 +474,8 @@ void RenderSharedAddressList(App* app, float listH, float availW,
{
const auto& starRect = rowLayout.favoriteButton;
ImVec2 bMin(starRect.x, starRect.y), bMax(starRect.x + starRect.width, starRect.y + starRect.height);
bool bHov = ImGui::IsMouseHoveringRect(bMin, bMax);
// material::IsRectHovered so the button inherits overlay/popup input blocking
bool bHov = material::IsRectHovered(bMin, bMax);
dl->AddRectFilled(bMin, bMax, row.favorite ? favGoldFill : (bHov ? btnFillHov : btnFill), btnRound);
dl->AddRect(bMin, bMax, row.favorite ? favGoldBorder : (bHov ? btnBorderHov : btnBorder), btnRound, 0, 1.0f * dp);
ImFont* iconFont = Type().iconSmall();
@@ -492,7 +501,8 @@ void RenderSharedAddressList(App* app, float listH, float availW,
if (showEye) {
const auto& eyeRect = rowLayout.visibilityButton;
ImVec2 bMin(eyeRect.x, eyeRect.y), bMax(eyeRect.x + eyeRect.width, eyeRect.y + eyeRect.height);
bool bHov = ImGui::IsMouseHoveringRect(bMin, bMax);
// material::IsRectHovered so the button inherits overlay/popup input blocking
bool bHov = material::IsRectHovered(bMin, bMax);
dl->AddRectFilled(bMin, bMax, bHov ? btnFillHov : btnFill, btnRound);
dl->AddRect(bMin, bMax, bHov ? btnBorderHov : btnBorder, btnRound, 0, 1.0f * dp);
ImFont* iconFont = Type().iconSmall();
@@ -539,12 +549,24 @@ void RenderSharedAddressList(App* app, float listH, float availW,
snprintf(typeBuf, sizeof(typeBuf), "%s%s%s", typeLabel, hiddenTag, miningTag);
dl->AddText(capFont, capFont->LegacySize, ImVec2(labelX, cy), typeCol, typeBuf);
// User label next to type
// User label next to type — clip to the gap before the right-aligned
// balance so a long custom label can't overrun the balance on this line
// (mirrors the address-line width guard below).
if (!row.label.empty()) {
float typeLabelW = capFont->CalcTextSizeA(capFont->LegacySize, FLT_MAX, 0, typeBuf).x;
dl->AddText(capFont, capFont->LegacySize,
ImVec2(labelX + typeLabelW + Layout::spacingLg(), cy),
OnSurfaceMedium(), row.label.c_str());
float userLabelX = labelX + typeLabelW + Layout::spacingLg();
// Balance is drawn right-aligned at contentRight; recompute its left edge here.
char balBufPeek[32];
snprintf(balBufPeek, sizeof(balBufPeek), "%.8f", addr.balance);
float balW = body2->CalcTextSizeA(body2->LegacySize, FLT_MAX, 0, balBufPeek).x;
float userLabelAvailW = (contentRight - balW - Layout::spacingMd()) - userLabelX;
std::string userLabel = material::TruncateToWidth(
row.label, capFont, capFont->LegacySize, userLabelAvailW);
if (userLabelAvailW > 0.0f) {
dl->AddText(capFont, capFont->LegacySize,
ImVec2(userLabelX, cy),
OnSurfaceMedium(), userLabel.c_str());
}
}
}
@@ -783,6 +805,7 @@ void RenderSharedRecentTx(App* app, float recentH, float availW, float hs, float
const float kRecentTxRowHeight = S.drawElement("tabs.balance", "recent-tx-row-height").sizeOr(22.0f);
const auto& state = app->state();
float headerStartY = ImGui::GetCursorPosY();
ImGui::Dummy(ImVec2(0, Layout::spacingSm()));
Type().textColored(TypeStyle::Overline, OnSurfaceMedium(), TR("recent_transactions"));
ImGui::SameLine();
@@ -790,6 +813,7 @@ void RenderSharedRecentTx(App* app, float recentH, float availW, float hs, float
app->setCurrentPage(NavPage::History);
}
ImGui::Spacing();
float headerHeight = ImGui::GetCursorPosY() - headerStartY;
float scaledRowH = std::max(S.drawElement("tabs.balance", "recent-tx-row-min-height").size, kRecentTxRowHeight * vs);
float availableListH = ImGui::GetContentRegionAvail().y;
@@ -798,14 +822,17 @@ void RenderSharedRecentTx(App* app, float recentH, float availW, float hs, float
ImGuiWindowFlags_NoBackground);
const auto& txs = state.transactions;
int count = std::min(4, (int)txs.size()); // show only the 4 most recent (state.transactions is newest-first)
float rowH = std::max(18.0f * dp, kRecentTxRowHeight * vs);
// Only draw as many rows as fully fit within the reserved section height (header + rows);
// dropping the overflow row is fine since "View All" already links to full History.
int maxRows = std::max(1, (int)((recentH - headerHeight) / rowH));
int count = std::min({4, maxRows, (int)txs.size()}); // show only the most recent (state.transactions is newest-first)
if (count == 0) {
Type().textColored(TypeStyle::Caption, OnSurfaceDisabled(), TR("no_transactions_yet"));
} else {
ImDrawList* dl = ImGui::GetWindowDrawList();
ImFont* capFont = Type().caption();
float rowH = std::max(18.0f * dp, kRecentTxRowHeight * vs);
float iconSz = std::max(S.drawElement("tabs.balance", "recent-tx-icon-min-size").size,
S.drawElement("tabs.balance", "recent-tx-icon-size").size * hs);
@@ -820,7 +847,11 @@ void RenderSharedRecentTx(App* app, float recentH, float availW, float hs, float
dl->AddText(capFont, capFont->LegacySize,
ImVec2(tx_x, rowPos.y + 2 * dp), OnSurfaceMedium(), display.typeText.c_str());
float addrX = tx_x + S.drawElement("tabs.balance", "recent-tx-addr-offset").sizeOr(65.0f);
// Start the address column past the MEASURED type-label width (plus a fixed gap) so it can
// never overlap the label — a fixed schema offset shrinks below the label width at narrow
// widths (hs < 1) and collides. Mirrors how amtX/agoSz measure their own text below.
ImVec2 typeSz = capFont->CalcTextSizeA(capFont->LegacySize, 10000, 0, display.typeText.c_str());
float addrX = tx_x + typeSz.x + Layout::spacingMd();
dl->AddText(capFont, capFont->LegacySize,
ImVec2(addrX, rowPos.y + 2 * dp), OnSurfaceDisabled(), display.addressText.c_str());
@@ -835,13 +866,13 @@ void RenderSharedRecentTx(App* app, float recentH, float availW, float hs, float
ImVec2 agoSz = capFont->CalcTextSizeA(capFont->LegacySize, 10000, 0, display.timeText.c_str());
dl->AddText(capFont, capFont->LegacySize,
ImVec2(rightEdge - agoSz.x - S.drawElement("tabs.balance", "recent-tx-time-margin").sizeOr(4.0f), rowPos.y + 2 * dp),
ImVec2(rightEdge - agoSz.x - S.drawElement("tabs.balance", "recent-tx-time-margin").sizeOr(4.0f) * hs, rowPos.y + 2 * dp),
OnSurfaceDisabled(), display.timeText.c_str());
float rowW = ImGui::GetContentRegionAvail().x;
ImVec2 rowEnd(rowPos.x + rowW, rowPos.y + rowH);
if (material::IsRectHovered(rowPos, rowEnd)) {
dl->AddRectFilled(rowPos, rowEnd, IM_COL32(255, 255, 255, 15), S.drawElement("tabs.balance", "row-hover-rounding").sizeOr(4.0f));
dl->AddRectFilled(rowPos, rowEnd, SurfaceOverlay(15), S.drawElement("tabs.balance", "row-hover-rounding").sizeOr(4.0f));
ImGui::SetMouseCursor(ImGuiMouseCursor_Hand);
if (ImGui::IsMouseClicked(0))
app->setCurrentPage(NavPage::History);
@@ -868,7 +899,7 @@ void RenderSyncBar(App* app, ImDrawList* dl, float vs) {
ImVec2 barPos = ImGui::GetCursorScreenPos();
dl->AddRectFilled(barPos,
ImVec2(barPos.x + barW, barPos.y + barH),
IM_COL32(255, 255, 255, 15), 1.0f * dp);
SurfaceOverlay(15), 1.0f * dp);
dl->AddRectFilled(barPos,
ImVec2(barPos.x + barW * prog, barPos.y + barH),
WithAlpha(Warning(), 200), 1.0f * dp);

View File

@@ -26,7 +26,8 @@ extern bool s_generating_z_address;
void UpdateBalanceLerp(App* app);
void RenderCompactHero(App* app, ImDrawList* dl, float availW, float hs, float vs,
float heroHeightOverride = -1.0f);
void RenderSharedAddressList(App* app, float listH, float availW, float glassRound, float hs, float vs);
void RenderSharedAddressList(App* app, float listH, float availW, float glassRound, float hs, float vs,
float reserveBelow = 0.0f);
void RenderSharedRecentTx(App* app, float recentH, float availW, float hs, float vs);
void RenderSyncBar(App* app, ImDrawList* dl, float vs);

View File

@@ -26,10 +26,12 @@
#include "../effects/imgui_acrylic.h"
#include "../sidebar.h"
#include "../notifications.h"
#include "../staleness_badge.h"
#include "../../embedded/IconsMaterialDesign.h"
#include "imgui.h"
#include <toml++/toml.hpp>
#include <algorithm>
#include <cctype>
#include <cstring>
#include <ctime>
#include <cmath>
@@ -195,7 +197,9 @@ void RenderBalanceTab(App* app)
for (const auto& l : allLayouts) {
if (l.id == layoutId) { displayName = l.name; break; }
}
Notifications::instance().info("Layout: " + displayName);
char layoutToast[128];
snprintf(layoutToast, sizeof(layoutToast), TR("balance_layout_switched"), displayName.c_str());
Notifications::instance().info(layoutToast);
}
}
}
@@ -298,9 +302,9 @@ static void RenderBalanceClassic(App* app)
float cardPadLg = (classicPadOverride >= 0.0f) ? classicPadOverride : Layout::spacingLg();
// Card height: must fit the Market card's content (overline + price + 24h)
const float ovGap = S.drawElement("tabs.balance", "overline-value-gap").sizeOr(6.0f);
const float valGap = S.drawElement("tabs.balance", "value-caption-gap").sizeOr(4.0f);
const float tickGap = S.drawElement("tabs.balance.classic", "ticker-gap").sizeOr(4.0f);
const float ovGap = S.drawElement("tabs.balance", "overline-value-gap").sizeOr(6.0f) * dp;
const float valGap = S.drawElement("tabs.balance", "value-caption-gap").sizeOr(4.0f) * dp;
const float tickGap = S.drawElement("tabs.balance.classic", "ticker-gap").sizeOr(4.0f) * dp;
float marketContentH = cardPadLg
+ ovFont->LegacySize + ovGap
+ sub1->LegacySize + 2.0f * dp
@@ -319,7 +323,7 @@ static void RenderBalanceClassic(App* app)
// Helper: draw accent stripe on left edge, clipped to card rounded corners.
// We draw a full-size rounded rect (left corners only) and clip it to the
// stripe width so the shape itself follows the card rounding.
const float accentW = S.drawElement("tabs.balance", "accent-width").sizeOr(4.0f);
const float accentW = S.drawElement("tabs.balance", "accent-width").sizeOr(4.0f) * dp;
auto drawAccent = [&](const ImVec2& cMin, const ImVec2& cMax, ImU32 col) {
dl->PushClipRect(cMin, ImVec2(cMin.x + accentW, cMax.y), true);
dl->AddRectFilled(cMin, cMax, col, cardSpec.rounding,
@@ -358,8 +362,11 @@ static void RenderBalanceClassic(App* app)
IM_COL32(255, 255, 255, (int)S.drawElement("tabs.balance.classic", "logo-opacity").sizeOr(180.0f)));
}
std::string totalLabelUpper = TR("total_balance_label");
std::transform(totalLabelUpper.begin(), totalLabelUpper.end(), totalLabelUpper.begin(),
[](unsigned char c){ return (char)std::toupper(c); });
dl->AddText(ovFont, ovFont->LegacySize, ImVec2(cx, cy),
OnSurfaceMedium(), "TOTAL BALANCE");
OnSurfaceMedium(), totalLabelUpper.c_str());
cy += ovFont->LegacySize + ovGap;
snprintf(buf, sizeof(buf), "%.8f", s_dispTotal);
@@ -386,7 +393,7 @@ static void RenderBalanceClassic(App* app)
// Sync progress or mining indicator (whichever fits)
if (state.sync.syncing && state.sync.headers > 0) {
float pct = static_cast<float>(state.sync.verification_progress) * 100.0f;
snprintf(buf, sizeof(buf), "Syncing %.1f%%", pct);
snprintf(buf, sizeof(buf), TR("balance_syncing_pct"), pct);
dl->AddText(capFont, capFont->LegacySize, ImVec2(cx, cy),
Warning(), buf);
@@ -400,7 +407,7 @@ static void RenderBalanceClassic(App* app)
dl->PushClipRect(ImVec2(cMin.x, barTop), cMax, true);
// Background track
dl->AddRectFilled(cMin, cMax,
IM_COL32(255, 255, 255, 15), cardSpec.rounding);
SurfaceOverlay(15), cardSpec.rounding);
// Progress fill — additional horizontal clip
float progRight = cMin.x + (cMax.x - cMin.x) * prog;
dl->PushClipRect(ImVec2(cMin.x, barTop), ImVec2(progRight, cMax.y), true);
@@ -417,16 +424,39 @@ static void RenderBalanceClassic(App* app)
dl->AddCircleFilled(ImVec2(cx + 4 * dp, cy + capFont->LegacySize * 0.5f),
S.drawElement("tabs.balance.classic", "mining-dot-radius").sizeOr(3.0f), mineCol);
double hr = state.mining.localHashrate;
snprintf(buf, sizeof(buf), " Mining %s", FormatHashrate(hr).c_str());
// Leading indent clears the mining dot drawn at cx+4dp; the text
// itself starts at cx+12dp so keep the two spaces for spacing parity.
char mineFmt[64];
snprintf(mineFmt, sizeof(mineFmt), " %s", TR("balance_mining_rate"));
snprintf(buf, sizeof(buf), mineFmt, FormatHashrate(hr).c_str());
dl->AddText(capFont, capFont->LegacySize,
ImVec2(cx + 12 * dp, cy),
WithAlpha(Success(), 200), buf);
} else {
// Refresh-staleness badge (W6-2): connected, not syncing/mining, but the balance
// hasn't refreshed in a while (a busy daemon can fail z_gettotalbalance without
// dropping the whole connection). The node banner only covers full disconnects, so
// this pill is the sole signal that the shown number may be out of date.
StalenessBadge badge = evaluateStalenessBadge(
state.last_balance_update, std::time(nullptr), state.connected);
if (badge.show) {
const bool err = (badge.severity == StalenessSeverity::Error);
ImU32 fg = err ? Error() : Warning();
ImU32 bg = WithAlpha(fg, 38);
ImU32 bd = WithAlpha(fg, 90);
snprintf(buf, sizeof(buf), "%s %s",
TR("data_stale_prefix"), timeAgo(state.last_balance_update).c_str());
ImVec2 pillSz = DrawPill(dl, ImVec2(cx, cy), buf, capFont, fg, bg, bd, ImVec2(4.0f * dp, 2.0f * dp));
// Hover → explain what stale means and how old the data actually is.
if (material::IsRectHovered(ImVec2(cx, cy), ImVec2(cx + pillSz.x, cy + pillSz.y)))
Tooltip("%s", TR("data_stale_tooltip"));
}
}
// Hover glow
if (material::IsRectHovered(cMin, cMax)) {
dl->AddRect(cMin, cMax, IM_COL32(255, 255, 255, (int)S.drawElement("tabs.balance", "hover-glow-alpha").sizeOr(40.0f)),
cardSpec.rounding, 0, S.drawElement("tabs.balance", "hover-glow-thickness").sizeOr(1.5f));
cardSpec.rounding, 0, S.drawElement("tabs.balance", "hover-glow-thickness").sizeOr(1.5f) * dp);
}
}
@@ -457,7 +487,7 @@ static void RenderBalanceClassic(App* app)
{
float privPct = (s_dispTotal > 1e-9)
? (float)(s_dispShielded / s_dispTotal * 100.0) : 0.0f;
snprintf(buf, sizeof(buf), "%.0f%% of total · %d Z-addr",
snprintf(buf, sizeof(buf), TR("baltab_pct_of_total_zaddr"),
privPct, (int)state.z_addresses.size());
dl->AddText(capFont, capFont->LegacySize, ImVec2(cx, cy),
WithAlpha(Success(), 160), buf);
@@ -468,8 +498,8 @@ static void RenderBalanceClassic(App* app)
snprintf(buf, sizeof(buf), "+%.4f", state.unconfirmed_balance);
ImVec2 ts = capFont->CalcTextSizeA(
capFont->LegacySize, 10000, 0, buf);
float bp = S.drawElement("tabs.balance.classic", "unconfirmed-badge-padding").sizeOr(4.0f);
float br = S.drawElement("tabs.balance.classic", "unconfirmed-badge-rounding").sizeOr(4.0f);
float bp = S.drawElement("tabs.balance.classic", "unconfirmed-badge-padding").sizeOr(4.0f) * dp;
float br = S.drawElement("tabs.balance.classic", "unconfirmed-badge-rounding").sizeOr(4.0f) * dp;
ImVec2 bMin(cMax.x - ts.x - bp * 3,
cMin.y + cardPadLg);
ImVec2 bMax(cMax.x - bp, bMin.y + ts.y + bp);
@@ -483,7 +513,7 @@ static void RenderBalanceClassic(App* app)
// Hover glow + click to Receive
if (material::IsRectHovered(cMin, cMax)) {
dl->AddRect(cMin, cMax, IM_COL32(255, 255, 255, (int)S.drawElement("tabs.balance", "hover-glow-alpha").sizeOr(40.0f)),
cardSpec.rounding, 0, S.drawElement("tabs.balance", "hover-glow-thickness").sizeOr(1.5f));
cardSpec.rounding, 0, S.drawElement("tabs.balance", "hover-glow-thickness").sizeOr(1.5f) * dp);
ImGui::SetMouseCursor(ImGuiMouseCursor_Hand);
if (ImGui::IsMouseClicked(0))
app->setCurrentPage(NavPage::Receive);
@@ -513,7 +543,7 @@ static void RenderBalanceClassic(App* app)
OnSurfaceMedium(), DRAGONX_TICKER);
cy += sub1->LegacySize + valGap;
snprintf(buf, sizeof(buf), "%d T-addresses",
snprintf(buf, sizeof(buf), TR("baltab_t_addresses_count"),
(int)state.t_addresses.size());
dl->AddText(capFont, capFont->LegacySize, ImVec2(cx, cy),
OnSurfaceDisabled(), buf);
@@ -521,7 +551,7 @@ static void RenderBalanceClassic(App* app)
// Hover glow + click to Receive
if (material::IsRectHovered(cMin, cMax)) {
dl->AddRect(cMin, cMax, IM_COL32(255, 255, 255, (int)S.drawElement("tabs.balance", "hover-glow-alpha").sizeOr(40.0f)),
cardSpec.rounding, 0, S.drawElement("tabs.balance", "hover-glow-thickness").sizeOr(1.5f));
cardSpec.rounding, 0, S.drawElement("tabs.balance", "hover-glow-thickness").sizeOr(1.5f) * dp);
ImGui::SetMouseCursor(ImGuiMouseCursor_Hand);
if (ImGui::IsMouseClicked(0))
app->setCurrentPage(NavPage::Receive);
@@ -554,15 +584,18 @@ static void RenderBalanceClassic(App* app)
ImVec2 usdSz = capFont->CalcTextSizeA(capFont->LegacySize, 10000, 0, "USD");
// Measure widest text line to determine sparkline left edge
std::string marketLabel = TR("market");
std::transform(marketLabel.begin(), marketLabel.end(), marketLabel.begin(),
[](unsigned char c){ return (char)std::toupper(c); });
float textW = std::max(pSz.x + tickGap + usdSz.x,
ovFont->CalcTextSizeA(ovFont->LegacySize, 10000, 0, "MARKET").x);
float sparkGap = S.drawElement("tabs.balance.classic", "sparkline-gap").sizeOr(12.0f);
ovFont->CalcTextSizeA(ovFont->LegacySize, 10000, 0, marketLabel.c_str()).x);
float sparkGap = S.drawElement("tabs.balance.classic", "sparkline-gap").sizeOr(12.0f) * dp;
float sparkLeft = cx + textW + sparkGap;
float sparkRight = cMax.x - cardPadLg;
// Left side: label + price + 24h change
dl->AddText(ovFont, ovFont->LegacySize, ImVec2(cx, cy),
OnSurfaceMedium(), "MARKET");
OnSurfaceMedium(), marketLabel.c_str());
cy += ovFont->LegacySize + ovGap;
dl->AddText(sub1, sub1->LegacySize, ImVec2(cx, cy),
@@ -578,7 +611,7 @@ static void RenderBalanceClassic(App* app)
bool pos = market.change_24h >= 0;
ImU32 chgCol = pos ? Success()
: Error();
snprintf(buf, sizeof(buf), "%s%.1f%% 24h",
snprintf(buf, sizeof(buf), TR("baltab_pct_change_24h"),
pos ? "+" : "", market.change_24h);
dl->AddText(capFont, capFont->LegacySize,
ImVec2(cx, cy), chgCol, buf);
@@ -600,7 +633,7 @@ static void RenderBalanceClassic(App* app)
// Hover glow + click to Market
if (material::IsRectHovered(cMin, cMax)) {
dl->AddRect(cMin, cMax, IM_COL32(255, 255, 255, (int)S.drawElement("tabs.balance", "hover-glow-alpha").sizeOr(40.0f)),
cardSpec.rounding, 0, S.drawElement("tabs.balance", "hover-glow-thickness").sizeOr(1.5f));
cardSpec.rounding, 0, S.drawElement("tabs.balance", "hover-glow-thickness").sizeOr(1.5f) * dp);
ImGui::SetMouseCursor(ImGuiMouseCursor_Hand);
if (ImGui::IsMouseClicked(0))
app->setCurrentPage(NavPage::Market);
@@ -628,7 +661,7 @@ static void RenderBalanceClassic(App* app)
float addrH = (classicAddrH >= 0.0f) ? classicAddrH * dp
: ImGui::GetContentRegionAvail().y - recentReserve
- Layout::spacingXl() - Type().h6()->LegacySize - Layout::spacingMd();
RenderSharedAddressList(app, addrH, contentAvail.x, glassRound, hs, vs);
RenderSharedAddressList(app, addrH, contentAvail.x, glassRound, hs, vs, recentReserve);
RenderSharedRecentTx(app, recentReserve, contentAvail.x, hs, vs);
}
}
@@ -662,7 +695,7 @@ static void RenderBalanceDonut(App* app) {
else
ImGui::Dummy(ImVec2(0, S.drawElement("tabs.balance.donut", "hero-pad-ratio").sizeOr(8.0f) * vs));
{
Type().textColored(TypeStyle::Overline, OnSurfaceMedium(), "TOTAL BALANCE");
Type().textColored(TypeStyle::Overline, OnSurfaceMedium(), TR("total_balance_label"));
ImGui::Dummy(ImVec2(0, 2 * dp));
snprintf(buf, sizeof(buf), "%.8f", s_dispTotal);
ImFont* heroFont = Type().h2();
@@ -757,20 +790,20 @@ static void RenderBalanceDonut(App* app) {
ImFont* capFont = Type().caption();
ImFont* body2 = Type().body2();
float legendDotR = S.drawElement("tabs.balance.donut", "legend-dot-radius").sizeOr(4.0f);
float legendXOff = S.drawElement("tabs.balance.donut", "legend-x-offset").sizeOr(14.0f);
float legendLineGap = S.drawElement("tabs.balance.donut", "legend-line-gap").sizeOr(6.0f);
float legendSectionGap = S.drawElement("tabs.balance.donut", "legend-section-gap").sizeOr(10.0f);
float legendDotR = S.drawElement("tabs.balance.donut", "legend-dot-radius").sizeOr(4.0f) * dp;
float legendXOff = S.drawElement("tabs.balance.donut", "legend-x-offset").sizeOr(14.0f) * dp;
float legendLineGap = S.drawElement("tabs.balance.donut", "legend-line-gap").sizeOr(6.0f) * dp;
float legendSectionGap = S.drawElement("tabs.balance.donut", "legend-section-gap").sizeOr(10.0f) * dp;
// Shielded legend
dl->AddCircleFilled(ImVec2(legendX + 5 * dp, legendY + capFont->LegacySize * 0.5f), legendDotR, Success());
snprintf(buf, sizeof(buf), "Shielded %.8f", s_dispShielded);
snprintf(buf, sizeof(buf), TR("baltab_shielded_amount"), s_dispShielded);
dl->AddText(capFont, capFont->LegacySize, ImVec2(legendX + legendXOff, legendY), Success(), buf);
legendY += capFont->LegacySize + legendLineGap;
// Transparent legend
dl->AddCircleFilled(ImVec2(legendX + 5 * dp, legendY + capFont->LegacySize * 0.5f), legendDotR, Warning());
snprintf(buf, sizeof(buf), "Transparent %.8f", s_dispTransparent);
snprintf(buf, sizeof(buf), TR("baltab_transparent_amount"), s_dispTransparent);
dl->AddText(capFont, capFont->LegacySize, ImVec2(legendX + legendXOff, legendY), Warning(), buf);
legendY += capFont->LegacySize + legendSectionGap;
@@ -778,15 +811,15 @@ static void RenderBalanceDonut(App* app) {
const auto& market = state.market;
if (market.price_usd > 0) {
if (market.price_usd >= 0.01)
snprintf(buf, sizeof(buf), "Market: $%.4f", market.price_usd);
snprintf(buf, sizeof(buf), TR("baltab_market_price_4dp"), market.price_usd);
else
snprintf(buf, sizeof(buf), "Market: $%.8f", market.price_usd);
snprintf(buf, sizeof(buf), TR("baltab_market_price_8dp"), market.price_usd);
dl->AddText(capFont, capFont->LegacySize, ImVec2(legendX + legendXOff, legendY),
OnSurfaceMedium(), buf);
legendY += capFont->LegacySize + 4 * dp;
bool pos = market.change_24h >= 0;
snprintf(buf, sizeof(buf), "%s%.1f%% 24h", pos ? "+" : "", market.change_24h);
snprintf(buf, sizeof(buf), TR("baltab_pct_change_24h"), pos ? "+" : "", market.change_24h);
dl->AddText(capFont, capFont->LegacySize, ImVec2(legendX + legendXOff, legendY),
pos ? Success() : Error(), buf);
}
@@ -802,7 +835,7 @@ static void RenderBalanceDonut(App* app) {
float addrH = (donutAddrOverride >= 0.0f) ? donutAddrOverride * dp
: ImGui::GetContentRegionAvail().y - recentReserve
- Layout::spacingXl() - Type().h6()->LegacySize - Layout::spacingMd();
RenderSharedAddressList(app, addrH, availW, glassRound, hs, vs);
RenderSharedAddressList(app, addrH, availW, glassRound, hs, vs, recentReserve);
RenderSharedRecentTx(app, recentReserve, availW, hs, vs);
}
@@ -913,7 +946,7 @@ static void RenderBalanceConsolidated(App* app) {
float divY = cardMin.y + cardH * S.drawElement("tabs.balance.consolidated", "divider-y-ratio").sizeOr(0.55f);
dl->AddLine(ImVec2(cardMin.x + pad, divY), ImVec2(cardMax.x - pad, divY),
IM_COL32(255, 255, 255, (int)S.drawElement("tabs.balance.consolidated", "divider-alpha").sizeOr(20.0f)),
S.drawElement("tabs.balance.consolidated", "divider-thickness").sizeOr(1.0f));
S.drawElement("tabs.balance.consolidated", "divider-thickness").sizeOr(1.0f) * dp);
// Bottom half: proportion bars
float barY = divY + Layout::spacingSm();
@@ -927,10 +960,13 @@ static void RenderBalanceConsolidated(App* app) {
// Shielded bar
float shieldX = cardMin.x + pad;
dl->AddText(ovFont, ovFont->LegacySize, ImVec2(shieldX, barY), Success(), "SHIELDED");
std::string shieldLabelUpper = TR("shielded");
std::transform(shieldLabelUpper.begin(), shieldLabelUpper.end(), shieldLabelUpper.begin(),
[](unsigned char c){ return (char)std::toupper(c); });
dl->AddText(ovFont, ovFont->LegacySize, ImVec2(shieldX, barY), Success(), shieldLabelUpper.c_str());
barY += ovFont->LegacySize + 4 * dp;
dl->AddRectFilled(ImVec2(shieldX, barY), ImVec2(shieldX + halfW, barY + barH),
IM_COL32(255, 255, 255, 15), barH * 0.5f);
SurfaceOverlay(15), barH * 0.5f);
dl->AddRectFilled(ImVec2(shieldX, barY), ImVec2(shieldX + halfW * shieldRatio, barY + barH),
WithAlpha(Success(), 180), barH * 0.5f);
barY += barH + 2 * dp;
@@ -940,10 +976,13 @@ static void RenderBalanceConsolidated(App* app) {
// Transparent bar
float transX = cardMin.x + pad * 2 + halfW;
barY = divY + Layout::spacingSm();
dl->AddText(ovFont, ovFont->LegacySize, ImVec2(transX, barY), Warning(), "TRANSPARENT");
std::string transLabelUpper = TR("transparent");
std::transform(transLabelUpper.begin(), transLabelUpper.end(), transLabelUpper.begin(),
[](unsigned char c){ return (char)std::toupper(c); });
dl->AddText(ovFont, ovFont->LegacySize, ImVec2(transX, barY), Warning(), transLabelUpper.c_str());
barY += ovFont->LegacySize + 4 * dp;
dl->AddRectFilled(ImVec2(transX, barY), ImVec2(transX + halfW, barY + barH),
IM_COL32(255, 255, 255, 15), barH * 0.5f);
SurfaceOverlay(15), barH * 0.5f);
dl->AddRectFilled(ImVec2(transX, barY), ImVec2(transX + halfW * transRatio, barY + barH),
WithAlpha(Warning(), 180), barH * 0.5f);
barY += barH + 2 * dp;
@@ -962,7 +1001,7 @@ static void RenderBalanceConsolidated(App* app) {
dl->PushClipRect(ImVec2(cardMin.x, syncBarTop), cardMax, true);
// Background track
dl->AddRectFilled(cardMin, cardMax,
IM_COL32(255, 255, 255, 15), glassRound);
SurfaceOverlay(15), glassRound);
// Progress fill — additional horizontal clip
float progRight = cardMin.x + (cardMax.x - cardMin.x) * prog;
dl->PushClipRect(ImVec2(cardMin.x, syncBarTop), ImVec2(progRight, cardMax.y), true);
@@ -979,7 +1018,7 @@ static void RenderBalanceConsolidated(App* app) {
float addrH = (consAddrOverride >= 0.0f) ? consAddrOverride * dp
: ImGui::GetContentRegionAvail().y - recentReserve
- Layout::spacingXl() - Type().h6()->LegacySize - Layout::spacingMd();
RenderSharedAddressList(app, addrH, availW, glassRound, hs, vs);
RenderSharedAddressList(app, addrH, availW, glassRound, hs, vs, recentReserve);
RenderSharedRecentTx(app, recentReserve, availW, hs, vs);
}
@@ -1055,11 +1094,20 @@ static void RenderBalanceDashboard(App* app) {
snprintf(shBuf, sizeof(shBuf), "%.8f", s_dispShielded);
snprintf(trBuf, sizeof(trBuf), "%.8f", s_dispTransparent);
// Localized captions — raw dl->AddText (no auto-uppercase), so uppercase to preserve the caption look.
auto upperTR = [](const char* key) {
std::string s = TR(key);
std::transform(s.begin(), s.end(), s.begin(), [](unsigned char c){ return (char)std::toupper(c); });
return s;
};
std::string lblShielded = upperTR("shielded"), lblTransparent = upperTR("transparent");
std::string lblQuickSend = upperTR("quick_send"), lblQuickReceive = upperTR("quick_receive");
TileInfo tiles[4] = {
{"SHIELDED", shBuf, S.resolveColor("var(--accent-shielded)", Success()), ICON_MD_SHIELD, NavPage::Receive, false},
{"TRANSPARENT", trBuf, S.resolveColor("var(--accent-transparent)", Warning()), ICON_MD_CIRCLE, NavPage::Receive, false},
{"QUICK SEND", "Send", S.resolveColor("var(--accent-action)", Primary()), ICON_MD_CALL_MADE, NavPage::Send, true},
{"QUICK RECEIVE", "Receive", S.resolveColor("var(--accent-action)", Primary()), ICON_MD_CALL_RECEIVED, NavPage::Receive, true},
{lblShielded.c_str(), shBuf, S.resolveColor("var(--accent-shielded)", Success()), ICON_MD_SHIELD, NavPage::Receive, false},
{lblTransparent.c_str(), trBuf, S.resolveColor("var(--accent-transparent)", Warning()), ICON_MD_CIRCLE, NavPage::Receive, false},
{lblQuickSend.c_str(), "Send", S.resolveColor("var(--accent-action)", Primary()), ICON_MD_CALL_MADE, NavPage::Send, true},
{lblQuickReceive.c_str(), "Receive", S.resolveColor("var(--accent-action)", Primary()), ICON_MD_CALL_RECEIVED, NavPage::Receive, true},
};
for (int i = 0; i < 4; i++) {
@@ -1074,7 +1122,7 @@ static void RenderBalanceDashboard(App* app) {
// Accent stripe — clipped to tile rounded corners
{
float aw = S.drawElement("tabs.balance", "accent-width").sizeOr(4.0f);
float aw = S.drawElement("tabs.balance", "accent-width").sizeOr(4.0f) * dp;
dl->PushClipRect(tMin, ImVec2(tMin.x + aw, tMax.y), true);
dl->AddRectFilled(tMin, tMax, tiles[i].accent, tileSpec.rounding,
ImDrawFlags_RoundCornersLeft);
@@ -1105,13 +1153,13 @@ static void RenderBalanceDashboard(App* app) {
tiles[i].accent, tiles[i].value);
} else {
dl->AddText(capFont, capFont->LegacySize, ImVec2(tMin.x + tilePad, py),
OnSurfaceMedium(), "Click to open");
OnSurfaceMedium(), TR("tile_click_to_open"));
}
// Click
if (material::IsRectHovered(tMin, tMax)) {
dl->AddRect(tMin, tMax, IM_COL32(255, 255, 255, (int)S.drawElement("tabs.balance", "hover-glow-alpha").sizeOr(40.0f)),
tileSpec.rounding, 0, S.drawElement("tabs.balance", "hover-glow-thickness").sizeOr(1.5f));
tileSpec.rounding, 0, S.drawElement("tabs.balance", "hover-glow-thickness").sizeOr(1.5f) * dp);
ImGui::SetMouseCursor(ImGuiMouseCursor_Hand);
if (ImGui::IsMouseClicked(0))
app->setCurrentPage(tiles[i].nav);
@@ -1127,7 +1175,7 @@ static void RenderBalanceDashboard(App* app) {
float addrH = (dashAddrOverride >= 0.0f) ? dashAddrOverride * dp
: ImGui::GetContentRegionAvail().y - recentReserve
- Layout::spacingXl() - Type().h6()->LegacySize - Layout::spacingMd();
RenderSharedAddressList(app, addrH, availW, glassRound, hs, vs);
RenderSharedAddressList(app, addrH, availW, glassRound, hs, vs, recentReserve);
RenderSharedRecentTx(app, recentReserve, availW, hs, vs);
}
@@ -1160,7 +1208,7 @@ static void RenderBalanceVerticalStack(App* app) {
// Font-content floor per row: icon + label + value must fit
float vstackRowFontFloor = std::max(body2->LegacySize, capFont->LegacySize)
+ Layout::spacingSm() * 2;
float rowGap = S.drawElement("tabs.balance.vertical-stack", "row-gap").sizeOr(2.0f);
float rowGap = S.drawElement("tabs.balance.vertical-stack", "row-gap").sizeOr(2.0f) * dp;
float vstackFontFloor = vstackRowFontFloor * 4 + rowGap * 3;
float vstackCardH = S.drawElement("tabs.balance.vertical-stack", "card-height").size;
float stackH;
@@ -1190,10 +1238,10 @@ static void RenderBalanceVerticalStack(App* app) {
};
RowInfo rowInfos[4] = {
{"Total Balance", ICON_MD_ACCOUNT_BALANCE_WALLET, S.resolveColor("var(--accent-total)", OnSurface()), s_dispTotal, 1.0f},
{"Shielded", ICON_MD_SHIELD, S.resolveColor("var(--accent-shielded)", Success()), s_dispShielded, shieldRatio},
{"Transparent", ICON_MD_CIRCLE, S.resolveColor("var(--accent-transparent)", Warning()), s_dispTransparent, transRatio},
{"Market", ICON_MD_TRENDING_UP, S.resolveColor("var(--accent-action)", Primary()), state.market.price_usd, 0.0f},
{TR("baltab_total_balance"), ICON_MD_ACCOUNT_BALANCE_WALLET, S.resolveColor("var(--accent-total)", OnSurface()), s_dispTotal, 1.0f},
{TR("baltab_shielded"), ICON_MD_SHIELD, S.resolveColor("var(--accent-shielded)", Success()), s_dispShielded, shieldRatio},
{TR("baltab_transparent"), ICON_MD_CIRCLE, S.resolveColor("var(--accent-transparent)", Warning()), s_dispTransparent, transRatio},
{TR("baltab_market"), ICON_MD_TRENDING_UP, S.resolveColor("var(--accent-action)", Primary()), state.market.price_usd, 0.0f},
};
for (int i = 0; i < 4; i++) {
@@ -1248,7 +1296,7 @@ static void RenderBalanceVerticalStack(App* app) {
// Proportion bar (for shielded/transparent rows — fills gap between label and amount)
if (i == 1 || i == 2) {
ImVec2 labelSz = capFont->CalcTextSizeA(capFont->LegacySize, 10000, 0, rowInfos[i].label);
float barGap = S.drawElement("tabs.balance.vertical-stack", "sparkline-gap").sizeOr(12.0f);
float barGap = S.drawElement("tabs.balance.vertical-stack", "sparkline-gap").sizeOr(12.0f) * dp;
float barPad = S.drawElement("tabs.balance.vertical-stack", "sparkline-pad").sizeOr(4.0f);
float barH = std::max(
S.drawElement("tabs.balance.vertical-stack", "bar-min-height").sizeOr(3.0f),
@@ -1259,7 +1307,7 @@ static void RenderBalanceVerticalStack(App* app) {
float barW = barRight - barLeft;
float barY = rowPos.y + (rowH - barH) * 0.5f;
dl->AddRectFilled(ImVec2(barLeft, barY), ImVec2(barRight, barY + barH),
IM_COL32(255, 255, 255, 15), barH * 0.5f);
SurfaceOverlay(15), barH * 0.5f);
dl->AddRectFilled(ImVec2(barLeft, barY),
ImVec2(barLeft + barW * rowInfos[i].ratio, barY + barH),
WithAlpha(rowInfos[i].accent, 180), barH * 0.5f);
@@ -1278,8 +1326,8 @@ static void RenderBalanceVerticalStack(App* app) {
// Sparkline in the gap between label and 24h change
if (state.market.price_history.size() >= 2) {
ImVec2 labelSz = capFont->CalcTextSizeA(capFont->LegacySize, 10000, 0, rowInfos[i].label);
float sparkGap = S.drawElement("tabs.balance.vertical-stack", "sparkline-gap").sizeOr(12.0f);
float sparkPad = S.drawElement("tabs.balance.vertical-stack", "sparkline-pad").sizeOr(4.0f);
float sparkGap = S.drawElement("tabs.balance.vertical-stack", "sparkline-gap").sizeOr(12.0f) * dp;
float sparkPad = S.drawElement("tabs.balance.vertical-stack", "sparkline-pad").sizeOr(4.0f) * dp;
float sparkLeft = px + labelSz.x + sparkGap;
float sparkRight = chgX - sparkGap;
if (sparkLeft < sparkRight) {
@@ -1306,7 +1354,7 @@ static void RenderBalanceVerticalStack(App* app) {
float addrH = (vstackAddrOverride >= 0.0f) ? vstackAddrOverride * dp
: ImGui::GetContentRegionAvail().y - recentReserve
- Layout::spacingXl() - Type().h6()->LegacySize - Layout::spacingMd();
RenderSharedAddressList(app, addrH, availW, glassRound, hs, vs);
RenderSharedAddressList(app, addrH, availW, glassRound, hs, vs, recentReserve);
RenderSharedRecentTx(app, recentReserve, availW, hs, vs);
}
@@ -1339,8 +1387,8 @@ static void RenderBalanceVertical2x2(App* app) {
ImFont* iconFont = Type().iconSmall();
// Font-content floor per row: caption text + vertical padding
float v2x2RowFontFloor = capFont->LegacySize + Layout::spacingSm() * 2;
float rowGap = S.drawElement(cfgSec, "row-gap").sizeOr(2.0f);
float colGap = S.drawElement(cfgSec, "col-gap").sizeOr(8.0f);
float rowGap = S.drawElement(cfgSec, "row-gap").sizeOr(2.0f) * dp;
float colGap = S.drawElement(cfgSec, "col-gap").sizeOr(8.0f) * dp;
float v2x2FontFloor = v2x2RowFontFloor * 2 + rowGap;
float cardHOverride = S.drawElement(cfgSec, "card-height").size;
float stackH;
@@ -1383,13 +1431,13 @@ static void RenderBalanceVertical2x2(App* app) {
CellInfo cells[2][2] = {
// Row 0: Total Balance (left), Shielded (right)
{
{"Total Balance", ICON_MD_ACCOUNT_BALANCE_WALLET, S.resolveColor("var(--accent-total)", OnSurface()), s_dispTotal, 1.0f, false, false},
{"Shielded", ICON_MD_SHIELD, S.resolveColor("var(--accent-shielded)", Success()), s_dispShielded, shieldRatio, false, true},
{TR("baltab_total_balance"), ICON_MD_ACCOUNT_BALANCE_WALLET, S.resolveColor("var(--accent-total)", OnSurface()), s_dispTotal, 1.0f, false, false},
{TR("baltab_shielded"), ICON_MD_SHIELD, S.resolveColor("var(--accent-shielded)", Success()), s_dispShielded, shieldRatio, false, true},
},
// Row 1: Market (left), Transparent (right)
{
{"Market", ICON_MD_TRENDING_UP, S.resolveColor("var(--accent-action)", Primary()), state.market.price_usd, 0.0f, true, false},
{"Transparent", ICON_MD_CIRCLE, S.resolveColor("var(--accent-transparent)", Warning()), s_dispTransparent, transRatio, false, true},
{TR("baltab_market"), ICON_MD_TRENDING_UP, S.resolveColor("var(--accent-action)", Primary()), state.market.price_usd, 0.0f, true, false},
{TR("baltab_transparent"), ICON_MD_CIRCLE, S.resolveColor("var(--accent-transparent)", Warning()), s_dispTransparent, transRatio, false, true},
},
};
@@ -1453,7 +1501,7 @@ static void RenderBalanceVertical2x2(App* app) {
float barX = cellMax.x - amtSz.x - rowPad - barW - Layout::spacingSm();
float barY = cellMin.y + (rowH - barH) * 0.5f;
dl->AddRectFilled(ImVec2(barX, barY), ImVec2(barX + barW, barY + barH),
IM_COL32(255, 255, 255, 15), barH * 0.5f);
SurfaceOverlay(15), barH * 0.5f);
dl->AddRectFilled(ImVec2(barX, barY),
ImVec2(barX + barW * cell.ratio, barY + barH),
WithAlpha(cell.accent, 180), barH * 0.5f);
@@ -1471,8 +1519,8 @@ static void RenderBalanceVertical2x2(App* app) {
// Sparkline between label and 24h change
if (state.market.price_history.size() >= 2) {
ImVec2 labelSz = capFont->CalcTextSizeA(capFont->LegacySize, 10000, 0, cell.label);
float sparkGap = S.drawElement(cfgSec, "sparkline-gap").sizeOr(12.0f);
float sparkPad = S.drawElement(cfgSec, "sparkline-pad").sizeOr(4.0f);
float sparkGap = S.drawElement(cfgSec, "sparkline-gap").sizeOr(12.0f) * dp;
float sparkPad = S.drawElement(cfgSec, "sparkline-pad").sizeOr(4.0f) * dp;
float sparkLeft = px + labelSz.x + sparkGap;
float sparkRight = chgX - sparkGap;
if (sparkLeft < sparkRight) {
@@ -1501,7 +1549,7 @@ static void RenderBalanceVertical2x2(App* app) {
float addrH = (addrOverride >= 0.0f) ? addrOverride * dp
: ImGui::GetContentRegionAvail().y - recentReserve
- Layout::spacingXl() - Type().h6()->LegacySize - Layout::spacingMd();
RenderSharedAddressList(app, addrH, availW, glassRound, hs, vs);
RenderSharedAddressList(app, addrH, availW, glassRound, hs, vs, recentReserve);
RenderSharedRecentTx(app, recentReserve, availW, hs, vs);
}
@@ -1531,7 +1579,7 @@ static void RenderBalanceShield(App* app) {
else
ImGui::Dummy(ImVec2(0, S.drawElement("tabs.balance", "compact-hero-pad").sizeOr(8.0f) * vs));
{
Type().textColored(TypeStyle::Overline, OnSurfaceMedium(), "TOTAL BALANCE");
Type().textColored(TypeStyle::Overline, OnSurfaceMedium(), TR("total_balance_label"));
ImGui::Dummy(ImVec2(0, 2 * dp));
snprintf(buf, sizeof(buf), "%.8f", s_dispTotal);
ImFont* heroFont = Type().h2();
@@ -1617,7 +1665,7 @@ static void RenderBalanceShield(App* app) {
ImVec2 needleTip(gaugeCx + cosf(needleAngle) * needleLen,
gaugeCy + sinf(needleAngle) * needleLen);
dl->AddLine(ImVec2(gaugeCx, gaugeCy), needleTip, gaugeCol,
S.drawElement("tabs.balance.shield", "needle-thickness").sizeOr(2.0f));
S.drawElement("tabs.balance.shield", "needle-thickness").sizeOr(2.0f) * dp);
// Center text: percentage
ImFont* sub1 = Type().subtitle1();
@@ -1645,13 +1693,19 @@ static void RenderBalanceShield(App* app) {
float infoY = panelMin.y + shieldPad;
ImFont* ovFont = Type().overline();
dl->AddText(ovFont, ovFont->LegacySize, ImVec2(infoX, infoY), Success(), "SHIELDED");
std::string shieldLabelUpper = TR("shielded");
std::transform(shieldLabelUpper.begin(), shieldLabelUpper.end(), shieldLabelUpper.begin(),
[](unsigned char c){ return (char)std::toupper(c); });
std::string transLabelUpper = TR("transparent");
std::transform(transLabelUpper.begin(), transLabelUpper.end(), transLabelUpper.begin(),
[](unsigned char c){ return (char)std::toupper(c); });
dl->AddText(ovFont, ovFont->LegacySize, ImVec2(infoX, infoY), Success(), shieldLabelUpper.c_str());
infoY += ovFont->LegacySize + 2 * dp;
snprintf(buf, sizeof(buf), "%.8f", s_dispShielded);
dl->AddText(capFont, capFont->LegacySize, ImVec2(infoX, infoY), Success(), buf);
infoY += capFont->LegacySize + 6 * dp;
dl->AddText(ovFont, ovFont->LegacySize, ImVec2(infoX, infoY), Warning(), "TRANSPARENT");
dl->AddText(ovFont, ovFont->LegacySize, ImVec2(infoX, infoY), Warning(), transLabelUpper.c_str());
infoY += ovFont->LegacySize + 2 * dp;
snprintf(buf, sizeof(buf), "%.8f", s_dispTransparent);
dl->AddText(capFont, capFont->LegacySize, ImVec2(infoX, infoY), Warning(), buf);
@@ -1677,7 +1731,7 @@ static void RenderBalanceShield(App* app) {
float addrH = (shieldAddrOverride >= 0.0f) ? shieldAddrOverride * dp
: ImGui::GetContentRegionAvail().y - recentReserve
- Layout::spacingXl() - Type().h6()->LegacySize - Layout::spacingMd();
RenderSharedAddressList(app, addrH, availW, glassRound, hs, vs);
RenderSharedAddressList(app, addrH, availW, glassRound, hs, vs, recentReserve);
RenderSharedRecentTx(app, recentReserve, availW, hs, vs);
}
@@ -1707,7 +1761,7 @@ static void RenderBalanceTimeline(App* app) {
else
ImGui::Dummy(ImVec2(0, S.drawElement("tabs.balance", "compact-hero-pad").sizeOr(8.0f) * vs));
{
Type().textColored(TypeStyle::Overline, OnSurfaceMedium(), "TOTAL BALANCE");
Type().textColored(TypeStyle::Overline, OnSurfaceMedium(), TR("total_balance_label"));
ImGui::Dummy(ImVec2(0, 2 * dp));
snprintf(buf, sizeof(buf), "%.8f", s_dispTotal);
ImFont* heroFont = Type().h2();
@@ -1796,10 +1850,16 @@ static void RenderBalanceTimeline(App* app) {
spec.rounding = glassRound;
struct SumCard { const char* label; ImU32 col; double val; bool isMoney; };
auto upperTR = [](const char* key) {
std::string s = TR(key);
std::transform(s.begin(), s.end(), s.begin(), [](unsigned char c){ return (char)std::toupper(c); });
return s;
};
std::string cShielded = upperTR("shielded"), cTransparent = upperTR("transparent"), cMarket = upperTR("market");
SumCard cards[3] = {
{"SHIELDED", Success(), s_dispShielded, false},
{"TRANSPARENT", Warning(), s_dispTransparent, false},
{"MARKET", Primary(), state.market.price_usd, true},
{cShielded.c_str(), Success(), s_dispShielded, false},
{cTransparent.c_str(), Warning(), s_dispTransparent, false},
{cMarket.c_str(), Primary(), state.market.price_usd, true},
};
for (int i = 0; i < 3; i++) {
ImVec2 cMin(origin.x + i * (cardW + cGap), origin.y);
@@ -1830,7 +1890,7 @@ static void RenderBalanceTimeline(App* app) {
float addrH = (tlAddrOverride >= 0.0f) ? tlAddrOverride * dp
: ImGui::GetContentRegionAvail().y - recentReserve
- Layout::spacingXl() - Type().h6()->LegacySize - Layout::spacingMd();
RenderSharedAddressList(app, addrH, availW, glassRound, hs, vs);
RenderSharedAddressList(app, addrH, availW, glassRound, hs, vs, recentReserve);
RenderSharedRecentTx(app, recentReserve, availW, hs, vs);
}
@@ -1867,32 +1927,36 @@ static void RenderBalanceTwoRow(App* app) {
ImFont* capFont = Type().caption();
if (state.sync.syncing && state.sync.headers > 0) {
float pct = static_cast<float>(state.sync.verification_progress) * 100.0f;
snprintf(buf, sizeof(buf), "Syncing %.1f%%", pct);
snprintf(buf, sizeof(buf), TR("balance_syncing_pct"), pct);
Type().textColored(TypeStyle::Caption, Warning(), buf);
ImGui::SameLine();
}
if (state.mining.generate) {
double hr = state.mining.localHashrate;
snprintf(buf, sizeof(buf), "Mining %s", FormatHashrate(hr).c_str());
snprintf(buf, sizeof(buf), TR("balance_mining_rate"), FormatHashrate(hr).c_str());
Type().textColored(TypeStyle::Caption, WithAlpha(Success(), 200), buf);
ImGui::SameLine();
}
// Action buttons right-aligned
float btnW = S.drawElement("tabs.balance.two-row", "action-btn-width").sizeOr(80.0f);
float btnW = S.drawElement("tabs.balance.two-row", "action-btn-width").sizeOr(80.0f) * dp;
float rightEdge = ImGui::GetWindowWidth() - Layout::spacingLg();
ImGui::SameLine(rightEdge - btnW * 2 - Layout::spacingSm());
if (TactileButton("Send", ImVec2(btnW, 0), S.resolveFont("button"))) {
// Stable ## ids keep the button identity fixed across translations.
char sendBtn[64], recvBtn[64];
snprintf(sendBtn, sizeof(sendBtn), "%s##tworow-send", TR("send"));
snprintf(recvBtn, sizeof(recvBtn), "%s##tworow-receive", TR("receive"));
if (TactileButton(sendBtn, ImVec2(btnW, 0), S.resolveFont("button"))) {
app->setCurrentPage(NavPage::Send);
}
ImGui::SameLine();
if (TactileButton("Receive", ImVec2(btnW, 0), S.resolveFont("button"))) {
if (TactileButton(recvBtn, ImVec2(btnW, 0), S.resolveFont("button"))) {
app->setCurrentPage(NavPage::Receive);
}
}
RenderSyncBar(app, dl, vs);
ImGui::Dummy(ImVec2(0, S.drawElement("tabs.balance.two-row", "sync-gap").sizeOr(2.0f)));
ImGui::Dummy(ImVec2(0, S.drawElement("tabs.balance.two-row", "sync-gap").sizeOr(2.0f) * dp));
// Row 2: 3 mini-cards inline
{
@@ -1915,7 +1979,7 @@ static void RenderBalanceTwoRow(App* app) {
S.drawElement("tabs.balance.two-row", "mini-rounding-min").sizeOr(4.0f),
glassRound * S.drawElement("tabs.balance.two-row", "mini-rounding-ratio").sizeOr(0.5f));
ImFont* capFont = Type().caption();
float indicatorR = S.drawElement("tabs.balance.two-row", "indicator-radius").sizeOr(3.0f);
float indicatorR = S.drawElement("tabs.balance.two-row", "indicator-radius").sizeOr(3.0f) * dp;
int balDecimals = (int)S.drawElement("tabs.balance.two-row", "balance-decimals").sizeOr(4.0f);
float twoRowPadOverride = S.drawElement("tabs.balance.two-row", "card-padding").size;
float miniPad = (twoRowPadOverride >= 0.0f) ? twoRowPadOverride : Layout::spacingSm();
@@ -1990,8 +2054,8 @@ static void RenderBalanceTwoRow(App* app) {
// Sparkline between price and percentage
if (market.price_history.size() >= 2) {
float sparkGap = S.drawElement("tabs.balance.two-row", "sparkline-gap").sizeOr(6.0f);
float sparkPad = S.drawElement("tabs.balance.two-row", "sparkline-pad").sizeOr(4.0f);
float sparkGap = S.drawElement("tabs.balance.two-row", "sparkline-gap").sizeOr(6.0f) * dp;
float sparkPad = S.drawElement("tabs.balance.two-row", "sparkline-pad").sizeOr(4.0f) * dp;
float sparkLeft = cx + priceSz.x + sparkGap;
float sparkRightEdge = sparkRight - sparkGap;
if (sparkLeft < sparkRightEdge) {
@@ -2017,7 +2081,7 @@ static void RenderBalanceTwoRow(App* app) {
float addrH = (twoRowAddrOverride >= 0.0f) ? twoRowAddrOverride * dp
: ImGui::GetContentRegionAvail().y - recentReserve
- Layout::spacingXl() - Type().h6()->LegacySize - Layout::spacingMd();
RenderSharedAddressList(app, addrH, availW, glassRound, hs, vs);
RenderSharedAddressList(app, addrH, availW, glassRound, hs, vs, recentReserve);
RenderSharedRecentTx(app, recentReserve, availW, hs, vs);
}
@@ -2089,10 +2153,10 @@ static void RenderBalanceMinimal(App* app) {
{
ImGui::Dummy(ImVec2(0, Layout::spacingSm()));
ImVec2 sepPos = ImGui::GetCursorScreenPos();
float dashLen = S.drawElement("tabs.balance.minimal", "dash-length").sizeOr(6.0f);
float gapLen = S.drawElement("tabs.balance.minimal", "dash-gap").sizeOr(4.0f);
float dashLen = S.drawElement("tabs.balance.minimal", "dash-length").sizeOr(6.0f) * dp;
float gapLen = S.drawElement("tabs.balance.minimal", "dash-gap").sizeOr(4.0f) * dp;
float sepAlpha = S.drawElement("tabs.balance.minimal", "separator-alpha").sizeOr(25.0f);
float sepThick = S.drawElement("tabs.balance.minimal", "separator-thickness").sizeOr(1.0f);
float sepThick = S.drawElement("tabs.balance.minimal", "separator-thickness").sizeOr(1.0f) * dp;
float x = sepPos.x;
float endX = sepPos.x + availW;
while (x < endX) {
@@ -2110,7 +2174,7 @@ static void RenderBalanceMinimal(App* app) {
float addrH = (minAddrOverride >= 0.0f) ? minAddrOverride * dp
: ImGui::GetContentRegionAvail().y - recentReserve
- Layout::spacingXl() - Type().h6()->LegacySize - Layout::spacingMd();
RenderSharedAddressList(app, addrH, availW, glassRound, hs, vs);
RenderSharedAddressList(app, addrH, availW, glassRound, hs, vs, recentReserve);
RenderSharedRecentTx(app, recentReserve, availW, hs, vs);
}

View File

@@ -59,7 +59,7 @@ static void handleBlockResponseUnified(const json& result, const std::string& er
s_loading = false;
if (!error.empty()) {
s_error = "Error: " + error;
s_error = std::string(TR("grpa_error_prefix")) + error;
return;
}
@@ -84,7 +84,7 @@ static void handleBlockResponseUnified(const json& result, const std::string& er
s_has_data = true;
} else {
s_error = "Invalid response from daemon";
s_error = TR("grpa_invalid_response_from_daemon");
}
}
@@ -112,7 +112,7 @@ void BlockInfoDialog::render(App* app)
// Height input
ImGui::Text("%s", TR("block_height"));
ImGui::SetNextItemWidth(heightInput.width);
ImGui::SetNextItemWidth(heightInput.width * Layout::dpiScale());
ImGui::InputInt("##Height", &s_height);
if (s_height < 1) s_height = 1;
// Clamp to the chain tip so navigation/typing can't request a height
@@ -125,7 +125,7 @@ void BlockInfoDialog::render(App* app)
// Current block info
if (state.sync.blocks > 0) {
ImGui::TextDisabled("(Current: %d)", state.sync.blocks);
ImGui::TextDisabled(TR("grpa_current_block_paren"), state.sync.blocks);
}
ImGui::SameLine();
@@ -135,7 +135,7 @@ void BlockInfoDialog::render(App* app)
ImGui::BeginDisabled();
}
if (material::StyledButton(TR("block_get_info"), ImVec2(0,0), S.resolveFont(closeBtn.font))) {
if (material::TactileButton(TR("block_get_info"), ImVec2(0,0), S.resolveFont(closeBtn.font))) {
if (rpc && rpc->isConnected() && app->worker()) {
s_loading = true;
s_error.clear();
@@ -152,7 +152,7 @@ void BlockInfoDialog::render(App* app)
rpc::RPCClient::TraceScope trace("Explorer / Block info");
auto hashResult = rpc->call("getblockhash", {height});
if (!hashResult.is_string()) {
error = "unexpected getblockhash result";
error = TR("grpa_unexpected_getblockhash_result");
} else {
block = rpc->call("getblock", {hashResult.get<std::string>()});
}
@@ -303,7 +303,7 @@ void BlockInfoDialog::render(App* app)
// Navigation buttons
if (s_has_data) {
if (s_height > 1) {
if (material::StyledButton(TR("block_nav_prev"), ImVec2(0,0), S.resolveFont(closeBtn.font))) {
if (material::TactileButton(TR("block_nav_prev"), ImVec2(0,0), S.resolveFont(closeBtn.font))) {
s_height--;
s_has_data = false;
s_error.clear();
@@ -315,7 +315,7 @@ void BlockInfoDialog::render(App* app)
// nextblockhash, so this stays hidden there).
if (!s_next_hash.empty() &&
(state.sync.blocks <= 0 || s_height < state.sync.blocks)) {
if (material::StyledButton(TR("block_nav_next"), ImVec2(0,0), S.resolveFont(closeBtn.font))) {
if (material::TactileButton(TR("block_nav_next"), ImVec2(0,0), S.resolveFont(closeBtn.font))) {
s_height++;
s_has_data = false;
s_error.clear();
@@ -323,9 +323,10 @@ void BlockInfoDialog::render(App* app)
}
}
// Close button at bottom
ImGui::SetCursorPosY(ImGui::GetWindowHeight() - 40);
if (material::StyledButton(TR("close"), ImVec2(closeBtn.width, 0), S.resolveFont(closeBtn.font))) {
// Close button at bottom — centered via the shared footer helper (no separator, matching
// the prior hand-rolled placement).
material::BeginOverlayDialogFooter(closeBtn.width, /*drawSeparator=*/false);
if (material::TactileButton(TR("close"), ImVec2(closeBtn.width, 0), S.resolveFont(closeBtn.font))) {
s_open = false;
}
material::EndOverlayDialog();

View File

@@ -144,7 +144,7 @@ private:
if (!s_bootstrap) {
s_state = State::Failed;
s_errorMsg = "Bootstrap not initialized";
s_errorMsg = TR("grpc_bootstrap_not_initialized");
return;
}
@@ -227,7 +227,7 @@ private:
s_state = State::Done;
} else {
s_errorMsg = finalProg.error;
if (s_errorMsg.empty()) s_errorMsg = "Bootstrap failed";
if (s_errorMsg.empty()) s_errorMsg = TR("grpc_bootstrap_failed");
s_state = State::Failed;
}
s_bootstrap.reset();

View File

@@ -10,6 +10,7 @@
#include "../../data/address_book.h"
#include "../../chat/chat_service.h"
#include "../../util/i18n.h"
#include "../../util/address_validation.h" // isShieldedAddress — chat requires a z-address recipient
#include "../../util/platform.h" // getConfigDir + writeFileAtomically — conversation export (Q11)
#include "../../config/settings.h" // per-conversation mute (Q10)
#include "../material/colors.h"
@@ -59,6 +60,10 @@ bool s_msgsel_dragging = false;
// 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
// Live byte offset of the composer's text caret, kept in sync by composeInputCallback while the composer
// is active (the callback only fires then). The emoji picker uses it to splice a glyph at the cursor
// instead of always appending. -1 = unknown/never-focused => append at the end.
int s_composeCursor = -1;
// On-chain chat body cap in bytes = (512 len("utf8:"))/2 secretstream ABYTES (see chat_outgoing.cpp).
// The composer hard-caps input to this; the emoji picker respects it too.
constexpr int kChatBodyMaxBytes = (512 - 5) / 2 - 17; // = 236
@@ -70,11 +75,16 @@ float s_composerTargetH = 0.0f; // target height measured in the composer block
// neither the plain-Enter (submit) nor the Ctrl+Enter shortcut — ImGui does nothing with it. We insert the
// newline ourselves here (running under CallbackAlways), respecting the on-chain byte cap.
int composeInputCallback(ImGuiInputTextCallbackData* data) {
// Track the live caret so the emoji picker can insert at the cursor. This callback runs under
// CallbackAlways, which ImGui only invokes while the field is active — so when the composer loses
// focus (e.g. to the emoji picker) s_composeCursor keeps the last edit position.
s_composeCursor = data->CursorPos;
ImGuiIO& io = ImGui::GetIO();
if (io.KeyShift
&& (ImGui::IsKeyPressed(ImGuiKey_Enter) || ImGui::IsKeyPressed(ImGuiKey_KeypadEnter))
&& data->BufTextLen < kChatBodyMaxBytes) {
data->InsertChars(data->CursorPos, "\n");
s_composeCursor = data->CursorPos; // InsertChars advanced the caret past the newline
}
return 0;
}
@@ -83,6 +93,10 @@ char s_new_zaddr[128] = "";
char s_new_msg[256] = "";
char s_search[80] = ""; // conversation-list filter (Q8)
bool s_show_hidden = false; // when on, the list also shows hidden conversations (with an Unhide action)
bool s_show_delete_confirm = false; // "Delete conversation?" confirm overlay (revive vs block)
std::string s_delete_cid; // conversation targeted by the delete confirm
std::string s_delete_name; // its peer name (for the confirm copy / block-list label)
bool s_show_blocked = false; // blocked-conversations manager overlay (unblock)
bool s_show_emoji_picker = false; // emoji picker overlay — fills the conversation-list pane while open
char s_emoji_search[48] = ""; // emoji picker keyword filter
@@ -415,9 +429,9 @@ void RenderChatSettingsPreview(App* app, float width) {
struct PMsg { const char* body; bool outgoing; bool startGroup; bool lastInGroup; std::string meta; };
const std::string peer = "Ava";
const PMsg msgs[] = {
{ u8"Did the payment go through? \U0001F642", false, true, true, peer + " " + t1 },
{ u8"Yep — just confirmed", true, true, false, std::string(TR("chat_you")) + " " + t2 },
{ u8"Sending the rest now \U0001F44D", true, false, true, std::string() },
{ TR("grpb_preview_msg_payment_through"), false, true, true, peer + " " + t1 },
{ TR("grpb_preview_msg_yep_confirmed"), true, true, false, std::string(TR("chat_you")) + " " + t2 },
{ TR("grpb_preview_msg_sending_rest"), true, false, true, std::string() },
};
const int N = 3;
@@ -441,7 +455,7 @@ void RenderChatSettingsPreview(App* app, float width) {
totalH += padIn;
const ImVec2 origin = ImGui::GetCursorScreenPos();
material::GlassPanelSpec g; g.rounding = 10.0f * dp; g.fillAlpha = 16; g.borderAlpha = 36;
material::GlassPanelSpec g; g.rounding = Layout::glassRounding(); g.fillAlpha = 16; g.borderAlpha = 36;
material::DrawGlassPanel(dl, origin, ImVec2(origin.x + width, origin.y + totalH), g);
const float leftX = origin.x + padIn;
@@ -513,6 +527,16 @@ struct ConvSummary {
bool hidden = false; // shown only while "Show hidden" is on
};
// Per-frame memoization of the conversation-list build and the open-thread message list (both otherwise
// rescan + copy + sort the whole chat history every frame). File-scope so ResetChatTab() can reset them
// on a wallet switch; the hide/unhide/rename handlers reset s_convsKey directly to force a rebuild.
std::vector<ConvSummary> s_convs;
int s_convsHidden = 0;
std::uint64_t s_convsKey = ~0ull;
std::string s_threadCid;
std::uint64_t s_threadRev = ~0ull;
std::vector<chat::ChatMessage> s_threadMsgs;
// Centered, muted, wrapped hint for the empty states.
void centeredHint(const char* text) {
ImVec2 avail = ImGui::GetContentRegionAvail();
@@ -537,7 +561,7 @@ void centeredEmptyState(const char* icon, const char* title, const char* hint) {
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 wrap = std::min(avail.x - 40.0f * Layout::dpiScale(), 360.0f * Layout::dpiScale());
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;
@@ -625,16 +649,22 @@ static const EmojiEntry kEmoji[] = {
// Emoji picker overlay: fills the conversation-list pane (cancel + keyword search at the top, then a
// grid). Clicking an emoji appends its UTF-8 bytes to `buf` (the composer), respecting the buffer.
void renderEmojiPickerOverlay(char* buf, std::size_t bufSize, ImTextureID drgxTex) {
// Insert a token (emoji glyph or the ":drgx:" shortcode) at the end of the draft, prepending a space
// when the draft isn't empty and doesn't already end in whitespace. Respects the on-chain byte cap.
// Insert a token (emoji glyph or the ":drgx:" shortcode) at the composer's caret (s_composeCursor,
// kept live by composeInputCallback; -1 => end of draft), prepending a space when the char before the
// caret is a non-space word char so the emoji doesn't fuse onto it. Respects the on-chain byte cap.
// The composer is inactive whenever the picker is open, so it renders straight from buf — splicing
// here shows immediately.
auto insertToken = [&](const char* tok) {
const std::size_t cur = std::strlen(buf), add = std::strlen(tok);
const bool needsSpace = cur > 0 && static_cast<unsigned char>(buf[cur - 1]) > ' ';
const std::size_t pos = (s_composeCursor < 0)
? cur : std::min(static_cast<std::size_t>(s_composeCursor), cur);
const bool needsSpace = pos > 0 && static_cast<unsigned char>(buf[pos - 1]) > ' ';
const std::size_t pad = needsSpace ? 1 : 0;
if (cur + pad + add <= static_cast<std::size_t>(kChatBodyMaxBytes) && cur + pad + add < bufSize) {
if (needsSpace) buf[cur] = ' ';
std::memcpy(buf + cur + pad, tok, add);
buf[cur + pad + add] = '\0';
std::memmove(buf + pos + pad + add, buf + pos, (cur - pos) + 1); // shift tail right (incl NUL)
if (needsSpace) buf[pos] = ' ';
std::memcpy(buf + pos + pad, tok, add);
s_composeCursor = static_cast<int>(pos + pad + add); // keep the caret after the inserted token
}
};
if (ImGui::SmallButton(TR("chat_cancel"))) { s_show_emoji_picker = false; s_emoji_search[0] = '\0'; return; }
@@ -709,32 +739,46 @@ void RenderChatTab(App* app)
}
// Build conversation summaries (single scan per conversation), sorted by most-recent activity.
std::vector<ConvSummary> convs;
int hiddenCount = 0;
for (const auto& cid : store.conversationIds()) {
const bool hidden = app->settings() && app->settings()->isChatHidden(cid);
if (hidden) ++hiddenCount;
if (hidden && !s_show_hidden) continue; // filtered out unless "Show hidden" is on
const auto messages = store.conversation(cid);
if (messages.empty()) continue;
ConvSummary c;
c.cid = cid;
c.hidden = hidden;
c.count = static_cast<int>(messages.size());
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)
// MEMOIZED: this previously rescanned + copied + sorted the ENTIRE chat history every frame. Rebuild
// only when the store changed (revision), the show-hidden toggle flipped, or the contact list grew
// (peerName resolution). Hide/unhide and rename don't move any of those, so those handlers force a
// rebuild by resetting s_convsKey (delete/block already bump the store revision). s_convsKey is reset
// in ResetChatTab on wallet switch.
const std::uint64_t convsKey =
store.revision() * 1000003ull
+ static_cast<std::uint64_t>(s_show_hidden ? 1 : 0)
+ (book.revision() << 20); // book.revision() catches in-place contact edits (rename) that keep size()
if (convsKey != s_convsKey) {
s_convs.clear();
s_convsHidden = 0;
for (const auto& cid : store.conversationIds()) {
const bool hidden = app->settings() && app->settings()->isChatHidden(cid);
if (hidden) ++s_convsHidden;
if (hidden && !s_show_hidden) continue; // filtered out unless "Show hidden" is on
const auto messages = store.conversation(cid);
if (messages.empty()) continue;
ConvSummary c;
c.cid = cid;
c.hidden = hidden;
c.count = static_cast<int>(messages.size());
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;
c.lastTs = last.timestamp;
const int idx = c.peerZaddr.empty() ? -1 : book.findByAddress(c.peerZaddr);
c.peerName = (idx >= 0) ? book.entries()[idx].label
: shorten(!c.peerZaddr.empty() ? c.peerZaddr : cid);
s_convs.push_back(std::move(c));
}
const auto& last = messages.back();
c.lastBody = last.body;
c.lastTs = last.timestamp;
const int idx = c.peerZaddr.empty() ? -1 : book.findByAddress(c.peerZaddr);
c.peerName = (idx >= 0) ? book.entries()[idx].label
: shorten(!c.peerZaddr.empty() ? c.peerZaddr : cid);
convs.push_back(std::move(c));
std::sort(s_convs.begin(), s_convs.end(),
[](const ConvSummary& a, const ConvSummary& b) { return a.lastTs > b.lastTs; });
s_convsKey = convsKey;
}
std::sort(convs.begin(), convs.end(),
[](const ConvSummary& a, const ConvSummary& b) { return a.lastTs > b.lastTs; });
std::vector<ConvSummary>& convs = s_convs;
int hiddenCount = s_convsHidden;
// Keep the selection valid (only when there is something to select).
if (!convs.empty() &&
@@ -747,6 +791,7 @@ void RenderChatTab(App* app)
// 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_composeCursor = -1; // fresh draft — next emoji appends until the caret is known again
s_compose_cid = s_selected_cid;
s_composerAnimH = 0.0f; // re-arm the first-frame snap so the box doesn't animate-collapse on switch
}
@@ -759,7 +804,7 @@ void RenderChatTab(App* app)
}
const ImVec2 avail = ImGui::GetContentRegionAvail();
const float listW = std::clamp(avail.x * 0.32f, 220.0f, 360.0f);
const float listW = std::clamp(avail.x * 0.32f, 220.0f * Layout::dpiScale(), 360.0f * Layout::dpiScale());
// Row geometry is logical px — scale by dpiScale() so rows/padding grow with the (DPI-scaled)
// 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.
@@ -778,7 +823,7 @@ void RenderChatTab(App* app)
{
ImDrawList* paneDL = ImGui::GetWindowDrawList();
const ImVec2 pMin = ImGui::GetCursorScreenPos();
material::GlassPanelSpec g; g.rounding = 12.0f * Layout::dpiScale(); g.fillAlpha = 20; g.borderAlpha = 34;
material::GlassPanelSpec g; g.rounding = Layout::glassRounding(); g.fillAlpha = 20; g.borderAlpha = 34;
material::DrawGlassPanel(paneDL, pMin, ImVec2(pMin.x + listW, pMin.y + avail.y), g);
}
// Inner padding so the list content (buttons, search, conversation cards) doesn't hug the glass
@@ -828,6 +873,15 @@ void RenderChatTab(App* app)
ImGui::SetNextItemWidth(-FLT_MIN);
ImGui::InputTextWithHint("##chatsearch", TR("chat_search"), s_search, sizeof(s_search));
}
// Blocked-conversations manager opener — only when at least one is blocked. Blocked convs have no
// stored messages (deleted), so they can't appear in the list; this opens a small manager to unblock.
const int blockedCount = app->settings() ? (int)app->settings()->blockedChatConversations().size() : 0;
if (blockedCount > 0) {
const std::string bl = std::string(TR("chat_blocked_manage")) + " (" + std::to_string(blockedCount) + ")";
ImGui::PushStyleColor(ImGuiCol_Text, material::OnSurfaceMedium());
if (ImGui::SmallButton(bl.c_str())) s_show_blocked = true;
ImGui::PopStyleColor();
}
const std::string search = s_search;
ImGui::Separator();
if (convs.empty()) {
@@ -878,16 +932,22 @@ void RenderChatTab(App* app)
}
const float textX = avC.x + avR + pad;
// Name (top). Hidden conversations (shown via "Show hidden") render dimmed.
dl->AddText(nameFont, nameSz, ImVec2(textX, p.y + pad),
c.hidden ? material::OnSurfaceMedium() : material::OnSurface(), c.peerName.c_str());
// Time (top-right, muted) — compact relative form (Q5).
// Time (top-right, muted) — compact relative form (Q5). Measure/draw it FIRST so the name can be
// clipped to the column left of it — otherwise a long peer name overruns the timestamp (worse at
// HiDPI, where the fixed-length name grows ~1.5x).
const std::string when = relativeTime(c.lastTs);
float nameRight = mx.x - pad;
if (!when.empty()) {
const ImVec2 wsz = metaFont->CalcTextSizeA(metaSz, FLT_MAX, 0.0f, when.c_str());
dl->AddText(metaFont, metaSz, ImVec2(mx.x - pad - wsz.x, p.y + pad + 1.0f),
material::OnSurfaceMedium(), when.c_str());
nameRight = mx.x - pad - wsz.x - pad; // reserve the timestamp column + a gap
}
// Name (top), clipped to the space left of the timestamp. Hidden conversations render dimmed.
dl->PushClipRect(ImVec2(textX, p.y), ImVec2(std::max(textX, nameRight), mx.y), true);
dl->AddText(nameFont, nameSz, ImVec2(textX, p.y + pad),
c.hidden ? material::OnSurfaceMedium() : material::OnSurface(), c.peerName.c_str());
dl->PopClipRect();
// Preview (bottom, clipped to the text column, muted).
const std::string preview = previewOf(c.lastBody);
dl->PushClipRect(ImVec2(textX, p.y), ImVec2(mx.x - pad, mx.y), true);
@@ -950,7 +1010,7 @@ void RenderChatTab(App* app)
ImDrawList* paneDL = ImGui::GetWindowDrawList();
const ImVec2 pMin = ImGui::GetCursorScreenPos();
const float pW = ImGui::GetContentRegionAvail().x;
material::GlassPanelSpec g; g.rounding = 12.0f * tdp; g.fillAlpha = 12; g.borderAlpha = 34;
material::GlassPanelSpec g; g.rounding = Layout::glassRounding(); g.fillAlpha = 12; g.borderAlpha = 34;
material::DrawGlassPanel(paneDL, pMin, ImVec2(pMin.x + pW, pMin.y + (avail.y - composerAreaH)), g);
}
// Inner padding so the header + messages don't hug the glass card's edges (the message child
@@ -995,7 +1055,7 @@ void RenderChatTab(App* app)
// The toolbar's left edge is known up front (from the button count). A rename (edit) icon is
// shown whenever there's an address to save the contact under; the settings "notch" gear is
// always the rightmost icon.
const int nBtns = 4 + (hasAddr ? 1 : 0);
const int nBtns = 5 + (hasAddr ? 1 : 0); // export, mute, hide, delete, settings (+rename)
const float toolbarLeft = rightX - (nBtns * ib + (nBtns - 1) * gap);
// Compact address + lock (or waiting-chip) metrics, reserved to the right of the name.
@@ -1046,6 +1106,7 @@ void RenderChatTab(App* app)
} else {
Notifications::instance().error(TR("address_book_exists"));
}
s_convsKey = ~0ull; // peerName changed — force the conversation-list memo to rebuild
}
s_rename_cid.clear();
} else if (cancel) {
@@ -1147,6 +1208,21 @@ void RenderChatTab(App* app)
s_selected_cid.clear();
Notifications::instance().info(TR("chat_hidden_toast"));
}
s_convsKey = ~0ull; // hidden-state changed — force the conversation-list memo to rebuild
}
bx += ib + gap;
}
// Delete — clears this conversation's LOCAL history. Destructive (and offers a
// "delete & block" variant), so it opens a confirm dialog rather than acting inline.
{
ImGui::SetCursorScreenPos(ImVec2(bx, by));
material::IconButtonStyle a = base;
a.tooltip = TR("chat_delete");
a.hoverColor = material::Error();
if (material::IconButton("##hdr_delete", ICON_MD_DELETE_OUTLINE, ifont, ImVec2(ib, ib), a)) {
s_delete_cid = sel->cid;
s_delete_name = sel->peerName;
s_show_delete_confirm = true;
}
bx += ib + gap;
}
@@ -1255,7 +1331,15 @@ void RenderChatTab(App* app)
const float groupGap = (compact ? 4.0f : 7.0f) * dp;
const float msgGap = (compact ? 2.0f : 3.0f) * dp;
const ImU32 accentBase = bubbleAccentColor(cs ? cs->getChatBubbleAccent() : 0);
const auto messages = store.conversation(s_selected_cid);
// MEMOIZED: store.conversation() linear-scans ALL messages across ALL conversations and
// copies+sorts the match every call. Rebuild only when the open thread or the store changes,
// not every frame while the thread is simply being read/scrolled.
if (s_selected_cid != s_threadCid || store.revision() != s_threadRev) {
s_threadMsgs = store.conversation(s_selected_cid);
s_threadCid = s_selected_cid;
s_threadRev = store.revision();
}
const auto& messages = s_threadMsgs;
// Grouping + per-day separators (Tier 1). Same-sender messages within kGroupWindow share
// one meta header and stack tightly; a date pill is drawn once per calendar day.
const std::int64_t nowTs = static_cast<std::int64_t>(std::time(nullptr));
@@ -1290,7 +1374,7 @@ void RenderChatTab(App* app)
}
const float availW = ImGui::GetContentRegionAvail().x;
const float maxBubbleW = std::max(140.0f * dp, availW * 0.72f);
const float maxBubbleW = std::clamp(availW * 0.72f, 140.0f * dp, 560.0f * dp);
const float innerW = maxBubbleW - 2.0f * bpad;
// ── Date separator (once per calendar day): a centered pill.
@@ -1576,7 +1660,8 @@ void RenderChatTab(App* app)
const float ringR = std::max(7.0f, lineH * 0.42f);
const float ringPad = 9.0f * tdp;
const float ringSlot = 2.0f * ringR + ringPad * 1.6f;
const float inputW = std::max(ringSlot + 48.0f * tdp, cw - emojiBtn - 2.0f * inGap - sendW);
const float inputW = std::min(720.0f * tdp,
std::max(ringSlot + 48.0f * tdp, cw - emojiBtn - 2.0f * inGap - sendW));
const float sendX = inputX + inputW + inGap;
const float textW = std::max(40.0f * tdp, inputW - ringSlot); // input area, left of the ring
const ImVec2 ringC(inputX + inputW - ringPad - ringR, rowY + composerBoxH - ringPad - ringR);
@@ -1598,7 +1683,7 @@ void RenderChatTab(App* app)
// FrameBg); a flat FrameBg showed the sharp texture.
{
ImDrawList* cdl = ImGui::GetWindowDrawList();
material::GlassPanelSpec g; g.rounding = 8.0f * tdp; g.fillAlpha = 16; g.borderAlpha = 34;
material::GlassPanelSpec g; g.rounding = Layout::glassRounding(); g.fillAlpha = 16; g.borderAlpha = 34;
material::DrawGlassPanel(cdl, ImVec2(inputX, rowY),
ImVec2(inputX + inputW, rowY + composerBoxH), g);
}
@@ -1705,6 +1790,7 @@ void RenderChatTab(App* app)
if (submit && s_compose[0] != '\0' && !overCap) {
app->sendChatMessage(sel->cid, s_compose);
sodium_memzero(s_compose, sizeof(s_compose));
s_composeCursor = -1;
s_scroll_to_cid = sel->cid;
s_composerAnimH = 0.0f; // snap back to collapsed instead of animating while unfocused
// Sending closes the emoji picker (it takes over the conversation-list pane) so the list
@@ -1726,6 +1812,17 @@ void RenderChatTab(App* app)
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);
// Chat rides on encrypted memos, which only shielded (z) addresses carry — a transparent (t)
// address can't receive one. Contacts can hold t-addresses, so guard the manual field too:
// warn when the entry isn't a valid z-address and keep Send disabled below.
const bool newAddrIsZ = dragonx::util::isShieldedAddress(s_new_zaddr);
if (s_new_zaddr[0] != '\0' && !newAddrIsZ) {
ImGui::PushStyleColor(ImGuiCol_Text, material::Warning());
ImGui::PushTextWrapPos(0.0f);
ImGui::TextUnformatted(TR("chat_new_needs_zaddr"));
ImGui::PopTextWrapPos();
ImGui::PopStyleColor();
}
// 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);
@@ -1757,7 +1854,7 @@ void RenderChatTab(App* app)
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 bool canSend = newAddrIsZ && 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();
@@ -1784,6 +1881,122 @@ void RenderChatTab(App* app)
}
}
// ---- Delete-conversation confirm (revive-on-new-message vs delete & block) ----
if (s_show_delete_confirm) {
const float dp = Layout::dpiScale();
material::OverlayDialogSpec ov;
ov.title = TR("chat_delete_title");
ov.p_open = &s_show_delete_confirm; // X / backdrop closes it (no-op)
ov.style = material::OverlayStyle::BlurFloat;
ov.cardWidth = 500.0f; ov.idSuffix = "chatdelete";
if (material::BeginOverlayDialog(ov)) {
ImGui::PushTextWrapPos(0.0f);
ImGui::TextUnformatted((std::string(TR("chat_delete_body_prefix")) + s_delete_name +
TR("chat_delete_body_suffix")).c_str());
ImGui::Dummy(ImVec2(0, Layout::spacingXs()));
ImGui::PushStyleColor(ImGuiCol_Text, material::OnSurfaceMedium());
ImGui::TextUnformatted(TR("chat_delete_revive_note"));
ImGui::Dummy(ImVec2(0, Layout::spacingXs()));
ImGui::TextUnformatted(TR("chat_delete_local_note"));
ImGui::PopStyleColor();
ImGui::PopTextWrapPos();
ImGui::Dummy(ImVec2(0, Layout::spacingMd()));
auto doDelete = [&](bool block) {
// Delete first — if the persisted rows can't be removed, change nothing else (no block,
// no toast) so the store and DB can't diverge.
if (!app->chatService().deleteConversation(s_delete_cid, block)) {
Notifications::instance().error(TR("chat_delete_failed"));
s_show_delete_confirm = false;
s_delete_cid.clear(); s_delete_name.clear();
return;
}
if (app->settings()) {
if (block) app->settings()->setChatBlocked(s_delete_cid, s_delete_name, true);
app->settings()->setChatHidden(s_delete_cid, false); // clear any prior hide flag
app->settings()->save();
}
// Revive mode: forget the seen-watermark so a re-imported message badges as unread even if
// its stamped time predates the deleted thread. Block mode keeps it, so an unblock-restored
// history doesn't all re-badge.
if (!block) app->forgetChatConversationSeen(s_delete_cid);
if (s_selected_cid == s_delete_cid) s_selected_cid.clear();
Notifications::instance().info(block ? TR("chat_blocked_toast") : TR("chat_deleted_toast"));
s_show_delete_confirm = false;
s_delete_cid.clear(); s_delete_name.clear();
};
auto textW = [&](const char* t) {
return ImGui::CalcTextSize(t).x + ImGui::GetStyle().FramePadding.x * 2.0f + 20.0f * dp;
};
const float gap2 = Layout::spacingSm();
const float wDel = std::max(100.0f * dp, textW(TR("chat_delete_confirm")));
const float wBlk = std::max(130.0f * dp, textW(TR("chat_delete_block")));
const float wCan = std::max(90.0f * dp, textW(TR("chat_cancel")));
material::BeginOverlayDialogFooter(wDel + wBlk + wCan + gap2 * 2.0f, /*drawSeparator=*/false);
ImGui::PushStyleColor(ImGuiCol_Button, ImGui::ColorConvertU32ToFloat4(material::WithAlpha(material::Error(), 205)));
ImGui::PushStyleColor(ImGuiCol_ButtonHovered, ImGui::ColorConvertU32ToFloat4(material::Error()));
ImGui::PushStyleColor(ImGuiCol_ButtonActive, ImGui::ColorConvertU32ToFloat4(material::WithAlpha(material::Error(), 235)));
ImGui::PushStyleColor(ImGuiCol_Text, ImGui::ColorConvertU32ToFloat4(material::OnError()));
const bool doDel = material::TactileButton(TR("chat_delete_confirm"), ImVec2(wDel, 0));
ImGui::SameLine(0, gap2);
const bool doBlk = material::TactileButton(TR("chat_delete_block"), ImVec2(wBlk, 0));
ImGui::PopStyleColor(4);
ImGui::SameLine(0, gap2);
const bool doCancel = material::TactileButton(TR("chat_cancel"), ImVec2(wCan, 0));
if (doDel) doDelete(false);
if (doBlk) doDelete(true);
if (doCancel) { s_show_delete_confirm = false; s_delete_cid.clear(); s_delete_name.clear(); }
material::EndOverlayDialog();
}
}
// ---- Blocked-conversations manager (unblock) ----
if (s_show_blocked) {
const float dp = Layout::dpiScale();
material::OverlayDialogSpec ov;
ov.title = TR("chat_blocked_title");
ov.p_open = &s_show_blocked;
ov.style = material::OverlayStyle::BlurFloat;
ov.cardWidth = 500.0f; ov.idSuffix = "chatblocked";
if (material::BeginOverlayDialog(ov)) {
ImGui::PushTextWrapPos(0.0f);
ImGui::PushStyleColor(ImGuiCol_Text, material::OnSurfaceMedium());
ImGui::TextUnformatted(TR("chat_blocked_desc"));
ImGui::PopStyleColor();
ImGui::PopTextWrapPos();
ImGui::Dummy(ImVec2(0, Layout::spacingSm()));
if (app->settings()) {
// Copy so unblocking (which mutates the settings vector) during iteration is safe.
const auto blocked = app->settings()->blockedChatConversations();
std::string unblockCid;
for (const auto& b : blocked) {
ImGui::PushID(b.cid.c_str());
const std::string label = b.name.empty() ? shorten(b.cid, 10, 6) : b.name;
const float bw = ImGui::CalcTextSize(TR("chat_unblock")).x +
ImGui::GetStyle().FramePadding.x * 2.0f + 16.0f * dp;
ImGui::AlignTextToFramePadding();
ImGui::TextUnformatted(label.c_str());
ImGui::SameLine();
ImGui::SetCursorPosX(ImGui::GetCursorPosX() + ImGui::GetContentRegionAvail().x - bw);
if (material::TactileButton(TR("chat_unblock"), ImVec2(bw, 0))) unblockCid = b.cid;
ImGui::PopID();
}
if (!unblockCid.empty()) {
app->settings()->setChatBlocked(unblockCid, "", false);
app->settings()->save();
Notifications::instance().info(TR("chat_unblocked_toast")); // re-imports on the next chat scan
if (app->settings()->blockedChatConversations().empty()) s_show_blocked = false;
}
}
material::EndOverlayDialog();
}
}
// ---- Chat customization modal (opened by the header settings "notch") — house BlurFloat overlay ----
if (s_show_chat_settings) {
material::OverlayDialogSpec ov;
@@ -1847,20 +2060,51 @@ void ResetChatTab()
s_rename_focus = false;
s_show_new_convo = false;
s_show_chat_settings = false;
s_show_delete_confirm = false;
s_delete_cid.clear();
s_delete_name.clear();
s_show_blocked = false;
// Drop the per-frame memoization caches so the next wallet doesn't briefly render the previous one's
// conversations/thread (store.revision() is monotonic and would rebuild anyway, but be explicit).
s_convs.clear();
s_convsHidden = 0;
s_convsKey = ~0ull;
s_threadCid.clear();
s_threadRev = ~0ull;
s_threadMsgs.clear();
}
void RenderChatSettingsControls(App* app, float contentWidth)
void RenderChatSettingsControls(App* app, float contentWidth, bool drawCards)
{
auto* st = app ? app->settings() : nullptr;
if (!st) return;
const float dp = Layout::dpiScale();
const float ctrlW = 250.0f * dp; // control column width (fits a 3-segment control comfortably)
const float rowGap = 5.0f * dp;
const float rowGap = 10.0f * dp;
// Optionally paint two glass cards (Appearance | Messaging) around our own two columns so the
// Settings tab matches the mockup's card-per-group layout. The chat modal passes drawCards=false
// and keeps its plain single-surface layout — the controls themselves are identical either way.
ImDrawList* cardDL = ImGui::GetWindowDrawList();
const float cardPad = drawCards ? Layout::cardInnerPadding() : 0.0f;
material::GlassPanelSpec cardSpec; cardSpec.rounding = Layout::glassRounding();
float cardTopScr = 0.0f, cardBaseXScr = 0.0f, cardLeftBotScr = 0.0f;
if (drawCards) {
cardTopScr = ImGui::GetCursorScreenPos().y;
cardBaseXScr = ImGui::GetCursorScreenPos().x;
cardDL->ChannelsSplit(2);
cardDL->ChannelsSetCurrent(1);
ImGui::SetCursorScreenPos(ImVec2(cardBaseXScr, cardTopScr + cardPad));
ImGui::Indent(cardPad);
}
// Right-align controls to the row's true right edge. The Settings tab renders us inside a GlassCard
// whose content region isn't narrowed to the card padding, so it passes an explicit contentWidth;
// the chat modal's dialog content region is correct, so it passes 0 (auto).
const float leftX = ImGui::GetCursorPosX();
const float rowW = (contentWidth > 0.0f) ? contentWidth : ImGui::GetContentRegionAvail().x;
// leftX/rowW define the current column the rows lay out in; retargeted below
// to split Appearance | Messaging into two columns when the card is wide.
float leftX = ImGui::GetCursorPosX();
float rowW = drawCards ? (contentWidth - 2.0f * cardPad)
: ((contentWidth > 0.0f) ? contentWidth : ImGui::GetContentRegionAvail().x);
// Label left, control right-aligned within [leftX, leftX+rowW]. Leaves the cursor at the control origin.
auto beginRow = [&](const char* label) {
@@ -1925,6 +2169,17 @@ void RenderChatSettingsControls(App* app, float contentWidth)
return result;
};
// Two internal columns when the card is wide enough: Appearance on the left,
// Messaging on the right — fills the width and roughly halves the height.
// (Mirrors the Node & Security card.) Narrow (the chat modal) stays single-column.
const float chatColGap = drawCards ? (Layout::cardGap() + 2.0f * cardPad) : (24.0f * dp);
const bool chatTwoCol = rowW > 760.0f * dp;
const float chatColW = chatTwoCol ? (rowW - chatColGap) * 0.5f : rowW;
const float chatBaseLeftX = leftX;
const float chatTopY = ImGui::GetCursorPosY();
float chatLeftBottomY = 0.0f;
if (chatTwoCol) rowW = chatColW; // left column width
// ── Appearance ────────────────────────────────────────────────────────────────
section("chat_sec_appearance");
// Emoji style (monochrome / color). Color needs a FreeType build (native + the cross-built Windows
@@ -1933,6 +2188,7 @@ void RenderChatSettingsControls(App* app, float contentWidth)
int v = st->getChatEmojiColor() ? 1 : 0;
const char* items[] = { TR("chat_emoji_mono"), TR("chat_emoji_color") };
int nv = segmented(TR("chat_opt_emoji"), items, 2, v);
if (ImGui::IsItemHovered()) material::Tooltip("%s", TR("tt_chat_emoji_style"));
if (nv != v) { st->setChatEmojiColor(nv == 1); st->save(); app->requestFontRebuild(); }
}
// Bubble style (segmented) + accent color (a 6-way dropdown — too many for a segmented control).
@@ -1940,6 +2196,7 @@ void RenderChatSettingsControls(App* app, float contentWidth)
const char* items[] = { TR("chat_bubble_rounded"), TR("chat_bubble_square"), TR("chat_bubble_minimal") };
int v = st->getChatBubbleStyle();
int nv = segmented(TR("chat_opt_bubble_style"), items, 3, v);
if (ImGui::IsItemHovered()) material::Tooltip("%s", TR("tt_chat_bubble_style"));
if (nv != v) { st->setChatBubbleStyle(nv); st->save(); }
}
{
@@ -1948,12 +2205,14 @@ void RenderChatSettingsControls(App* app, float contentWidth)
const char* items[] = { TR("chat_accent_theme"), TR("chat_accent_blue"), TR("chat_accent_green"),
TR("chat_accent_purple"), TR("chat_accent_amber"), TR("chat_accent_pink") };
if (ImGui::Combo("##chat_baccent", &v, items, 6)) { st->setChatBubbleAccent(v); st->save(); }
if (ImGui::IsItemHovered()) material::Tooltip("%s", TR("tt_chat_bubble_accent"));
}
// Message density (segmented).
{
const char* items[] = { TR("chat_density_comfortable"), TR("chat_density_compact") };
int v = st->getChatDensity();
int nv = segmented(TR("chat_opt_density"), items, 2, v);
if (ImGui::IsItemHovered()) material::Tooltip("%s", TR("tt_chat_density"));
if (nv != v) { st->setChatDensity(nv); st->save(); }
}
// Message text size (slider).
@@ -1963,6 +2222,17 @@ void RenderChatSettingsControls(App* app, float contentWidth)
if (ImGui::SliderFloat("##chat_font", &v, 0.8f, 1.5f, "%.2fx", ImGuiSliderFlags_AlwaysClamp)) {
st->setChatFontScale(v); st->save();
}
if (ImGui::IsItemHovered()) material::Tooltip("%s", TR("tt_chat_font_size"));
}
// Move Messaging into the right column (float it with Indent so every row's
// line-start holds the column; retarget leftX so controls right-align in it).
if (chatTwoCol) {
chatLeftBottomY = ImGui::GetCursorPosY();
cardLeftBotScr = ImGui::GetCursorScreenPos().y; // left column bottom (screen), for its card panel
ImGui::SetCursorPosY(chatTopY);
ImGui::Indent(chatColW + chatColGap);
leftX = chatBaseLeftX + chatColW + chatColGap;
}
// ── Messaging ─────────────────────────────────────────────────────────────────
@@ -1974,12 +2244,14 @@ void RenderChatSettingsControls(App* app, float contentWidth)
if (ImGui::SliderFloat("##chat_poll", &v, 0.5f, 15.0f, "%.1f s", ImGuiSliderFlags_AlwaysClamp)) {
st->setChatPollRateSec(v); st->save();
}
if (ImGui::IsItemHovered()) material::Tooltip("%s", TR("tt_chat_poll_rate"));
}
// Chat timestamps (segmented) — overrides the app-wide clock (Settings → General) for this tab only.
{
const char* items[] = { TR("chat_ts_global_short"), TR("chat_ts_24h"), TR("chat_ts_12h") };
int v = st->getChatTimeFormat();
int nv = segmented(TR("chat_opt_timestamp"), items, 3, v);
if (ImGui::IsItemHovered()) material::Tooltip("%s", TR("tt_chat_timestamp"));
if (nv != v) { st->setChatTimeFormat(nv); st->save(); }
}
// Enter-to-send (checkbox).
@@ -1987,6 +2259,44 @@ void RenderChatSettingsControls(App* app, float contentWidth)
ImGui::Dummy(ImVec2(0.0f, rowGap));
bool v = st->getChatEnterSends();
if (ImGui::Checkbox(TR("chat_opt_enter_sends"), &v)) { st->setChatEnterSends(v); st->save(); }
if (ImGui::IsItemHovered()) material::Tooltip("%s", TR("tt_chat_enter_sends"));
}
// Close the two-column band: un-indent and drop below the taller column.
const float cardRightBotScr = ImGui::GetCursorScreenPos().y; // right (or only) column bottom, screen
if (chatTwoCol) {
ImGui::Unindent(chatColW + chatColGap);
const float chatRightBottomY = ImGui::GetCursorPosY();
ImGui::SetCursorPosX(chatBaseLeftX);
ImGui::SetCursorPosY(std::max(chatLeftBottomY, chatRightBottomY));
}
// Paint the glass card(s) behind the content, then merge the channels.
if (drawCards) {
ImGui::Unindent(cardPad);
cardDL->ChannelsSetCurrent(0);
const float cardW = (contentWidth - Layout::cardGap()) * 0.5f;
if (chatTwoCol) {
const float eqBot = std::max(cardLeftBotScr, cardRightBotScr); // equal-height cards (mockup grid stretch)
material::DrawGlassPanel(cardDL, ImVec2(cardBaseXScr, cardTopScr),
ImVec2(cardBaseXScr + cardW, eqBot + cardPad), cardSpec);
material::DrawGlassPanel(cardDL, ImVec2(cardBaseXScr + cardW + Layout::cardGap(), cardTopScr),
ImVec2(cardBaseXScr + contentWidth, eqBot + cardPad), cardSpec);
} else {
material::DrawGlassPanel(cardDL, ImVec2(cardBaseXScr, cardTopScr),
ImVec2(cardBaseXScr + contentWidth, cardRightBotScr + cardPad), cardSpec);
}
cardDL->ChannelsMerge();
const float botScr = chatTwoCol ? std::max(cardLeftBotScr, cardRightBotScr) : cardRightBotScr;
// Reserve the card footprint with a Dummy so the parent scroll region grows to include it
// (a bare SetCursorScreenPos past content warns in ImGui).
ImGui::SetCursorScreenPos(ImVec2(cardBaseXScr, cardTopScr));
ImGui::Dummy(ImVec2(contentWidth, (botScr - cardTopScr) + cardPad));
// Live conversation preview below the two cards (Settings tab only — the chat modal renders
// its own preview column beside these controls, so it passes drawCards=false and skips this).
ImGui::Dummy(ImVec2(0.0f, Layout::spacingMd()));
RenderChatSettingsPreview(app, contentWidth);
}
}

View File

@@ -34,7 +34,7 @@ void RenderChatTab(App* app);
* width from the Settings tab (whose GlassCard doesn't narrow the content region). 0 = auto
* (use the current content region, correct inside the chat modal's dialog).
*/
void RenderChatSettingsControls(App* app, float contentWidth = 0.0f);
void RenderChatSettingsControls(App* app, float contentWidth = 0.0f, bool drawCards = false);
/**
* @brief Securely wipe the Chat tab's UI-local state (composer / new-conversation

View File

@@ -160,10 +160,10 @@ const ConsoleCommandEntry kWalletCommands[] = {
"z_sendmany \"RfromAddr\" [{\"address\":\"zs1toAddr\",\"amount\":1.0}]", "send pay private shielded transfer money", true},
{"z_shieldcoinbase", "Shield transparent coinbase funds to a z-address", "\"fromaddress\" \"tozaddress\" [fee] [limit]",
"Moves newly mined (coinbase) transparent funds into a private shielded z-address, since mined rewards must be shielded before they can be spent normally. Runs in the background and returns an operation id.",
"z_shieldcoinbase \"RyourMiningAddr\" \"zs1yourShieldedAddr\"", "shield mining rewards coinbase private hide mined funds move to shielded"},
"z_shieldcoinbase \"RyourMiningAddr\" \"zs1yourShieldedAddr\"", "shield mining rewards coinbase private hide mined funds move to shielded", true},
{"z_mergetoaddress", "Merge multiple UTXOs/notes to one address", "[\"fromaddress\",...] \"toaddress\" [fee] [limit]",
"Combines many small balances (from transparent and/or shielded addresses) into a single destination address in one transaction, to consolidate funds. Runs in the background and returns an operation id.",
"z_mergetoaddress [\"RyourAddr\",\"zs1yourShieldedAddr\"] \"zs1destShieldedAddr\"", "merge combine consolidate funds sweep small balances into one address"},
"z_mergetoaddress [\"RyourAddr\",\"zs1yourShieldedAddr\"] \"zs1destShieldedAddr\"", "merge combine consolidate funds sweep small balances into one address", true},
{"listtransactions", "List recent wallet transactions", "[\"account\"] [count] [from]",
"Your most recent wallet transactions, newest first \xE2\x80\x94 amounts, addresses and confirmations.",
"listtransactions", "transactions history recent payments received sent"},

View File

@@ -37,18 +37,21 @@ ConsoleModel::DrainResult ConsoleModel::drain()
lines_.pop_front();
++result.popped;
}
++revision_; // deque changed — lets the view memoize its filter/layout passes
return result;
}
void ConsoleModel::clear()
{
lines_.clear();
++revision_;
}
bool ConsoleModel::toggleCollapsed(std::size_t i)
{
if (i >= lines_.size() || lines_[i].foldSpan <= 0) return false;
lines_[i].collapsed = !lines_[i].collapsed;
++revision_; // fold change alters which lines are visible
return lines_[i].collapsed;
}

View File

@@ -22,6 +22,7 @@
#include "console_channel.h"
#include <cstddef>
#include <cstdint>
#include <deque>
#include <mutex>
#include <string>
@@ -76,11 +77,16 @@ public:
const ConsoleModelLine& operator[](std::size_t i) const { return lines_[i]; }
const ConsoleModelLine& back() const { return lines_.back(); }
// Monotonic counter bumped whenever the visible deque changes (drain added/evicted lines, clear,
// fold toggle). The view memoizes its per-frame filter + text-layout passes against this.
std::uint64_t revision() const { return revision_; }
private:
const std::size_t max_lines_;
std::deque<ConsoleModelLine> lines_; // visible model — main thread only
std::vector<ConsoleModelLine> pending_; // guarded by ingest_mutex_
std::mutex ingest_mutex_;
std::uint64_t revision_ = 0;
};
} // namespace ui

View File

@@ -337,7 +337,7 @@ void ConsoleTab::render(ConsoleCommandExecutor& exec)
float outputH = ComputeConsoleOutputHeight(
availHeight,
input_height,
schema::UI().drawElement("tabs.console", "output-min-height").size,
schema::UI().drawElement("tabs.console", "output-min-height").size * Layout::dpiScale(),
schema::UI().drawElement("tabs.console", "output-min-height-ratio").size);
ImDrawList* dlOut = ImGui::GetWindowDrawList();
@@ -564,12 +564,27 @@ void ConsoleTab::renderToolbar(ConsoleCommandExecutor& exec)
ImGui::SameLine();
}
// Line count
ImGui::TextDisabled(TR("console_line_count"), model_.size());
// Line count — the least-critical trailing element. When the row is too narrow to fit the
// filter box (at its placeholder-sized minimum) AND its trailing controls, drop the line count
// rather than starve/hide the filter (worst at 1024px). Mirror the reservation formula in
// drawFilterInput(): trailing = 4 frame-height buttons + the group spacers, and the filter's
// hard floor = its placeholder width + frame padding.
{
char lineCountBuf[64];
snprintf(lineCountBuf, sizeof(lineCountBuf), TR("console_line_count"), model_.size());
float lineCountW = ImGui::CalcTextSize(lineCountBuf).x + Layout::spacingSm() * 2.0f; // text + its trailing spacer
float trailingW = ImGui::GetFrameHeight() * 4.0f + Layout::spacingSm() * 7.0f;
float filterMinW = ImGui::CalcTextSize(TR("console_filter_hint")).x
+ ImGui::GetStyle().FramePadding.x * 2.0f + 8.0f * Layout::dpiScale();
bool showLineCount = ImGui::GetContentRegionAvail().x >= lineCountW + trailingW + filterMinW;
ImGui::SameLine();
ImGui::Spacing();
ImGui::SameLine();
if (showLineCount) {
ImGui::TextDisabled(TR("console_line_count"), model_.size());
ImGui::SameLine();
ImGui::Spacing();
ImGui::SameLine();
}
}
// Output filter input
drawFilterInput();
@@ -617,7 +632,7 @@ void ConsoleTab::drawToolbarStatus(ConsoleCommandExecutor& exec)
ConsoleStatusLine st = exec.toolbarStatus();
if (!st.text.empty()) {
ImVec2 cp = ImGui::GetCursorScreenPos();
float dotR = schema::UI().drawElement("tabs.console", "status-dot-radius-base").size + schema::UI().drawElement("tabs.console", "status-dot-radius-scale").size * Layout::hScale();
float dotR = (schema::UI().drawElement("tabs.console", "status-dot-radius-base").size + schema::UI().drawElement("tabs.console", "status-dot-radius-scale").size) * Layout::hScale();
float dotY = cp.y + ImGui::GetTextLineHeight() * 0.5f;
float dotX = cp.x + dotR + 2.0f * Layout::dpiScale();
@@ -687,9 +702,21 @@ void ConsoleTab::drawLogFilterToggles(const ConsoleLogFilterCaps& caps)
void ConsoleTab::drawFilterInput()
{
using namespace material;
float zoomBtnSpace = ImGui::GetFrameHeight() * 2.0f + Layout::spacingSm() * 3.0f;
float filterAvail = ImGui::GetContentRegionAvail().x - zoomBtnSpace;
float filterW = std::min(schema::UI().drawElement("tabs.console", "filter-max-width").size, filterAvail * schema::UI().drawElement("tabs.console", "filter-width-ratio").size);
// Reserve room for EVERY trailing same-line control drawn AFTER this filter on the toolbar
// row: the two icon toggles (accent-fill + text-color) and the two zoom buttons, plus the
// group spacers between them. Otherwise the filter eats the row and the trailing controls
// run off-window (worst at 1024px / font_scale 1.5). All four are GetFrameHeight() wide.
float trailingBtnSpace = ImGui::GetFrameHeight() * 4.0f + Layout::spacingSm() * 7.0f;
float filterAvail = ImGui::GetContentRegionAvail().x - trailingBtnSpace;
float filterMaxW = schema::UI().drawElement("tabs.console", "filter-max-width").size * Layout::dpiScale();
float filterW = std::min(filterMaxW, filterAvail * schema::UI().drawElement("tabs.console", "filter-width-ratio").size);
// Never shrink below the placeholder — otherwise the hint clips to "Filter outp" (or the box
// vanishes) at narrow widths. Floor = placeholder text + frame padding + a little breathing room.
// (drawToolbar() drops the "NNN lines" count when even this floor won't fit alongside the row's
// trailing controls, so this max() doesn't push the zoom/color buttons off-window.)
float filterMinW = ImGui::CalcTextSize(TR("console_filter_hint")).x
+ ImGui::GetStyle().FramePadding.x * 2.0f + 8.0f * Layout::dpiScale();
filterW = std::max(filterMinW, filterW);
ImGui::SetNextItemWidth(filterW);
ImGui::InputTextWithHint("##ConsoleFilter", TR("console_filter_hint"), filter_text_, sizeof(filter_text_));
if (filter_text_[0] != '\0') {
@@ -768,7 +795,10 @@ void ConsoleTab::renderOutput()
// height. The inter-line gap is added explicitly to layout_.heights
// so that layout_.cumulativeY stays perfectly in sync with actual
// cursor positions (avoiding selection-offset drift).
float interLineGap = S.drawElement("tabs.console", "output").getFloat("line-spacing", 0.0f);
// Raw logical px from the schema; scale it so the inter-line gap grows at font_scale 1.5
// (it is added to the already-DPI-scaled GetTextLineHeight in BuildConsoleLayout — scale the
// gap only, never the line height).
float interLineGap = S.drawElement("tabs.console", "output").getFloat("line-spacing", 0.0f) * Layout::dpiScale();
ImGui::PushStyleVar(ImGuiStyleVar_ItemSpacing, ImVec2(0, 0));
// Inner padding for glass panel
@@ -790,11 +820,23 @@ void ConsoleTab::renderOutput()
// segment records which bytes of the source text appear on that visual row, so
// hit-testing and selection highlight can map screen positions to exact char offsets.
float wrap_width = ClampConsoleWrapWidth(ImGui::GetContentRegionAvail().x, padX);
ImFontConsoleMeasure measure(ImGui::GetFont(), ImGui::GetFontSize());
layout_ = BuildConsoleLayout(
static_cast<int>(visible_indices_.size()),
[this](int vi) -> const std::string& { return model_[visible_indices_[vi]].text; },
wrap_width, line_height, interLineGap, measure);
// Memoize the text-shaping pass: rebuild only when the visible set, wrap width, line height, gap or
// zoom changed. Otherwise this re-wrapped and re-measured every visible line (glyph-by-glyph) every
// frame, even while idle/scrolled. layout_ is a member, so the cached geometry stays valid for
// drawVisibleLines / screenToTextPos when we skip the rebuild.
std::uint64_t layoutKey = vis_generation_ * 1000003ull;
layoutKey = layoutKey * 131ull + static_cast<std::uint64_t>(wrap_width * 16.0f);
layoutKey = layoutKey * 131ull + static_cast<std::uint64_t>(line_height * 16.0f);
layoutKey = layoutKey * 131ull + static_cast<std::uint64_t>(interLineGap * 16.0f);
layoutKey = layoutKey * 131ull + static_cast<std::uint64_t>(s_console_zoom * 1000.0f);
if (layoutKey != layout_key_) {
ImFontConsoleMeasure measure(ImGui::GetFont(), ImGui::GetFontSize());
layout_ = BuildConsoleLayout(
static_cast<int>(visible_indices_.size()),
[this](int vi) -> const std::string& { return model_[visible_indices_[vi]].text; },
wrap_width, line_height, interLineGap, measure);
layout_key_ = layoutKey;
}
// Mouse/keyboard interaction (wheel-up detach, selection drag, Ctrl+C/A). Raw IO bypasses
// the child window's event consumption.
@@ -861,6 +903,20 @@ void ConsoleTab::renderOutput()
void ConsoleTab::computeVisibleLines(bool& hasTextFilter, std::string& filterLower)
{
// Memoize: rebuild the visible set only when the model changed or the filter state changed.
// Otherwise this scanned the entire (up to 10k-line) model with a per-line filter predicate every
// frame. The out-params + filter_match_count_/folding_active_ are members that stay valid until the
// key moves, so an early return leaves last frame's (still-correct) results in place.
std::uint64_t visKey = model_.revision() * 1000003ull;
for (const char* p = filter_text_; *p; ++p) visKey = visKey * 131ull + static_cast<unsigned char>(*p);
visKey = visKey * 2ull + (s_daemon_messages_enabled ? 1u : 0u);
visKey = visKey * 2ull + (s_errors_only_enabled ? 1u : 0u);
visKey = visKey * 2ull + (s_rpc_trace_enabled ? 1u : 0u);
visKey = visKey * 2ull + (s_app_messages_enabled ? 1u : 0u);
if (visKey == vis_key_) return; // nothing that affects the visible set changed
vis_key_ = visKey;
++vis_generation_; // the layout pass keys off this
ConsoleOutputFilter outputFilter{filter_text_, s_daemon_messages_enabled,
s_errors_only_enabled, s_rpc_trace_enabled,
s_app_messages_enabled};
@@ -1050,8 +1106,11 @@ void ConsoleTab::drawVisibleLines(float padX, float lineHeight, bool hasTextFilt
ImVec2(cx, cy + sz * 0.7f), triCol); // ▼ expanded
}
// Click anywhere in the gutter cell for this line's first row toggles the fold.
// Guard against a click that is actually dismissing the ConsoleContextMenu (or any
// popup) — the same popup guard the text-selection path uses (see mouse_in_output).
ImVec2 mp = ImGui::GetIO().MousePos;
if (ImGui::IsMouseClicked(ImGuiMouseButton_Left) &&
!ImGui::IsPopupOpen("", ImGuiPopupFlags_AnyPopup) &&
mp.x >= output_origin_.x - padX && mp.x < output_origin_.x &&
mp.y >= lineOrigin.y && mp.y < lineOrigin.y + lineHeight) {
pendingFoldToggle = i;
@@ -1406,18 +1465,24 @@ void ConsoleTab::renderInput(ConsoleCommandExecutor& exec)
ImGui::PopItemWidth();
ImGui::PopFont();
// Auto-focus on input
if (reclaim_focus) {
// Auto-focus on input — after submitting a command (reclaim), or once when the Console tab is opened
// (focus_input_pending_, set by requestInputFocus() and gated on the console_auto_focus setting).
// Skip while a command is running: SetKeyboardFocusHere can't focus the disabled field anyway.
if ((reclaim_focus || focus_input_pending_) && !busy) {
ImGui::SetKeyboardFocusHere(-1);
}
focus_input_pending_ = false;
}
bool ConsoleTab::submitConsoleCommand(ConsoleCommandExecutor& exec, const std::string& cmd)
{
if (cmd.empty()) return false;
addLine("> " + cmd, ConsoleChannel::Command);
AppendConsoleHistory(command_history_, cmd, 100);
// Redact secret-bearing commands (walletpassphrase, z_importkey, …) before they reach the visible
// log and the recall history. The real `cmd` below is still executed unredacted.
const std::string display = RedactConsoleCommand(cmd);
addLine("> " + display, ConsoleChannel::Command);
AppendConsoleHistory(command_history_, display, 100);
history_index_ = -1;
// First token, lowercased, for built-in interception.
@@ -1885,7 +1950,7 @@ void ConsoleTab::renderCommandsPopup(ConsoleCommandExecutor& exec)
// Both panes sit on soft Material glass surfaces (no hard 1px child border) with inner padding.
GlassPanelSpec paneGlass;
paneGlass.rounding = 14.0f;
paneGlass.rounding = 14.0f * dp;
paneGlass.fillAlpha = 30;
paneGlass.borderAlpha = 30;
@@ -2040,6 +2105,12 @@ void ConsoleTab::clear()
// line indices) here to avoid an out-of-bounds crash. computeVisibleLines() rebuilds them next frame.
visible_indices_.clear();
selection_.clear();
// Force both memoized passes to rebuild: renderOutput() runs later THIS frame against the now-empty
// visible_indices_, and computeVisibleLines() recomputes next frame — without these resets the layout
// memo would skip the rebuild and keep stale geometry for the just-emptied set.
vis_key_ = ~0ull;
layout_key_ = ~0ull;
++vis_generation_;
stop_confirm_pending_ = false; // a pending 'stop' confirmation is cancelled by clearing
addLine(TR("console_cleared"), ConsoleChannel::Info);
}

View File

@@ -15,6 +15,7 @@
#include "../../rpc/rpc_client.h"
#include "../../rpc/rpc_worker.h"
#include <cstdint>
#include <string>
#include <vector>
#include <deque>
@@ -65,6 +66,10 @@ public:
*/
void clear();
// Ask the console to place the keyboard focus in the command input on the next render (consumed once).
// Called when the user switches to the Console tab, gated by the console_auto_focus setting.
void requestInputFocus() { focus_input_pending_ = true; }
// Scanline effect toggle (set from settings)
static bool s_scanline_enabled;
@@ -156,6 +161,7 @@ private:
int history_index_ = -1;
char input_buffer_[4096] = {0};
bool stop_confirm_pending_ = false; // 'stop' typed once, awaiting a confirming second 'stop'
bool focus_input_pending_ = false; // one-shot: focus the command input next render (tab-open auto-focus)
// (log-ingestion cursors + result queue moved to the ConsoleCommandExecutor)
// Auto-scroll state machine (pin-to-bottom, wheel-up cooldown, new-line backlog count).
@@ -183,6 +189,14 @@ private:
bool has_text_filter_ = false; // computed once per frame (before the toolbar draws it)
std::string filter_lower_; // lowercased filter needle for match highlighting
// Memoization keys so the two expensive per-frame passes rebuild only on change (not every frame):
// computeVisibleLines (filter scan over the whole model) is keyed on the model revision + filter
// state; BuildConsoleLayout (glyph-by-glyph text shaping of every visible line) is keyed on the
// resulting visible-set generation + wrap width + line height/zoom.
std::uint64_t vis_key_ = ~0ull; // key of the last computeVisibleLines
std::uint64_t vis_generation_ = 0; // bumped whenever visible_indices_ is rebuilt
std::uint64_t layout_key_ = ~0ull; // key of the last BuildConsoleLayout
// Wrap layout for the visible lines (segments + per-line heights + cumulative Y),
// recomputed each frame by the pure BuildConsoleLayout (console_text_layout.h) and
// consumed by the renderer + hit-testing.

View File

@@ -1,10 +1,34 @@
#include "console_tab_helpers.h"
#include <algorithm>
#include <cctype>
namespace dragonx {
namespace ui {
namespace {
// First tokens (lowercase) of console/RPC commands that carry a secret argument on the command line.
// Output-secret commands (dumpprivkey / z_exportkey / z_exportmnemonic) are deliberately absent —
// their secret is in the RESULT, which is a separate redaction concern.
const char* const kSecretConsoleCommands[] = {
"walletpassphrase", "walletpassphrasechange", "encryptwallet",
"importprivkey", "importwallet", "importmulti",
"z_importkey", "z_importviewingkey", "z_importwallet",
"signrawtransaction", "magicrecoverkey", "sethdseed", "importmnemonic",
};
std::string firstConsoleTokenLower(const std::string& cmd, size_t& tokenEnd) {
size_t b = cmd.find_first_not_of(" \t");
if (b == std::string::npos) { tokenEnd = cmd.size(); return {}; }
size_t e = cmd.find_first_of(" \t", b);
tokenEnd = (e == std::string::npos) ? cmd.size() : e;
std::string t = cmd.substr(b, tokenEnd - b);
std::transform(t.begin(), t.end(), t.begin(),
[](unsigned char c) { return static_cast<char>(std::tolower(c)); });
return t;
}
} // namespace
float ComputeConsoleInputHeight(float frameHeightWithSpacing,
float itemSpacingY,
float spacingSm,
@@ -27,5 +51,27 @@ float ClampConsoleWrapWidth(float contentWidth, float paddingX)
return std::max(50.0f, contentWidth - paddingX * 2.0f);
}
bool ConsoleCommandCarriesSecret(const std::string& cmd)
{
size_t end = 0;
const std::string name = firstConsoleTokenLower(cmd, end);
if (name.empty()) return false;
for (const char* s : kSecretConsoleCommands) if (name == s) return true;
return false;
}
std::string RedactConsoleCommand(const std::string& cmd)
{
size_t end = 0;
const std::string name = firstConsoleTokenLower(cmd, end);
if (name.empty()) return cmd;
bool secret = false;
for (const char* s : kSecretConsoleCommands) if (name == s) { secret = true; break; }
if (!secret) return cmd;
// Only redact if there are actually arguments after the command name.
if (cmd.find_first_not_of(" \t", end) == std::string::npos) return cmd;
return cmd.substr(0, end) + " ****";
}
} // namespace ui
} // namespace dragonx

View File

@@ -1,5 +1,7 @@
#pragma once
#include <string>
namespace dragonx {
namespace ui {
@@ -14,5 +16,14 @@ float ComputeConsoleOutputHeight(float availableHeight,
float minHeightRatio);
float ClampConsoleWrapWidth(float contentWidth, float paddingX);
// True if `cmd`'s first token names a console/RPC command that carries a SECRET on its command line
// (passphrase, private/spending/viewing key, mnemonic). Output-secret commands (dumpprivkey,
// z_exportkey, z_exportmnemonic) are NOT covered — their secret is in the result, a separate concern.
bool ConsoleCommandCarriesSecret(const std::string& cmd);
// A display/history-safe copy of `cmd`: the command name with its arguments replaced by "****" when
// it carries a secret, else `cmd` unchanged. The real command is still executed unredacted.
std::string RedactConsoleCommand(const std::string& cmd);
} // namespace ui
} // namespace dragonx

View File

@@ -40,6 +40,7 @@ static bool s_show_edit_dialog = false;
static bool s_show_contacts_settings = false; // Contacts customization modal (gear button)
static bool s_focus_edit_field = false; // focus the first field the frame the add/edit dialog opens
static int s_confirm_delete_idx = -1; // armed storage index; a 2nd Delete confirms
static int s_confirm_avatar_del_idx = -1; // armed avatar-library index; a 2nd badge click confirms the (irreversible) file delete
static char s_edit_label[128] = "";
static char s_edit_address[512] = "";
static char s_edit_notes[512] = "";
@@ -206,6 +207,28 @@ static bool isShieldedAddr(const std::string& a) {
return !a.empty() && a[0] == 'z';
}
// Width-aware middle-ellipsis truncation (mirrors the add/edit dialog's local fitMiddle lambda, but
// file-scope so the Cards/List rows can share it too): keeps head + tail, shrinking symmetrically
// until the rendered width fits maxW. minFront/minBack are the schema-configured floor — below that
// the fixed-length util::truncateMiddle result is used instead, so very cramped rows still read as
// "front...back" rather than collapsing to a near-empty stub. Addresses are ASCII, so byte-wise
// trimming is safe.
static std::string truncateAddressToWidth(const std::string& s, ImFont* f, float size, float maxW,
int minFront, int minBack) {
const std::string floor = util::truncateMiddle(s, minFront, minBack);
auto w = [&](const std::string& t){ return f->CalcTextSizeA(size, FLT_MAX, 0, t.c_str()).x; };
if (w(s) <= maxW) return s; // fits in full — no truncation needed at all
if (maxW <= 0.0f) return floor; // no room to measure against — fall back to the floor
const std::string ell = "\xE2\x80\xA6";
size_t head = s.size() / 2, tail = s.size() - head;
while (head + tail > static_cast<size_t>(minFront + minBack)) {
std::string cand = s.substr(0, head) + ell + s.substr(s.size() - tail);
if (w(cand) <= maxW) return cand;
if (head >= tail) --head; else --tail;
}
return floor; // couldn't fit even at the configured floor — use the fixed-length result
}
// Accent colour for a contact's address type (Z = shielded/green, T = transparent/amber), tuned per
// theme. File-scope so both the list rows and the edit-dialog preview share one source of truth.
static ImU32 contactTypeColor(bool shielded, bool light) {
@@ -555,7 +578,7 @@ void RenderContactsTab(App* app)
sdl->AddRectFilled(ImVec2(cMin.x + 2.0f * dp, cMin.y + 2.0f * dp),
ImVec2(cMax.x - 2.0f * dp, cMax.y - 2.0f * dp),
material::WithAlpha(material::Primary(), 210), (segH - 4.0f * dp) * 0.5f);
ImU32 fg = active ? IM_COL32(255, 255, 255, 255) : (hov ? material::OnSurface() : material::OnSurfaceMedium());
ImU32 fg = active ? material::OnPrimary() : (hov ? material::OnSurface() : material::OnSurfaceMedium());
float igW = segIcoF->CalcTextSizeA(segIcoF->LegacySize, FLT_MAX, 0, segIco[i]).x;
float lbW = btnFont->CalcTextSizeA(btnFont->LegacySize, FLT_MAX, 0, segLbl[i]).x;
float gapI = 5.0f * dp;
@@ -747,12 +770,16 @@ void RenderContactsTab(App* app)
float dr = std::max(7.0f * dp, cell * 0.15f);
ImVec2 dcc(mx.x - dr - 3.0f * dp, mn.y + dr + 3.0f * dp);
bool dhov = ImGui::IsMouseHoveringRect(ImVec2(dcc.x-dr, dcc.y-dr), ImVec2(dcc.x+dr, dcc.y+dr));
const int libIdx = n - 1;
const bool delArmed = (s_confirm_avatar_del_idx == libIdx);
ImGui::PushID(n);
bool thumbClicked = ImGui::InvisibleButton("##avthumb", ImVec2(cell, cell));
bool thumbHov = ImGui::IsItemHovered();
ImGui::PopID();
if (thumbHov || sel || hov) { // draw the delete badge
gdl->AddCircleFilled(dcc, dr, dhov ? material::ReadableError() : IM_COL32(0, 0, 0, 175));
if (thumbHov || sel || hov || delArmed) { // draw the delete badge (persist while armed)
gdl->AddCircleFilled(dcc, dr, (dhov || delArmed) ? material::ReadableError() : IM_COL32(0, 0, 0, 175));
// While armed, ring the badge so the "click again to delete" state is unmistakable.
if (delArmed) gdl->AddCircle(dcc, dr + 1.5f * dp, material::ReadableError(), 0, 1.5f * dp);
ImFont* xf = material::Type().iconSmall();
float xsz = dr * 1.35f;
ImVec2 xs = xf->CalcTextSizeA(xsz, FLT_MAX, 0, ICON_MD_CLOSE);
@@ -760,11 +787,18 @@ void RenderContactsTab(App* app)
}
if (dhov) { // badge takes priority over selecting the thumbnail
ImGui::SetMouseCursor(ImGuiMouseCursor_Hand);
material::Tooltip("%s", TR("delete"));
if (ImGui::IsMouseClicked(ImGuiMouseButton_Left)) pendingDelete = n - 1;
// Two-stage confirm: the first click arms this badge; a second click on the SAME
// badge deletes the image file (fs::remove is irreversible — no undo).
material::Tooltip("%s", delArmed ? TR("address_book_confirm_delete") : TR("delete"));
if (ImGui::IsMouseClicked(ImGuiMouseButton_Left)) {
if (delArmed) { pendingDelete = libIdx; s_confirm_avatar_del_idx = -1; }
else s_confirm_avatar_del_idx = libIdx; // arm; requires a 2nd deliberate click
}
} else {
if (thumbHov) ImGui::SetMouseCursor(ImGuiMouseCursor_Hand);
if (thumbClicked) s_edit_avatar = "img:" + path;
// Any click that lands off this badge (selecting the thumbnail or elsewhere)
// disarms it, so a stale armed badge can't be confirmed by an unrelated click.
if (thumbClicked) { s_edit_avatar = "img:" + path; s_confirm_avatar_del_idx = -1; }
}
}
col = (col + 1) % cols;
@@ -948,7 +982,7 @@ void RenderContactsTab(App* app)
}
// Search / filter (tight against the toolbar row above — no extra spacer)
ImGui::SetNextItemWidth(ImGui::GetContentRegionAvail().x);
ImGui::SetNextItemWidth(std::min(ImGui::GetContentRegionAvail().x, 700.0f * dp));
ImGui::InputTextWithHint("##ContactSearch", TR("contacts_search_placeholder"),
s_search, sizeof(s_search));
bool searchActive = ImGui::IsItemActive();
@@ -1014,7 +1048,7 @@ void RenderContactsTab(App* app)
const float tpW = ImGui::GetContentRegionAvail().x;
const float tIpad = 12.0f * dp, tVpad = 10.0f * dp;
{
material::GlassPanelSpec g; g.rounding = 12.0f * dp; g.fillAlpha = 14; g.borderAlpha = 34;
material::GlassPanelSpec g; g.rounding = Layout::glassRounding(); g.fillAlpha = 14; g.borderAlpha = 34;
material::DrawGlassPanel(tpdl, tpMin, ImVec2(tpMin.x + tpW, tpMin.y + listH), g);
}
ImGui::SetCursorScreenPos(ImVec2(tpMin.x + tIpad, tpMin.y + tVpad));
@@ -1097,6 +1131,10 @@ void RenderContactsTab(App* app)
}
ImGui::EndTable();
}
// Land the cursor at the glass-panel bottom (tpMin.y + listH) so the count footer lines up
// with the Cards/List views. The table is inset by tVpad and its outer_size is listH-2*tVpad,
// so it would otherwise end tVpad higher and pull the footer up.
ImGui::SetCursorScreenPos(ImVec2(tpMin.x, tpMin.y + listH));
} else {
// ── CARDS (0) / LIST (1) mode — tactile Material items, no grid lines. ──
const bool asCard = (viewMode == 0);
@@ -1113,7 +1151,7 @@ void RenderContactsTab(App* app)
ImDrawList* pdl = ImGui::GetWindowDrawList();
const ImVec2 pMin = ImGui::GetCursorScreenPos();
const float pW = ImGui::GetContentRegionAvail().x;
material::GlassPanelSpec g; g.rounding = 12.0f * dp; g.fillAlpha = 14; g.borderAlpha = 34;
material::GlassPanelSpec g; g.rounding = Layout::glassRounding(); g.fillAlpha = 14; g.borderAlpha = 34;
material::DrawGlassPanel(pdl, pMin, ImVec2(pMin.x + pW, pMin.y + listH), g);
}
// AlwaysUseWindowPadding: a borderless child ignores WindowPadding without it, so the rows
@@ -1203,10 +1241,13 @@ void RenderContactsTab(App* app)
dl->AddText(lblF, lblSz, ImVec2(tx, ty), material::OnSurface(), entry.label.c_str());
dl->PopClipRect();
// Un-collapse to the full address on hover (clipped to the text column so it never
// runs under the trailing actions); middle-truncated otherwise.
// runs under the trailing actions); otherwise middle-truncated to FIT the actual text
// column width (tx..textMaxX) rather than a fixed char count — wide rows show more of
// the address instead of leaving a dead gap before the action-icon cluster.
std::string addr = rowHovered
? entry.address
: util::truncateMiddle(entry.address, addrFrontLbl.truncate, addrBackLbl.truncate);
: truncateAddressToWidth(entry.address, adrF, adrSz, textMaxX - tx,
addrFrontLbl.truncate, addrBackLbl.truncate);
dl->PushClipRect(ImVec2(tx, mn.y), ImVec2(textMaxX, mx.y), true);
dl->AddText(adrF, adrSz, ImVec2(tx, ty + lblSz + 3.0f * dp),
material::OnSurfaceMedium(), addr.c_str());
@@ -1374,7 +1415,7 @@ void RenderContactsTab(App* app)
const float rowH = avR * 2.0f + 16.0f * dp;
const float panelH = 2.0f * rowH + gap + 2.0f * padIn;
const ImVec2 pOrigin = ImGui::GetCursorScreenPos();
material::GlassPanelSpec g; g.rounding = 12.0f * dp; g.fillAlpha = 14; g.borderAlpha = 34;
material::GlassPanelSpec g; g.rounding = Layout::glassRounding(); g.fillAlpha = 14; g.borderAlpha = 34;
material::DrawGlassPanel(pdl, pOrigin, ImVec2(pOrigin.x + w, pOrigin.y + panelH), g);
const ImVec2 rmn(pOrigin.x + padIn, pOrigin.y + padIn);
const float rowW = w - 2.0f * padIn;

View File

@@ -408,7 +408,9 @@ private:
// ---- Below the info card (outside the surface): verify note + install button ----
ImGui::Dummy(ImVec2(0, Layout::spacingSm()));
ImGui::PushTextWrapPos(ImGui::GetCursorPosX() + ImGui::GetContentRegionAvail().x);
Type().textColored(TypeStyle::Caption, downgrade ? Warning() : OnSurfaceMedium(), noteStr);
ImGui::PopTextWrapPos();
ImGui::Spacing();
// Install button sized to its text and centered in the pane.
const float bw = ImGui::CalcTextSize(label).x + ImGui::GetStyle().FramePadding.x * 2.0f + Layout::spacingLg();

View File

@@ -108,13 +108,13 @@ static const char* relativeTime(int64_t timestamp) {
int64_t diff = now - timestamp;
if (diff < 0) diff = 0;
if (diff < 60)
snprintf(buf, sizeof(buf), "%lld sec ago", (long long)diff);
snprintf(buf, sizeof(buf), TR("grpa_sec_ago"), (long long)diff);
else if (diff < 3600)
snprintf(buf, sizeof(buf), "%lld min ago", (long long)(diff / 60));
snprintf(buf, sizeof(buf), TR("grpa_min_ago"), (long long)(diff / 60));
else if (diff < 86400)
snprintf(buf, sizeof(buf), "%lld hr ago", (long long)(diff / 3600));
snprintf(buf, sizeof(buf), TR("grpa_hr_ago"), (long long)(diff / 3600));
else
snprintf(buf, sizeof(buf), "%lld days ago", (long long)(diff / 86400));
snprintf(buf, sizeof(buf), TR("grpa_days_ago"), (long long)(diff / 86400));
return buf;
}
@@ -446,10 +446,10 @@ static void renderSearchBar(App* app, float availWidth) {
float navW = navBtnSz * 2.0f + pageW + navGap * 2.0f;
float inputW = std::min(
S.drawElement("tabs.explorer", "search-input-width").size,
S.drawElement("tabs.explorer", "search-input-width").size * Layout::dpiScale(),
availWidth * 0.65f);
float btnW = S.drawElement("tabs.explorer", "search-button-width").size;
float barH = S.drawElement("tabs.explorer", "search-bar-height").size;
float btnW = S.drawElement("tabs.explorer", "search-button-width").size * Layout::dpiScale();
float barH = S.drawElement("tabs.explorer", "search-bar-height").size * Layout::dpiScale();
// Clamp so search bar never overflows
float maxInputW = availWidth - btnW - navW - pad * 4 - Type().iconMed()->LegacySize;
@@ -620,7 +620,16 @@ static void renderChainStats(App* app, float availWidth) {
ImVec2(cardMin.x + pad, cardMin.y + pad * 0.5f), Primary(), TR("explorer_chain_stats"));
drawStatusPill(cardMin, cardW);
float labelY = cardMin.y + pad * 0.5f + headerH + Layout::spacingLg();
// Distribute the two stat blocks (Height, Best Block) evenly through the
// content region so the card matches the density of the sibling 2x2 metric
// grid instead of pinning Height to the top and Best Block to the bottom
// edge with a large empty gap between them.
float contentTop = cardMin.y + pad * 0.5f + headerH;
float contentBottom = cardMax.y - pad;
float blockGap = std::max(Layout::spacingMd(),
(contentBottom - contentTop - heroLineH - hashLineH) / 3.0f);
float labelY = contentTop + blockGap;
dl->AddText(capFont, capFont->LegacySize,
ImVec2(cardMin.x + pad, labelY), OnSurfaceMedium(), TR("explorer_block_height"));
@@ -640,7 +649,7 @@ static void renderChainStats(App* app, float availWidth) {
ImVec2(cardMin.x + pad + barW * progress, barY + barH), WithAlpha(Warning(), 180), barH * 0.5f);
}
float hashLabelY = cardMax.y - pad - hashLineH;
float hashLabelY = labelY + heroLineH + blockGap;
dl->AddText(capFont, capFont->LegacySize,
ImVec2(cardMin.x + pad, hashLabelY), OnSurfaceMedium(), TR("peers_best_block"));
@@ -755,8 +764,8 @@ static void renderRecentBlocks(App* app, float availWidth) {
ImFont* body2 = Type().body2();
ImFont* sub1 = Type().subtitle1();
float baseRowH = S.drawElement("tabs.explorer", "row-height").size;
float rowRound = S.drawElement("tabs.explorer", "row-rounding").size;
float baseRowH = S.drawElement("tabs.explorer", "row-height").size * dp;
float rowRound = S.drawElement("tabs.explorer", "row-rounding").size * dp;
float headerH = ovFont->LegacySize + Layout::spacingSm() + pad * 0.5f;
// Stretch card to fill the remaining tab height; rows scroll inside.
@@ -989,7 +998,7 @@ static void renderRecentBlocks(App* app, float availWidth) {
ImGui::EndChild();
float fadeZone = S.drawElement("tabs.explorer", "scroll-fade-zone").size;
float fadeZone = S.drawElement("tabs.explorer", "scroll-fade-zone").size * dp;
ApplyScrollEdgeMask(dl, parentVtx, childDL, childVtx,
rowAreaTop, rowAreaTop + rowAreaH, fadeZone, scrollY, scrollMaxY);
@@ -1116,8 +1125,9 @@ static void renderBlockDetailModal(App* app) {
// ── Info grid ──
ImDrawList* dl = ImGui::GetWindowDrawList();
float dp = Layout::dpiScale();
float rowH = capFont->LegacySize + Layout::spacingXs() + sub1->LegacySize;
float labelW = S.drawElement("tabs.explorer", "label-column").size;
float labelW = S.drawElement("tabs.explorer", "label-column").size * dp;
{
ImVec2 gridPos = ImGui::GetCursorScreenPos();
float gx = gridPos.x;
@@ -1178,7 +1188,7 @@ static void renderBlockDetailModal(App* app) {
ImGui::Spacing();
float txRowH = S.drawElement("tabs.explorer", "tx-row-height").size;
float txRowH = S.drawElement("tabs.explorer", "tx-row-height").size * dp;
ImU32 linkCol = schema::UI().resolveColor("var(--secondary-light)");
for (int i = 0; i < (int)s_detail_txids.size(); i++) {
@@ -1206,7 +1216,7 @@ static void renderBlockDetailModal(App* app) {
txDL->AddRectFilled(rowStart,
ImVec2(rowStart.x + txContentW, rowStart.y + txRowH),
WithAlpha(OnSurface(), 10),
S.drawElement("tabs.explorer", "row-rounding").size);
S.drawElement("tabs.explorer", "row-rounding").size * dp);
ImGui::SetMouseCursor(ImGuiMouseCursor_Hand);
material::Tooltip("%s", txid.c_str());
}
@@ -1305,7 +1315,7 @@ static void renderBlockDetailModal(App* app) {
}
if (s_detail_txids.size() > 100) {
snprintf(buf, sizeof(buf), "... showing first 100 of %d", (int)s_detail_txids.size());
snprintf(buf, sizeof(buf), TR("grpa_showing_first_100_of"), (int)s_detail_txids.size());
ImGui::TextDisabled("%s", buf);
}
}

View File

@@ -211,12 +211,11 @@ void ExportAllKeysDialog::render(App* app)
std::string filepath = configDir + "/" + filename;
bool writeOk = false;
if (exported > 0) {
std::ofstream file(filepath);
if (file.is_open()) {
file << keys;
file.close();
writeOk = true;
}
// Write the plaintext private keys 0600 + atomically (never
// world/group-readable, never a half-written file) — the same restricted
// atomic-write idiom the PIN vault uses. A default ofstream would create
// the key dump with umask-derived (often 0644) permissions.
writeOk = util::Platform::writeFileAtomically(filepath, keys, /*restrictPermissions=*/true);
}
if (!keys.empty()) sodium_memzero(&keys[0], keys.size()); // don't leave every key in freed heap

View File

@@ -36,21 +36,30 @@ static bool s_exporting = false;
// Helper to escape CSV field
static std::string escapeCSV(const std::string& field)
{
if (field.find(',') != std::string::npos ||
field.find('"') != std::string::npos ||
field.find('\n') != std::string::npos) {
// Neutralize spreadsheet formula injection: a field beginning with '=', '+', '-', '@',
// tab, or CR is interpreted as a formula by Excel/LibreOffice, letting an attacker-supplied
// memo/address execute on open. Prefix such fields with a single quote so they render as text.
std::string safe = field;
if (!safe.empty()) {
const char c0 = safe.front();
if (c0 == '=' || c0 == '+' || c0 == '-' || c0 == '@' || c0 == '\t' || c0 == '\r')
safe.insert(safe.begin(), '\'');
}
if (safe.find(',') != std::string::npos ||
safe.find('"') != std::string::npos ||
safe.find('\n') != std::string::npos) {
// Escape quotes and wrap in quotes
std::string escaped;
escaped.reserve(field.size() + 4);
escaped.reserve(safe.size() + 4);
escaped += '"';
for (char c : field) {
for (char c : safe) {
if (c == '"') escaped += "\"\"";
else escaped += c;
}
escaped += '"';
return escaped;
}
return field;
return safe;
}
void ExportTransactionsDialog::show()

View File

@@ -0,0 +1,135 @@
// DragonX Wallet - ImGui Edition
// Copyright 2024-2026 The Hush Developers
// Released under the GPLv3
#include "faq_content.h"
namespace dragonx {
namespace ui {
namespace faq {
// ── Wallet group ───────────────────────────────────────────────────────────
// Variant-aware: gs_1 (what the app IS) and sec_1 (where encryption lives) differ between the
// full-node and Lite builds; migrate-to-seed (seed_2) is full-node only; and the Lite build gets a
// trailing "Lite Wallet" subcategory that stands in for the (hidden) Daemon group. The remaining
// answers are worded neutrally so one string serves both variants.
static std::vector<FaqSubcategory> buildWalletFaq(bool fullNode)
{
std::vector<FaqSubcategory> w;
w.push_back({ "faq_w_gs_title", {
{ fullNode ? "faq_w_gs_1_q" : "faq_l_gs_1_q", fullNode ? "faq_w_gs_1_a" : "faq_l_gs_1_a" },
{ "faq_w_gs_2_q", "faq_w_gs_2_a" },
{ "faq_w_gs_3_q", "faq_w_gs_3_a" },
{ "faq_w_gs_4_q", "faq_w_gs_4_a" },
}});
w.push_back({ "faq_w_addr_title", {
{ "faq_w_addr_1_q", "faq_w_addr_1_a" },
{ "faq_w_addr_2_q", "faq_w_addr_2_a" },
{ "faq_w_addr_3_q", "faq_w_addr_3_a" },
{ "faq_w_addr_4_q", "faq_w_addr_4_a" },
}});
w.push_back({ "faq_w_send_title", {
{ "faq_w_send_1_q", "faq_w_send_1_a" },
{ "faq_w_send_2_q", "faq_w_send_2_a" },
{ "faq_w_send_3_q", "faq_w_send_3_a" },
{ "faq_w_send_4_q", "faq_w_send_4_a" },
}});
w.push_back({ "faq_w_bal_title", {
{ "faq_w_bal_1_q", "faq_w_bal_1_a" },
{ "faq_w_bal_2_q", "faq_w_bal_2_a" },
{ "faq_w_bal_3_q", "faq_w_bal_3_a" },
}});
w.push_back({ "faq_w_sec_title", {
{ "faq_w_sec_1_q", fullNode ? "faq_w_sec_1_a" : "faq_l_sec_1_a" },
{ "faq_w_sec_2_q", "faq_w_sec_2_a" },
{ "faq_w_sec_3_q", "faq_w_sec_3_a" },
}});
{
std::vector<FaqEntry> seed = { { "faq_w_seed_1_q", "faq_w_seed_1_a" } };
if (fullNode) seed.push_back({ "faq_w_seed_2_q", "faq_w_seed_2_a" }); // migrate-to-seed is full-node only
seed.push_back({ "faq_w_seed_3_q", "faq_w_seed_3_a" });
seed.push_back({ "faq_w_seed_4_q", "faq_w_seed_4_a" });
w.push_back({ "faq_w_seed_title", std::move(seed) });
}
w.push_back({ "faq_w_chat_title", {
{ "faq_w_chat_1_q", "faq_w_chat_1_a" },
{ "faq_w_chat_2_q", "faq_w_chat_2_a" },
{ "faq_w_chat_3_q", "faq_w_chat_3_a" },
{ "faq_w_chat_4_q", "faq_w_chat_4_a" },
}});
w.push_back({ "faq_w_set_title", {
{ "faq_w_set_1_q", "faq_w_set_1_a" },
{ "faq_w_set_2_q", "faq_w_set_2_a" },
{ "faq_w_set_3_q", "faq_w_set_3_a" },
{ "faq_w_set_4_q", "faq_w_set_4_a" },
}});
// Lite-only: explain the server model (stands in for the hidden Daemon group).
if (!fullNode) {
w.push_back({ "faq_l_lite_title", {
{ "faq_l_lite_1_q", "faq_l_lite_1_a" },
{ "faq_l_lite_2_q", "faq_l_lite_2_a" },
{ "faq_l_lite_3_q", "faq_l_lite_3_a" },
}});
}
return w;
}
const std::vector<FaqSubcategory>& walletFaq(bool fullNode)
{
static const std::vector<FaqSubcategory> kFull = buildWalletFaq(true);
static const std::vector<FaqSubcategory> kLite = buildWalletFaq(false);
return fullNode ? kFull : kLite;
}
// ── Daemon group (full-node only) ──────────────────────────────────────────
const std::vector<FaqSubcategory>& daemonFaq()
{
static const std::vector<FaqSubcategory> kDaemon = {
{ "faq_d_node_title", {
{ "faq_d_node_1_q", "faq_d_node_1_a" },
{ "faq_d_node_2_q", "faq_d_node_2_a" },
{ "faq_d_node_3_q", "faq_d_node_3_a" },
}},
{ "faq_d_sync_title", {
{ "faq_d_sync_1_q", "faq_d_sync_1_a" },
{ "faq_d_sync_2_q", "faq_d_sync_2_a" },
{ "faq_d_sync_3_q", "faq_d_sync_3_a" },
{ "faq_d_sync_4_q", "faq_d_sync_4_a" },
}},
{ "faq_d_mgmt_title", {
{ "faq_d_mgmt_1_q", "faq_d_mgmt_1_a" },
{ "faq_d_mgmt_2_q", "faq_d_mgmt_2_a" },
{ "faq_d_mgmt_3_q", "faq_d_mgmt_3_a" },
}},
{ "faq_d_upd_title", {
{ "faq_d_upd_1_q", "faq_d_upd_1_a" },
{ "faq_d_upd_2_q", "faq_d_upd_2_a" },
{ "faq_d_upd_3_q", "faq_d_upd_3_a" },
}},
{ "faq_d_mine_title", {
{ "faq_d_mine_1_q", "faq_d_mine_1_a" },
{ "faq_d_mine_2_q", "faq_d_mine_2_a" },
{ "faq_d_mine_3_q", "faq_d_mine_3_a" },
{ "faq_d_mine_4_q", "faq_d_mine_4_a" },
}},
{ "faq_d_net_title", {
{ "faq_d_net_1_q", "faq_d_net_1_a" },
{ "faq_d_net_2_q", "faq_d_net_2_a" },
}},
{ "faq_d_perf_title", {
{ "faq_d_perf_1_q", "faq_d_perf_1_a" },
{ "faq_d_perf_2_q", "faq_d_perf_2_a" },
}},
{ "faq_d_trbl_title", {
{ "faq_d_trbl_1_q", "faq_d_trbl_1_a" },
{ "faq_d_trbl_2_q", "faq_d_trbl_2_a" },
{ "faq_d_trbl_3_q", "faq_d_trbl_3_a" },
{ "faq_d_trbl_4_q", "faq_d_trbl_4_a" },
}},
};
return kDaemon;
}
} // namespace faq
} // namespace ui
} // namespace dragonx

View File

@@ -0,0 +1,44 @@
// DragonX Wallet - ImGui Edition
// Copyright 2024-2026 The Hush Developers
// Released under the GPLv3
//
// FAQ content model. The FAQ screen is data-driven: this header exposes the two
// top-level groups (Wallet, Daemon) as ordered lists of subcategories, each a list
// of question/answer entries. Every string is an i18n KEY (looked up with TR at
// render time), so wording + translations live in src/util/i18n.cpp + res/lang/*.json
// and never require touching UI code. Add a Q&A by appending a {qKey,aKey} pair here
// and its two strings to loadBuiltinEnglish().
#pragma once
#include <vector>
namespace dragonx {
namespace ui {
namespace faq {
// One question and its answer, both i18n keys. The answer may contain "\n\n"
// paragraph breaks; it is rendered wrapped. Keep answers free of printf specifiers
// (%d/%s/…) — the i18n layer rejects translations whose format signature drifts.
struct FaqEntry {
const char* questionKey;
const char* answerKey;
};
// A named group of questions. titleKey is an i18n key for the subcategory header.
struct FaqSubcategory {
const char* titleKey;
std::vector<FaqEntry> entries;
};
// The two top-level groups. daemonFaq() is full-node material and is only shown when
// the build supports full-node lifecycle actions (see App::supportsFullNodeLifecycleActions()).
// walletFaq() is variant-aware: pass fullNode=false for the Lite variant, which swaps in
// lite-appropriate answers (no local node / daemon), drops full-node-only entries
// (e.g. migrate-to-seed), and appends a "Lite Wallet" subcategory explaining the server model.
const std::vector<FaqSubcategory>& walletFaq(bool fullNode);
const std::vector<FaqSubcategory>& daemonFaq();
} // namespace faq
} // namespace ui
} // namespace dragonx

View File

@@ -0,0 +1,203 @@
// DragonX Wallet - ImGui Edition
// Copyright 2024-2026 The Hush Developers
// Released under the GPLv3
#include "faq_dialog.h"
#include "faq_content.h"
#include "../../app.h"
#include "../../util/i18n.h"
#include "../../util/text_format.h"
#include "../../embedded/IconsMaterialDesign.h"
#include "../schema/ui_schema.h"
#include "../layout.h"
#include "../material/type.h"
#include "../material/colors.h"
#include "../material/draw_helpers.h"
#include "imgui.h"
#include <algorithm>
#include <string>
#include <unordered_map>
#include <vector>
namespace dragonx {
namespace ui {
namespace {
// Persists across frames (the dialog is re-entered each frame while open). Group 0 = Wallet,
// 1 = Daemon. `expanded` keys are FaqEntry::questionKey (stable string literals from faq_content).
struct FaqDialogState {
int group = 0;
char search[128] = "";
std::unordered_map<std::string, bool> expanded;
};
FaqDialogState s_faq;
bool entryMatches(const faq::FaqEntry& e, const char* query)
{
return util::containsIgnoreCase(TR(e.questionKey), query) ||
util::containsIgnoreCase(TR(e.answerKey), query);
}
// One answer body: wrapped, muted. Shared by the collapsible (non-search) and search paths.
void renderAnswer(const char* answerKey)
{
ImGui::Indent(Layout::spacingMd());
ImGui::PushFont(material::Type().body2());
ImGui::PushStyleColor(ImGuiCol_Text, ImGui::ColorConvertU32ToFloat4(material::OnSurfaceMedium()));
ImGui::TextWrapped("%s", TR(answerKey));
ImGui::PopStyleColor();
ImGui::PopFont();
ImGui::Unindent(Layout::spacingMd());
ImGui::Dummy(ImVec2(0, Layout::spacingSm()));
}
} // namespace
void RenderFaqDialog(App* app, bool* p_open)
{
auto& S = schema::UI();
auto win = S.window("dialogs.faq");
const float dp = Layout::dpiScale();
const bool daemonAvailable = app && app->supportsFullNodeLifecycleActions();
if (!daemonAvailable) s_faq.group = 0; // no Daemon tab in lite builds
// Floating "BlurFloat" modal, matching the Wallets dialog: live-blur backdrop, no boxed card, a
// plain heading (no ✕ — a Close button sits at the bottom). Roomy fixed width; height capped to the
// viewport so the content flexes + scrolls on small / HiDPI screens.
const float vpH = ImGui::GetMainViewport()->Size.y;
const float wantH = (win.height > 0 ? win.height : 640.0f) * dp;
material::OverlayDialogSpec spec;
spec.title = TR("faq_title");
spec.p_open = p_open;
spec.style = material::OverlayStyle::BlurFloat;
spec.cardWidth = (win.width > 0 ? win.width : 860.0f);
spec.cardHeight = std::min(wantH, vpH * 0.86f) / dp;
spec.idSuffix = "faq";
if (!material::BeginOverlayDialog(spec)) {
return;
}
// Esc closes (ImGui consumes Esc itself while the search box is being edited, so this only
// fires when the field isn't capturing it).
if (ImGui::IsKeyPressed(ImGuiKey_Escape)) *p_open = false;
// Subtitle under the plain heading, matching the Wallets dialog's intro caption.
material::Type().textColored(material::TypeStyle::Caption, material::OnSurfaceMedium(),
TR(daemonAvailable ? "faq_intro" : "faq_intro_lite"));
ImGui::Dummy(ImVec2(0, Layout::spacingSm()));
const float contentW = ImGui::GetContentRegionAvail().x;
// ── Group tabs: Wallet | Daemon — only when there's a choice. Lite has just the Wallet group, so a
// lone "Wallet" selector is redundant; skip it entirely (the group is already pinned to 0 above).
if (daemonAvailable) {
const float gap = ImGui::GetStyle().ItemSpacing.x;
const float tabW = (contentW - gap) / 2.0f;
auto tab = [&](const char* label, int idx) {
const bool active = (s_faq.group == idx);
if (active) {
ImGui::PushStyleColor(ImGuiCol_Button, ImGui::ColorConvertU32ToFloat4(material::Primary()));
ImGui::PushStyleColor(ImGuiCol_Text, ImGui::ColorConvertU32ToFloat4(material::OnPrimary()));
}
if (material::TactileButton(label, ImVec2(tabW, 0))) s_faq.group = idx;
if (active) ImGui::PopStyleColor(2);
};
tab(TR("faq_group_wallet"), 0);
ImGui::SameLine();
tab(TR("faq_group_daemon"), 1);
}
ImGui::Dummy(ImVec2(0, Layout::spacingXs()));
// ── Search ──
ImGui::SetNextItemWidth(contentW);
ImGui::InputTextWithHint("##FaqSearch", TR("faq_search_hint"), s_faq.search, sizeof(s_faq.search));
const bool searching = s_faq.search[0] != '\0';
ImGui::Dummy(ImVec2(0, Layout::spacingSm()));
// ── Scrollable Q&A body ── (reserve room for the Close button footer below)
const float footerH = ImGui::GetFrameHeightWithSpacing() + Layout::spacingMd();
float bodyH = ImGui::GetContentRegionAvail().y - footerH;
if (bodyH < 80.0f * dp) bodyH = 80.0f * dp;
// Inner padding gives the content breathing room and, on the right, a clear gap to the LEFT of the
// scrollbar (WindowPadding.x is exactly that gap); the scrollbar itself is made chunkier than the
// app default. NoScrollWithMouse + ApplySmoothScroll gives the wheel eased scrolling, matching the
// Wallets dialog / Settings page (ApplySmoothScroll owns the wheel input and lerps ScrollY).
ImGui::PushStyleVar(ImGuiStyleVar_WindowPadding, ImVec2(Layout::spacingMd(), Layout::spacingXs()));
ImGui::PushStyleVar(ImGuiStyleVar_ScrollbarSize, 16.0f * dp);
ImGui::BeginChild("##FaqScroll", ImVec2(0, bodyH), false,
ImGuiWindowFlags_NoMove | ImGuiWindowFlags_NoScrollWithMouse);
material::ApplySmoothScroll();
ImDrawList* dl = ImGui::GetWindowDrawList();
const auto& groups = (s_faq.group == 1 && daemonAvailable) ? faq::daemonFaq() : faq::walletFaq(daemonAvailable);
bool anyShown = false;
bool firstSection = true;
for (const auto& subcat : groups) {
// Collect the entries visible under the current search.
std::vector<const faq::FaqEntry*> visible;
for (const auto& e : subcat.entries) {
if (!searching || entryMatches(e, s_faq.search)) visible.push_back(&e);
}
if (visible.empty()) continue;
// Section break: generous space above every section after the first, so topic groups read as
// clearly separated bands rather than one uniform list.
if (!firstSection) ImGui::Dummy(ImVec2(0, Layout::spacingLg()));
firstSection = false;
anyShown = true;
// Section header — accent overline + a thin full-width rule beneath it, anchoring the group
// above its (brighter, normal-case) question rows.
material::Type().textColored(material::TypeStyle::Overline,
material::Primary(), TR(subcat.titleKey));
{
const ImVec2 rp = ImGui::GetCursorScreenPos();
const float rw = ImGui::GetContentRegionAvail().x;
dl->AddLine(ImVec2(rp.x, rp.y + dp), ImVec2(rp.x + rw, rp.y + dp),
material::Divider(), 1.0f * dp);
}
ImGui::Dummy(ImVec2(0, Layout::spacingSm()));
for (const auto* e : visible) {
const float rowW = ImGui::GetContentRegionAvail().x;
if (searching) {
// Search results: show question + answer directly (no collapsing).
ImGui::PushFont(material::Type().subtitle2());
ImGui::TextWrapped("%s", TR(e->questionKey));
ImGui::PopFont();
renderAnswer(e->answerKey);
} else {
bool& exp = s_faq.expanded[e->questionKey];
std::string id = std::string("##faq_") + e->questionKey;
material::CollapsibleHeader(dl, id.c_str(), TR(e->questionKey), exp, rowW,
material::Type().subtitle2(), material::OnSurface());
if (exp) renderAnswer(e->answerKey);
}
}
}
if (!anyShown) {
ImGui::Dummy(ImVec2(0, Layout::spacingLg()));
ImGui::PushStyleColor(ImGuiCol_Text, ImGui::ColorConvertU32ToFloat4(material::OnSurfaceMedium()));
ImGui::TextWrapped("%s", TR("faq_no_results"));
ImGui::PopStyleColor();
}
ImGui::EndChild();
ImGui::PopStyleVar(2); // ScrollbarSize, WindowPadding
// Close button footer (BlurFloat has no ✕ in the heading).
const float closeW = 120.0f * dp;
material::BeginOverlayDialogFooter(closeW, false);
if (material::TactileButton(TR("close"), ImVec2(closeW, 0))) *p_open = false;
material::EndOverlayDialog();
}
} // namespace ui
} // namespace dragonx

Some files were not shown because too many files have changed in this diff Show More