Commit Graph

167 Commits

Author SHA1 Message Date
9389859ee9 fix(ui): stop overlay dialogs flashing open-then-closed (BeginOverlayDialog)
BeginOverlayDialog dismisses on a click outside the card via IsMouseClicked (mouse-down). When
the dialog is opened by a button that fires on the same frame (e.g. the mining tab's
"Update miner…" button), that opening click is still registered as an outside-click, so the
dialog opens and instantly closes — it just "flashes". Skip the outside-click dismissal on the
frame the scrim window first appears (ImGui::IsWindowAppearing()); normal outside-click closing
is unaffected on every subsequent frame. Fixes all overlay dialogs, not just the xmrig updater.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-10 17:27:18 -05:00
e63aba6959 fix(build): embed xmrig in the Windows exe (extract it from the published zip)
The wallet is meant to ship xmrig embedded (HAS_EMBEDDED_XMRIG -> getXmrigPath() extracts it
at runtime), but build.sh only embedded a flat prebuilt-binaries/xmrig-hac/xmrig.exe — while
the published DRG-XMRig archive ships the binary inside a versioned subdir
(drg-xmrig-6.25.3-win-x64/xmrig.exe). So xmrig.exe was never present, HAS_EMBEDDED_XMRIG stayed
undefined, and the Windows wallet ran with no miner: "xmrig binary not found", pool mining and
the thread benchmark both fail.

