Commit Graph
302 Commits
Author SHA1 Message Date
DanSandClaude Opus 4.8 a80be443d1 feat(market): flag a stale price instead of showing it as a fresh green number
The aggregate price is deliberately kept on a failed or old refresh (it's never
cleared), so the hero number could be hours out of date while still drawn in the
"live" green. It now turns amber with an "as of N min/h ago" caption and a hover
tooltip carrying the friendly fetch error, when the last refresh failed or the
aggregate is >=5 min old (only when the hero is the aggregate, not an exchange
pair with its own feed). The hardcoded "Price fetch failed" default is now i18n.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-10-18 09:42:34 -05:00
DanSandClaude Opus 4.8 a792a1ec0c feat(ux): clearer chat delivery, explorer empty/range states, honest settings label
Three next-batch UX-audit fixes:
- Chat delivery: the sending/sent glyph has higher contrast and a tooltip
  ("Sent to the network — arrives after it confirms", so a check isn't mistaken
  for "delivered"); a failed send now carries the real reason (ChatMessage::
  fail_reason, set from onChatBroadcastResult via friendlyRpcError, in-memory
  only) in the "not sent" hover tooltip.
- Explorer: a block height beyond the tip says so ("Block N is beyond the chain
  tip (M)") instead of silently snapping to the top; the empty block list shows
  Not connected / Syncing (N behind) / Loading instead of an endless "Loading".
- Settings: the misleading "Reset to Defaults" button (which only reloads the
  last-saved values) is relabeled "Discard unsaved changes" with accurate copy.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-10-18 09:20:47 -05:00
DanSandClaude Opus 4.8 a11130769d feat(send): show fee + total on the compose form; fix the stuck confirm on recheck
- The network fee and running total are now shown on the compose form (once an
  amount is entered), instead of only being revealed in the confirm popup.
- When the pre-broadcast re-validation fails (balance dropped, sync restarted,
  fee pushed total over available), the confirm popup now closes and surfaces a
  warning toast — previously the reason was drawn on the form behind the still-
  open popup, so the user was left re-clicking a button that did nothing.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-10-18 09:14:44 -05:00
DanSandClaude Opus 4.8 4c6a78b3b0 fix(security): color seed-save by outcome; explain the Encrypt button; friendly key-export errors
- Seed-backup dialog: a failed "Save to file" was drawn in the success (green)
  colour; it's now red on failure, green on success (flag reset on close).
- Encrypt-wallet dialog: the disabled Encrypt button now says why once the user
  starts typing ("Use at least 8 characters." / "The passphrases don't match."),
  and a "Show passphrase" checkbox reveals both fields.
- Key-export dialog: the raw RPC exception (e.g. "...walletpassphrase first")
  is routed through util::friendlyRpcError(), so a locked wallet reads
  "Unlock your wallet first, then try again." instead of daemon jargon.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-10-18 09:09:29 -05:00
DanSandClaude Opus 4.8 7d2972f315 feat(mining): explain solo/pool, add a payout-address create path, surface the miner download
New-user mining cluster from the UX audit:
- Solo/Pool toggle: hover tooltips on each half explaining the trade-off
  (solo = no fees but long waits on a small CPU; pool = frequent small payouts,
  needs the miner + a shielded payout address).
- Payout field: when it's empty (new wallets have no z-address, and a pool
  payout must be shielded) the bookmark button is replaced by a one-click
  "Generate a shielded payout address" button (createNewZAddress → fills the
  field, persists, toasts), instead of only a hover hint to the Receive tab.
- Miner updater pill: when no miner is installed it becomes a prominent accent
  "Download miner" call-to-action with a "pool mining needs DRG-XMRig" tooltip,
  instead of a subtle grey "Update" pill that's easy to miss.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-10-18 09:03:24 -05:00
DanSandClaude Opus 4.8 8eee351faa fix(history): show shielding moves as neutral "Shielded", not red "Sent"
A coinbase/transparent→shielded move (often auto-shield) was drawn with a red
"Sent" label and send arrow yet a green "+X" amount — an internally
contradictory row that reads as "money leaving" to anyone who auto-shielded. It
now renders as a neutral "Shielded" row (shield icon + the shielded accent
colour for label and amount), and since a shield is your own funds going
private — not a spend — it's excluded from the Sent card total and the Sent
filter (it merges into one row visible under "All").

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-10-18 08:57:38 -05:00
DanSandClaude Opus 4.8 09f39f9183 feat(i18n,errors): route node-action toasts through i18n + friendlyRpcError
Finish the error-surfacing foundation across app_network.cpp (+ one in
app_wizard.cpp): ~21 toasts that were hardcoded English — or raw daemon/RPC
error text concatenated after an English prefix — now go through TR() keys, and
every raw-error one maps the detail through util::friendlyRpcError() (warming
up / locked / method-not-found / insufficient / unreachable → plain guidance,
raw text as fallback). Covers mining start/stop, pool start, peer ban/unban/
clear, key export, rescan, wallet-switch guards, and the lite-unavailable
notices.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-10-18 08:55:29 -05:00
DanSandClaude Opus 4.8 9db73323b6 feat(ui): friendly RPC errors + pause-on-hover/ellipsis toasts
Two of the systemic error-surfacing foundations from the UX audit:

- util::friendlyRpcError() maps the common raw daemon/JSON-RPC errors
  (warming up / -28, locked / -13, method-not-found / -32601, insufficient
  funds, unreachable node) to short translated guidance with a next step,
  falling back to the trimmed raw text. Wired into the send (money) path: the
  toast shows the friendly cause, the overlay keeps the raw text below it for
  diagnostics. The helper is reusable for the remaining raw-error sites.
- Toasts now pause their auto-dismiss while hovered and ellipsize over-long
  text with the full message in a hover tooltip, instead of hard-clipping it
  mid-word (durations were already lengthened earlier).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-10-17 16:53:37 -05:00
DanSandClaude Opus 4.8 4a8d07edce feat(balance): show a "catching up — funds are safe" strip while syncing
A syncing node skips the balance RPC, so the balance card paints a bare
0.00000000 / $-.-- that reads as lost funds after an update (a documented
support trap). Add one shared notice at the top of the Balance tab — above
every layout — shown only for the normal catching-up case (connected, network
tip known, still verifying, not a rescan/witness rebuild). Offline, no-peers,
a busy node and rescans stay the node-status banner's job, so the two never
stack.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-10-17 16:48:33 -05:00
DanSandClaude Opus 4.8 643ac7daef feat(seed,console): persistent re-nudging seed backup; confirm destructive console commands
- Seed backup: the one-time 12s toast that marked itself "reminded" the instant
  it fired (so a funded, unbacked wallet was never nudged again) is replaced by
  an actionable alert with a "Back up now" link that opens the backup dialog and
  persists in the bell panel. "Reminded" now means the user actually viewed the
  phrase (set from the backup dialog), so the nudge re-fires each session until
  they do, and escalates to a warning once a balance exists.
- Console: fund-moving / key-revealing commands (dumpprivkey, z_exportkey,
  z_sendmany, z_mergetoaddress, …) now require a confirming re-entry when typed
  or PASTED, honoring the command reference's `destructive` flag that was
  previously enforced only by the reference dialog's Run button. (Output
  masking of key dumps in the scrollback is still a follow-up.)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-10-17 16:46:09 -05:00
