Commit Graph
100 Commits
Author SHA1 Message Date
DanSandClaude Opus 4.8 f2d9590058 feat(send): usable-while-syncing form, calmer validation, honest clear/memo/source
Send-tab UX pass (presentation/validation only — the z_sendmany submit path, its
amounts, fee, recipients and memo bytes are unchanged, and the Review button plus
the confirm-time live re-check still block any send until the wallet is synced):
- The form is now editable while syncing (so the recipient/amount/memo can be
  pre-filled); only Review/Send stays gated, with an "available once synced" note.
- The recipient's red "Invalid address" no longer flashes from the first
  keystroke — it waits until the field loses focus.
- The memo field stays visible (text preserved) with a "requires a shielded
  recipient" explainer for transparent recipients, instead of vanishing + silently
  dropping what was typed.
- "Cancel" (which only cleared the form) is relabeled "Clear form", disabled when
  empty, and drops its redundant confirm (the timed Undo already covers it).
- The source picker shows address labels, and warns when the chosen source is
  transparent (non-private on this chain).

Also adds the string keys used here and by the next commit.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-10-18 22:50:30 -05:00
DanSandClaude Opus 4.8 ee6e99965c feat(balance,notifications): shielded-card tooltip + clearer toast severity
- The Shielded balance card gains the explanatory hover tooltip the mining card
  already has (suppressed while the pending-chip tooltip is showing).
- Toasts get a severity-tinted background fill and a slightly thicker accent
  border, so Success/Warning/Error/Info differ by more than hue alone (the
  per-severity leading icon already existed). Tint opacity is a ui.toml token.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-10-18 18:36:55 -05:00
DanSandClaude Opus 4.8 b3e09a3314 feat(onboarding): friendlier wizard bootstrap + Done screen starts the daemon
First-run bootstrap polish:
- Failed card wraps the (often long) error instead of clipping it, and offers a
  "Try mirror" button; Retry now keeps whichever source (main/mirror) was used.
- Bootstrap offer shows an honest size/time hint and relabels "Mirror" →
  "Use mirror" with a clearer tooltip.
- Daemon status dot/label is keyed off the EmbeddedDaemon::State enum instead of
  matching the English substring "Stopping" (which broke in every other
  language), and the faint ~55%-alpha label is raised to ~86%.
- Bootstrap Done screen: for the embedded daemon it now offers "Start Daemon"
  (and says the node will start) instead of telling the user to start it by hand.

Also carries the new string-table keys for these (and the shielded-card tooltip
used by the next commit).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-10-18 18:36:55 -05:00
DanSandClaude Opus 4.8 2fdf33f99b feat(chat): "Request" pill on unsolicited conversations in the list
The conversation list now marks a pending stranger request with a small
"Request" pill (bottom-right of the row), so it's distinguishable before you
open it; the preview text is clipped to the pill's left so they don't overlap.
Completes the request-affordance pair with the in-thread Accept/Block card.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-10-18 18:27:44 -05:00
DanSandClaude Opus 4.8 092a802959 feat(balance): pending chip in the Donut, Shield and Timeline layouts too
Extends the labeled "Pending +X" chip (with its awaiting-confirmations tooltip)
to three more layouts via a shared DrawPendingChip() helper. It draws on the
ticker line (the USD value sits on the line below in these layouts, so there's no
overlap) and is skipped if it would overflow the card. The remaining draw-list
layouts with right-aligned content (Consolidated, Minimal, the grid variants)
are left for a follow-up.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-10-18 18:27:44 -05:00
DanSandClaude Opus 4.8 e9eeaf00c3 feat(chat): Accept/Block card for unsolicited contact requests
An unsolicited first message from a stranger (an incoming ContactRequest, with
nothing sent by us and the peer not in contacts) now shows an Accept / Block
card above the thread instead of looking like a normal conversation. Accept adds
the peer to the address book (which clears the request state); Block deletes and
blocks the conversation (reversible from the Blocked manager). ConvSummary gains
a derived pendingRequest flag. (The list-row "Request" pill and a header Block
button were left out to avoid the fragile hand-drawn header-toolbar geometry.)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-10-18 13:12:57 -05:00
DanSandClaude Opus 4.8 572b98cbcf feat(shield): pick the mining address from a dropdown + show a confirm preview
Shield-coinbase replaced its free-text "*"-wildcard address box with a dropdown
of the wallet's mining (transparent) addresses and their balances ("All mining
addresses" default), and the confirm step now previews "Shield up to X DRGX from
N mining address(es)… to <label>? Network fee: Y" instead of only relabeling the
button. Also blocks submitting from an empty source or one with no funds. The
z_shieldcoinbase call (args/fee/limit/destination) is unchanged.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-10-18 13:09:41 -05:00
DanSandClaude Opus 4.8 bcfc324426 feat(balance): labeled "Pending +X" chip with tooltip in more layouts
The unconfirmed balance was shown in only one layout as an unlabeled "+X" that
read as a mystery. It's now a labeled "Pending +X" chip with an "awaiting
confirmations" tooltip — the Classic card's badge is relabeled, and the shared
compact hero (Dashboard + Two-row layouts) now draws it too. (The remaining
hand-drawn layouts can reuse the same pattern as a follow-up.)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-10-18 13:07:43 -05:00
DanSandClaude Opus 4.8 7cea9b7339 feat(bootstrap,validate): Retry keeps the mirror; Validate works offline
- Bootstrap: Retry now reuses the source the user actually started (was always
  the default), and a "Try mirror" button offers the alternate server on
  failure (s_lastUrl remembered on start, cleared on a fresh Confirm).
- Validate Address: classification + checksum are local, so the dialog no
  longer requires a node connection — offline/lite now validate and show the
  type, with ownership reported as "Unknown — node offline / not checkable in
  the lite wallet" instead of a dead "not connected" screen.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-10-18 13:05:40 -05:00
DanSandClaude Opus 4.8 f3f0b32a48 feat(ux): robust sync ETA, persist Explorer settings, gate export-all out of lite
- Sync ETA: a new pure util::SyncRateEstimator (App-owned) replaces the
  function-local statics that went stale after a rescan/restart (producing the
  "412h" ETA). It self-resets on a height drop and on connect/disconnect/rescan/
  restart (via an atomic flag, since onConnected can run off-thread), formats
  with days, and uses the Warning() token.
- Settings: the Explorer-tab URL fields + custom-fees/fetch-prices checkboxes
  now persist on edit — they were the only controls still relying on the
  About-only Save button that a user on this tab never sees.
- Export-all-keys: hidden in the lite build (which has its own key export),
  instead of dead-ending on "Not connected to the daemon".

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-10-18 13:02:46 -05:00
DanSandClaude Opus 4.8 8fe20f6385 feat(mining): persistent start/stop error caption + timeout-aware pool status
- A mining start/stop/pool failure now sets a persistent App::mining_last_error_
  (cleared on the next attempt), rendered as a red caption with "Press Mine to
  try again" below the Mine button (plus a Dismiss, and a Windows-Defender
  "How to fix" on an AV block), so a crashed/blocked/failed miner is no longer
  just a button that "did nothing" once its toast faded. Two more hardcoded
  English strings (pool-payout-invalid, AV-blocked) moved to i18n.
