Commit Graph
213 Commits
Author SHA1 Message Date
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 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 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 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 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 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 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 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 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 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 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 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
DanSandClaude Opus 4.8 2c9e99c022 perf(daemon): memoize the external debug.log miss
When following the debug.log of a node this session did not launch, every
poll re-read the last 2 MB of the file and re-scanned it for the process
banner. If that node logs to its console instead, the banner is never
there, so the full re-scan repeated on every poll for the life of the
process.

- A miss is now remembered by pid and file size: if nothing has changed
  since the last miss, or the file is growing but the backoff has not
  elapsed, the scan is skipped.
- The retry schedule re-scans quickly during a fresh-node grace window
  (the banner flushes a beat after the pid appears) and then backs off
  from 4 s, doubling to a 60 s cap. The memo is cleared on a successful
  tie and when the followed process goes away.

Tests: the retry delay is fast within the grace window and then steps
4, 8, 16 s up to the 60 s cap.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-10-13 17:42:30 -05:00
DanSandClaude Opus 4.8 d601f786e2 perf(lite): cache walletExists() to stop per-frame FFI
While no lite wallet is open the startup auto-open check called
walletExists() every frame. That call crosses into the backend and
touches the filesystem (and prints), so it ran a disk stat on every
frame the app idled at the first-run prompt.

- walletExists() now caches its result for about two seconds on the main
  thread, and the cache is invalidated whenever an open or lifecycle
  result is consumed, since the wallet file may have changed.
- The auto-open condition is reordered so the cheap timer checks gate the
  call, and the click-time overwrite decision uses a new
  walletExistsFresh() that bypasses the cache.

Tests: the cache returns the first queried value within the TTL and the
fresh read re-queries the backend and refreshes the cache.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-10-13 17:42:30 -05:00
DanSandClaude Opus 4.8 bbe2bf94dc fix(export): write the transaction CSV atomically and report failures
The transaction export streamed rows straight into an ofstream. A write
that failed partway, on a full disk or removable media, left a truncated
CSV on disk behind a success toast. The CSV formatting also lived inline
in the dialog with no test coverage.

- The CSV is now built in memory and written atomically (temp file plus
  rename), so a write failure is reported with an error toast and leaves
  no partial file.
- Formatting moves into a reusable helper, and the timestamp conversions
  use the thread-safe localtime variants and tolerate a conversion
  failure rather than dereferencing a null result.

Tests: field escaping neutralises formula-injection prefixes and quotes
special characters, and the full CSV has the expected header and rows.

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

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

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

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-10-13 16:00:10 -05:00
DanSandClaude Opus 4.8 3f82af99c4 refactor(market): resolve exchange/pair selection through one helper
The chart fetch and the Market tab both resolved the saved exchange and
pair names into the registry, with their own near-identical scans and
slightly different fallbacks. Keeping two copies risked the chart and
the chips disagreeing about which pair was selected.

- A single resolver maps the saved names to a registry index, matching
  the exchange name (any exchange when none is saved) then the pair
  display name, and falling back to the registry's first pair, the UI
  default. The chart fetch and the Market tab both call it.
- The Market tab now derives its selection indices from the saved names
  against the current registry every frame, so the indices stay correct
  when the live exchange list replaces the fallback; a steady-state fast
  path skips the scan once the indices already name the saved selection.

Tests: the resolver returns the right index for a matched exchange/pair,
an empty-exchange match, and the first-pair fallback on misses.

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

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

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

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-10-13 16:00:10 -05:00
DanSandClaude Opus 4.8 13989cf268 fix(notifications): prune expired toasts and skip invisible draws
The status-bar notifications only expired the front of the queue, so an
expired newer toast could sit in front of a still-live older one and
hide it from view. The liveness check that drives continuous repaints
also disagreed with what render() actually showed.

- Expired toasts are now pruned wherever they sit in the queue, not just
  at the front, so the newest remaining toast is always a live one.