build.sh now extracts xmrig.exe (flattened) from the matching win-x64 zip when a raw binary
isn't already staged, so the existing embed step fires. (Checks the extracted file rather than
unzip's exit code, which is non-zero when a glob matches nothing.) Verified: --win-release now
logs "Extracted xmrig.exe", stages it (6.7M), and defines HAS_EMBEDDED_XMRIG=1.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-10 17:23:53 -05:00
fa240e7b99 refactor(mining): extract the Controls/CPU-grid card into mining_controls.{h,cpp} (audit #10, slice 3)
Third and largest slice of decomposing mining_tab.cpp. The ~843-line "Controls" card (CPU-core
grid + drag-to-select, mining start/stop button, benchmark + miner-update controls) is moved
verbatim into RenderMiningControls(). mining_tab.cpp is now 839 lines (was 2628 originally).

The most coupled section, so mutated state is passed BY REFERENCE — the benchmark
(ThreadBenchmark&), selected thread count (int&), and drag state (bool&/int&) — with local
reference aliases so the body stays byte-identical and interactions (drag, benchmark, start/stop)
behave exactly as before. Read-only context is passed by value/const; the compiler verified
const-correctness. Local statics inside the block moved with it.

Verified: full-node + Windows + lite build, tests, hygiene, no startup crash.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-10 16:57:09 -05:00
e21f7bf8aa refactor(mining): extract the Hashrate+Stats card into mining_stats.{h,cpp} (audit #10, slice 2)
Second slice of decomposing mining_tab.cpp. The ~313-line "Hashrate + Stats" card (stat values +
hashrate chart / live-log view) is moved verbatim into RenderMiningStats(); mining_tab.cpp is now
1680 lines (was 1992 after slice 1, 2628 originally). Body byte-identical apart from a s_pool_mode
alias; the chart/log toggle statics (s_show_pool_log/s_show_solo_log) moved with the card, and the
log buffer was already a function-local static. No App dependency in this section.

Verified: full-node + Windows + lite build, tests, hygiene, clean smoke start. Pending hands-on
visual check before the next slice.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-10 16:03:51 -05:00
47f228fa47 refactor(mining): extract the Earnings card into mining_earnings.{h,cpp} (audit #10, slice 1)
First incremental slice of decomposing the 2628-line mining_tab.cpp monolith (one giant
RenderMiningTabContent function). The ~636-line "Earnings" card section is moved verbatim into
RenderMiningEarnings(); mining_tab.cpp is now 1992 lines and calls it with the immediate-mode
layout context as parameters (draw list, fonts, scale/spacing, glass spec, pool-mode flag).

Behavior-preserving by construction: the body is byte-identical (the only additions are a
`const bool s_pool_mode = poolMode` alias and a local scratch `buf` so the moved code keeps its
original identifiers). The earnings-filter static moved with the card it belongs to. The
compiler surfaced every enclosing dependency, which became explicit parameters.

Verified: full-node + Windows + lite build, tests, hygiene, clean smoke start. Pending hands-on
visual check of the Earnings card before extracting the next section.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-10 15:47:34 -05:00
9f82bba260 perf(node): skip the mining-info poll during sync too
The sync throttle (kSyncProfile) covers the core/transactions/addresses/peers timers, but
getmininginfo runs off the separate 1s Fast timer and so still polled ~every 5s during
sync — another cs_main contender slowing block connection. Skip it while syncing unless the
user is on the Mining tab or actively mining (where live stats are wanted). Completes the
"no RPC contention during sync beyond the 10s progress poll" goal.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-10 15:21:44 -05:00
323cb341f1 perf(node): throttle RPC polling during sync so block download isn't slowed
The full-node wallet polled the daemon at the per-tab cadence regardless of sync state.
On the Peers/Network tab that meant getpeerinfo every 5s + core every 5s + a full
transaction scan on every new block — and blocks arrive fast during sync. Each of those
calls takes the daemon's cs_main lock, the same lock block connection needs, so the node
synced noticeably slower than on the lightweight Console tab (core 10s, no peer polling).

Make the refresh cadence sync-aware:
- RefreshScheduler::kSyncProfile {core 10s, transactions/addresses/peers disabled} is applied
  to ALL tabs while state_.sync.syncing, and reverts to the per-tab profile when sync ends.
  applyRefreshPolicy() picks the profile; update() re-applies it on the syncing<->synced
  transition. This suppresses getpeerinfo and the per-block tx scan during sync (that data is
  incomplete mid-sync anyway) — every tab now syncs as fast as Console.
- collectCoreRefreshResult(rpc, includeBalance): skip z_gettotalbalance (wallet lock + cs_main)
  while syncing; only getblockchaininfo runs, which is also what drives sync-progress detection.
  applyCoreRefreshResult already leaves the balance untouched when balanceOk is false.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-10 15:06:05 -05:00
ee6cac41c4 fix(robustness): guard malformed RPC error JSON + send single-flight (audit #7-8)
- rpc_client::callRaw: a daemon error object is no longer assumed to carry a string
  "message" — a malformed error now yields a clean "RPC error: <dump>" instead of throwing
  a json type-exception from .get<std::string>().
- sendTransaction (full-node): add a single-flight guard so a rapid double-click can't issue
  two z_sendmany before the first returns its opid. The lite path already guarded this; the
  send form guards it in the UI, but the controller entry point now does too.

(#9 from the audit was mostly false positives on verification — all popen sites already
null-check and the xmrig download FILE* path has no throwing calls. The payment-URI
checksum idea was dropped: the send flow already checksum-validates the recipient before
broadcasting, and tightening the parser would reject the placeholder addresses the existing
test relies on; added a comment noting this is format-only by design.)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-10 14:05:43 -05:00
094771af81 security: wipe RPC creds, lock down generated conf, auto-clear secret clipboard (audit #4-6)
- rpc_client: wipe the plaintext "user:password" temporary with sodium_memzero after
  base64-encoding it into the auth header (std::string doesn't zero its buffer on
  destruction).
- connection: the auto-generated DRAGONX.conf holds rpcuser/rpcpassword in plaintext but
  was written with the default umask (often world-readable 0644). Restrict it to owner
  read/write after creation so another local user can't read the credentials.
- app: copying a seed phrase / private key to the clipboard now arms an auto-clear —
  App::copySecretToClipboard() copies the secret and, after 45s, wipes the clipboard IF it
  still holds that secret (compared via a stored hash, never the plaintext). Wired into the
  lite first-run wizard's seed Copy and the Settings export-secret Copy, with a
  "clipboard auto-clears in 45s" notice. pumpSecretClipboardClear() runs each frame.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-10 14:00:45 -05:00
e40962cdf2 perf(ui): dedupe time-ago + allocation-free case-insensitive filter (audit #1-3)
Per-frame hot paths in the immediate-mode UI were allocating needlessly:

- Address filtering in the balance tab rebuilt a std::string filter per address AND
  containsIgnoreCase() lower-cased two fresh copies per call — ~6×N allocations/frame
  on large wallets. New util::containsIgnoreCase(string_view, string_view) is
  allocation-free, and the filter is now built once outside the loop.
- Four duplicated "time ago" implementations (balance_tab_helpers, balance_recent_tx,
  send_tab, transactions_tab) are consolidated into util::formatTimeAgo (localized long
  form) + util::formatTimeAgoShort (compact "5s ago"), preserving each call site's exact
  display style. Both use snprintf, no per-row string concatenation.
- The send-tab address-suggestion scan (a walk over the whole tx list) is memoized on the
  typed text + tx count, so it no longer recomputes every frame while the user pauses.

New src/util/text_format.{h,cpp}; the two existing containsIgnoreCase/timeAgo definitions
now delegate to it. Added to both the app and test targets (test target also gains i18n.cpp,
which text_format's localized path needs).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-10 13:54:27 -05:00
63b3a04716 fix(history): let the shielded scan complete + unstick send-progress on many-z-addr wallets
Two issues shared one root cause: the shielded-receive scan marked each z-address "scanned
at the EXACT current tip," but a new block (~36s on DRGX) advances the tip and invalidates
every prior per-address scan. A wallet with more z-addresses than one refresh cycle can
scan therefore never reached "all scanned at tip" — so shieldedScanComplete stayed false
and transactions_dirty_ stayed true forever, which (a) kept the history-refresh banner lit
and the full rescan churning every cycle, and (b) blocked maybeFinishTransactionSendProgress
(it waited on transactions_dirty_), leaving the send-progress indicator stuck on.

Fix 1 — completion tolerance. Add TransactionRefreshSnapshot::shieldedScanTipTolerance: an
address counts as fresh if its last scan is within N blocks of the tip (0 = old strict
behavior, so existing tests are unchanged). The app scales N with the z-address count
(2 + count/96, capped at 50), so a multi-block pass can COMPLETE before its earliest scan
goes stale. This also throttles full rescans to ~N blocks instead of every block —
transactions_dirty_ clears, the banner stops, and CPU/RPC churn drops. Already-fresh
addresses are skipped, so the per-block cost falls back to just the (cheap) transparent
listtransactions.

Fix 2 — send-progress gate. maybeFinishTransactionSendProgress() no longer waits on the
transaction history scan (transactions_dirty_ / Transactions job): the sent tx is already
shown via the optimistic pending insert, and the spend is reflected once the balance
refresh lands, so it now finishes on the address/balance signal alone.

Test: a tolerant snapshot skips recently-scanned addresses (shieldedAddressesScanned == 0,
shieldedScanComplete) while a strict one re-scans them.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-09 22:55:30 -05:00
cf77c6cbe0 fix(history): stop the "refreshing wallet history" banner from never clearing
The banner was driven by transactions_dirty_, which refreshTransactionData() sets to
!shieldedScanComplete. The shielded-receive scan marks each z-address "scanned at tip,"
but every new block (~36s on DRGX) advances the tip and invalidates all prior per-address
scans, so for a wallet with more z-addresses than the per-cycle budget (8 on History) the
scan can never catch the tip — shieldedScanComplete stays false, transactions_dirty_ stays
true, and the banner stayed lit indefinitely.

Decouple the user-facing banner from that perpetual background scan:
- A just-sent transaction being enriched still surfaces (the user is waiting on it).
- Once any history is displayed, stay quiet for routine background refreshes — new receives
  still appear live as they're scanned.
- The loading banner now only shows during the genuine INITIAL load (nothing displayed yet)
  and send enrichment.

This is a UI-visibility fix; the underlying per-block full shielded rescan (and the related
send-progress flag that maybeFinishTransactionSendProgress gates on transactions_dirty_) are
separate follow-ups.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-09 22:41:03 -05:00
560f2bcf91 perf(history): memoize the transaction display list instead of rebuilding it every frame
The History tab rebuilt its entire display list on every render frame: indexing all
transactions by txid, merging autoshield send+receive pairs into "shield" rows, and
std::sort-ing the result — O(N log N) plus several heap allocations at ~60fps, only to
show one 50-row page. The data is already sorted newest-first by the refresh service,
so the per-frame sort was redundant on top.

Memoize the merged+sorted list, rebuilding only when the underlying transactions
actually change. The cache key is a cheap, allocation-free FNV-1a fingerprint over the
display-relevant fields (count, last update time, and each tx's confirmations /
timestamp / type+address first char) — a new block bumps every confirmation so the key
changes and we rebuild; otherwise (the common read/scroll case) the cache is reused.
Filtering, search, and pagination still run per-frame over the cached list (cheap linear
scans that depend on interactive state).

Also document that App::shouldRefreshTransactions() is block-height/dirty driven (not
interval-gated) — the Transactions timer only paces the check; the recent-poll handles
between-block mempool/unconfirmed deltas.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-09 22:24:58 -05:00
255d9399fa fix(build): stop disabling the embedded daemon on full-node builds (1.3.0 regression)
The 1.3.0 lite-capability work gated isUsingEmbeddedDaemon() on the compile flag
DRAGONX_ENABLE_EMBEDDED_DAEMON (in 1.2.0 it was hardcoded true, so the daemon
always launched). The lite branch in CMakeLists set that flag OFF with
`CACHE BOOL ... FORCE`, which POISONS the build dir's cache: a later full-node
reconfigure of the same dir keeps the forced-OFF value (the full-node branch
never re-asserts it), so embeddedDaemonAvailable=false and the wallet extracts
dragonxd but never starts it — exactly the reported "unpacks dragonxd.exe but
does not start the daemon, manual start works."

Note the two gates are independent: the binary is EMBEDDED/extracted via build.sh
(HAS_EMBEDDED_DAEMON), while LAUNCHING is gated by DRAGONX_ENABLE_EMBEDDED_DAEMON
— so they diverged (extract yes, launch no).

The forced cache write was also pointless: makeWalletCapabilities() already
forces the embedded-daemon capability off for any lite build via
`fullNodeBuild && embeddedDaemonCompiled`, so lite never launches a daemon
regardless of the flag.

Fix:
- CMakeLists: remove the FORCE cache poisoning (the root cause).
- build.sh: set DRAGONX_ENABLE_EMBEDDED_DAEMON explicitly per variant (ON for
  full-node, OFF for lite), mirroring the existing DRAGONX_BUILD_LITE handling,
  so an already-poisoned build dir is HEALED on the next build rather than
  silently keeping the stale OFF.

Verified: a poisoned Windows cache (=0) flips to =1 on reconfigure; full-node
builds define =1, lite =0; tests + hygiene green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-09 21:50:34 -05:00
1fb6dc44d9 fix(node): fail the localhost connect probe fast (8s, not 30s)
The connection probe (getinfo) used a 30s request timeout, so when something on
the local RPC port accepts TCP but never answers — a daemon still loading the
block index, or a wedged/foreign occupant — every attempt blocked the full 30s
before the wallet could retry or update its status. That is the "stuck, timing
out every 30s" behaviour users hit.

A healthy local daemon answers getinfo in milliseconds, and a warming one
returns -28 just as fast, so a long hang on localhost only ever means trouble.
Probe localhost with an 8s timeout (remote/TLS keeps the 30s budget). The
per-call override restores the persistent 30s afterwards, so normal RPC calls
that legitimately take longer are unaffected — only the probe fails faster, so
the wallet retries promptly and reflects "initializing" / recovery within
seconds of the daemon becoming ready.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-09 21:25:22 -05:00
2ba8a799ff fix(node): surface why an embedded daemon dies right after spawning
The daemon can spawn successfully (CreateProcess OK) and then exit immediately —
a missing runtime DLL, wrong architecture, corrupt binary, datadir lock, etc.
EmbeddedDaemon's crash monitor already builds a detailed reason for this
(translated Windows exit code, e.g. "STATUS_DLL_NOT_FOUND — required DLL not
found", plus the launch command and a debug.log tail) and stores it in
lastError(), but it runs on a background thread and was never shown. The result
was the exact symptom users reported: the wallet unpacks dragonxd.exe, looks
"stuck connecting", and the node silently dies-and-respawns until it gives up —
with no visible reason (manually starting dragonxd works, so the wallet then
connects to it).

tryConnect now watches the daemon's crash count (on the main thread, where it
already logs daemon state) and surfaces each NEW crash's lastError() once, as a
sticky error notification, with a concise "Couldn't start dragonxd" status. The
counter resets on a successful connect (alongside the daemon's own crash-count
reset), so a later crash re-notifies.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-09 21:14:25 -05:00
41b380449e fix(node): don't get stranded when the daemon can't start on startup
Two failure modes left the wallet stuck on a silent "connecting / Starting
dragonxd…" spinner with no path forward:

1. Stale external-daemon latch. EmbeddedDaemon::start() sets
   external_daemon_detected_ whenever the RPC port was busy at a prior attempt
   and never re-checks it, so tryConnect's no-config branch trusted that latch
   and waited forever for a config the phantom would never write — even after a
   stale/half-dead process freed the port. Now the port is re-evaluated LIVE
   (EmbeddedDaemon::isRpcPortInUse()) each attempt: if it's genuinely busy we
   keep waiting (and, after a bounded ~20s with no config, warn that whatever
   owns the port isn't a usable DragonX node and how to fix it); if it's free we
   fall through and start our own daemon.

2. Silent start failure. When startEmbeddedDaemon() failed (binary not found,
   Sapling params missing, spawn failure) the status stayed on "Starting
   dragonxd…" with the real reason only in a VERBOSE log. Now the reason
   (daemon_controller_->lastError()) is surfaced once as a sticky error
   notification, with a short "Couldn't start dragonxd" status.

Both counters reset on a successful connect so the messages re-arm for the next
disconnect. Lite is unaffected (tryConnect returns early for lite builds).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-09 20:55:41 -05:00
4a65dce947 feat(lite): make the Console tab interactive (run backend commands)
The lite backend's litelib_execute() is the same command interface as
silentdragonxlite-cli (balance, info, height, list, notes, addresses, sync,
syncstatus, new, send, shield, encrypt, …), so the lite Console can be a real
interactive console — like the full-node RPC console — instead of a read-only
diagnostics log.

Controller: add an async arbitrary-command runner mirroring the broadcast
pattern — runConsoleCommand() splits "<command> [args]" (the first token is the
command, the remainder is passed as the single arg string litelib_execute
expects, since it does NOT whitespace-split), runs the bridge call on a detached
thread that captures the shared bridge (never `this`), and delivers the result
to a main-thread slot drained by takeConsoleResult(). Results are NEVER routed
through LiteDiagnostics (seed/export can return secrets).

Console tab: a command input (Enter to run, Up/Down history via the shared
console_input_model helpers) over a unified scroll buffer that interleaves the
automatic diagnostics events with user command I/O, colour-coded, with the live
status header preserved. The input is disabled while a command runs.

Two backend footguns are intercepted at the UI layer before forwarding:
`clear` (the backend command WIPES wallet tx history — re-bound to clearing the
view, what the user expects) and `quit`/`exit` (would only save; the embedded
backend must stay running with the app).

Test: runConsoleCommand drives the fake backend (info -> raw response; "new zs"
-> exercises the command/arg split; blank line rejected).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-09 20:32:03 -05:00
c8183241c3 feat(node): show a live daemon console tail on the initializing overlay
The full-node Console tab already streams the daemon's output, but during
startup the user is held on the loading overlay (wallet-data tabs are blocked),
so they can't watch progress without navigating away. Surface the last few
console lines the node printed (UpdateTip height=…, "Verifying blocks…", etc.)
directly under the status/description on the overlay while initializing or
warming up, so progress is visible where the user is already looking.

Full-node only (guarded on daemon_controller_); each line is trimmed and
ellipsis-truncated to one row. Reuses DaemonController::recentLines().

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-09 19:37:48 -05:00
0bf80d2757 feat(node): show "node initializing" feedback when the daemon isn't answering yet
When the full-node connect probe (getinfo) times out, the daemon is reachable
at the TCP level but busy initializing (loading the block index, verifying,
activating best chain, …) and won't answer RPC. The wallet only recognized the
JSON-RPC -28 warmup reply, so a raw socket timeout fell through to a bare,
alarming "Connection failed" retry with no indication of what the user was
waiting on.

Add a daemon-initializing UI state that drives the existing loading overlay:

  - WalletState::daemon_initializing — daemon up/launching but not serving yet
    (distinct from warming_up, which needs a -28 reply).
  - App::applyDaemonInitStatus() infers the current phase from the daemon's own
    console output (scanning recent lines for Loading/Verifying/Activating/
    Rescanning/Rewinding/Pruning) and the latest block height, producing a
    friendly title + description, e.g. "Processing blocks… (Block 123456)".
  - The connect loop calls it from the daemon-starting and external-detected
    branches: a timeout -> "reachable but initializing", a connect refusal ->
    "launching, waiting to come online". Cleared on a real connect.
  - The loading overlay now shows the description for daemon_initializing too,
    and the status-bar amber indicator covers it (so Peers/Console tabs without
    the overlay still explain the wait).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-09 19:32:35 -05:00
6f9123f651 feat(lite): async + failover for Settings-page create/open/restore
The Settings page drove the controller's synchronous createWallet/openWallet/
restoreWallet, which blocks the UI thread on the (often flaky) lightwalletd and
gives up after the first server. Add a generic async lifecycle path that mirrors
the async-open failover but carries the full request (passphrase, restore seed/
birthday/account/overwrite):

  - beginCreateWalletAsync / beginOpenWalletAsync / beginRestoreWalletAsync run
    on a detached thread that builds its OWN local LiteWalletLifecycleService
    from captured value copies + the shared bridge (never `this`, so it can
    safely outlive the controller). Each request type's serverUrl override field
    feeds the failover: try the preferred server, then every other usable
    default; stop on the first ready wallet or a structural block; keep the
    preferred server's error on total failure. The request's secrets are wiped
    once the attempt finishes.
  - pumpLifecycleResult() finalizes on the main thread (flip walletOpen, persist,
    start sync) and caches the result for the UI; wired into App::update next to
    pumpAsyncOpen(). beginAsyncLifecycle() now also yields to an in-flight
    lifecycle request so the auto-open loop can't race it on the same bridge.
  - settings_page kicks off the async op, disables the button while in flight,
    and polls the cached result each frame for the status/summary.

Tests: testLiteWalletControllerAsyncLifecycleFailover covers async create (with
passphrase) and restore failing over preferred->fallback, plus all-servers-down.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-08 11:42:47 -05:00
320c659689 feat(lite): async wallet creation with server failover
Mirror the async-open path for wallet creation. beginOpenExisting() and
beginCreateWallet() now both delegate to beginAsyncLifecycle(bool create),
which runs the backend init on a detached thread and walks the failover
server list (preferred server first, then all usable defaults), reporting
the preferred server's error on total failure. The first-run wizard's
Create button drives this through a non-blocking "creating" poll state so
the UI no longer freezes while the backend contacts a (possibly flaky)
lightwalletd. The created seed response is securely wiped immediately and
read back via exportSeed for the reveal/verify steps.

Safe because litelib_initialize_new contacts the server before writing any
wallet file and LightClient::new errors if a wallet already exists, so a
failed candidate leaves no partial state.

Tests: fake backend's initialize_new now honors the dead/warmup server
substrings; testLiteWalletControllerOpenFailover gains a create-failover
case (preferred dead, fallback good -> walletOpen).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-08 11:29:59 -05:00
6ff1fda870 feat(lite): harden seed restore + backup UX in Settings
- Restore: live "N / 24 words" count, a one-line birthday explanation, and a guard
  that rejects a restore unless all 24 words are entered (the secret scrubber still
  wipes the input on the early return).
- Backup: "Show seed" now also shows the birthday (needed to restore quickly) with a
  "back this up too" note, a stronger "only way to restore" warning, and a "Save to
  file" button that writes the seed + birthday to an owner-only (0600) file in the
  config dir via the atomic-write helper.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-08 11:12:56 -05:00
8c51b092f8 feat(lite): guided seed backup on wallet creation
Creating a wallet was one-click and silent — it never showed the seed, relying on
the user to later find Settings -> Show seed, which is an easy-to-miss fund-loss
risk. Replace the first-run prompt with a 3-step guided flow mirroring the upstream
SilentDragonXLite wizard:

  1. Welcome (Create / Restore / Later) — unchanged entry.
  2. Reveal: after createWallet, read the seed back via exportSeed and show all 24
     words (numbered grid) + the birthday, with a strong "only way to restore"
     warning, plus Copy. ("Skip" leaves the wallet created, seed backable later.)
  3. Verify: tap the words in order (shuffled chips) to confirm the backup before
     finishing; out-of-order taps are rejected with a hint.

The seed is held only for the wizard and securely wiped (sodium_memzero) on finish.
Builds clean for full-node, lite, and Windows cross-compile.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-08 11:12:56 -05:00
788d71a549 fix(lite): report the preferred server's error on a failed open
The failover overwrote outcome.error on every attempt, so a total open failure
reported whichever (often broken) fallback was tried last — e.g. lite5's
"CertNotValidForName" — instead of what the user's preferred server actually did.
Keep the first (preferred) server's error as the summary so "Open failed: …" names
the actionable reason; the per-server attempts are still in the Console log, and
the warmup flag is still set if any server was warming up.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-07 22:26:06 -05:00
3d4b013b0c fix(lite): fast retry when a server is only warming up (-28)
When the preferred lightwalletd server is reachable but warming up (JSON-RPC -28
/ "Activating best chain"), the failover treated it like a dead server and fell
through to the others, so the wallet didn't open until the next 20s retry — even
though the healthy server was ready within seconds.

Detect the warmup error during failover, flag it on the open outcome
(lastOpenWasWarmup()), and have the App retry on a short ~4s interval in that case
instead of 20s, so the wallet opens promptly once warmup clears. A unit test
covers a warming-preferred + dead-fallback open setting the flag.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-07 21:26:14 -05:00
dc07491abb fix(lite): name the cause when the backend isn't linked
A lite build compiled without the SDXL backend (DRAGONX_ENABLE_LITE_BACKEND off,
i.e. built with --lite instead of --lite-backend) leaves the controller null, so
the wallet never opens and the UI shows a silent "disconnected" state. The Console
status now states the cause and the fix directly ("Lite backend not linked in this
build (rebuild with --lite-backend)") instead of a vague "unavailable".

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-07 20:11:16 -05:00
7e568e4bf1 fix(lite): always-populated Console (live status) + single-instance log
The Console could look empty if the wallet produced few events. Make it useful
in every state and remove a cross-platform footgun:

- Add a live status header read straight from the controller (connected /
  connecting / disconnected, sync %, and the last open error) — independent of the
  diagnostics event log, so the Console always shows the current connection +
  wallet-open state even when the log is sparse.
- Move LiteDiagnostics::instance() into a single .cpp so there is exactly one
  instance across the binary, rather than relying on the linker folding an
  inline-function static across translation units (a known fragility, especially
  on mingw/Windows — the most likely cause of a stuck-empty event log there).

Verified the writer and reader share one instance on Linux; builds clean for
full-node, lite, and Windows cross-compile; tests pass.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-07 19:28:28 -05:00
85a1080b52 feat(lite): Console tab with connection + open/create diagnostics
The lite variant had no visibility into why a wallet failed to open — just a
"disconnected" spinner. Add a lite-only Console tab (full-node keeps its RPC
console) that shows a live diagnostic log.

- LiteDiagnostics: a small thread-safe, bounded ring buffer (header-only). The
  controller writes to it from its background threads: each failover server
  attempt and result, wallet open/create/restore outcomes, sync start, and
  blocked-open reasons. The App logs controller (re)builds with the preferred
  server.
- lite_console_tab: a terminal-styled, read-only view of the log (newest at the
  bottom, error/success lines coloured) with Clear / Copy / Auto-scroll. Reachable
  even when the wallet is locked (it's diagnostics, no secrets). Registered as
  NavPage::LiteConsole, gated lite-only via WalletUiSurface::LiteConsole.

A unit test drives an open-with-failover and asserts the log records the
connection attempt and the successful open. Built clean for full-node, lite, and
Windows cross-compile.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-07 18:46:25 -05:00
dbeae3ac98 feat(lite): async wallet open with server failover
Opening an existing lite wallet ran synchronously on the UI thread and used a
single server, so a dead/unreachable lightwalletd server froze startup for the
connect timeout and then stranded the wallet ("disconnected" spinner) — and the
DragonX lite servers are flaky (often several down at once).

Add LiteWalletController::beginOpenExisting() / pumpAsyncOpen(): the open runs on
a background thread (mirroring the sync/broadcast shared-lifetime pattern — it
captures only shared_ptrs + value copies, never `this`), trying the preferred
server first and then every other usable default until one succeeds. The main
thread finalizes the result (flips walletOpen, starts sync) or records the reason.
The rollout gate is still checked up-front on the main thread.

App: auto-open now calls beginOpenExisting() and pumps it each tick, retrying on
a 20s interval so a transient outage self-heals once a server returns; a failed
open surfaces its reason (notification + Network tab) instead of a silent spinner.

Tested: a fake bridge that fails specific servers exercises both
preferred-dead -> fallback-opens and all-dead -> fails-with-reason. Built clean
for full-node, lite, and Windows cross-compile.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-07 16:53:24 -05:00
9ff5508989 fix(lite): re-open the wallet after a controller rebuild (server-switch recovery)
The wallet auto-open is a one-shot (lite_autoopen_done_), but rebuildLiteWallet()
creates a fresh, closed controller — so switching the lite server from the Network
tab (rebuildLiteWallet force=true), or any later rebuild, left the wallet
permanently closed ("disconnected" spinner) because auto-open never fired again.

Re-arm the one-shot (and clear the surfaced open-error) in rebuildLiteWallet so
the next update() tick reopens the existing wallet against the new server. This is
the recovery path when the configured lightwalletd server is unreachable: the
Network tab surfaces the failure reason, the user picks a reachable server, and
the wallet reopens. Also makes the Network tab's apply-immediately server switch
actually take effect.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-07 16:34:37 -05:00
79e5adcbd3 fix(lite): give the lite variant its own config folder (ObsidianDragonLite)
Both variants hardcoded "ObsidianDragon" as the per-user config folder
(settings.json, themes, the lite_rollout cache), so the lite app and the
full-node app shared one settings.json. That cross-variant pollution can leave
the lite server selection in a bad state — and since openWallet() contacts the
selected lightwalletd server, a wrong/empty server URL there makes an existing
wallet fail to open (a silent "disconnected" spinner).

Use DRAGONX_APP_NAME (already "ObsidianDragon" / "ObsidianDragonLite" per variant)
for the config-dir name in Settings::getDefaultPath, Platform::getConfigDir and
getObsidianDragonDir (and the theme-setup exe-name probe). Full-node is unchanged;
lite now reads/writes %APPDATA%\ObsidianDragonLite (and ~/.config/ObsidianDragonLite),
so it starts from a clean, isolated config and uses default servers.

Note: the lite wallet file itself lives in the litelib backend's own data dir
(unaffected); this isolates the GUI config only.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-07 16:06:12 -05:00
6531d0c4d2 fix(lite): surface auto-open failures instead of a silent disconnected spinner
The startup auto-open of an existing lite wallet discarded openWallet()'s result,
so when initialize_existing failed (e.g. the lightwalletd server is unreachable)
the UI just showed a "disconnected" spinner with no reason — and DEBUG_LOGF is
compiled out of release builds, so there was no way to see why. Capture the
failure: store the reason, show it in the Network tab status line (in place of
"no wallet open"), and raise a notification. Cleared once a wallet opens.

This doesn't change open behaviour — it makes a stuck open diagnosable.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-07 15:39:22 -05:00
142a6826af fix(rpc): abort in-flight curl on disconnect/shutdown to avoid UI freezes
stop()-ing a worker that is mid curl_easy_perform joined on the UI thread, so a
slow/hung transfer froze the UI until the request timeout. Add RPCClient::
requestAbort() (a thread-safe atomic read by a curl progress callback that aborts
the transfer), and call it before stopping the workers on disconnect
(onDisconnected) and shutdown (beginShutdown + the synchronous fallback). The
flag is cleared on each connect() so a fresh connection never starts aborted.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-07 14:43:34 -05:00
070a516f4e fix(send): validate recipient address checksums (Base58Check + Bech32)
The send screen labelled any prefix+length match as a "Valid" address, so a
mistyped address that still matched the pattern passed the gate. Add pure,
offline checksum validation — Base58Check (transparent R-addresses) and Bech32
(Sapling zs-addresses) — and require it in the validity check. Both verifiers are
version-byte/HRP agnostic (the HRP is taken from the string, the Base58 checksum
is chain-independent), so a correct implementation never rejects a genuine
address while catching transcription errors. Works for both build variants
(no daemon round-trip), unit-tested against standard BIP173 / Base58Check vectors.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-07 14:43:34 -05:00
3cec333d84 fix(storage): fsync the vault secure-delete overwrite
removeVault() overwrote vault.dat with zeros then unlinked it, but never flushed
to stable storage, so the zeros could stay in the OS cache and never reach disk.
flush + fsync before unlink on POSIX (still best-effort on CoW/SSD, but now does
what it claims).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-07 14:25:44 -05:00
fc438ab962 fix(rpc): invalidate stale in-flight refreshes on reset/reconnect
resetJobs() cleared the in-progress flags but left generations_ untouched, so a
refresh WorkFn still executing on the worker when a disconnect cleared state_
could pass completeDispatch's generation check and apply last-connection data
onto the new session. Bump every job's generation in resetJobs() so any
pre-reset ticket is treated as stale.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-07 14:25:44 -05:00
8c2c1c2aaf fix(lite): import-key fallback on mis-routed key + clamp shield fee
- importKey routed transparent vs. shielded purely by the first character, which
  can mis-route (e.g. testnet/regtest WIFs). On failure, try the other import
  command before reporting an error (each validates the encoding, so a wrong
  command rejects rather than mis-imports). The key copy is wiped after both tries.
- Clamp the shield dialog's fee input to [0, 1] DRGX, mirroring the UTXO-limit
  clamp, so a negative or fat-fingered huge fee can't be submitted.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-07 14:25:44 -05:00
a605e35409 fix(ui): consistent hashrate units, full-address tooltips, drop dead vars
- Balance card hashrate now uses the shared FormatHashrate() (TH/GH/MH/KH/H)
  instead of a bespoke two-tier KH/s formatter.
- Recent-tx rows show the full untruncated address on hover — two z-addresses can
  truncate to the same first/last window — and the truncate helpers guard maxLen<=3.
- Remove the unused viewTop/viewBot "viewport culling" locals in the tx list
  (pagination already bounds per-frame work).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-07 14:25:43 -05:00
6ed80d2d79 fix(send): result-driven status styling + full-precision USD preview
The transaction-status overlay decided error vs. success styling by searching the
status string for "Error"/"Failed" — so under a non-English locale a failed send
rendered as a green success. Drive it from the existing s_status_success flag
instead. Also show the USD-mode DRGX preview at 8 dp so it matches the confirm
panel and the amount actually sent (was 4 dp).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-07 14:25:43 -05:00
e978db85ca test: cover audit fixes (atomic writes, opid routing, sqlite GC, lite tx)
- testAtomicFileWrite: Platform::writeFileAtomically creates dirs, overwrites,
  leaves no .tmp, and honors owner-only perms.
- failureByOpid assertion in the operation-status poll parser test.
- testTransactionHistoryCachePrunesOldWallets: a save under a new identity prunes
  the prior identity's snapshot.
- testLiteSendShowsRecipientFromOutgoing / testLitePartialRefreshKeepsPriorAddressBalances.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-07 14:18:34 -05:00
e00772db6e fix(i18n): reject format-incompatible translations
Many strings are used directly as printf/ImGui format strings, and translations
are loaded from user/installer-modifiable JSON with no validation. A translated
value that drops or changes a conversion specifier would be passed to printf with
mismatched varargs (undefined behavior) on a wallet screen.

overlayTranslations() now compares each translated value's argument signature
against the English source and keeps English on mismatch. Also adds the
send_status_unconfirmed string used by the deferred-send-result path.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-07 14:18:25 -05:00
8f22db5eea fix(send): resolve source balance by address, not list index
GetAvailableBalance() read state.addresses[s_selected_from_idx], but the index
desyncs from s_from_address (the value actually debited) after an address-list
refresh, and is left at -1 when the source is chosen from another tab's "Send
from this address" — which made the sufficiency check see a 0 balance and wrongly
block a valid send. Look the balance up by matching the source address string.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-07 14:18:16 -05:00
5c883d4b91 fix(lite): faithful tx list, balances, and persistence on partial results
- A Send record carries its recipient in outgoing_metadata, not the top-level
  address/memo, so sent txs showed a blank destination + memo. Surface the first
  recipient (single-recipient case) into the transaction list.
- A tolerated partial refresh where the notes/utxo command failed (addresses
  present, spendable outputs absent) zeroed every per-address balance, which looks
  like fund loss. Preserve the last-known per-address balances in that case.
- Retry the post-send/shield save once on transient failure instead of ignoring
  the result (the backend does not auto-save after send/shield).
- An unparseable broadcast response now uses cautious wording ("status could not
  be confirmed — check Transactions before retrying") rather than implying a hard
  failure, avoiding a blind double-spend retry.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-07 14:18:05 -05:00
274f7ea1af fix(storage): owner-only secret files + bound SQLite cache growth
- Write vault.dat atomically and 0600 (it holds the PIN-encrypted passphrase, so
  a world-readable copy enables an offline brute-force of the short PIN), and
  chmod the tx-history SQLite + its WAL/SHM sidecars to 0600 on open.
- The tx-history snapshot and key-salt rows are keyed on a hash of the full
  address set, which changes whenever a new address is generated — orphaning the
  prior hash's full-history blob and salt forever. pruneOtherWallets() now drops
  rows for every non-live wallet hash on each save, bounding the database.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-07 14:17:54 -05:00
3799330bb0 fix(ui): show real data and consistent values across tabs
- Market chart now plots the real accumulated price_history instead of a
  rand()-generated curve; the hover tooltip no longer claims a specific "Xh ago"
  price and the x-axis only labels the truthful "Now" point. Falls back to the
  existing empty state until there are >=2 real samples.
- Transactions summary cards exclude autoshield legs (same txid send + receive-to-z)
  so a shield isn't double-counted into both Sent and Received, matching the list.
- Send/Receive sync banners use verification_progress like every other surface,
  instead of the blocks/headers ratio that over-reports during early sync.
- Fix printf format/type mismatches: %.0f<-int (market % shielded), %d<-size_t
  (peer counts), %ld<-int64_t (peer byte counters, wrong on Windows).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-07 14:17:42 -05:00
53a10e149d fix(rpc): detect mid-session disconnects and stop blocking the UI thread
The connection state machine never tore down on a lost connection: refresh-loop
RPC errors were swallowed, rpc_->isConnected() stayed true after a daemon
crash/restart/socket drop, and the UI showed stale balances with no reconnect.
Several operations also ran synchronous curl straight from ImGui handlers.

- Add handleLostConnection(): after N consecutive cycles where BOTH core RPCs
  fail (warmup excluded, so no reconnect loop), disconnect so update()'s
  reconnect branch re-enters tryConnect().
- Move banPeer/unbanPeer/clearBans and key export/import onto the worker thread
  (import requests a rescan that could freeze the UI for the curl timeout).
- Run the block-info dialog's two chained RPCs on the worker thread (+ guard the
  getblockhash result type).
- Detect daemon warmup via the JSON-RPC -28 code (new RpcError carrying the code;
  message text preserved so 401/warmup string-matching is unaffected), and widen
  CONNECTTIMEOUT to 10s for remote/TLS hosts (2s localhost).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-07 14:17:17 -05:00
1bc7f5c8cd fix(tx): track async operations to completion (send/shield/auto-shield)
z_sendmany returns an opid immediately; the tx is built/signed/broadcast
asynchronously afterward. The send path showed "Transaction sent successfully!"
and cleared the form on opid receipt, so a later async failure contradicted it.
Shield/merge stored the opid only in a dialog-local static (never polled), and
auto-shield ran a blocking z_shieldcoinbase on the UI thread and discarded its
opid — async failures of all three were silently lost.

- Add App::trackOperation(opid) so shield/merge/auto-shield register with the
  shared opid poller (failures surface, balances refresh on completion).
- Defer the full-node send's success/failure to the poller via per-opid callbacks
  (parseOperationStatusPoll now exposes failureByOpid); the "Sending..." spinner
  covers the finalizing window, and the form is kept until terminal status.
- Dispatch auto-shield through the worker thread and use the configured fee.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-07 14:16:48 -05:00
20b22410e9 fix(persistence): atomic + owner-only settings/address-book writes
settings.json and addressbook.json were written in place with a bare ofstream —
a crash or power loss mid-write truncated the file, and on the next launch the
parse failure silently reset every preference (hidden/favorite addresses, labels,
pool workers, language, theme, lite-server list) because the next save overwrote
the corrupt file with defaults.

Add Platform::writeFileAtomically() (temp file -> fsync -> atomic rename; dir
fsync on POSIX, MoveFileEx on Windows; optional owner-only 0600) and route both
saves through it. On a parse failure, quarantine the unreadable settings file to
settings.json.corrupt-<ts> instead of clobbering it.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-07 14:16:10 -05:00
7195c25376 fix(send): pass the user-selected fee to z_sendmany
The full-node send built the recipients array and called z_sendmany with only
(fromaddress, amounts) — dropping the minconf and fee positional args. The whole
fee-tier UI (Low/Normal/High, send-max math, the confirmation fee) was collected
and shown but never sent, so the daemon silently applied its own default fee and
the Low/High tiers were cosmetic.

Pass {from, recipients, 1, fee}, with the fee formatted fixed-decimal so the
daemon's ParseFixedPoint accepts it (a small double like 0.00005 would otherwise
serialize to "5e-05" and be rejected).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-07 14:15:58 -05:00