- Pool connection status is now a persistent "Status" row in the pool stats
  card (PoolMiningState.running_since / last_error): it flips from "Connecting…"
  to "Can't reach pool — check the URL" after a 30s timeout instead of sitting
  on red forever, and no longer vanishes once logs arrive. The old transient
  banner in mining_tab.cpp was removed.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-10-18 12:09:49 -05:00
DanSandClaude Opus 4.8 15c1181aef feat(shield): colour shield/merge status by outcome + spinner; friendly errors
The shield/merge status line was amber only while in-flight and uncoloured for
every terminal state (success, failure, uncertain). It now tracks an OpOutcome
(display-only) and colours the terminal line Success/Error/Warning accordingly,
shows a LoadingDots spinner while the 120s scan runs, and maps the raw daemon
error through friendlyRpcError. The actual shield/merge submit logic is
unchanged (no "Try again" that could double-submit a fund move).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-10-18 11:55:40 -05:00
DanSandClaude Opus 4.8 0101cc707c feat(network): a clear auth-failed node banner with a fix-it action
When the node rejects the wallet's RPC login (bad rpcuser/rpcpassword), the
status bar showed developer jargon ("Auth failed — check rpcuser/rpcpassword")
and the only action was Reconnect, which just repeats the failure. Now a
dedicated AuthFailed banner reason surfaces "Wallet can't sign in to the node"
with a plain-language detail and a real fix: Restart node for the embedded
daemon (regenerates its cookie creds) or Open config folder for an external one
(to fix DRAGONX.conf). Driven by a new rpc_auth_failed_ flag (set on the auth
path, cleared on each connect attempt), with evaluator unit tests.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-10-18 11:53:29 -05:00
DanSandClaude Opus 4.8 adf61e43d6 feat(transfer): add Send-tab guards, a correct TxID label + copy, inline reasons
The address-transfer dialog (own-address moves) enabled Confirm on amount alone,
so a disconnected / syncing / locked wallet failed only at submit. It now
mirrors the Send tab's guards (strictly more restrictive — the transfer logic is
unchanged) and re-checks live state before broadcast, shows an inline reason
when blocked (enter amount / not connected / syncing / locked), and labels the
result as a "Transaction ID" (not "Operation ID") with a copy button.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-10-18 11:44:19 -05:00
DanSandClaude Opus 4.8 fb281714f9 feat(i18n,mining): translate warmup block/tx-type labels; fix the disconnected-solo mine tooltip
- Warmup/daemon status no longer appends a hardcoded English " (Block N)" — it
  uses a translated warmup_block_fmt key.
- Recent-transaction type labels (Sent/Received/Mined) are now translated in the
  UI consumer (recentTypeLabel in balance_recent_tx.cpp) with a generic
  "Transaction" fallback, instead of the data layer's raw English getTypeDisplay().
- Mining: a disconnected solo miner hovering the dead Mine button now gets a
  "not connected to the node" tooltip instead of a misleading "Start Mining"
  (the tooltip chain had no branch for the solo !isConnected() disable).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-10-18 11:42:29 -05:00
DanSandClaude Opus 4.8 74c497922e fix(hidpi): scale security-dialog buttons, market caption, QR inset by dpiScale
HiDPI geometry debt from the audit: 15 security-dialog buttons used a raw 40px
height (undersized vs the scaled label at 1.5x), the market "Updated" caption
used a raw SameLine(0,12), and the QR popup's 28px overlay-inset budget wasn't
scaled (wrapping the address at HiDPI). All now go through ui::Layout::dpiScale().

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-10-18 11:39:45 -05:00
DanSandClaude Opus 4.8 ae16c06d24 feat(contacts,chat): a "Message" action linking Contacts to Chat
- Contacts: a "Message" context-menu item for shielded (z) contacts hands the
  address to the Chat tab (new OpenChatNewConversation entry point) and switches
  there with the New-conversation dialog prefilled; disabled with a reason for
  transparent-only contacts. (Kept to the safe context-menu + picker path, not
  the hand-drawn 4-slot row action cluster, to avoid mis-wiring the destructive
  Delete slot.)
- Chat: the New-conversation picker no longer silently hides transparent
  contacts — they're shown disabled with a "needs a shielded address to chat"
  tooltip instead of vanishing.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-10-18 09:52:21 -05:00
DanSandClaude Opus 4.8 408393a547 feat(chat): add a "Copy my chat address" button
The conversation header only copies a PEER's address, so a user had no way to
hand out their own chat address for others to message them. Add a "Copy my chat
address" link in the left pane (public App::chatMyAddress() forwards the stable
seed-derived reply z-address; copied on click only, with an "address copied"
toast and a warning if none exists yet).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-10-18 09:45:03 -05:00
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 5cfa713be1 test(sweep): capture the no-peers node banner on its full-node sweep target
renderNodeStatusBanner suppresses itself during a UI sweep (the forced demo
state would otherwise paint a banner over every tab). Add a narrow opt-in
(sweep_show_node_banner_) so ONLY the overview-no-peers target shows it; every
other capture is unchanged. The target also clears connection_in_progress_ so
the evaluator classifies the state as no-peers rather than "connecting".

Verified in a full-node + DRAGONX_ENABLE_UI_SWEEP build: the banner renders as
"Connected, but no network peers — the node can't sync · Check your internet
connection and firewall, then restart the node to find peers." (Restart button
only appears with an embedded daemon, which the sweep has none of.)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-10-17 23:59:03 -05:00
DanSandClaude Opus 4.8 cd9ca4cd77 test(sweep): add UI-sweep targets for the new syncing / no-peers / chat-identity states
Capture the states the earlier verification couldn't reach:
- overview-syncing: the balance "catching up — funds are safe" strip (P4).
- overview-no-peers: the node-status banner's no-peers detail (P2; full-node
  only — renders as a plain Overview in the Lite sweep binary).
- chat-identity-preparing / -unavailable: the chat empty states that used to
  all say "unlock your wallet" (P3). Setup clears the fabricated demo identity
  (clearIdentity wipes only the key; the demo message store survives);
  sweepEnsureDemoChatIdentity() re-fabricates on teardown so later targets keep
  the populated chat UI.

Also make the demo baseline a genuinely synced node (tip_known=true) so the
no-peers banner only appears on its own target, and dismiss the lite first-run
welcome prompt during the demo so it stops occluding every lite screenshot
(snapshotted + restored). Verified: all four new captures render correctly.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-10-17 17:27:22 -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 6efe3c0f5c feat(send,peers): show full recipient + contact label on confirm; actionable peers empty state
- send confirm: the FROM/TO cards now show the FULL address (wrapped) instead
  of a middle-truncated preview, and the saved-contact label when the address
  matches the address book ("TO  ·  Alice") — the strongest human check against
  a swapped recipient on the one screen meant to catch it. Cards auto-grow.