- The visible toast is the newest live entry, skipping an expired newer
  one. The liveness check reports active while a toast is visible plus
  one trailing frame, so the final faded-out state is painted once and
  then repaints stop; it self-expires if render() is skipped.

Tests: pruning removes expired entries from anywhere in the queue, the
visible toast skips an expired newest entry, and liveness tracks it.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-10-13 13:30:20 -05:00
DanSandClaude Opus 4.8 c5b325a752 perf(charts): fill chart areas in one linear pass
The filled sparklines and the market price chart built their area fill
with PathFillConcave, which runs a per-scanline pass over the whole
filled region. For a simple area under an x-monotone curve that work is
unnecessary.

- A new helper fills the area between an x-monotone polyline and a
  horizontal baseline directly as a triangle strip, in one pass over the
  points. The market sparklines, the market price chart and the mining
  hashrate sparkline all use it. The fill is flat and non-anti-aliased;
  the anti-aliased curve drawn on top hides the stepped top edge, and
  the faint side and bottom edges are imperceptible at these alphas.

Tests: the strip's vertex and index counts, index bounds, and that its
triangle area matches the trapezoid area under the polyline.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-10-13 13:30:20 -05:00
DanSandClaude Opus 4.8 968d6d3875 fix(effects): capture the acrylic backdrop at framebuffer size on HiDPI
The acrylic effect blits its backdrop from the default framebuffer using
the logical viewport size as the source rectangle. On a HiDPI display
the framebuffer is larger than the logical size, so the blur sampled
only the lower-left corner of the screen and the glass showed the wrong
region stretched across each panel.

- The capture blit source now uses the real framebuffer pixel size
  (logical size times ImGui's DisplayFramebufferScale, truncated the
  same way the GL backend does), while the capture and blur targets stay
  at logical size. The framebuffer size is tracked separately from the
  viewport size so quality or buffer-lifetime resets cannot lose it, and
  moving the window between a Retina and a non-Retina display redoes the
  capture. DX11 is unaffected; its scale is one.
- The debug screenshot sweep captures at framebuffer size too, so HiDPI
  captures are no longer cropped.

Tests: the framebuffer-extent helper matches the GL backend's
truncation and falls back to the logical size when the scale is unknown.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-10-13 13:30:20 -05:00
DanSandClaude Opus 4.8 cd83038de5 perf(effects): cull off-screen card effects and sample border by arc
Glass cards scrolled out of view still ran their full effect path, and
the rainbow border always used a segment count derived from a fixed
per-pixel budget, so wide cards drew far more line segments than their
slowly changing gradient needed.

- A glass panel whose rectangle lies wholly outside the draw list's clip
  rect now returns early, so a scrolled-out card draws nothing (its
  border and ember passes are skipped along with the fill).
- The rainbow border is split into segments by perimeter arc length:
  straight edges get one segment per ~32 dp, corner arcs get a few
  chords each to stay round, and a fully transparent border is skipped
  outright. A wide card now draws tens of segments instead of a couple
  hundred, with no visible change.

Tests: the segment fractions are ascending and bounded, each corner arc
keeps at least three chords, and the rect-cull margin test.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-10-13 13:30:20 -05:00
DanSandClaude Opus 4.8 fbc429f914 perf(effects): keep the theme animation clock in double precision
The theme effects stored their animation time in a 32-bit float taken
from the ImGui clock. After a few days of uptime a float can no longer
resolve a single frame step, so every oscillation, drift and sweep began
to stutter, snapping forward a frame at a time instead of moving
smoothly.

- The clock is now held as a double, and the phase/oscillator maths runs
  through a small set of shared helpers that reduce the argument in
  double before returning a float. A float clock at 30 days steps by a
  quarter second per ULP; the double clock stays frame-accurate.
- Hue cycling, the rainbow and gradient borders, shimmer, specular
  glare, glow pulse, edge trace, embers, the sandstorm and the viewport
  wash all use the shared helpers, so their timing no longer drifts with
  uptime.