DanSandClaude Opus 4.8 e27d88eca4 feat(chat): explain the no-identity state and why a send is stuck
- Chat tab no longer shows "Unlock your wallet" for every missing-identity
  case. A new App::chatIdentityStatus() distinguishes Locked (real),
  Preparing (unlocked, deriving the identity) and Unavailable (view-only or a
  legacy wallet with no exportable secret — correctly latched). Each gets its
  own icon + message, and the Unavailable prompt points at Back up / Migrate
  instead of telling the user to unlock an already-unlocked wallet.
- A chat message that sits queued ("Sending") because the wallet is offline or
  has no shielded funds for the fee now surfaces the reason (throttled, after a
  short dwell), wiring the ready-made chat_toast_not_connected /
  chat_toast_need_funds strings that were never shown. The send stays queued
  and goes out when the condition clears.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-10-17 16:41:30 -05:00
DanSandClaude Opus 4.8 750771ebfb feat(network): give the "no peers" banner an honest detail and a Restart action
The no-peers banner headline ("Connected, but no network peers") was followed
by a detail line that just repeated connection_status ("Connected"/"Peers: 0"),
reading as a contradiction, and it offered no action at the exact moment the
wallet looks synced but is silently stalled at a stale height.

- NoPeers now clears the passthrough detail; App renders a translated,
  actionable hint (node_banner_no_peers_detail) for this reason instead.
- When the embedded daemon is the node, offer a Restart button (re-triggers
  peer discovery via the -addnode seeds); an external node stays action-less
  since it is the user's own to restart.
- Update + extend the evaluator unit tests (external = no action/empty detail;
  embedded = RestartNode).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-10-17 16:33:17 -05:00
DanSandClaude Opus 4.8 a99f71a0cd fix(ux): land quick-win UX audit fixes across settings, exports, toasts, nav
Batch of safe, isolated fixes from the 10-surface UX audit:

- settings: Auto-shield / Save-z-transactions toggles now persist on change
  (they discarded CB()'s return and relied on a Save button that only renders
  on the About tab, so the choice was silently lost on relaunch).
- exports: show the resolved Platform::getConfigDir() instead of a hardcoded
  ~/.config path (wrong on Windows/macOS) in both the all-keys and CSV
  dialogs; all-keys also reveals the file path on a partial export.
- notifications: error/warning toasts last 9s/7s (were 4s/3.5s) so failures
  are readable.