- peers tab: the "No connected peers" empty state now carries the same
  actionable hint as the node-status banner instead of being a bare dead-end.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-10-17 16:37:28 -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 9ff64b1846 perf(history): band the full sent-sweep so it doesn't hold cs_main for minutes
A full z_listsentbyaddress holds the daemon's cs_main for the whole sweep --
~140 s on a large wallet on a daemon without the sent-history decrypt cache --
which starves block connection and makes every other RPC (getinfo, balances)
wait, so the node looks "busy". This runs the full sweep in depth bands so no
single call holds the lock for long.

- The full case now covers [0, tip] in bands: z_listsentbyaddress(z, minconf=a,
  filter-2, maxDepth=b) returns exactly depth [a, b]. Newest band first (its
  minconf=0 includes the mempool); results dedupe by txid across the overlap.
- Each band is a separate call, so cs_main is held only ~5 s at a time and the
  node stays responsive between bands (block connection isn't starved, the busy
  canary answers). Total CPU is unchanged -- just not in one long lock.
- sentSweepNextBandSize adapts the next band toward ~5 s: grows up to 4x in
  sparse depth (empty regions covered fast), shrinks to 1/2x in dense depth.
- W advances only after every band succeeds; a mid-sweep failure re-sweeps
  rather than marking a partial result. The windowed sweep is unchanged.
- Each band also fits the 120 s timeout on its own, so the seed completes even
  on a daemon where the single whole-wallet call used to time out.

This removes the ~2.5 min hang on the first-run seed, the weekly safety-net
re-sweep, and any invalidation. Multi-band + band-sizing covered by tests.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-10-17 14:42:16 -05:00
DanSandClaude Opus 4.8 b47d55a3d0 feat(balance): show total period-earnings in every layout
#4 wired the total-earnings figure into the compact hero and the classic
layout only. This adds it to the remaining layouts in each one's native
style: donut / shield / timeline / minimal beside the USD line, consolidated
to the right of the USD, vertical-stack left of the Total row's amount, and
vertical-2x2 as a second line under the Total cell (dashboard and two-row
already show it via RenderCompactHero). All go through FormatTotalEarnings,
which returns nothing unless the global earnings toggle is on, so every
layout's default appearance is unchanged.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-10-16 20:02:04 -05:00
DanSandClaude Opus 4.8 07437fb447 perf(history): weekly safety-net full re-sweep for sent history
Persisting the sent-sweep watermark means a relaunch runs a windowed sweep,
which can't see a change made outside the GUI (a Console z_importviewingkey,
an external same-seed restore) that adds an old send below the window. This
adds a periodic full re-sweep so that case is still caught within ~7 days.

- sentSweepSafetyNetDue(now, lastFull) = lastFull > 0 && now - lastFull > 7d
  (a wallet that has never full-swept doesn't trigger one; the seeding sweep
  records the time). lastFullTime / lastFullHeight ride in the sent_sweep
  snapshot; any successful full sweep (seed, verify-mismatch or safety-net)
  resets the clock. On load, a valid watermark without a clock starts it now,
  so even watermarks seeded before this field eventually re-sweep.
- When due, the snapshot forces a full sweep for that cycle. Gated on: a
  watermark exists, a routine (non-syncing) cycle, not the Mining page, and
  not in full-sweep backoff -- so a full sweep that can't finish on a slow
  daemon backs off instead of retrying every cycle.

Degrades gracefully: ~0.4 s on a note-cache daemon; callLong(120 s) with
backoff on an old one (the deferred banding would keep each cs_main hold
short so it fits there too). Helper + persistence covered by tests.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-10-16 17:56:30 -05:00
DanSandClaude Opus 4.8 e28cacbe8e style(balance): use the shared SegmentedControl for the earnings toolbar
The Overview earnings controls (interval 1H/1D/1M/1Y, received/net mode, and the
global show-all toggle) were ad-hoc ImGui buttons. They now use
material::SegmentedControl -- the same rounded-track pill the Receive tab's
All/Shielded/Mining filter uses -- so the interval is a text segmented control,
the mode is two icon segments with per-segment tooltips, and the toggle is a
single matching segment (highlighted when on). Verified in a headless GL capture.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-10-16 16:48:33 -05:00
DanSandClaude Opus 4.8 46371c69df perf(balance): stop large-wallet balance polls from saturating the node
On a wallet with a very large shielded note set, the daemon re-decrypts all
notes under cs_main on every z_gettotalbalance / z_listunspent (GetFilteredNotes),
~15-33s per pass. The GUI issued these roughly every 15s and timed them out at
the 30s RPC limit, so the node looked permanently "busy" and per-address
balances read 0 (nothing to carry on a fresh session) while the total was fine.

Two wallet-side mitigations:
- A cost-proportional floor on VISIBLE balance polls (wallet_scan_governor): the
  adaptive backoff previously applied only when the balance was off-screen. It
  now also holds a visible poll until ~3x the measured scan cost has elapsed, so
  a wallet whose sweep costs tens of seconds can't re-poll before the node
  recovers. Negligible for small wallets (cost ~ms); forced / first-read bypass it.
- The heavy balance RPCs (z_gettotalbalance, z_listunspent) now use a 120s reply
  timeout via the existing callLong path, so a slow pass completes and populates
  per-address balances instead of aborting at 30s and leaving them at 0.

The root fix is daemon-side (cache decrypted notes so GetFilteredNotes isn't
O(notes x decrypt) per call); specced separately. Adds governor test coverage.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-10-16 16:48:24 -05:00
DanSandClaude Opus 4.8 3cda8c9e31 perf(history): persist the sent-sweep watermark (windowed relaunch)
z_listsentbyaddress re-walks the whole wallet when there's no usable
watermark, which the daemon does under cs_main for every send -- hundreds
of seconds, 400+ s and ~19 GB RSS on a large wallet. The watermark lived
only in memory, so every relaunch, reconnect or daemon restart paid for a
full sweep. This persists it so those cases run a bounded windowed sweep
([z, 0, 2, tip - W + margin]) instead (SENT-HISTORY-SWEEP Part 1).