Tests: the helpers advance smoothly after seven days, pin the legacy
float breakdown at 30 days, and stay in range for negative offsets.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-10-13 13:30:20 -05:00
DanSandClaude Opus 4.8 ebbeb846e8 feat(explorer): indexed block search with a bounded cache
The explorer block cache searched with a LIKE query that had to escape
wildcards, grew without any limit, and retried a failed open on every
frame, including a two-second busy wait on the UI thread.

- Search now uses an instr() substring test on a lowercased needle, and
  only checks the height text when the query is all digits, since a
  non-numeric needle can never appear inside a decimal height.
- The table is pruned to the highest 20000 blocks, once per open and
  periodically as blocks are stored, so the file stops growing at its
  high-water mark.
- A failed open is negative-cached (30 s by default) instead of retried
  every frame, and the busy timeout drops to 250 ms.
- When the cache database is unavailable or refuses a write, the block
  spills to a small bounded in-memory map that both the list and search
  read, so a spilled block never appears in one but not the other; the
  spill is cleared when the chain tip regresses.
- A batch of blocks is written under one savepoint instead of one commit
  per block.

Tests: case-insensitive hash and height search, the negative-cache and
spill paths with tip-regression eviction, and batch store plus prune.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-10-13 09:24:51 -05:00
DanSandClaude Opus 4.8 c3f981b52e fix(history): keep the open detail row across paging and filtering
The inline detail panel was tracked by the filtered list index. The
display list is rebuilt and re-sorted on every data change, sort switch
or filter edit, so a stored index pointed at a different transaction
after the rebuild, and a page reset was triggered by any change in the
row count, bouncing the view back to page 1.

- The open row is now tracked by a stable content key (txid, display
  type, address and the amount's exact bits) so the right row stays open
  across rebuilds.
- The page resets and the open row collapses only when a filter input
  changes (type, sort or the full search text). A new or removed row is
  data and now just clamps the current page.
- The open row is also cleared on page navigation and when the list
  empties, so a wallet switch does not resurrect an old expansion.

Adds pure helpers rowKey/filterInputsChanged to
transactions_tab_helpers.h.

Tests: rowKey distinguishes each field and separates +0.0 from -0.0, and
filterInputsChanged latches each input change but not the first frame.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-10-13 09:24:51 -05:00
DanSandClaude Opus 4.8 a5e4fd5c02 perf(history): group transaction rows by txid once per change
The History tab built a txid-to-indices map twice when its inputs
changed, once for the summary-card totals and once for the display list,
each a full pass over every transaction with string-keyed hashing.

- The rows are now grouped by txid a single time whenever the
  transaction list changes, into an index-only structure (first/next
  chains with no string copies), and both the summary pass and the
  display pass reuse it. A sort-combo change re-sorts without regrouping.

Adds a pure, unit-testable groupByTxid helper in
transactions_tab_helpers.h.

Tests: the chains match a reference map over a random id stream, and the
empty and single-row cases.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-10-13 09:24:51 -05:00
DanSandClaude Opus 4.8 f7ed1ae115 perf(console): tail-cap large RPC results without a full copy
A large RPC result was fully split into lines and copied before being
handed to the console, even though only the last few lines could survive
the model's line cap.

- The formatter now takes a line cap and keeps only the last N lines,
  located by a backward newline scan, so a huge result is never fully
  split or copied. JSON classification still keys off the first
  character of the whole result.
- It works over a string view and moves line text through, and fold
  spans are computed over the formatted lines directly instead of a
  separate vector of copies.
- getline semantics are preserved: a trailing newline adds no empty
  line and an empty result yields no lines.

Tests: getline parity for empty, trailing-newline and blank-line inputs,
the backward tail cap, and JSON classification and fold spans over a
capped result.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-10-13 08:23:30 -05:00
DanSandClaude Opus 4.8 116a997a3a fix(console): bound the pending queue by lines and bytes
The console's pending, not-yet-drained queue was unbounded, so a hidden
Console page that kept receiving verbose daemon-log chunks could grow
without limit.

- Pending lines are now bounded by both a line count and a byte budget.
  The oldest pending lines are dropped once either is exceeded, while
  the newest line is always kept so one huge line is never lost.
- A formatted RPC result is ingested as one block under a single lock,
  so lines from other producers can never interleave inside it.

The front-sequence accounting is also corrected: a pending line dropped
by the cap never entered the visible buffer, so it no longer advances
the front sequence. Only a front eviction or a clear does.

Tests: the line and byte caps keep the newest lines, a single oversized
line survives, a batch appends contiguously, and the front sequence
advances only on a front eviction or clear.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-10-13 08:23:30 -05:00
DanSandClaude Opus 4.8 8255923f33 fix(settings): scope portfolio groups by wallet identity
Market-tab portfolio groups were tied to a wallet by a hash of its
address set. That hash changed whenever an address was added, so a
group built for a wallet could silently drop out of view once the
wallet gained a new address.

- Groups are now scoped by the wallet's stable id, which is persisted
  and never derived from the address set, so adding an address can no
  longer orphan a group.
- An older build's address-hash scope fails open (the group stays
  visible) and is migrated once per wallet per session to the stable id.
  A legacy group is claimed when its old hash matches, when one of its
  own addresses belongs to this wallet, or when only one wallet is
  known; anything ambiguous is left legacy and stays visible.