- qr popup / tx-details: copy-to-clipboard now confirms with a toast.
- about: Escape-to-close + readable heading contrast via OnSurfaceMedium().
- sidebar: suppress single-key nav (J/K/arrows/[) while an overlay dialog or
  ImGui popup is open, so it no longer switches the tab behind a modal.
- i18n: strip the stale hardcoded path out of file_save_location in i18n.cpp
  and all eight res/lang/*.json locales (additive, one key each).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-10-17 16:27:58 -05:00
DanSandClaude Opus 4.8 064c9d1b61 feat(balance): inline period-earnings in the address list and totals
Surfaces the period-earnings aggregator in the Balance tab:
- Toolbar (shared address list): interval pills (1H/1D/1M/1Y), a received-
  vs-net mode toggle, and a global show-all toggle, placed right of "Hide 0
  Balances". The New Z-Address / New mining address buttons move to a footer
  row across from "Showing X of Y" to make room.
- Per address: the row draws its earnings on line 2, left of the $USD value
  (green positive, red negative in net mode), shown when the global toggle
  is on or the address is opted in via a new "Show/Hide earnings" right-click
  item. Works in every layout via the shared list.
- Total: FormatTotalEarnings (gated on the global toggle, so the default
  appearance is unchanged) is wired into the compact hero (dashboard/minimal)
  and the classic layout, left of / beside their USD figure. The remaining
  layouts' totals are not wired yet.
- Settings > Appearance: an "Earnings Display Format" selector (Coin /
  Coin + fiat / Fiat).

New i18n keys are translated into all eight locales; the CJK subset font is
rebuilt for the new glyphs. Builds + test suites pass on both variants.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-10-15 11:22:12 -05:00
DanSandClaude Opus 4.8 faf45e60e4 fix(mining): make "Est. Daily" honest about variance and fees
The mining "Est. Daily" was a solo expected-value (your hashrate share x
blocks/day x a fixed block reward) rendered in the same green as the real
Today / Yesterday / All-Time earnings, off an instantaneous hashrate and
gross of pool fees. It read like a dependable daily income when it is a
long-run statistical average with huge (Poisson) variance.

Framing:
- The estimate is now drawn in a neutral colour, not the green "money
  earned" colour (the actual mined totals stay green).
- A sub-label in BOTH modes marks it as an average ("avg", or "avg, net"
  in pool mode when the fee is applied), replacing the pool-only
  "before pool fee" note.
- A hover tooltip over the column explains the variance ("often nothing
  for long stretches, then a lump - NOT a steady daily income") and the
  fee status (after the N% fee / before the fee when unknown / nothing for
  solo and 0%-fee pools).

Accuracy:
- Uses a trailing-average hashrate instead of the instantaneous / 10s
  snapshot: pool prefers hashrate_15m (then 60s, then 10s); solo uses the
  mean of hashrate_history once there are >=10 samples, else the instant
  rate. This stops a spike or a momentary dip from swinging the figure.
- Subtracts the known pool fee so pool-mode figures are net; synthetic or
  unmatched pools stay gross and the tooltip says the fee is unknown.

The static block reward and true pool-reported earnings are intentionally
out of scope (the latter needs a new pool-API field). New i18n keys are
translated into all eight locales; the CJK subset font is rebuilt for the
three new glyphs. Builds + test suites pass on both variants.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-10-15 10:56:36 -05:00
DanSandClaude Opus 4.8 2282f332a6 feat(history): view the transactions involving an address
Adds a "View transactions" action that opens the History tab pre-filtered
to a chosen address. SetTransactionsAddressFilter() stores a pending filter
that RenderTransactionsTab consumes on its next frame — pre-filling the
search box and resetting the type filter to All — then the caller navigates
to NavPage::History. This reuses the existing "prefill + navigate" pattern
(cf. SetSendFromAddress) and all of History's rendering, detail and export.

Triggers:
- Contacts: a context-menu item and a 4th per-row action icon (copy /
  view-transactions / edit / delete; the trailing width is widened to fit).
- Balance address list: a per-address context-menu item (own z/t addresses).
- Receive tab: the recent-received rows now carry their address into the
  filter instead of jumping to History unfiltered.

The History search also now matches from_address, so filtering by one of your
own addresses catches sends FROM it, not only receives INTO it.

Scoping is honest, not exhaustive: transparent is well covered, but shielded
counterparties are private and the transparent sender of received funds is
never recorded on-chain, so this shows transactions *involving* the address.
On Lite, from_address is thin, so own-address send filtering is weaker.

New i18n keys view_transactions / tt_view_transactions translated into all
eight locales (no new glyphs). Builds + test suites pass on both variants.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-10-15 10:13:15 -05:00
DanSandClaude Opus 4.8 5fcab14e4e feat(settings): adjustable opacity for the favorite gold outline + star
Adds a "Favorite Highlight Opacity" slider (15-100%, default 100%) to
Settings > Appearance > Scale & Effects. It scales the alpha of the four
favorite gold tokens (fill/border/icon/outline) at their resolve site in
the Balance address list via material::ScaleAlpha, so the gold row outline
and the star button fade together. The multiplier is skin-agnostic (it
scales whatever alpha the theme resolved) and touches only favorited rows;
non-favorite rows and the white selection highlight are unchanged, and the
100% default is byte-identical to the previous look.

The preference lives in Settings (favorite_opacity, clamped 0.15-1.0 in both
the setter and on load), persists across restarts, updates live, and is not
low-spec gated (it's a pure color-alpha preference, independent of the
transparency sliders). New i18n keys favorite_opacity / tt_favorite_opacity
are translated into all eight locales; the CJK subset font is rebuilt to add
the two new glyphs the zh string needs.

Verified: full + lite builds clean, full + lite test suites pass, hygiene OK.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-10-15 09:58:17 -05:00
DanSandClaude Opus 4.8 e77b960cc5 build: embed ui.toml and banlist with INCBIN instead of a configure loop
ui.toml and the default banlist were embedded by a CMake function that
read each file and emitted a C array header one byte at a time in a
configure-time loop. That is slow to configure, and because the result
is a header, editing either resource recompiled every translation unit
that included it.

- Both files are now embedded with INCBIN from a small generated
  translation unit, the same pattern already used for the fonts. There
  is no generated header, so editing a resource re-assembles one tiny
  object file instead of triggering a wide recompile, and configure does
  no per-byte work.
- Consumers include a hand-written header declaring the symbols and
  always use the embedded data (the __has_include fallback is gone). The
  data is not NUL-terminated, so every consumer passes an explicit size.
- The old EmbedResources.cmake script is removed, and the generated unit
  is added to the app and test builds.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-10-13 22:03:00 -05:00
DanSandClaude Opus 4.8 3554ba1e2d perf(noise): tile the resident noise texture on GL instead of a copy
Drawing the tiled background noise built a viewport-sized pre-tiled
texture and re-created it whenever the viewport changed, copying the
256x256 noise tile across the whole screen in memory.

- On GL the draw now samples the resident 256x256 tile directly with
  GL_REPEAT, computing UVs from the screen rect over the tile size with
  the phase anchored at the viewport origin. The result is pixel-
  identical to the old pre-tiled texture (same generator, same origin)
  with no per-viewport copy.
- DX11 keeps the pre-tiled viewport-sized texture, since its sampler is
  CLAMP and cannot repeat.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-10-13 22:03:00 -05:00
DanSandClaude Opus 4.8 aa486266c6 perf(texture): cap and downscale theme images on load
Theme images were decoded straight to RGBA at their full source size
and uploaded to a GPU texture as-is. A large or over-sized image wasted
VRAM and could exceed the GL maximum texture size, and each code path
also hand-managed the decoded pixel buffer with its own free calls.

- Loading from file and from memory now goes through the shared
  size-aware decoder with a 2048-pixel cap on the long side. An image
  larger than that is downscaled with its aspect ratio kept; an image
  whose area is absurd is rejected; a small image is left untouched.
- The decoder owns the pixel buffer for the life of the upload, so the
  manual free calls are gone.

Tests cover downscaling a wide image to a 2048 long side with the aspect
kept, rejecting an over-area image, and leaving an under-cap image as-is,
plus the fit-dims math at the theme-art cap.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-10-13 22:03:00 -05:00
DanSandClaude Opus 4.8 8c5bd485dd fix(lite): probe servers without joining the thread on the UI thread
Adding a lite server from the network tab restarted the background
server probe immediately. Starting a probe while one is still running
joins the previous worker thread, and that join happened on the UI
thread, so the frame could stall until the outstanding probe finished.

- Adding a server now sets a dirty flag instead of restarting the probe
  inline; the next frame starts a fresh probe once the current pass has
  finished, so the UI thread never blocks on a join.
- The probe's libcurl transfers honor the cancel flag through a progress
  callback and abort cleanly, so a probe in flight stops promptly.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-10-13 22:03:00 -05:00
DanSandClaude Opus 4.8 8168e26d93 perf(updater): read the downloaded archive in one bounded allocation
Before verifying a downloaded archive, both updaters slurped it into a
string with an istreambuf_iterator, which grows the buffer repeatedly
and reallocates as it reads a multi-megabyte file. It also placed no
upper bound on the size it would read into memory.

- A shared readFileExact helper sizes the file first and reads it in a
  single allocation, failing closed if the file is missing, empty,
  larger than a caller-supplied cap, or comes up short. A truncated read
  can never reach the SHA-256 / signature verifier.
- Both updaters use it with the existing max-archive cap.

Tests cover a good read, a read exactly at the cap, over the cap, an
empty file, and a missing file.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-10-13 22:03:00 -05:00
DanSandClaude Opus 4.8 e685d425ed fix(updater): cancel the release-metadata request on dialog close
The daemon and miner updaters fetch release metadata with a blocking
HTTP GET that ignored the updater's cancel flag. Closing the dialog set
the flag, but an in-flight metadata request kept running to completion
on the worker thread, so the dialog could not tear down promptly on a
slow or stalled connection.

- The metadata GET now passes the updater's cancel flag through to the
  HTTP helper, so closing the dialog aborts the request in progress.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-10-13 22:02:59 -05:00
DanSandClaude Opus 4.8 f47528de53 fix(startup): move the single-instance lock to a private per-user file
The single-instance lock lived in /tmp, which is world-writable. Creating
it with mode 0666 there gets masked to 0644, so a second local user
cannot open it O_RDWR and is wrongly blocked from running the wallet at
all. A world-writable directory also lets another user pre-create or
symlink the lock path.

- The lock now lives in a per-user directory: $XDG_RUNTIME_DIR when it is
  an absolute directory the user owns, otherwise the per-user, per-variant
  config directory that startup has already created. Never /tmp.
- It is created mode 0600 with O_NOFOLLOW, and after opening we fstat it
  and refuse anything that is not a regular file we own, rejecting a
  planted symlink or a foreign-owned file.
- The lock file is no longer unlinked on release; flock drops on close,
  and leaving the file in place avoids an unlink/relock race (a stale
  file is harmless).

Tests cover acquiring and conflicting on the lock, the 0600 mode, that
release leaves the file in place, and that a symlinked lock path is
refused.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-10-13 20:57:21 -05:00
DanSandClaude Opus 4.8 bf5cb39566 fix(bootstrap): restrict download redirects to https
Both the HEAD size probe and the archive download followed redirects
with no restriction on where they could go. A redirect could therefore
downgrade the transfer to plain http or point at a file:// URL, defeating
the TLS verification the initial https request was relying on.

- Redirects on both requests are now limited to https, so a redirect can
  no longer leave TLS or reach a local path.
- The limit is set with the string-based curl option where available and
  the protocol-mask option on older libcurl.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-10-13 20:57:21 -05:00
DanSandClaude Opus 4.8 667c89ba51 fix(bootstrap): resume downloads without discarding a valid archive
Resuming a partial download leaned entirely on the remote size from a
HEAD request. When that size could not be determined the code played it
safe by deleting the partial file and starting over, so a flaky HEAD (or
an offline mirror) threw away gigabytes that were fine to resume. It also
could not tell a real size from an error page, and if the server answered
a ranged request with the whole file (200, not 206) libcurl could append
it to the partial and silently corrupt the archive.

- The HEAD now returns transport status, HTTP code, and size together,
  and a size counts as authoritative only on a 2xx reply with a positive
  Content-Length.
- A pure helper decides what to do from the existing bytes and that
  authoritative flag: start fresh, truncate a stale oversized file, reuse
  a complete one, or resume with a ranged GET. When the size is not
  authoritative the partial is kept and a ranged GET is attempted rather
  than discarded.
- A ranged request that comes back as 200 instead of 206 now restarts
  cleanly from scratch instead of appending to the partial.
- The free-space pre-check runs only when the size is authoritative, and
  a failed download reports how many bytes were kept for the retry.

Tests cover the resume-decision helper across the fresh, stale, complete,
resuming, and non-authoritative cases.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-10-13 20:57:21 -05:00
DanSandClaude Opus 4.8 4d9d528566 fix(bootstrap): keep the archive unless a checksum truly mismatches
Any verification failure deleted the downloaded archive and forced a
full multi-GB re-download on the next attempt. But most failures are not
a corrupt archive: a missing or unreadable checksum sidecar, or a fetch
blip while pulling the checksum, would throw away a perfectly good file.
On top of that the checksum parser took the first token of whatever came
back, so an HTTP error page could be mistaken for a digest.

- The archive is now removed only on a genuine digest mismatch. A
  missing sidecar, an unusable one, or a transient fetch error keeps the
  file so the next attempt can reuse or resume it; the no-checksum case
  still fails closed and keeps the archive.
- The checksum parser now accepts a token only if it is exactly the
  expected length and all hex, lowercasing it; anything else (an error
  page, a truncated or wrong-width digest) is rejected.
- The small-file fetch moved onto the shared capped HTTP helper, and MD5
  is consulted only as a fallback when no SHA-256 is published.

Tests cover the checksum parser across good, wrong-length, non-hex, and
error-page inputs.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-10-13 20:57:21 -05:00
DanSandClaude Opus 4.8 b3a1a9e654 fix(bootstrap): report verify progress correctly past 2 GiB
The checksum and MD5 passes measured the file for their progress bar
with fseek/ftell. On Windows a long is 32-bit, so ftell overflows on
any archive larger than 2 GiB and hands back a wrong (often negative)
size, which makes the verify progress percentage meaningless on exactly
the multi-gigabyte downloads this code exists for.

- Both passes now take the size from std::filesystem::file_size, which
  is 64-bit on every platform, through a small helper that returns 0 if
  the size can't be read.
- No seek is performed just to size the file.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-10-13 20:57:21 -05:00
DanSandClaude Opus 4.8 f1b0ae5ce7 fix(net): cap market and bootstrap HTTP response bodies
Several HTTP fetches read a remote body into memory without a tight
bound. The price and exchange-ticker calls shared one large 16 MiB
metadata cap, and the bootstrap checksum download had no size limit at
all, so a hostile or MITM'd server streaming an unbounded (and
Content-Length-free) response could grow memory far past anything these
endpoints legitimately return.

- The HTTP GET helper now takes a per-call byte cap and redirect limit.
  The write callback refuses bytes past the cap, which aborts the
  transfer, and the same value is set as curl's max file size.
- The price call is capped at 256 KiB and the ticker list at 2 MiB,
  each sized to its real payload rather than the shared default.
- The bootstrap checksum fetch caps its body at 64 KiB in both the
  write callback and curl's max file size.

Tests cover the sink accept/abort boundary.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-10-13 19:24:12 -05:00
DanSandClaude Opus 4.8 2a535c844d fix(security): overwrite key/PIN files with fsync before deleting them
Two secure-delete paths overwrote a sensitive file and then unlinked it,
but neither reliably forced the overwrite to stable storage. The
plaintext key export used a buffered stream with only a flush, and the
PIN vault removal opened a second descriptor to fsync only on non-
Windows. On a crash, or on Windows, the zeroing write could stay in the
OS cache and the original bytes could survive on disk.

Both now go through one shared best-effort secure-delete helper:

- It overwrites the file in place in chunks, never truncating first, so
  the original blocks are the ones rewritten.
- It flushes the data to the device before unlinking (full fsync on
  macOS, write-through on Windows), then removes the file.
- It is safe on a missing file and reports whether the file is gone.

This is still best-effort: SSD wear-levelling and copy-on-write or
journaling filesystems may retain copies.

Tests exercise the overwrite-and-remove helper.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-10-13 19:24:12 -05:00
DanSandClaude Opus 4.8 63a422a615 fix(security): only a real wrong passphrase counts toward unlock lockout
A failed wallet unlock was always treated as a wrong guess: the lockout
counter was bumped and the back-off curve armed no matter why the RPC
failed. A busy or warming-up node, a transport timeout, or a wallet that
is not actually encrypted would all eat an attempt and could push a user
into a lockout without a single mistyped passphrase.

The failed call is now classified before it is acted on:

- Only the daemon's "passphrase incorrect" error counts as a wrong
  guess and advances the lockout.
- A warming-up or unreachable node, a reply timeout, or a full queue is
  reported as a transient busy-node condition with no attempt charged.
- A "wallet is not encrypted" reply refreshes the local encryption state
  instead of penalising the user.
- Any unknown error surfaces its message without touching the counter.

The PIN path got the same treatment: when the vault decrypts correctly
but the unlock RPC then fails for a non-credential reason, the attempt
is no longer counted.

Tests cover the classification of each failure kind.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-10-13 19:24:12 -05:00
DanSandClaude Opus 4.8 76daf9619d perf(send): poll the clipboard for the paste hint only when it helps
The send tab read the OS clipboard every frame the paste button was
hovered, to preview a pasted address. SDL's clipboard read is a
synchronous round-trip that can stall, and the preview can only ever
latch when the recipient field is empty, so most of those reads were
wasted work.

- The clipboard is now read only while the button is hovered and the
  recipient field is empty, polled once on hover entry and then at most
  twice a second.
- Trimming the clipboard text moves to a shared helper that also bounds
  the copy so a pathological multi-megabyte clipboard is ignored.

Tests: the trim helper handles null, surrounding whitespace, an
all-whitespace string, and the length bound.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-10-13 16:00:10 -05:00
DanSandClaude Opus 4.8 a631e4421a refactor(contacts): share case-insensitive compare, truncation helpers
The contacts list carried its own lowercase-and-compare sort, its own
lowercase substring search, and an inline linear middle-truncation loop
for fitting an address to a column width. These duplicated logic that
belongs in the shared text helpers.

- A new allocation-free case-insensitive compare replaces the per-sort
  lowercasing; it lowercases ASCII per byte and leaves bytes >= 0x80
  alone, so UTF-8 labels sort exactly as before.
- Search now uses the shared case-insensitive contains helper.
- Middle-truncation to a pixel width moves to a shared template that
  binary-searches the fit (about log2(n) width measurements instead of
  up to n) while producing the same result as the old linear shrink.

Tests: the compare matches lowercase-then-compare across ASCII, UTF-8
bytes and length ties, and the fit helper matches the old linear loop
across lengths and widths.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-10-13 16:00:10 -05:00
DanSandClaude Opus 4.8 579accd0c6 fix(ui): validate shielded addresses with the correct RPC
The Validate address dialog chose its RPC from the first character of
the input, then asked the transparent-only validateaddress for every
case. That RPC is Base58-only, so a valid Sapling (z) address came back
isvalid=false and was reported invalid.

- The input is now classified with the checksum-verified recognizers,
  and a shielded address is sent to z_validateaddress while a
  transparent (mining) address keeps validateaddress.
- Anything that is not an address - a typo, garbage, or a pasted
  viewing/spending key - is judged invalid on the spot and never sent to
  the node, so a key pasted into the field is not leaked to an RPC.
- On Lite there is no node to ask, so the dialog shows the checksum
  verdict alone and hides the "owned by this wallet" row.

Tests: validateRpcMethod maps shielded to z_validateaddress, transparent
to validateaddress, and a key or empty string to no RPC.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-10-12 21:42:23 -05:00
DanSandClaude Opus 4.8 0aaf33ecf0 fix(core): drive auto-lock idle from real input, not widget focus
The auto-lock idle clock was reset whenever any ImGui item was active,
so a text field left focused (a half-filled send form, say) counted as
continuous activity and kept the wallet unlocked indefinitely, well past
the configured idle timeout.

- The idle clock is now fed by real input events (key, text, mouse,
  touch, pen, gamepad button) as they are ingested on the main thread,
  plus a still-held mouse button so a drag or scrollbar grab that emits
  no new event still counts. A focused but untouched widget no longer
  does.
- A new header-only IdleTracker owns the clock and never moves backwards,
  so an event stamped before the previous one cannot shorten the idle
  time.

Behaviour change: a form left focused and idle past the timeout now
auto-locks. The form's buffers are app state and survive the lock and
unlock, so nothing typed is lost.

Tests: a new IdleTracker case covers idle time growing with no events
(enough to trip a 900s lock), activity resetting it, the clock never
moving backwards, and a query before the last activity reading zero
rather than a negative value.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-10-12 21:42:23 -05:00
DanSandClaude Opus 5.5 e7f084e3eb fix(bootstrap): harden archive names, the cancel window and quit wait
The bootstrap preflight's protected names could be bypassed by names
that resolve onto them, and its cancel and quit paths had two timing
gaps.

- Entry names are judged as the file system resolves them: '\' is a
  separator, "." components are dropped and each component loses its
  trailing dots and spaces, as Win32 does ("./wallet.dat" and
  "DRAGONX.conf." name protected files). The whole archive is refused
  for an absolute or drive-rooted name, a ".." component, any ':'
  (drive letter or NTFS alternate data stream, "DRAGONX.conf::$DATA"),
  an 8.3 short name ("DRAGON~1.CON"), a component of only dots or
  spaces (other than "."), and any byte >= 0x80 or ASCII control
  character: macOS folds or ignores some Unicode (U+017F, U+212A,
  U+200C) onto a protected name, and the published bootstrap archive
  uses plain ASCII names. A refused name is echoed
  with \xNN escapes. A member is written at the path it was checked
  under, and the protected-name check runs on the resolved name.
- The user's Cancel (Settings dialog and first-run wizard) goes through
  requestCancel(). It is refused once the old chain data starts being
  replaced, and the start of that window re-checks a cancel under the
  same lock, so a click lands before the old chain is touched or not at
  all.
- Quit cancels the wizard's and the Settings bootstrap when the
  shutdown starts; the HEAD and checksum requests now end at once on
  cancel, and a cancelled HEAD keeps the partial download. A run
  cancelled before the window leaves the old chain as it was. A run
  inside it stops extracting and removes its partial chain while the
  shutdown screen shows "Cleaning up...", so it ends with no chain data
  (wallet.dat untouched; Retry reuses the zip, or the node resyncs).
  That wait is bounded: at most 2 minutes from the start of the
  shutdown, then 3 s in shutdown(), after which a worker still running
  is left to the process exit. Only a cleanup that overruns that cap
  can leave a partial chain, which dragonxd then answers with its
  reindex offer.

Tests: refusal of the Windows-resolved, non-ASCII and control-character
names with the escaped echo; the protected-name list incl. the resolved
forms and a planted -wallet file, conf and database/ that stay
byte-identical; the user-cancel window; the bounded quit wait and
safeToAbandon(); a cancel ends a HEAD request that never answers.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 13:22:00 -05:00
DanSandClaude Opus 5.5 a581f42a33 fix(settings): disable the unimplemented Tor option and tell its users
The "Use Tor" checkbox was saved but never read: no build routed any
traffic (node, RPC, price/update/bootstrap/pool HTTP or Lite gRPC)
through Tor, so users who ticked it believed they were anonymous.

- The checkbox is disabled and always shown unticked, on the Settings
  page and in the legacy Settings window. Its tooltip says it is not
  available and gives the alternatives: for the node, proxy=127.0.0.1:9050
  in DRAGONX.conf with Tor running; otherwise a system-wide VPN/Tor (Lite
  builds get the short version). The "Use Tor" label is kept.
- Behaviour change: a stored use_tor=true is cleared once at startup
  (Settings::consumeLegacyUseTor + a synchronous save before the async
  writer and any Settings page state), with a 20 s warning in the user's
  language. The cleared key is the "already told" flag, so the notice
  never repeats. No settings format change.
- 3 new strings (tt_tor_unavailable, tt_tor_unavailable_lite,
  tor_never_applied_notice) in all 8 languages; the tooltip wraps at
  tor-tooltip-wrap-width from ui.toml.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 13:22:00 -05:00
DanSandClaude Opus 5.5 81733f5603 fix(rpc): run rescans and key imports without the 30 s timeout
A rescan or key import holds the node's cs_main for the whole wallet
scan. On the shared clients it hit the 30 s curl timeout, reported a
failure that wasn't one, abandoned a daemon RPC slot and flipped the app
into "node busy".

- One long wallet RPC at a time (rescan, importprivkey, importaddress,
  importwallet, z_importkey, z_importviewingkey, z_importwallet) runs on
  its own ungated client with no client timeout (rpc/long_call_lane.h).
  It can be aborted from any thread, and an abort is never lost, even
  one that lands before the client opens.
- The GUI rescan, key import and sweep use it. The sweep's fund-moving
  body is unchanged; only the client it calls differs. runtimeRescan()
  returns bool, and a refused post-bootstrap reconcile stays armed.
- The console routes rescan and the import commands through the lane;
  its input stays usable meanwhile, and a refusal is printed like any
  other result. RPCClient gains callRaw(.., timeoutSec).
- Restart, wallet switch, delete-chain, bootstrap (now also from
  Settings) and the other node-stopping flows are refused while it runs,
  with a message saying a key import or rescan is still running; quit
  asks for confirmation. The lane is aborted at quit.
- A second import or rescan is refused before the key is copied anywhere.
  An import cut off after the request reached the node (a lost reply,
  or a proxy's 502/504 while the node still imports) says the key may
  already be in the wallet and to run Settings -> Rescan. Only the
  node's own HTTP-layer refusals (401, 503, work queue full, unhandled
  request) say that the key was not imported. A pure
  classifyLongCallCutOff() makes that choice.
- New strings in all 8 languages; console results stay English.

Tests: the long-method list and the console's routing; one lane at a
time; an abort lands whether it comes before the client opens or during
the call; a call with no client timeout outlasts a 3 s scan, where a 1 s
gated client times out and marks the node busy; classifyLongCallCutOff()
for every failure kind with and without a sent request, and from raw
HTTP replies (502, 504, a plain 500 -> cut off; 401, 503, queue full,
unhandled -> refused).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 13:22:00 -05:00
DanSandClaude Opus 5.5 78e38570a8 fix(bootstrap): check archive and free space before deleting chain data
The bootstrap removed blocks/, chainstate/ and notarizations/ first and
only then opened the archive. An unsafe entry, a full disk or a corrupt
member therefore left no chain, or a half-written one for dragonxd to
start on.

- Preflight before anything is deleted: every archive entry must be
  safe (no absolute path, no "..") and readable, and the disk must hold
  the extracted size plus max(256 MiB, 5%), counting what removing the
  old chain frees (a symlinked chain dir counts 0). Free space counts as
  unknown only when the probe errors, the capacity is 0 or the
  available size is -1; a known disk with 0 bytes free is a refusal.
  The old chain is kept on every preflight failure.
- Protected members are skipped, compared case-insensitively: any path
  component starting with "wallet", any "database" component, a .cookie
  or *.conf file, and top-level *.dat files other than fee_estimates,
  peers and banlist. The number of non-critical member failures is
  logged.
- The removal of the old chain is verified (a locked file fails the run
  instead of mixing old and new chain files). If the extraction fails or
  is aborted, the partial chain is removed again before the failure is
  published; isDone() turns true only after the worker's last
  filesystem change.
- Cancel is disabled while the old chain data is being replaced, and
  quit aborts a running bootstrap.
- A complete zip from an earlier attempt is reused on Retry; its
  checksum is re-verified. A download that cannot fit fails early, and a
  disk-full write error says so.

Tests: the space rule (boundaries, unknown vs. full disk, saturation);
planArchive sizes, zip-slip, garbage and truncated zips; the
protected-name list; reclaimableBytes with symlinks; each preflight
refusal keeps the old chain and wallet.dat byte-identical; a corrupt
critical member removes the partial chain before the failure is
published.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 13:22:00 -05:00
DanSandClaude Opus 5.5 f6e5038043 fix(security): don't penalise a correct PIN after a passphrase change
A passphrase change left the PIN vault sealing the old passphrase. The
next PIN unlock opened the vault, walletpassphrase answered RPC -14, and
the generic catch ran the lockout curve on a correct PIN, while Settings
kept showing the PIN as active.

- Passphrase change: when the active wallet's vault exists at completion
  (same vault path as at dispatch, passphrase actually changed), the
  vault is marked out of date for the session and kept on disk: PIN entry
  is no longer offered, a warning toast says so, and Settings shows
  "PIN out of date" with a tooltip on how to fix it. The Change
  Passphrase dialog notes beforehand that a PIN will go out of date.
- PIN unlock: RPC -14 after the vault opened is treated as "PIN out of
  date": passphrase entry, a message, no attempt counted, no lockout, and
  the vault is not deleted (-14 is only the daemon's answer; the vault may
  hold the only copy of a passphrase that opens an older backup). After a
  restart Settings shows the PIN as active until the first PIN attempt.
- A malformed vault (zero-filled by a removeVault() whose unlink failed,
  truncated, unknown version) is treated as missing: passphrase entry,
  "PIN file unreadable", no penalty. New SecureVault::isWellFormed().
- Race guards: Set/Change/Remove PIN are disabled while a passphrase
  change runs, and Change Passphrase while a PIN operation runs.
- Setting or removing the PIN, or switching wallets, clears the mark;
  setting a PIN offers PIN entry again. Change PIN keeps the mark (the
  vault still seals the same passphrase).
- Pure decisions in data/pin_vault_policy.h, unit-tested. New strings in
  all 8 languages.

No vault, wallet or settings format change.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 13:22:00 -05:00
DanSandClaude Opus 5.5 62a5577491 fix(sweep): keep the UI sweep away from real wallet data
The full UI sweep (dev builds only) ran its demo surfaces against the
real profile. Its teardown deleted wallet-{cold,trading,mining,donations,
2023,payroll}.dat from the real datadir, it created zero-filled wallet
files and picker-demo/extra-wallets folders, its demo wallet-index upsert
re-minted the active wallet's scopeId (hiding "w:"-scoped contacts), the
Contacts tab could re-attach legacy-scoped contacts to such a throwaway
id in addressbook.json, and it persisted the demo active wallet file and
Mining pool worker.

- Preflight: a full sweep is refused (toast + log line) while a real
  workflow is in flight: a pending seed_migration_* setting or open
  migrate dialog, a wallet switch, an encryption/import workflow, an
  encrypt/PIN/lock operation, the seed-backup dialog, or a bootstrap.
  The DRAGONX_FULL_SWEEP hook then exits as for an empty catalog.
- Persistence latches: AddressBook (snapshot on the first sweepBegin,
  save/load latched while a demo-contacts surface is shown), WalletIndex
  and Settings (active wallet file, contacts view mode, pool URL/worker)
  for the whole sweep. Settings::sweepEnd() is the last statement of
  clearDemoWalletData and flushes once only if a save was suppressed; a
  quit mid-sweep writes nothing to the profile (the 0700 sandbox, with
  zero-filled demo files only, may stay in the temp dir).
- Scope ids: during a full sweep activeWalletScopeId() returns the
  persisted id or "" and never mints one, so the Contacts tab cannot
  move legacy-scoped contacts to a throwaway id. The only address-book
  write a sweep can still cause is the one-time legacy re-attach to the
  wallet's persisted id, which opening Contacts does anyway.
- Sandbox: demo wallet files and folders go to a fresh random 0700 dir
  under the temp dir (exclusive create, a pre-existing path or symlink
  is refused), removed at sweep end. The teardown that deleted those six
  wallet files is gone.
- Cleanup: the migrate-to-seed state, wizard phase, demo chat
  conversations and a fabricated demo chat identity are reset at sweep
  end; the seed-backup surface always shows the demo phrase and a real
  z_exportmnemonic result is dropped while the sweep runs.
- i18n: one new key (screenshot_sweep_refused) in i18n.cpp and the 8
  language files.

Tests: the address book snapshots only on the first sweepBegin, keeps
addressbook.json byte-identical while latched and restores the real
book; the wallet index keeps wallets.json and the real scope id, also
with the background writer attached; Settings restores the latched
values and flushes once only when a save was suppressed; the sandbox
helper refuses a pre-existing path or symlink and removes only a
prefixed dir, never a symlink's target.

Release note: v2.0.x UI sweeps could delete wallet-{cold,trading,mining,
donations,2023,payroll}.dat from the data directory and re-scope
contacts; this cannot be undone automatically.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 13:22:00 -05:00
DanSandClaude Opus 5.5 eac0993512 fix(lite): require a confirmed 8+ character passphrase to encrypt
The Lite "Encrypt wallet" field and the Create/Restore passphrase had no
confirmation and no minimum length; only an empty passphrase was
refused. Encrypting locks the wallet at once, and a locked wallet cannot
reveal its seed, so a typo there could cost a user without a seed backup
their funds. The full-node encrypt dialog already requires 8 characters
and a confirmation.

This applies to new passphrases only. Opening and unlocking are
unchanged: an existing wallet with a shorter passphrase still opens and
unlocks.

- A pure liteEncryptPassphraseValid(pass, confirm): at least 8 bytes and
  equal to the confirmation. It takes string_views, so the page's secret
  buffers are never copied.
- liteLifecyclePassphraseAcceptable(op, pass, confirm) gates the Settings
  create/open/restore request. Open is never checked, because its
  passphrase field also unlocks an existing wallet. Create and Restore
  are checked only when a passphrase was entered; an empty one still
  creates an unencrypted wallet.
- Encrypt gets a Confirm field. The button stays disabled until the
  passphrase is valid, and a hint says why (too short, with the count,
  or not matching).
- Create/Restore show a Confirm row once a passphrase is typed, with the
  same hint. Validate stays disabled while the passphrase is invalid, so
  a click never wipes a typed seed over a passphrase typo. The request
  builder re-checks the rule.
- The confirmation buffers are wiped with their passphrase: after a run
  or an encrypt, and when the Confirm row is hidden.
- Once the wallet is closed, the Settings page wipes the Encrypt,
  Confirm and Decrypt passphrase buffers whole every time, also when no
  secret was revealed; before, closing the wallet wiped them only while
  clearing a revealed backup or secret. They are zeroed in full because
  an emptied field can still hold the old tail past its first NUL.
- New copy under Encrypt: "Anyone with the wallet file can try
  passphrases offline. Back up your seed phrase first and use a long
  passphrase."
- i18n: three new keys (lite_confirm_label, tt_lite_confirm_pass,
  lite_encrypt_offline_warning) in i18n.cpp and the 8 language files,
  added additively. The hints reuse the wizard's wiz_pass_too_short and
  wiz_pass_mismatch. No new CJK glyphs, so the subset font is unchanged.

Tests: the validator rejects empty, short (7) and mismatched or
unconfirmed passphrases, and accepts 8 or more with a match. The request
gate always accepts Open, even "abc" with no confirmation, accepts
Create/Restore without a passphrase, and applies the rule to them
otherwise. An encrypted, locked wallet still opens and unlocks through
the controller with the 3-character passphrase "abc".

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 13:22:00 -05:00
DanSandClaude Opus 5.5 e52c9e1b92 fix(lite): classify imported keys before calling the backend
importKeyCore guessed the key type from its first character, then
retried a failed import with the other command. That retry is what sent
shielded keys to the backend's transparent-key decoder, which panicked
on them and poisoned the wallet lock. A WIF typo that was still
valid base58 also reached the backend unchecked.

- The key is classified first, and exactly one backend command runs, or
  none:
  - secret-extended-key-... and zxview... go to "import";
  - a Base58Check WIF with version 0xBC or 0x80 and the compressed flag
    goes to "timport";
  - everything else is refused before any backend call.
  There is never a retry with the other command. A testnet (0xEF) WIF
  is not accepted, because the Lite app is mainnet-only.
- New fixed, untranslated reasons: "uncompressed transparent keys are
  not supported by the Lite wallet", a full-node incoming viewing key
  (zivks) is explained as such, and anything else is "unrecognized key
  format". The key is never echoed.
- The backend's refusal while the wallet is encrypted now maps to "wallet
  is encrypted - remove encryption to import a private key". It is
  checked before "locked", because unlocking would not help.
- base58Decode reserves its buffers up front, and it and
  decodeBase58Check wipe their copies of the decoded bytes with
  sodium_memzero on every return path, since the input can be a private
  key.
- exportSeed rejects the all-zero-entropy phrase (23 x "abandon" + "art").
  A backend built before the seed-export fix returns it for a wallet
  whose lock failed halfway (seed wiped, wallet still unlocked); it is
  never a real backup and must not seed a chat identity.
- An open that the backend recovered from the .bak ("OK: recovered from
  backup; damaged file kept as <name>") is written to the lite log.

Tests: the retry control now expects one "import" and no "timport", and
"typo-key" makes no backend call; the controller's transparent-import
test uses a real compressed WIF. A new classifier test covers both WIF
versions, the shielded and viewing keys, a single call on failure, the
uncompressed, zivks, testnet, bad-flag and bad-checksum refusals, the
encrypted reason and its lite-log line, the zero-entropy seed and the
recovery log line.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 13:22:00 -05:00
DanSandClaude Opus 5.5 1f162ec830 build(i18n): embed language JSON only on Windows, add lookup fallbacks
CMake turned every res/lang/*.json into an xxd header that i18n.cpp
compiled in as a fallback, on every platform. Only the Windows
single-file exe needs it: the Linux zip and AppImage and the macOS .app
always ship res/lang, and loadLanguage() reads the loose file first. On
Linux the embedded copy added about 1.5 MB to the binary, and every
translation edit recompiled i18n.cpp.

A new option, DRAGONX_EMBED_LANG, defaults to ON for WIN32 and OFF
elsewhere:
- ON without xxd is now a configure error (it used to warn, then fail to
  compile i18n.cpp on the missing headers);
- i18n.cpp compiles the embedded includes and the fallback only when ON;
- outside Windows, loadLanguage() also tries
  <exe>/../share/<app>/res/lang (the cmake --install layout and the
  full-node AppImage) and <exe>/../Resources/res/lang (the macOS .app
  binary run directly), so those layouts find the loose files whatever
  the working directory; before, only the embedded copy covered that.

Measured:
- full-node Linux binary: .data 1,699,696 -> 168,592 bytes; stripped
  19,983,136 -> 18,456,128 bytes (-1.53 MB);
- with the ImGui demo stubs, the Linux zips and AppImages are
  0.50-0.55 MB smaller, with identical file lists including all 8
  language files;
- a Lite build started from an unrelated working directory loads the
  ja.json next to the binary (2422 strings), and the packaged Lite zip
  and AppImage load ja.json with no new not-found or failed lines;
- Windows keeps the embedded copy: the 8 language headers are generated
  and the ja.json bytes are in the exe.

A new test loads "de" from the loose files and checks a translated
string; with bin/res/lang moved away exactly its two assertions fail.

Behaviour changes on Linux and macOS: a missing or corrupt res/lang now
falls back to English instead of the embedded copy, and a translation
edit needs only the copy_langs step instead of a recompile.

Not verifiable here: the macOS .app in a non-English language, and the
Windows single-file exe at runtime.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 06:05:07 -05:00
DanSandClaude Opus 5.5 3f13d79ccb perf(ui): memoize the node-status banner ellipsis
While the node-status banner shows, its detail line was fitted every
frame by popping one character at a time and re-measuring the whole
"·  " + detail + "…" string: O(L^2) per frame in the detail length L,
which adds up on the ~1.5 KB Windows crash report. The pop also left a
dangling UTF-8 lead byte before the "…", so a cut in non-ASCII text drew
replacement characters in place of the ellipsis.

- util::textfit::ellipsizeTail builds the same string in O(L + m log m),
  m being the length of the line the cut lands in: ImGui measures
  multi-line text as its widest line, so the old loop could only stop
  inside the first line that overflows on its own. Byte-identical for
  ASCII; the cut now lands on a code-point boundary.
- EllipsisFitCache keeps the result until the detail, budget, font size,
  font (by FontId) or rasterizer density changes, so an unchanged frame
  costs one string compare.
- When ImGui would draw a line wider than the per-line split predicts
  (a malformed byte joined with the following newline, as in CP-1252
  output from the Windows daemon), the old whole-string loop is used for
  that text.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 09:50:03 -05:00
DanSandClaude Opus 5.5 8c0a22b7bc fix(ui): cut ellipsis truncation on UTF-8 code-point boundaries
TruncateToWidth, the middle "front...back" ellipsis for addresses, pool
URLs, paths, labels and the status bar, cut at byte offsets: a
multi-byte character at either cut was split into invalid UTF-8 and
drawn as replacement characters (e.g. a translated Cyrillic or CJK
status). It also tried front lengths from n/2 down until one fit,
building and measuring a new string for each: up to O(n^2) per
truncated label per frame.

util::textfit::middleEllipsis keeps the same contract and is
byte-identical for ASCII, but snaps the front cut down and the back cut
up to code-point boundaries, and finds the largest fitting front length
by bisection into one reused buffer: O(log n) measures for single-line
text. Text containing a newline keeps the linear scan, because its
measured width (the widest line) does not grow steadily with the cut.

text_fit.h also gains largestFittingPrefix, a bisection over code-point
boundaries; its first caller is the node-status banner in a following
change.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 09:50:03 -05:00
DanSandClaude Opus 5.5 a69380817e perf(chat): use reentrant local time for chat timestamps
The open chat thread called std::localtime at least twice per message
per frame (two day keys for grouping), and more for day separators and
time labels. On glibc every call re-checks the timezone with a stat of
/etc/localtime. The chat time helpers now use util::localTime() from
the new util/local_time.h: localtime_r (localtime_s on Windows), with
no per-call timezone check and no shared static result buffer.

glibc's localtime_r reads the timezone only once, so local_time.h also
adds util::refreshTimezone() (tzset) for caches to call when they
rebuild. Nothing calls it yet; its first caller is the chat thread's
grouping-flags cache in the next commit.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 09:50:03 -05:00
DanSandClaude Opus 5.5 c6fc1ac168 fix(images): cap and stream image decoding in a GL-free decoder
Image decoding lived in the GL texture file and had no limits: a file of
any size was read whole, stb and libwebp allocated whatever the header
claimed (a WebP alone up to 1 GB), and a GIF was decoded at full size,
every frame, before the 300-frame cap. A 720p 150-frame GIF avatar cost
0.2-1.5 s and 140-370 MB on the UI thread. stb's GIF loader also read
before the start of its buffer on disposal-3 frames, and when a long GIF
ran out of memory it freed the frame delays that the caller then freed
again (an abort).

Decoding, and the stb implementation, now live in the GL-free
util/image_decode_core, safe on any thread:

- the header is probed first; stills above 256 MP, any GIF or animated
  WebP canvas above 32 Mi px, and files above 256 MiB are rejected
  before anything is allocated (real photos, 200 MP phone shots and
  wide panoramas still load);
- a GIF decodes one composited frame at a time, downscaled as it goes,
  and stops at the frame cap: peak memory is stb's working buffers, two
  canvases and the small frames; a truncated GIF keeps the frames
  decoded so far;
- a header stb cannot read means "unsupported", never a blind decode;
- decodes above 16 MP run one at a time; a decode can be cancelled
  between frames.

util::StartImageDecode runs one decode on a detached worker that owns its
job and its lane's in-flight counter, so nothing waits at exit; the next
commits move avatars and the image picker onto it. The limits apply now
to avatars and the picker's hover previews; thumbnails and avatar imports
get them when they move to workers.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 21:09:48 -05:00
DanSandClaude Opus 5.5 971f2abae5 perf(startup): prepare first-run node files in the background
On a first run or after an upgrade, Windows release builds wrote the
bundled Sapling params, asmap, node and miner (about 88 MB) in
App::init, before the first frame: 0.5-3 s of frozen blank window, 5-30 s
on a slow disk or under antivirus. Zip, AppImage and .app builds copied
the bundled files into the daemon dir and hashed the 51.5 MB of params on
the UI thread at the first node start, a 0.3-1.5 s freeze in the wizard
or the connect loop.

A stat-only check at startup decides whether anything is missing, stale
or unverified; a normal launch starts no thread and shows nothing.
Otherwise one background job extracts (Windows) or copies (Linux, macOS)
the files, then verifies the params once:

- The first frame paints at once. If the job takes longer than 0.4 s, a
  chip at the bottom shows "Preparing wallet files... N%", then
  "Verifying wallet files...".
- A node start on the UI thread while the job runs is deferred and
  replayed when it ends (never into a bootstrap download, a restart or a
  live connection); worker threads wait, and give up on quit. After an
  upgrade that ships a new miner, the node starts after the miner is
  written instead of before. Pool-miner starts and the miner version
  check wait too, so the old miner is never run or reported while it is
  being replaced.
- Each file goes to "<name>.partial", is flushed and renamed into place
  under one lock shared with the daemon start, the bundled-node
  reinstall and the miner extraction, and the job stops between 4 MB
  chunks at shutdown. An interrupted write used to leave a truncated
  binary that was trusted forever; a leftover .partial is now removed.

Other behaviour changes:

- Linux and macOS full-node builds copy the bundled node files (about
  77 MB) into the daemon dir at first launch, even if the wallet ends up
  on an external node; before, this happened at the first embedded start.
- Lite no longer writes the 53 MB of params and asmap it never reads.
- A disk-full or permission failure that leaves the node files unusable
  shows a warning; a miner blocked by antivirus alone does not.
- A corrupt or truncated param in a zip, AppImage or .app install is
  replaced from the bundle and verified again instead of trusted forever.
- Reinstalling the bundled node holds off the connect loop and a deferred
  start for its whole run (either could launch the old binary while the
  new one was written), and is refused while another restart is running.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 21:09:48 -05:00
DanSandClaude Opus 5.5 a700d2c7ee perf(wallets): walk added wallet folders off the UI thread
Every open of the Wallets dialog, Refresh, folder add or remove, and Open
on a stale row walked each user-added folder recursively on the UI thread
before the dialog drew: up to 40,000 entries per folder, roughly
50-150 ms warm on Linux and seconds on a cold disk, a network share or
Windows.

The datadir listing (a few top-level files) stays synchronous, so a
wallet just created shows at once. The recursive walk runs on a detached
worker with the same depth, hit and entry limits; its rows are merged at
the start of a later frame and only the new rows are probed.

- The dialog opens at once with the datadir rows. The external rows
  arrive when the walk ends, with "Scanning folders..." beside the
  scanned-folders caption, and the card grows once.
- Reopening within 60 s with the same folders reuses the last walk
  without touching the disk, so its rows and sizes can be up to a minute
  stale: a wallet copied into a folder in that minute needs Refresh.
  Refresh and Open on a stale row always walk again.
- While a walk or a wallet probe runs (up to 30 s) the dialog keeps
  frames coming, so rows and badges appear without moving the mouse.
- The 200-row cap now holds across all folders; each extra folder could
  add one row past it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 21:09:48 -05:00