- The watermark (W = last swept tip), its anchor (A = W - margin) and anchor
  hash (H_A = getblockhash(A)), plus the enrichment backlog and the full-
  sweep backoff, ride in the encrypted history snapshot as an optional
  `sent_sweep` object (schema stays v1; a missing/inconsistent one reads as
  "no watermark" = today's behaviour, so older builds and payloads are safe).
- Before the first windowed sweep after a load, the anchor is verified
  (getblockhash(A) == H_A). A match means any fork is inside the window; a
  mismatch (reorg deeper than the margin, different chain/datadir) or an
  inability to check drops the watermark and sweeps the whole wallet.
- The first sweep after a load uses a bigger margin (absorbs notifier lag),
  and a watermark older than 2400 blocks uses the long timeout (callLong).
- The backlog is persisted WITH W: a pure z->z send's txid lives only in a
  sweep result and would be lost if the window moved past an un-enriched one.
- Watermark changes reach disk (folded into the store signature), and every
  hard case still forces a full sweep: reorg (anchor verify), GUI rescan /
  zap / import / address change, restoreOriginalWallet, and disconnect.

Deferred (follow-ups, each only turns a correct-but-slow case faster, never a
correctness risk): the banded full sweep for seeding on a pre-note-cache
daemon, the external-import tripwire, and the weekly safety-net re-sweep.
Part 2 (the daemon's decrypt cost) is already on the daemon's
perf/sapling-note-cache branch.

Pure helpers + cache round-trip + anchor-verify/large-window covered by
tests; the getblockhash-after-sweep updates the pinned RPC sequences.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-10-16 14:47:48 -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 2ef91ea7d4 feat(settings): period-earnings preferences + per-address opt-in
Persists the Balance-tab earnings settings: interval (0=Hour 1=Day 2=Month
3=Year, default Day), mode (0=Inflows 1=Net, default Inflows), display
format (0=Coin 1=Coin+fiat 2=Fiat, default Coin+fiat), a global show-all
toggle (default off), and an opt-in set of addresses whose earnings column
is shown when show-all is off. All values are clamped on load and saved,
mirroring the existing favorite/hidden address sets.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-10-15 11:22:12 -05:00
DanSandClaude Opus 4.8 19da7d1474 feat(data): period-earnings aggregation for the Balance tab
Adds src/data/earnings_series.h: a pure, header-only aggregator that sums
wallet inflows (received + mined) over the current calendar period (hour /
day / month / year), optionally net of sends, both per receiving address
and wallet-wide, at "as seen" values. Attribution is best-effort: inflows
map to the receiving address; in net mode a send debits from_address when
present (a send with no from_address still counts in the wallet total).

Includes a revision-keyed EarningsCache (mirrors ui::MiningEarningsCache)
and a 13-assertion unit test covering inflows vs net, per-address
attribution, period exclusion, period nesting, and cache invalidation.

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 e40c6c0a7c perf(market): clip off-screen rows in the portfolio editor
The portfolio-editor address list drew every row every frame inside the
fade-scroll child. It now runs through an ImGuiListClipper; the fixed
step (the InvisibleButton height plus item spacing) keeps each row's
cursor exactly where the unclipped loop put it, so the scroll extent for
the fade shader, the check marks, the selection and the "selected rows
float to the top" ordering are all unchanged.

Verified on DX11 (Windows) at normal and large window sizes and at
font scale 1.5, including skin switch and resize with the dialog open.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-10-14 16:55:07 -05:00
DanSandClaude Opus 4.8 4d95ef68f6 perf(contacts): clip off-screen rows in the Cards and List views
The contacts Cards/List row loop submitted every contact every frame.
It now runs through an ImGuiListClipper, so only the on-screen rows are
built; the fixed step (row height plus the item spacing pushed above)
matches the per-row cursor exactly, so the scroll extent, fade, hover
actions, selection and keyboard navigation are unchanged. Table view is
left unclipped (its rows can wrap).

Verified on DX11 (Windows) at normal and large window sizes and at
font scale 1.5, in both Cards and List views.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-10-14 16:55:07 -05:00
DanSandClaude Opus 4.8 3f85953452 perf(ui): stop backdrop effects requesting frames behind a modal
While a blur overlay (a modal dialog or the lock screen) covers the
window, the theme backdrop effects kept requesting animation frames
even though they are not visible, so the app never went idle and
re-rendered continuously.

The ambient animation predicate now drops the backdrop-effects term
when a blur overlay was active last frame. Reduce Motion and the
unfocused/hidden throttle were already handled, and the overlay's own
moving elements (the lock screen's unlock dots, a spinner) still request
frames through the progress term, so nothing on screen freezes.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-10-14 12:36:43 -05:00
DanSandClaude Opus 4.8 5a3d36904c test(litebe): make the retire test deterministic under the change-gate
The change-gated refresh worker only runs `balance` on a full-refresh
cycle, so testLiteWalletControllerRetire could no longer reliably park
the worker inside a blocked `balance` (it passed only by timing). Bump
the fake scanned height to force a full-refresh cycle, which enters the
now-blocked `balance` deterministically.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-10-14 12:36:43 -05:00
DanSandClaude Opus 4.8 ef14518089 perf(litebe): change-gate the idle lite refresh worker
The background 2 s refresh worker re-queried and re-copied the whole
wallet every cycle (a fresh TLS refresh plus a multi-megabyte JSON
build, copies and DOM parse), even when nothing had changed, so CPU and
the network never went idle.

The worker now skips the full refresh and the re-publish on a cycle that
is provably unchanged, while never letting the UI show stale confirmed
balance or transactions:

- The lite bridge keeps a monotonic mutation epoch, bumped after any
  command not on a read-only allow-list, so a local change made on any
  thread (send, shield, import, new address, encrypt/unlock/lock,
  console) is detected at one choke point.
- Each cycle the worker runs one cheap sync probe (one connection, the
  mandatory block ingest) and a local height read, and refreshes only
  when the scanned height advanced, the mutation epoch moved, the
  encryption state changed, a sync is in progress, or a ~10 s heartbeat
  elapsed; otherwise it skips. Direct/synchronous callers are not gated.
- A failed probe backs off instead of running a second sync, so a dead
  server is not retried back-to-back.

Confirmed funds can never be stale beyond one cycle (every confirmed
change advances the height or bumps the epoch); only unconfirmed mempool
rows can lag up to the heartbeat, and the spend path re-queries the
backend directly rather than reading the gated model. Average idle cost
drops from three data connections per cycle to about one.

Tests: a bridge mutation-epoch test and a worker change-gate test
(skip-when-unchanged; refresh on height advance, local mutation and
encryption change) against the fake backend.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-10-14 12:00:26 -05:00
DanSandClaude Opus 4.8 39292b1b1f feat(litebe): salted Argon2id KDF for new encrypted wallets
New Lite wallets now encrypt their seed and keys with a salted Argon2id
key instead of the unsalted double-SHA256 of the passphrase, which was
cheap to brute-force from a stolen wallet file.

- A shared derive_wallet_key() replaces double-SHA256 across encrypt,
  unlock, remove-encryption and seed recovery. KDF 0 is the old
  double-SHA256 (unchanged); KDF 1 is Argon2id (moderate: 3 passes,
  256 MiB), using sodiumoxide's existing pwhash -- no new dependency.
- The KDF id, salt and parameters are stored in the wallet. A wallet is
  written at version 10 only while it is actually Argon2id-encrypted;
  every pre-existing wallet (plaintext or double-SHA256) is read and
  written byte-for-byte as version 9.
- New-wallets-only: a freshly created or restored wallet encrypts with
  Argon2id; a wallet read from disk never changes its KDF, and removing
  encryption never changes it. Set DRAGONX_LITE_KDF=legacy to keep new
  wallets on double-SHA256.
- An older build (or SilentDragonXLite) opening a version-10 wallet
  fails cleanly with "don't know how to read wallet version 10" before
  parsing anything, never a silent misread; the seed phrase stays the
  cross-wallet recovery path.

Compatibility break (by design, opt-in): a wallet newly encrypted with
Argon2id cannot be opened by older ObsidianDragon or SilentDragonXLite.

Tests: the existing encryption tests now exercise the Argon2id path,
plus the version-10 header, legacy byte-identity and derive_wallet_key
parameter bounds. Validated by the lite oracle suite and the old-vs-new
serialization differential (legacy wallets byte-identical; version-10
round-trips).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-10-14 10:37:34 -05:00
DanSandClaude Opus 4.8 6d4ed96f6d fix(litebe): make HD t-address derivation atomic under scan
Deriving the next HD transparent address read the key count under a
read lock, released it, derived the address, and only then took a
write lock to append it. Between reading the position and appending,
another caller - for instance a scan deriving an address at the same
time - could read the same position, derive the same address, and
append it too, leaving a duplicate transparent key in the wallet.

- The whole read-modify-write now runs under a single write guard, so
  the position a caller derives from reflects every key already
  added. The slow seed stretch is computed once before the guard is
  taken, so holding the lock does not block on it.
- As a further guard, derivation skips a position whose address is
  already present and advances to the next, so no path can produce a
  duplicate even if positions were ever to overlap.
- The starting index is computed exactly as before (it still counts
  imported keys too), so a wallet with no contention derives the same
  addresses in the same order.

Uncontended derivation produces the same addresses in the same order,
verified against the differential harness. The wallet file format is
unchanged.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-10-14 08:50:12 -05:00
DanSandClaude Opus 4.8 b49f85bcbe perf(litebe): take a read lock for wallet serialization in do_save
Saving the wallet serializes it to a temporary file under the sync
lock, but it took a write guard on the wallet to do so. Serialization
only reads wallet state, so the write guard needlessly excluded other
readers for the whole duration of the write, buffering, and flush.

- The save path now takes a read guard to serialize. A read guard
  still excludes writers, so nothing can mutate the wallet mid-write;
  it only lets other readers proceed in parallel. The surrounding
  sync lock still serializes saves against syncs, and the atomic
  temp-file-then-rename sequence is unchanged.

The serialized bytes are identical, verified against the differential
harness. The wallet file format is unchanged.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-10-14 08:50:12 -05:00
DanSandClaude Opus 4.8 0bdea68337 perf(litebe): memoize per-note viewing-key decode on wallet open
Opening a wallet reads each shielded note from disk, and every note
record embeds a 169-byte extended full viewing key that was decoded
from scratch for every note. That decode is an elliptic-curve parse;
a wallet with many notes sharing a handful of keys paid for it once
per note, which dominated open time.

- Note reads now go through a small content-addressed cache keyed on
  the exact 169 raw key bytes. The first note with a given key
  decodes it; the rest reuse the result. Because the cache is keyed
  on the raw bytes and the decoder is deterministic, the cached key
  is identical to a fresh parse.
- The cache is thread-local and naturally bounded by the few distinct
  keys a wallet holds, so it adds no cross-thread state and cannot
  grow without bound.

The bytes on disk are read exactly as before - this only avoids
re-parsing identical bytes - so the loaded wallet is byte-identical,
verified against the differential harness. The wallet file format is
unchanged.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-10-14 08:50:12 -05:00
DanSandClaude Opus 4.8 c045e56d4e perf(litebe-sync): bound the gRPC block channel with backpressure
Each block range is streamed over one gRPC connection into a channel
that a worker thread drains, encodes, and scans. That channel was
unbounded, so when the server delivered blocks faster than the
scanner consumed them the backlog piled up in memory with no ceiling
- on a fast link against a large range it could grow without limit.

- The channel is now a bounded synchronous channel holding at most
  2048 blocks. When it fills, the fetch side waits, applying
  backpressure up the stream instead of buffering endlessly, so peak
  memory is capped no matter how far ahead the server runs.
- The batch size is untouched: it still streams a whole range over a
  single connection, which is what keeps the connection count low
  enough to avoid socket-buffer exhaustion on Windows. Only the
  in-flight depth between fetch and scan is now bounded.

The same blocks are fetched and scanned in the same order, so the
resulting wallet is byte-identical, verified against the differential
harness. The wallet file format is unchanged.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-10-14 08:50:12 -05:00
DanSandClaude Opus 4.8 be69c9bafe fix(litebe-sync): bound the reorg recovery window to the max depth
When sync hit a reorg it stepped the scan back to just before the
invalidated block, then set the top of the re-scan range to that
point plus the full forward batch size. That window is far larger
than any reorg can be: the batch size is tuned for forward streaming
(tens of thousands of blocks), while a reorg is capped at the maximum
reorg depth and the pass aborts once it is exceeded. So the recovery
pass re-fetched and re-scanned a large span of already-good blocks
before it could resume moving forward.

- The recovery range now reaches only the maximum reorg depth (plus
  one) above the rewind point, which is the most a reorg can ever
  disturb. Forward syncing still uses the full batch size; only the
  pass immediately after a reorg is bounded this way.
- The set of blocks that ultimately get scanned is unchanged - the
  next pass picks up from where recovery stops - so the resulting
  wallet is identical, just reached with far less redundant fetching
  and scanning.

The wallet file format is unchanged.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-10-14 08:50:12 -05:00
DanSandClaude Opus 4.8 d9dae45c39 fix(lite): flush the wallet save on quit before _Exit
On quit the lite controller's destructor never runs, because main()
ends the process with _Exit(). Any state the background refresh had
scanned since the last save was lost: the destructor is where that
final save would normally happen, and it was being skipped on the
one path that always runs.

- shutdown() now calls a new flushOnExit() that persists once on the
  way out, bounded to 4 s so the existing 15 s shutdown watchdog
  budget still holds. On timeout it abandons the save thread and lets
  _Exit follow.
- It is a plain save through the same backend path, which writes a
  temp file and renames it into place. That cannot corrupt the wallet
  if it is cut off or abandoned, and it does not change the wallet
  file format - it only persists sooner on the quit path.
- It reuses the destructor's guards and also skips when a wallet op
  is already in progress: shutdown() has already waited for ops to go
  idle, and a still-pending op holds the sync lock and issues its own
  save, so flushOnExit would only block the budget on a save that
  cannot take the lock.

Tests cover a save on the open, synced, idle path (exactly once), a
blocked save returning within the budget, and the retired and
never-opened guards skipping the save.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-10-14 07:51:34 -05:00
DanSandClaude Opus 4.8 e2210cf5d3 fix(lite): refresh the sidebar unconfirmed badge on confirmations
The sidebar unconfirmed-tx badge was recomputed only when the tx list
changed, keyed on last_tx_update plus the list size. That missed the
one case that matters most: a transaction confirming without the list
count changing. last_tx_update did not move for a same-second content
change, and lite never stamped it at all, so the badge stuck on a
stale 0-conf count until some unrelated refresh happened to shift the
list size.

- The badge now keys on tx_revision, which the state bumps on every
  content change - including a confirmation flip at an unchanged tx
  count - and which lite stamps too, so the count tracks confirmations
  on both the full node and the lite wallet.
- The sentinel starts at 0, which tx_revision never takes (it starts
  at 1), so the first frame always computes once.
- The recompute still runs only when the revision actually moved, so
  the per-frame work the previous guard avoided is still avoided.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-10-14 07:51:34 -05:00
DanSandClaude Opus 4.8 25ad23aee4 fix(tx): de-duplicate transparent rows across listtransactions pages
Transparent history is gathered by skip-paging listtransactions. The
underlying list can shift between the per-page RPCs - a new
transaction arriving pushes one page's tail into the next page's head
- so a row could be collected twice and show up as a duplicate in
History.

- Pages are now de-duplicated on the daemon's stable per-output
  identity: txid, send/non-send class, and vout. Keying on the class
  rather than the exact category means a coinbase that matures
  (immature to generate) between two fetches is still recognised as
  the same row; keeping vout means two equal-value outputs of one
  transaction stay distinct. No real transaction row is dropped or
  duplicated.
- Rows with no txid or vout ("move") are never keyed and pass through
  (they are dropped downstream anyway), so the de-dup fails open.
- The raw page size still drives the stop condition and the next-run
  row hint, independent of how many rows were unique.

Tests cover a shifted page whose head repeats the prior page's last
row (dropped), a sibling output of the same tx (kept), and an
un-keyable row (kept).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-10-14 07:21:07 -05:00
DanSandClaude Opus 4.8 6a6ac8cf99 fix(tx): list transactions with account "*" so labelled rows show
listtransactions was called with the default "" account, which
returns only rows in the unnamed account. Transactions tied to a
labelled address were left out of the History tab entirely.

- All three listtransactions call sites now pass "*", which spans
  every account, so labelled-address rows appear alongside the rest.
- With "*" the daemon also emits "move" accounting rows that have no
  txid, address or vout. Those are not wallet transactions, so they
  are skipped and never become blank History rows. No real
  transaction row is dropped by this filter.

Tests cover that a "move" row is skipped while a labelled receive row
is kept, and that the transparent collectors now page with "*".

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-10-14 07:21:07 -05:00
DanSandClaude Opus 4.8 998788d351 perf(rpc): move the result subtree out of the parse tree
parseRpcResult copied the "result" node out of the parsed reply,
because indexing the JSON object yields an lvalue reference. On large
replies - full transaction lists, unspent-note sets - that duplicated
the entire subtree for no reason, and it also meant a secret-bearing
result then existed in two trees at once.

- The result node is now moved out of the parse tree instead of
  copied, so the secret lives only in the returned value that the
  calling code already wipes. The leftover result slot is moved-from
  (null), so the existing secret-scrub of the discarded tree still
  runs harmlessly.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-10-14 07:21:07 -05:00
DanSandClaude Opus 4.8 16371c5879 fix(rpc): bypass the env proxy for loopback RPC and local stats
Loopback HTTP was still subject to the process environment's proxy
settings (http_proxy / all_proxy). That routed the local daemon's
basic-auth credentials and wallet data - and the miner's 127.0.0.1
stats API - through whatever proxy the user's shell happened to
export, and could break the connection outright.

- Local daemon RPC now sets curl's no-proxy to "*" when the host is
  loopback, so it always connects directly. This also overrides a
  no_proxy list. Remote daemons keep full proxy support unchanged.
- The xmrig stats fetch against the local miner API does the same.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-10-14 07:21:07 -05:00
DanSandClaude Opus 4.8 d9143f0e18 fix(rpc): keep the worker alive on non-std throws; atomic flags
The RPC worker loop only caught std::exception. A task that threw
anything else - a non-std exception propagating out of a posted job -
escaped the loop and tore down the worker thread, so every later RPC
silently went unanswered.

- The worker loop now also has a catch-all that swallows non-std
  throws and keeps the thread running. Such a job still loses its
  main-thread callback, exactly as the std::exception path already
  did; the only change is that the worker survives.
- RPCClient's connected_ and warming_up_ flags are now atomic. They
  are read from the UI thread while the worker updates them, so the
  plain bools were a data race.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-10-14 07:21:07 -05:00
DanSandClaude Opus 4.8 7e7804e577 perf(daemon): skip the UI-thread datadir lock wait
Starting the daemon polls for an already-running node holding the
datadir, sleeping up to three 100 ms intervals to let a shutting-down
node clear out. When a start runs on the UI thread, that is up to 300 ms
of blocking sleep on the render thread.

- The lock-wait poll count is now a parameter. A start invoked on the UI
  thread passes 0, which does a single check and never sleeps; every
  other caller keeps the three-poll wait. The default is unchanged, so
  background starts behave exactly as before.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-10-14 06:26:13 -05:00
DanSandClaude Opus 4.8 8df0fe1734 perf(app): run miner shutdown off the UI thread
At quit the UI thread drained and joined the mining-control thread and
then called the miner's blocking stop with a 3 s budget. Both ran on the
UI thread, so the window could stall for up to that long before the
shutdown screen rendered.

- Quit now only signals the mining-control thread (no join) on the UI
  thread. The blocking teardown — join the control thread, then stop the
  miner — moves to a background thread, so xmrig still has a single
  driver but the UI thread never blocks on it.
- On the daemon-stop path that teardown runs first on the shutdown
  thread, so the miner is fully stopped before the daemon stop begins.
  On the disconnect-only quit paths it runs on its own thread, which
  the final shutdown joins before tearing xmrig down, so there is never
  a second concurrent join of the control thread.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-10-14 06:26:13 -05:00
DanSandClaude Opus 4.8 30f5243dd1 perf(daemon): back off the monitor poll when idle
The daemon monitor thread drained output and then slept a fixed short
interval on every tick, waking several times a second for the life of
the node even long after startup, when the node emits almost nothing.
Stopping it also meant waiting out the current sleep before it saw the
stop flag.

- The monitor now waits on a condition variable instead of a plain
  sleep, so a stop request wakes it immediately rather than after the
  interval. The stop flag is set under the monitor's mutex so the wake
  cannot be lost.
- The poll interval stays fast for the first 30 s after the thread
  starts and for 3 s after any output, and otherwise doubles per idle
  tick up to a 500 ms cap. Any new output drops it straight back to
  fast, so responsiveness during startup, rescan and shutdown is
  unchanged while a quiet node is polled far less often. The interval
  choice is a pure function.

Tests cover the interval function across the warmup window, the
recent-data window, the reset on new output, the doubling, and the cap.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-10-14 06:26:13 -05:00
DanSandClaude Opus 4.8 b407b8927a perf(miner): abort xmrig stat fetches on stop
The miner's stats and pool-API fetches are blocking HTTP calls on the
monitor thread with multi-second timeouts. When the miner was stopped,
stop() still had to wait out an in-flight request before the monitor
thread could join.

- Both fetches now install a curl progress callback that aborts the
  transfer as soon as a stop is requested, so a request in flight ends
  promptly instead of running down its timeout.
- An abort triggered by stop is treated as a clean cancellation, not a
  fetch failure, so it is not logged or counted against the failure
  warning.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-10-14 06:26:13 -05:00
DanSandClaude Opus 4.8 60b3ad5d5a fix(miner): close the xmrig stdout fd after the monitor join
Stopping the miner closed the stdout pipe before joining the monitor
thread, and the UI thread's stats poll also drained stdout. Either path
could read or close a descriptor the other side was still using, so a
read could land on a closed or already-reused fd.

- stop() now joins the monitor thread first and only then closes the
  process handle and the stdout pipe, so nothing is draining the pipe
  when it is closed.
- The stdout drain is serialized under its own mutex, and the UI
  thread's stats poll no longer drains stdout at all: the monitor thread
  is the sole reader. The process handle, pid and fds are atomic so they
  can be observed safely from the memory probe.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-10-14 06:26:13 -05:00
DanSandClaude Opus 4.8 614dfbbc5b fix(daemon): let DRAGONX.conf dbcache override the pinned value
The wallet pins a -dbcache value on the daemon command line. A
command-line flag overrides the same key in DRAGONX.conf, so a user who
set their own dbcache in the conf never got it: the wallet's pin always
won.

- Before spawning, a normal start now reads DRAGONX.conf once and, if it
  sets dbcache, drops the wallet's own -dbcache pin from the arguments
  so the user's value takes effect. An isolated start keeps its own
  datadir and conf and is left alone.
- The conf is now read a single time for both this check and the
  existing rpcthreads check instead of twice.

Tests cover dropping the pin when the conf sets dbcache (in various
forms), and leaving the default arguments untouched when it is
commented out, empty, or sets something else.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-10-14 06:26:13 -05:00
DanSandClaude Opus 4.8 a6b7b966b0 fix(daemon): reset the console offset to the watermark on restart
When the daemon was restarted, the output-parse offset was reset to 0.
The daemon's output buffer is retained across runs, so the next poll
re-read the whole retained tail of the previous run and fed it to the
busy tracker as if it were fresh. A witness rebuild or rescan line left
over from the old run could then be mistaken for activity in the new
one.

- On restart the offset is now reset to the absolute end of the output
  at that moment, captured on the lifecycle thread after the stop and
  before the start, so the UI resumes parsing only the new run's output
  rather than replaying the old tail.
- The capture is handed to the UI thread through a pending flag and
  applied there before the next read, both on the normal poll and when
  skipping the backlog, so the offset is only ever moved on the thread
  that owns it.

Tests cover reading from the captured watermark (only the new run is
seen) versus the old reset-to-0 behaviour (the previous run replays).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-10-14 06:26:13 -05:00
DanSandClaude Opus 4.8 e9042f091e refactor(daemon): share the output ring-window helper
The embedded daemon and the xmrig manager each kept their own copy of
the console output ring: append new bytes, trim the front to a cap at a
newline boundary, and hand a reader everything since its offset. The two
copies had drifted. The daemon tracked an absolute byte total so a
reader's offset stayed valid across a front-trim, while the miner keyed
its reader off the live buffer size, so a trim could silently rewind the
console feed.

- The append/trim and the since-offset logic now live in one small
  header, outputWindowSince and appendBounded, included by both. The
  trim keeps the newline-boundary cut and the guard that stops a lone
  trailing newline from erasing the whole buffer.
- The xmrig manager now carries the same absolute byte total, so its
  reader resolves against the window the same way and survives a trim
  (and a process restart that clears the buffer but keeps the total).

Tests cover feeding the ring well past the trim point while a reader
polls each chunk (the feed is continuous and never dies at the trim),
the lone-trailing-newline guard, and a cleared buffer with a retained
total.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-10-14 06:26:13 -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 3fd52fbdaf perf(chat): ingest a message batch in one transaction
Ingesting a scan's worth of chat messages persisted each new message
with its own append() call, so a batch of fresh messages opened and
committed one SQLite transaction per row.

- A new appendBatch persists many messages in a single transaction,
  using the same INSERT OR IGNORE dedup key as append() and preserving
  input order so load()'s rowid ordering is unchanged.
- Every message is serialized and encrypted before the transaction
  opens, so a message with a non-UTF-8 body is skipped on its own
  without leaving a transaction open; the valid siblings still persist.
- Ingest collects the newly stored messages and writes them once after
  the loop instead of per message. It is inert (returns 0) while locked
  or on empty input.

Tests cover a batch insert, dedup on re-insert, empty input, bad-UTF-8
isolation with a valid sibling and a following append(), insert order,
and the locked no-op.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-10-13 22:03:00 -05:00
DanSandClaude Opus 4.8 1416654385 perf(tx-cache): skip Argon2id for unencrypted wallets
Opening the transaction-history cache always derived its encryption key
with Argon2id, even for an unencrypted wallet. For those wallets the
passphrase is just a value derivable from the database itself, so the
~50-100 ms Argon2id stall bought no additional secrecy and ran on the
UI thread at open.

- Unencrypted wallets now derive the cache key with a cheap,
  domain-separated BLAKE2b KDF over the wallet identity, keyed by the
  per-wallet salt. The secret is derivable from the DB, so this is
  security-equivalent for the unencrypted case and avoids the stall.
- Passphrase-encrypted wallets are unchanged: they keep the Argon2id
  key derivation. The two paths use separate domain labels, so the
  fast-path key and the Argon2id key never collide for the same DB and
  salt; a cache written under one cannot be read under the other.

Tests cover the fast-path round-trip and reopen, and assert that an
Argon2id unlock of the same DB produces a different key and fails to
read the fast-path snapshot.

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 2396ed33db fix(settings): tolerate wrongly-typed values instead of quarantining
Several settings were read with json's value()/get<T>() helpers that
assume the stored type. A hand-edited settings file, or one re-saved
after an inf/NaN float was written back as JSON null, could therefore
hold a value of the wrong type and throw during load. The whole load is
wrapped so a throw quarantines the entire settings file to a
.corrupt- copy, meaning one bad field reset every unrelated setting.

- ui_opacity is accepted only when it is actually a number, and it is
  clamped to the same 0.3..1.0 range the setter enforces; the legacy
  reduced_transparency migration likewise runs only when that key is a
  boolean.
- A non-string name in a blocked chat conversation, and a non-string
  scope in a portfolio entry, now fall back to an empty string instead
  of throwing.

The file loads with the offending field defaulted, and the rest of the
settings survive.

Tests cover ui_opacity as a string/null/int/float/bool, a non-string
blocked-conversation name and portfolio scope, and the legacy
reduced_transparency migration, asserting no quarantine file is written.

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 e1003cba43 perf(migrate): gate the seed-migration confirming balance scan
While the seed migration sits on the Confirming gate it polled the
sweep's confirmations and a full wallet balance together on every pass.
That balance call is the expensive one, and it only matters once the
sweep is mined and we do not yet have a post-mine figure, yet it ran
every poll even mid-sync when it cannot move the gate.

- The confirmation count is still read on every poll.
- The balance scan now runs only when it can actually advance the adopt
  gate: the sweep is mined, no post-mine balance has been recorded yet,
  and no sync is in progress. The manual Refresh button forces it
  regardless.
- The decision lives in a small pure helper, and the poller snapshots
  the main-thread state it needs rather than reading members off the
  worker thread.
- The Confirming screen now claims a leftover remainder (and offers the
  re-sweep button) only once the balance has actually been measured; while
  it is still unknown it keeps waiting instead of prompting on a sentinel.

Tests cover the gate helper across the forced, syncing, and mined cases.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-10-13 19:24:12 -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 edbb98d9ec perf(chat): single-pass watermark and move-append on chat load
Loading chat history did more work than it needed to. Seeding the unread
watermark walked every conversation and copied each one's full message
list out of the store just to read the last timestamp, and the database
reload copied every message into the store one at a time with no
reservation.

- The watermark now comes from one pass over the stored messages that
  returns the latest timestamp per conversation, with no per-
  conversation copy or sort.
- The store gained a move-append and a reserve, so the reload pre-sizes
  its vector and dedup set once and moves each row in instead of copying
  it. The dedup key is computed before the move.

Behaviour is unchanged: the watermark values and the blocked-conversation
filter on reload are the same as before.

Tests cover the per-conversation latest-timestamp pass and move-append.

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 100d76b88f fix(input): handle the first Alt+wheel while idle in the wait path
Alt + mouse wheel adjusts the font scale, but it was only handled in the
poll loop that runs after the app wakes. When the app was idle it blocks
in a single timed wait, and that first wheel event went straight to
ImGui, scrolling the window instead of changing the scale until a later
event arrived.

- The Alt+wheel handling is pulled into a shared helper used by both the
  idle wait path and the poll loop, so the first event while idle is
  consumed and triggers a redraw.
- Behaviour is otherwise unchanged: the visual scale updates smoothly and
  the font atlas is rebuilt once scrolling stops.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-10-13 18:16:50 -05:00
DanSandClaude Opus 4.8 fa1ba97286 perf(win): skip DWM blur/acrylic when the window starts opaque
The window always asked DWM for a full-window blur region and, on
Windows 11, an acrylic backdrop. When the user runs with an opaque
window (low-spec mode or full opacity) that desktop blur is never seen
but DWM still composites it every frame.

- An early read of the saved settings decides whether the window starts
  opaque before the backdrop is configured.
- When it does, the full-window blur region is left as SDL's zero-area
  default and the backdrop type is set to none instead of acrylic.

Windows-only.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-10-13 18:16:50 -05:00
DanSandClaude Opus 4.8 53da160546 fix(window): defer caption-button window ops out of the ImGui frame
The custom minimize / maximize / restore buttons are drawn from inside
app.render(), while an ImGui frame is open. Calling SDL to change the
window state there pushes resize events synchronously, which re-enters
the resize watcher and can nest a new frame inside the open one.

- A click now records the requested op instead of acting on it, and the
  window state change is applied once the frame has closed, after the
  pacing block so the follow-up redraw is not dropped.

Windows-only; it rides the borderless caption controls.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-10-13 18:16:50 -05:00
DanSandClaude Opus 4.8 49240facff perf(resize): dedupe the live-resize watcher, early-out DX11 resize
The live-resize event watcher redrew for every resize event, and each
resize tore down and recreated the DX11 render target even when the
pixel size had not actually changed. A single drag fires both a RESIZED
and a PIXEL_SIZE_CHANGED event reporting the same final size, so the
work ran twice.

- The watcher keys off the real pixel size and returns early when it has
  not moved since the last pass, collapsing the duplicate event pair into
  one redraw. It also bails out when called from inside an open ImGui
  frame, which the caption buttons can trigger.
- The DX11 resize now checks the swap chain's current dimensions up
  front and does nothing when the size already matches and the target is
  live, skipping the destroy/recreate churn.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-10-13 18:16:50 -05:00
DanSandClaude Opus 4.8 13059412ce fix(log): share one handle between stdout and stderr on Windows
On Windows stdout and stderr were reopened onto the same log path with
two separate freopen() calls. Each stream then had its own file handle
and its own write offset into the one file, so the two writers
overwrote each other and interleaved into a garbled log.

- stdout is reopened (truncating the file per run), stderr is pointed at
  the same underlying handle via _dup2 so both share one offset and
  append in order.
- stderr is first given a valid descriptor, since a GUI-subsystem
  process may start with no fd 2, and is left unbuffered so crash
  diagnostics are not lost.

Also corrects a stale comment that named the old log file.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-10-13 18:16:50 -05:00
DanSandClaude Opus 4.8 45d8bdccf0 perf(startup): drop the unused SDL gamepad subsystem and nav flag
Startup initialised SDL's gamepad subsystem and turned on ImGui gamepad
navigation, but the wallet is never driven by a gamepad. Initialising
the subsystem enumerates attached controllers and loads the mapping
database for nothing.

- SDL now starts with the video subsystem only.
- ImGui keeps keyboard navigation; the gamepad navigation flag is
  removed from both the DX11 and the OpenGL setup paths.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-10-13 18:16:50 -05:00
DanSandClaude Opus 4.8 a24ffea86e perf(startup): let Windows relax the system timer resolution
On Windows SDL3 raises the global timer resolution with
timeBeginPeriod(1) and holds it for the whole process lifetime, which
keeps the system on a 1 ms tick and costs power for no benefit here.

- A startup hint now asks SDL to leave the system timer alone. Nothing
  in the app needs sub-15 ms waits: vsync paces drawing, the idle wait
  is 200 ms, and background sleeps are 50 ms or longer.

The hint is Windows-only in effect and is ignored elsewhere; an
environment override that turns it off is harmless.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-10-13 18:16:50 -05:00
DanSandClaude Opus 4.8 5d9bd1688c fix(security): clear the secret clipboard on a monotonic clock
A secret copied to the clipboard auto-clears after a timeout, but the
deadline was stored as an ImGui::GetTime() value. That clock only
advances while frames are being drawn, so while the app idled (no
redraws) it effectively stood still and the pending clear could be put
off well past the intended window.

- The clear deadline is now a steady_clock time_point, which keeps
  counting whether or not the UI is rendering.
- The pump uses the armed hash as its "something is pending" flag rather
  than a sentinel deadline value, and compares the real wall time.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-10-13 18:16:50 -05:00
DanSandClaude Opus 4.8 f3f85d602d fix(mining): stamp hashrate history and label the chart by real time
The hashrate sparkline guessed the age of its oldest sample from the
sample count, labelling the left edge "5m ago", "1m ago" or "start"
regardless of how fast samples actually arrived. Solo and pool samples
come in at different intervals, and a slow or stalled poll made those
labels wrong.

- Each hashrate sample now carries a wall-clock timestamp, kept in
  lockstep with the value as the history fills and rolls over, for both
  the solo and pool histories.
- The chart's left-edge label is derived from the oldest sample's actual
  timestamp, falling back to "start" only when no stamp is available.

Tests: a mining refresh records a timestamp per sample, and the
timestamp history stays the same length as the value history once the
history is capped.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-10-13 17:42:30 -05:00