- Scope filtering waits until the wallet's addresses are loaded, so a
  group never flashes an empty 0.0000 balance and Add stays disabled
  until the wallet is resolved. This works on Lite, which never stamps
  an address-update time.

Behaviour: an older build opened after this change hides groups tagged
with a stable "w:" scope (they are not deleted and return on the next
upgrade) - worth a release note.

Tests: group visibility across global, stable and legacy scopes with the
wallet resolved and not; the migration rules (hash match, address
overlap, single-wallet claim, ambiguous left alone, idempotent); and a
save/load round trip where an address-set change cannot orphan the scope.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-10-12 21:42:23 -05:00
DanSandClaude Opus 4.8 c3ffc97b31 fix(ui): finish a stranded bootstrap even when its dialog is closed
A Settings bootstrap was only wrapped up inside the dialog's own draw
path. If the dialog finished downloading, verifying or extracting while
the user had clicked away or minimized the window, completion was never
processed: the connect and reconcile gate stayed held and the
post-bootstrap rescan was never armed.

- Completion is now a pure decision driven from a pump() that both the
  per-frame update and the dialog's render call, so a finished bootstrap
  is handled every tick whether or not the dialog is drawn.
- With the dialog open the behaviour is unchanged: it advances to the
  Done or Failed screen and those screens still own the gate. With the
  dialog closed, pump() releases the gate itself and shows a success or
  failure toast; a user-cancelled run is released silently.

User-visible: a bootstrap started from Settings now completes, arms its
rescan and reports its result even if the dialog is closed or the window
is minimized when it finishes.

Tests: the completion decision for an unfinished worker, a completed run
with the dialog open and closed, a failed run, and a user-cancelled run.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-10-12 21:42:23 -05:00
DanSandClaude Opus 4.8 d4339be94b fix(ui): keep a chosen send-from address from auto-select overwrite
The Send tab auto-selected the highest-balance address on first load.
When another tab had already pre-filled the source with "Send from this
address", that first-load pick overwrote the user's choice, and the
dropdown's selected index could drift out of step with the address that
would actually be debited when the address list changed.

- The send-from string is now the single source of truth. The one-shot
  auto-pick only fills an empty source and never replaces one the user
  or another tab set, and the list index is re-derived from the string
  each frame so it cannot desync.
- An explicit "Send from this address" choice latches the auto-pick off,
  so the first visit cannot undo it.
- A chosen source the dropdown does not list (zero balance, view-only,
  or the list still loading) now previews the address that will be
  debited instead of the "Select a source" placeholder.

User-visible: a send-from address chosen from another tab survives the
first load and later refresh ticks.

Tests: the source resolver keeps a pre-fill on first visit, fills an
empty source with the best spendable address, keeps a source missing
from a short list with index -1, latches nothing while disconnected or
with an empty list, and re-derives a stale index from the string.

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

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

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

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-10-12 21:42:23 -05:00
DanSandClaude Opus 4.8 abc82136c4 fix(util): use the POSIX daemon binary name, scan versions off-thread
The installed-daemon lookup built the binary path from the Windows
resource key "dragonxd.exe", which never exists on Linux or macOS, so
the installed full node was never found there. The version scan also
read the whole binary, now around 200 MB, into memory on the UI thread.

- A new DAEMON_BINARY_NAME gives the daemon's file name on this platform
  ("dragonxd" on POSIX, "dragonxd.exe" on Windows); the old resource
  constant stays only as the embedded-table key. The installed daemon is
  now found on POSIX.
- The version-stamp scan streams the file in 4 MiB chunks with bounded
  memory, carrying bytes across each boundary so a stamp straddling it is
  neither cut short nor missed, and stops at the first match. The result
  is cached by path, size and mtime, so only a changed binary is
  rescanned.
- Settings stats the binary on the UI thread but runs the streamed scan
  on a worker, guarded so a new scan is not started while one is still
  running (the future blocks in its destructor), and the version fills
  in when it lands.

Tests: the POSIX path and binary name, a version stamp in a small file,
a stamp straddling the 4 MiB chunk boundary, and a file with no stamp.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-10-12 21:42:23 -05:00
DanSandClaude Opus 4.8 21dc874988 fix(core): refresh transaction history on the Mining tab
The Mining tab shows earnings derived from transaction history, but it
was not treated as a wallet-data page, so newly mined or paid-out
earnings did not appear until the user left the tab and came back. A
coinbase row also changes category in place (immature becomes generate
at maturity, or orphan on a reorg), and the Recent merge kept both the
old and the new row, double-counting the earnings.

- The Mining tab now gets the cheap between-blocks Recent poll: one
  listtransactions per new block, bounded by the 15 s transaction-age
  timer, once history has loaded. It never runs a full routine scan and
  never pre-empts an owed dirty cycle; tracked sends still take their
  normal cycle on any page.
- The shielded-payout probe runs only while the pool miner is running,
  since pool payouts are shielded receives while a solo miner's earnings
  are transparent coinbase rows.
- The Recent merge drops the previous row of the other coinbase category
  when a coinbase transitions, so maturing or reorged rewards are no
  longer counted twice. A non-coinbase row sharing the txid is left
  alone.

Behaviour change: mining earnings update on the Mining tab per block
instead of only on tab re-entry, and maturing or reorged coinbase rows
no longer double-count.

Tests: the per-block poll gate (one poll per block, due only after
history loads, held while dirty or addresses are dirty), the Mining-page
cycle shaping, and the coinbase transition merge for immature to
generate, generate to orphan, and a shared-txid non-coinbase row.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-10-12 21:42:23 -05:00
DanSandClaude Opus 4.8 fbe09c6a4c fix(security): follow the daemon's lock state on Set-PIN and expiry
Setting a PIN always unlocked the wallet with a 5 s probe and then
re-locked it, even when the wallet was already unlocked, so a PIN check
silently re-locked an unlocked wallet. And once the daemon's absolute
unlock window ran out the GUI still showed "unlocked", so the next send
failed with nothing on screen explaining it.

- The Set-PIN check now decides on the main thread: an unlocked wallet
  is verified with the time it has left (capped at seven days) and is
  not re-locked, so the GUI and the daemon keep agreeing; a locked
  wallet still uses the 5 s probe and re-locks. Either way the daemon's
  lock state is re-read afterwards, which self-heals any drift.
- When the daemon's unlock window has expired but the GUI still says
  "unlocked", a bounded reconcile (once per expiry, 30 s back-off)
  re-reads getwalletinfo so the lock screen and the transaction-cache
  lock follow the daemon. Lite wallets, which have no daemon timer, are
  left alone.

Behaviour change: with auto-lock disabled the daemon still re-locks at
its window, and the GUI now lands on the lock screen instead of
appearing unlocked until a send fails.

Tests: the Set-PIN verify plan (unlocked keeps its remaining time and
skips the re-lock, locked or near-expiry falls back to the 5 s probe and
re-lock, long windows are capped) and the unlock-expiry predicate across
encrypted, locked, no-deadline and not-yet-expired cases.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-10-12 21:42:23 -05:00
DanSandClaude Opus 4.8 2fce9a7860 fix(security): reconcile the encryption-pending flag on every connect
The wizard persists an "encryption requested" flag so a quit or crash
before the wallet is actually encrypted is not silent. That flag was
only reconciled on the slow-path getwalletinfo refresh, so it could be
left stale, and the clear-and-warn logic lived inline in two places.

- A single reconcileEncryptionPending now runs on every connect and on
  every encryption-state refresh, keyed on the parsed getwalletinfo
  reply. It clears the flag whenever the wallet is encrypted, however it
  got there (deferred request, Settings, another client), and warns once
  per session when encryption was requested but the wallet is not
  encrypted and nothing is pending or in flight.
- A failed or warm-up (-28) RPC decides nothing, so a 15 s timeout that
  forces encrypted=false can no longer mis-clear or mis-warn.
- The flag is also cleared at encryptwallet success and when the user
  removes encryption, so neither path leaves a stale flag that would
  warn on the next connect. No settings format change.

Tests: the pure decision helper over the flag, RPC-ok, encrypted,
deferred-pending, in-flight and already-warned combinations (clear when
encrypted, warn once when lost, nothing on a failed RPC or no flag).

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

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

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

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

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-10-12 21:42:23 -05:00
DanSandClaude Opus 4.8 1ea584311e fix(lite): honor the backend sync result with retry and a failed state
A failed backend sync was silently swallowed: the reply was never
inspected, so a sync that errored out left the wallet showing a
"syncing" state that never completed and never reported a problem.

- The sync and rescan replies are now parsed. On failure the partial
  progress is saved and the sync is retried with exponential back-off
  (5s doubling up to 60s, six attempts in all), then an explicit failed
  state is published: done, sync not complete, and the backend's error
  text carried through.
- A failed rescan is retried as a plain sync, because a rescan has
  already cleared the wallet and re-running it would throw away the
  partial progress just saved.
- A terminal error, such as a reorg deeper than the wallet can unwind,
  stops the retries immediately instead of waiting out the attempts.
- Recovery is observed, not guessed: once a later refresh's server-bound
  reads all succeed, the failed state clears itself.

Behaviour change: a sync failure now surfaces to the user as a failed
state with a message, instead of a frozen "syncing", and the client
retries on its own with back-off.

Tests: a parser case for the success, failure, and terminal replies, and
fake-backend controller cases for retrying after a failure, giving up
after the attempt cap, a terminal reorg stopping at once, a failed
rescan retrying as sync, cancel stopping the back-off, and the failed
state self-healing on a good refresh.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-10-12 21:42:23 -05:00
DanSandClaude Opus 4.8 5ae5e5213b fix(lite): sort lite transactions newest-first to match the UI panels
The lite backend lists transactions oldest-first, but every consumer of
the transaction list assumes newest-first and takes a prefix of it, so
the Recent Transactions / Recent Sends / Recent Received panels and the
CSV export showed the oldest records instead of the latest.

- The UI copy is now sorted pending (0-conf) first, then newest-first by
  timestamp, the same order as the History tab's default.
- The sort is stable, so several records that share a txid (a send entry
  plus its per-note entries, or same-block transactions) keep a
  deterministic order across refreshes. That keeps the order-sensitive
  change check reporting "unchanged" and avoids bumping the transaction
  revision every couple of seconds.

Tests: a new case covers the pending-first, newest-first ordering, a
stable order within one txid, and the revision staying unchanged when
the identical model is re-applied.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-10-12 21:42:22 -05:00