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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
Hovering the Paste button in the import-key dialog read the OS clipboard
every frame to draw a ghost preview of the key, and the trimmed
plaintext was rebuilt each frame and never wiped. SDL's clipboard read
is a synchronous round-trip, and the pasted value is a secret private or
viewing key.
- The clipboard is now read only on hover entry and then at most twice a
second, and the classified preview is cached rather than recomputed
every frame.
- The cached preview is a dedicated secret buffer: it is wiped the moment
it stops being shown, wiped on secret wipe, and the transient trimmed
copy is zeroed right after it is classified.
- Trimming moves to a shared clipboard helper shared with the paste path.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
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>
The pool auto-balance step returned early whenever the pool selector was
in Manual mode, so a pending manual Refresh of the pool display stats was
dropped and the button did nothing unless Auto-balance was active.
- A pending refresh is now serviced in Manual mode too, re-querying the
pool display stats without auto-switching the pool or restarting xmrig.
- Auto-balance behaviour is unchanged; the manual path is display-only,
so it runs with no benchmark gate.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The global keyboard shortcuts tested keys without disabling key-repeat,
so holding one down fired it every frame: the theme cycle, balance and
market layout cycles, the effects and background toggles, the settings
and sidebar-collapse keys all re-triggered on a held key. Cycling themes
also stepped onto skins that had failed to load and wrote the broken
pick into settings.
- Every one-shot shortcut now checks for a fresh key press only, so a
held key fires once.
- Theme cycling skips over invalid skins and only applies, persists, and
shows the toast when a skin actually loads; if none can load, settings
are left untouched and no toast appears.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Once a shutdown was under way the app could still launch or restart the
full node and keep running the RPC connection state machine. That could
spawn a daemon process on the way out and surface a spurious connection
failure toast as the window tore down.
- Starting the embedded daemon now bails out immediately while a
shutdown is in progress, so the node is never (re)launched on exit.
- The connect attempt returns early during shutdown, so there is no
start/connect try and no stray failure toast on the exit screen.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
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>
Renaming or adding a contact from the chat tab called the address-book
mutator and then saved again, even though the mutator already persists.
It also reported success unconditionally, so a failed rename still
showed a success toast.
- The redundant explicit save is removed; the mutator's own save stands.
- A rename now shows success only when the mutator succeeds and an error
toast when it does not.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Pinning a lite server while a connection to it was already being
established tore the in-flight connect down and restarted it, slowing
the very connection the user was waiting on.
- Pinning is now skipped while a connect is already underway, so the
existing attempt is left to finish.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The request-payment dialog kept its generated payment URI across opens
and closes. A dialog opened for an address with no payable shielded
address could still show the previous request's URI, and the string
lingered after the dialog closed.
- The payment URI is cleared when the dialog opens, when it closes, and
on the close cleanup path.
- The URI field is shown only when there is both a URI and a real
address to pay.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
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>
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>
The portfolio address picker's sort comparator looked up each address in
the selected-address set on every comparison, two hash lookups per call,
and the per-row draw repeated the lookup again. On a long address list
that is a lot of redundant hashing every frame.
- Each filtered candidate now carries its selected flag, looked up once
when the list is built. The comparator reads the stored flag, so the
sort produces the same ordering with no lookups of its own.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
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>
The thread benchmark state machine was advanced from the Mining tab's
render path, so it only progressed while that tab was visible and the
window was on screen. Hiding the tab or minimizing the window froze the
benchmark mid-sweep.
- The benchmark now advances from the main update loop, ungated by
connectivity, so it keeps running when the tab is hidden or the window
is minimized.
- Its clock is driven from a steady wall-clock reading rather than the
render delta, and each step is clamped like the other logic timers so
a suspend/resume cannot leap a benchmark phase.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Cancelling a thread benchmark always stopped and restarted the pool
miner at its pre-benchmark thread count, even when the user had not been
mining before the benchmark began. That silently started the miner on a
plain cancel or a switch to solo mode.
- A cancel now always drops the candidate-thread miner, and restarts the
pool miner only when the user was mining before the benchmark, read
from a snapshot taken at the start rather than the live running state
(the benchmark sweep transiently stops the miner between candidates,
so a live check would drop the restore).
- Callers that must not restore the miner (leaving solo mode) pass the
new restore flag as false.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
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>
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>
Glass cards draw their own grain layer over the surface overlay, but the
acrylic primitive beneath them was also drawing its grain. That lower
layer sits under the overlay at well below one grey level, so it is
invisible and only costs an extra noise pass per card.
- The card now asks the acrylic primitive for zero grain and keeps its
own visible grain layer, removing the redundant pass with no visible
change.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
If the scroll-edge fade shader failed to compile or link, the init path
left its ready flag clear and tried again on the very next frame. On a
driver that rejects the shader that meant a fresh compile attempt every
frame, for the life of the program.
- A failed init is now latched and treated as final until destroy(), so
the compile is attempted once, the failure is logged once, and the
fades stay disabled without further retries. Destroying the shader
clears the latch so a later re-init can try again.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
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>
The acrylic material carried a full composite shader — vertex and
fragment source, compile and link helpers, the program handle and seven
uniform locations — that nothing ever invoked. The acrylic is composited
on the ImGui draw list, not through this program.
- Remove the dead shader source, its compile/link helpers, the program
and uniform fields, and the compile/link/cleanup steps in init and
shutdown. No behaviour change; init no longer builds a program that is
never bound.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Every panel class samples one shared blur texture, so a menu asking for
a different blur radius than cards could not actually get it. All it did
was force a second full-screen blur pass each frame as the radius
flipped between the card and menu values, and let whichever class drew
last decide the radius for everything.
- Setting the acrylic theme now copies the card blur radius onto the
menu class, so menus and cards share one radius and the blur runs
once per frame. The first-use default path goes through the same
setter so it is covered too.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
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>
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>
Each recent block was fetched with two round trips (getblockhash then
getblock), and a height that failed, such as one briefly above the tip
during a reorg, was refetched on every frame.
- getblock is now called once with the height directly, falling back to
the getblockhash plus getblock pair only when an older daemon answers
"not found" for the short string; any other error is a real answer.
- A failed fetch backs off with a doubling wait (2 s up to 32 s) before
any new fetch is scheduled, and a successful fetch resets the backoff.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
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>
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>
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>
The image picker laid out and drew every cell in the directory and image
grids, including the many cells scrolled out of view.
- A cell whose rectangle is not visible now reserves the same space with
a dummy of the cell size, so the scroll extent is unchanged, and skips
the hit-test and draw.
No visible change; only off-screen cells are skipped.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The block detail modal drew every transaction row of a block, running
the hit-test, the draw and a hash truncation for each one, even when the
row sat far outside the scroll viewport.
- A row entirely outside the scroll clip now keeps its layout slot (so
the scroll extent is unchanged) but skips the hit-test, draw and hash
truncation. The expanded row is always laid out so its inline detail
panel still appears.
- The old "showing first 100 of N" footer is dropped, since every row is
now cheap to lay out regardless of how many a block has.
No visible change; only off-screen rows are skipped.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
JSON folding in the console was bypassed in any filtered view, including
when only a channel toggle (daemon, RPC or app) was active. A collapsed
block silently expanded the moment a channel was toggled.
- Folding is now kept active unless the view is text-filtered or
errors-only, which are flat so that every match stays reachable.
Channel toggles keep folding, since JSON blocks are atomic per channel.
- The fold triangles and the collapsed " ... }" summary are drawn only
in the fold-aware view, so the glyph and the rendered block can never
disagree.
User-visible: JSON results stay foldable while channel filters are
applied.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
A long wrapped console line could extend far past the viewport, yet
every sub-row was still measured and drawn.
- Sub-rows that fall entirely outside the clip rect are now skipped. The
per-row ordinal still advances for a culled row, so scanline parity is
unchanged.
No visible change; only off-screen rows are skipped.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
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>
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>
Console text filtering and match highlighting lowercased a full copy of
every line, and of every drawn segment, on each pass.
- A new alloc-free ASCII case-insensitive substring search folds both
sides on the fly, so neither the filter test nor the highlight scan
allocates a lowercased copy per line any more.
- Both call sites use it; the case-insensitive match behaviour is
unchanged.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
When the daemon disconnects, teardown stops the fast RPC worker without
draining its queued main-thread callbacks. The console busy gate was
released inside that callback, so a command whose callback was discarded
left the input disabled for the rest of the session.
- The busy gate now drops from an RAII guard at the end of the worker
job itself, so it is released however the job ends: success, error, or
a worker torn down undrained.
- The result is published to the console before the gate drops, so a
freed-up console always has the output queued.
- An unknown, non-std::exception failure is now caught and reported
instead of escaping the job.
User-visible: the Console input can no longer stay stuck disabled after
a reconnect.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The UI-schema loader walked a hardcoded list of flat TOML sections and
left [banners.*] and [layout] out, so those sections in ui.toml were
silently dropped. The node-status error strip at the top of the content
column could not be styled from the schema at all.
- The flat-section list is now one shared constant used by the file,
string and overlay loaders, and it includes banners and layout.
- The node-status banner is themeable from ui.toml like every other
section; a note in the toml records that these parse as flat sections.
No visible change unless ui.toml overrides those sections.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
When a sync pass's transparent (t-address) scan failed, the backend
mishandled it three ways: the retry computed its start height as the
block height minus the batch size, an unsigned subtraction that wrapped
to a huge value for low heights; the block range left un-scanned was
not remembered, so a transient failure left a permanent hole in the
transparent history that nothing ever revisited; and the block times
needed to date the transactions in that range were discarded.
- The owed range and the block times seen so far are now kept and folded
into the next pass, which resumes from the lower of its own first block
and any owed range.
- The scan start never subtracts, so it can no longer underflow.
- A t-address scan owed by an earlier failed pass is paid on the next
sync even when no new block is pending.
Behaviour change: a transparent scan that fails is retried in-process on
the next sync rather than being lost. The owed range lives only in
memory and is never written to the wallet file, so the file format is
unchanged and remains interchangeable with other builds. The debt is
lost if the process exits or the server is switched before a retry
completes; Re-download blocks (rescan) recovers it, as today.
Tests: library tests on the extracted t-scan seam cover a failed pass
keeping its range and block times, a later success clearing the debt,
and the scan start never exceeding the pass start across the batch-size
boundary (the old formula wrapped there).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
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>
The bootstrap preflight's protected names could be bypassed by names
that resolve onto them, and its cancel and quit paths had two timing
gaps.
- Entry names are judged as the file system resolves them: '\' is a
separator, "." components are dropped and each component loses its
trailing dots and spaces, as Win32 does ("./wallet.dat" and
"DRAGONX.conf." name protected files). The whole archive is refused
for an absolute or drive-rooted name, a ".." component, any ':'
(drive letter or NTFS alternate data stream, "DRAGONX.conf::$DATA"),
an 8.3 short name ("DRAGON~1.CON"), a component of only dots or
spaces (other than "."), and any byte >= 0x80 or ASCII control
character: macOS folds or ignores some Unicode (U+017F, U+212A,
U+200C) onto a protected name, and the published bootstrap archive
uses plain ASCII names. A refused name is echoed
with \xNN escapes. A member is written at the path it was checked
under, and the protected-name check runs on the resolved name.
- The user's Cancel (Settings dialog and first-run wizard) goes through
requestCancel(). It is refused once the old chain data starts being
replaced, and the start of that window re-checks a cancel under the
same lock, so a click lands before the old chain is touched or not at
all.
- Quit cancels the wizard's and the Settings bootstrap when the
shutdown starts; the HEAD and checksum requests now end at once on
cancel, and a cancelled HEAD keeps the partial download. A run
cancelled before the window leaves the old chain as it was. A run
inside it stops extracting and removes its partial chain while the
shutdown screen shows "Cleaning up...", so it ends with no chain data
(wallet.dat untouched; Retry reuses the zip, or the node resyncs).
That wait is bounded: at most 2 minutes from the start of the
shutdown, then 3 s in shutdown(), after which a worker still running
is left to the process exit. Only a cleanup that overruns that cap
can leave a partial chain, which dragonxd then answers with its
reindex offer.
Tests: refusal of the Windows-resolved, non-ASCII and control-character
names with the escaped echo; the protected-name list incl. the resolved
forms and a planted -wallet file, conf and database/ that stay
byte-identical; the user-cancel window; the bounded quit wait and
safeToAbandon(); a cancel ends a HEAD request that never answers.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
ensureLogoTexture() still freed the chat emoji, header logo and coin logo
textures it replaced at once, while every other theme-driven replacement
already goes through retireTexture() and is freed at the next preFrame().
It runs at the top of render(), so nothing referenced them yet; this is
hygiene, so that the same rule covers every replaced texture.
- ensureLogoTexture(): the three direct DestroyTexture() calls become
retireTexture(). Cost: the old textures live one extra frame on a theme
or accent change.
- setGradientTexture() (unused today) retires the texture it replaces
instead of dropping it.
- No drain at shutdown: the process exits and the live logo and gradient
textures are not freed at exit either.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The "Use Tor" checkbox was saved but never read: no build routed any
traffic (node, RPC, price/update/bootstrap/pool HTTP or Lite gRPC)
through Tor, so users who ticked it believed they were anonymous.
- The checkbox is disabled and always shown unticked, on the Settings
page and in the legacy Settings window. Its tooltip says it is not
available and gives the alternatives: for the node, proxy=127.0.0.1:9050
in DRAGONX.conf with Tor running; otherwise a system-wide VPN/Tor (Lite
builds get the short version). The "Use Tor" label is kept.
- Behaviour change: a stored use_tor=true is cleared once at startup
(Settings::consumeLegacyUseTor + a synchronous save before the async
writer and any Settings page state), with a 20 s warning in the user's
language. The cleared key is the "already told" flag, so the notice
never repeats. No settings format change.
- 3 new strings (tt_tor_unavailable, tt_tor_unavailable_lite,
tor_never_applied_notice) in all 8 languages; the tooltip wraps at
tor-tooltip-wrap-width from ui.toml.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Builds that embed xmrig (Windows release) re-extracted the bundled miner
whenever its size differed from the file on disk. An "Update miner..."
install always differs, so the next start silently put the bundled build
back while settings still showed the updated version.
- The bundled miner is now placed only when it is missing, like the node
binaries (kXmrigRefreshOnSizeMismatch = false in the provision table).
Params and asmap still follow the bundled size.
- Behaviour change: a wallet upgrade no longer refreshes a miner the user
never updated; "Update miner..." is the way to get a newer build.
- The uncalled needsXmrigExtraction() is removed.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A rescan or key import holds the node's cs_main for the whole wallet
scan. On the shared clients it hit the 30 s curl timeout, reported a
failure that wasn't one, abandoned a daemon RPC slot and flipped the app
into "node busy".
- One long wallet RPC at a time (rescan, importprivkey, importaddress,
importwallet, z_importkey, z_importviewingkey, z_importwallet) runs on
its own ungated client with no client timeout (rpc/long_call_lane.h).
It can be aborted from any thread, and an abort is never lost, even
one that lands before the client opens.
- The GUI rescan, key import and sweep use it. The sweep's fund-moving
body is unchanged; only the client it calls differs. runtimeRescan()
returns bool, and a refused post-bootstrap reconcile stays armed.
- The console routes rescan and the import commands through the lane;
its input stays usable meanwhile, and a refusal is printed like any
other result. RPCClient gains callRaw(.., timeoutSec).
- Restart, wallet switch, delete-chain, bootstrap (now also from
Settings) and the other node-stopping flows are refused while it runs,
with a message saying a key import or rescan is still running; quit
asks for confirmation. The lane is aborted at quit.
- A second import or rescan is refused before the key is copied anywhere.
An import cut off after the request reached the node (a lost reply,
or a proxy's 502/504 while the node still imports) says the key may
already be in the wallet and to run Settings -> Rescan. Only the
node's own HTTP-layer refusals (401, 503, work queue full, unhandled
request) say that the key was not imported. A pure
classifyLongCallCutOff() makes that choice.
- New strings in all 8 languages; console results stay English.
Tests: the long-method list and the console's routing; one lane at a
time; an abort lands whether it comes before the client opens or during
the call; a call with no client timeout outlasts a 3 s scan, where a 1 s
gated client times out and marks the node busy; classifyLongCallCutOff()
for every failure kind with and without a sent request, and from raw
HTTP replies (502, 504, a plain 500 -> cut off; 401, 503, queue full,
unhandled -> refused).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
fake_dragonxd now answers rescan, importprivkey, z_importkey and
z_importviewingkey, so the GUI's long wallet RPCs can be reproduced
without a real node.
- Each one holds cs_main for FAKE_RESCAN_SECS (default 0). The lock-free
getrescaninfo reports the scan while it runs, and, like v1.3.0, the
scan stops early when a shutdown is requested.
- Argument checks, error codes and return values follow dragonxd
(z_importkey returns null, importprivkey the t-address, the default
"whenkeyisnew" skips a known key, z_importviewingkey needs its zaddr).
Keys are only prefix-checked and never stored or logged; an imported
spending key gets a made-up address that the address lists then show.
- FAKE_ENCRYPTED=1 / FAKE_WALLET_PASSPHRASE: an encrypted wallet that
starts locked, with walletpassphrase (-14 on a wrong passphrase),
walletpassphrasechange, walletlock and getwalletinfo unlocked_until;
the imports answer -13 while it is locked. As in dragonxd, the right
passphrase with a non-integer timeout unlocks first and only then
fails (-1): the wallet stays unlocked with no relock timer of its
own. A timer pending from an earlier unlock keeps running; otherwise
only walletlock locks it again. Without FAKE_ENCRYPTED, those three
methods answer -15 like dragonxd.
- FAKE_METHODS_ALL call events show keys and passphrases as <redacted>.
A plain run is unchanged: the same 63 GUI calls before and after, and
identical replies to every one of them.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The bootstrap removed blocks/, chainstate/ and notarizations/ first and
only then opened the archive. An unsafe entry, a full disk or a corrupt
member therefore left no chain, or a half-written one for dragonxd to
start on.
- Preflight before anything is deleted: every archive entry must be
safe (no absolute path, no "..") and readable, and the disk must hold
the extracted size plus max(256 MiB, 5%), counting what removing the
old chain frees (a symlinked chain dir counts 0). Free space counts as
unknown only when the probe errors, the capacity is 0 or the
available size is -1; a known disk with 0 bytes free is a refusal.
The old chain is kept on every preflight failure.
- Protected members are skipped, compared case-insensitively: any path
component starting with "wallet", any "database" component, a .cookie
or *.conf file, and top-level *.dat files other than fee_estimates,
peers and banlist. The number of non-critical member failures is
logged.
- The removal of the old chain is verified (a locked file fails the run
instead of mixing old and new chain files). If the extraction fails or
is aborted, the partial chain is removed again before the failure is
published; isDone() turns true only after the worker's last
filesystem change.
- Cancel is disabled while the old chain data is being replaced, and
quit aborts a running bootstrap.
- A complete zip from an earlier attempt is reused on Retry; its
checksum is re-verified. A download that cannot fit fails early, and a
disk-full write error says so.
Tests: the space rule (boundaries, unknown vs. full disk, saturation);
planArchive sizes, zip-slip, garbage and truncated zips; the
protected-name list; reclaimableBytes with symlinks; each preflight
refusal keeps the old chain and wallet.dat byte-identical; a corrupt
critical member removes the partial chain before the failure is
published.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
After an unexpected exit the monitor thread returned on its own but
stayed joinable, and start() only spawned a monitor when none was
joinable. A node restarted after a crash (no stop() in between) then ran
unwatched: a second crash was never detected, its output was never
drained, and the dead run's stdout pipe leaked into the next fork.
- start() joins a monitor that returned on its own (only when joinable,
at the top, after the "already running" early returns), closes the
dead run's stdout pipe (Windows: process and pipe handles), and always
starts a new monitor for the process it launched. crash_count_ is kept.
- New lifecycle_mutex_ serializes start(), beginStop(), stop()'s flag
writes and joins, and endStop(), so two threads can never join the same
monitor. The monitor thread and the state callback never take it; a
beginStop() racing a start() waits for it to return (its longest wait
is the ~300 ms datadir-lock poll).
- start() is refused (returns false, state unchanged) while a stop is in
progress, i.e. between beginStop() and endStop().
- The daemon tests now start the stand-in only in isolation (temp
-datadir override, port check skipped): they no longer write the real
~/.hush/DRAGONX/obsidiandragon-dragonxd.pid or tail the real debug.log.
New tests: restart after a crash is supervised, a restarted run stops
cleanly, no fd leak over crash+restart cycles (Linux), and start() is
refused while stopping.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A passphrase change left the PIN vault sealing the old passphrase. The
next PIN unlock opened the vault, walletpassphrase answered RPC -14, and
the generic catch ran the lockout curve on a correct PIN, while Settings
kept showing the PIN as active.
- Passphrase change: when the active wallet's vault exists at completion
(same vault path as at dispatch, passphrase actually changed), the
vault is marked out of date for the session and kept on disk: PIN entry
is no longer offered, a warning toast says so, and Settings shows
"PIN out of date" with a tooltip on how to fix it. The Change
Passphrase dialog notes beforehand that a PIN will go out of date.
- PIN unlock: RPC -14 after the vault opened is treated as "PIN out of
date": passphrase entry, a message, no attempt counted, no lockout, and
the vault is not deleted (-14 is only the daemon's answer; the vault may
hold the only copy of a passphrase that opens an older backup). After a
restart Settings shows the PIN as active until the first PIN attempt.
- A malformed vault (zero-filled by a removeVault() whose unlink failed,
truncated, unknown version) is treated as missing: passphrase entry,
"PIN file unreadable", no penalty. New SecureVault::isWellFormed().
- Race guards: Set/Change/Remove PIN are disabled while a passphrase
change runs, and Change Passphrase while a PIN operation runs.
- Setting or removing the PIN, or switching wallets, clears the mark;
setting a PIN offers PIN entry again. Change PIN keeps the mark (the
vault still seals the same passphrase).
- Pure decisions in data/pin_vault_policy.h, unit-tested. New strings in
all 8 languages.
No vault, wallet or settings format change.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The full UI sweep (dev builds only) ran its demo surfaces against the
real profile. Its teardown deleted wallet-{cold,trading,mining,donations,
2023,payroll}.dat from the real datadir, it created zero-filled wallet
files and picker-demo/extra-wallets folders, its demo wallet-index upsert
re-minted the active wallet's scopeId (hiding "w:"-scoped contacts), the
Contacts tab could re-attach legacy-scoped contacts to such a throwaway
id in addressbook.json, and it persisted the demo active wallet file and
Mining pool worker.
- Preflight: a full sweep is refused (toast + log line) while a real
workflow is in flight: a pending seed_migration_* setting or open
migrate dialog, a wallet switch, an encryption/import workflow, an
encrypt/PIN/lock operation, the seed-backup dialog, or a bootstrap.
The DRAGONX_FULL_SWEEP hook then exits as for an empty catalog.
- Persistence latches: AddressBook (snapshot on the first sweepBegin,
save/load latched while a demo-contacts surface is shown), WalletIndex
and Settings (active wallet file, contacts view mode, pool URL/worker)
for the whole sweep. Settings::sweepEnd() is the last statement of
clearDemoWalletData and flushes once only if a save was suppressed; a
quit mid-sweep writes nothing to the profile (the 0700 sandbox, with
zero-filled demo files only, may stay in the temp dir).
- Scope ids: during a full sweep activeWalletScopeId() returns the
persisted id or "" and never mints one, so the Contacts tab cannot
move legacy-scoped contacts to a throwaway id. The only address-book
write a sweep can still cause is the one-time legacy re-attach to the
wallet's persisted id, which opening Contacts does anyway.
- Sandbox: demo wallet files and folders go to a fresh random 0700 dir
under the temp dir (exclusive create, a pre-existing path or symlink
is refused), removed at sweep end. The teardown that deleted those six
wallet files is gone.
- Cleanup: the migrate-to-seed state, wizard phase, demo chat
conversations and a fabricated demo chat identity are reset at sweep
end; the seed-backup surface always shows the demo phrase and a real
z_exportmnemonic result is dropped while the sweep runs.
- i18n: one new key (screenshot_sweep_refused) in i18n.cpp and the 8
language files.
Tests: the address book snapshots only on the first sweepBegin, keeps
addressbook.json byte-identical while latched and restores the real
book; the wallet index keeps wallets.json and the real scope id, also
with the background writer attached; Settings restores the latched
values and flushes once only when a save was suppressed; the sandbox
helper refuses a pre-existing path or symlink and removes only a
prefixed dir, never a symlink's target.
Release note: v2.0.x UI sweeps could delete wallet-{cold,trading,mining,
donations,2023,payroll}.dat from the data directory and re-scope
contacts; this cannot be undone automatically.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The screenshot sweep and the full UI sweep are dev tools. The full sweep
injects demo data into the live wallet state and the real data and
config dirs, and the tab sweep screenshots real balances and addresses.
Both were compiled into every build: the Settings buttons were
reachable in full-node releases, and the DRAGONX_FULL_SWEEP env hook
worked in every build, Lite releases included.
A default-OFF option alone would fail open: dev and release builds
share build/linux (and build/mac), so a release configured without
--clean would inherit a cached ON from the last dev configure.
- CMake: option(DRAGONX_ENABLE_UI_SWEEP ... OFF), passed to
ObsidianDragon and ObsidianDragonTests as a compile definition.
- build.sh passes it explicitly: ON in the dev build, OFF in every
release configure (Linux, Windows, macOS osxcross and native).
- Packaging guard: while the cache says ON, dragonx_release depends on
dragonx_release_sweep_guard, which fails, so a hand-configured ON
tree cannot package a release.
- startSweepImpl is a no-op unless the option is ON (an undefined macro
counts as 0, so it fails closed). The enabled branch logs the
sentinel "[Sweep] DRAGONX_UI_SWEEP_COMPILED_IN", so a release can be
checked with `strings <bin> | grep -c DRAGONX_UI_SWEEP_COMPILED_IN`
(must be 0).
- main.cpp: the DRAGONX_FULL_SWEEP hook is compiled out (autoSweep is
false). It must be: with the sweep a no-op, the hook would quit the
app once its warm-up frames ran out.
- Settings > Node > Debug options: both sweep buttons, open-dir, the
seed-demo-chat button and the current-theme checkbox are compiled only
into dev builds. The daemon debug categories are unchanged.
No i18n change: the existing strings stay in the tables.
Verified with an isolated release (DRAGONX_BUILD_ROOT/DRAGONX_RELEASE_ROOT
in a scratch dir) whose tree was first configured by hand with ON:
build.sh --linux-release without --clean leaves the cache OFF, and the
sentinel count is 0 in the built binary, the zip and the AppImage.
With the cache set back to ON by hand, --target dragonx_release fails
at the guard before compiling anything. Dev trees configured ON keep
the sweep (sentinel present; a one-surface DRAGONX_FULL_SWEEP run
against the fake daemon captured and exited cleanly).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The Lite "Encrypt wallet" field and the Create/Restore passphrase had no
confirmation and no minimum length; only an empty passphrase was
refused. Encrypting locks the wallet at once, and a locked wallet cannot
reveal its seed, so a typo there could cost a user without a seed backup
their funds. The full-node encrypt dialog already requires 8 characters
and a confirmation.
This applies to new passphrases only. Opening and unlocking are
unchanged: an existing wallet with a shorter passphrase still opens and
unlocks.
- A pure liteEncryptPassphraseValid(pass, confirm): at least 8 bytes and
equal to the confirmation. It takes string_views, so the page's secret
buffers are never copied.
- liteLifecyclePassphraseAcceptable(op, pass, confirm) gates the Settings
create/open/restore request. Open is never checked, because its
passphrase field also unlocks an existing wallet. Create and Restore
are checked only when a passphrase was entered; an empty one still
creates an unencrypted wallet.
- Encrypt gets a Confirm field. The button stays disabled until the
passphrase is valid, and a hint says why (too short, with the count,
or not matching).
- Create/Restore show a Confirm row once a passphrase is typed, with the
same hint. Validate stays disabled while the passphrase is invalid, so
a click never wipes a typed seed over a passphrase typo. The request
builder re-checks the rule.
- The confirmation buffers are wiped with their passphrase: after a run
or an encrypt, and when the Confirm row is hidden.
- Once the wallet is closed, the Settings page wipes the Encrypt,
Confirm and Decrypt passphrase buffers whole every time, also when no
secret was revealed; before, closing the wallet wiped them only while
clearing a revealed backup or secret. They are zeroed in full because
an emptied field can still hold the old tail past its first NUL.
- New copy under Encrypt: "Anyone with the wallet file can try
passphrases offline. Back up your seed phrase first and use a long
passphrase."
- i18n: three new keys (lite_confirm_label, tt_lite_confirm_pass,
lite_encrypt_offline_warning) in i18n.cpp and the 8 language files,
added additively. The hints reuse the wizard's wiz_pass_too_short and
wiz_pass_mismatch. No new CJK glyphs, so the subset font is unchanged.
Tests: the validator rejects empty, short (7) and mismatched or
unconfirmed passphrases, and accepts 8 or more with a match. The request
gate always accepts Open, even "abc" with no confirmation, accepts
Create/Restore without a passphrase, and applies the rule to them
otherwise. An encrypted, locked wallet still opens and unlocks through
the controller with the 3-character passphrase "abc".
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
importKeyCore guessed the key type from its first character, then
retried a failed import with the other command. That retry is what sent
shielded keys to the backend's transparent-key decoder, which panicked
on them and poisoned the wallet lock. A WIF typo that was still
valid base58 also reached the backend unchecked.
- The key is classified first, and exactly one backend command runs, or
none:
- secret-extended-key-... and zxview... go to "import";
- a Base58Check WIF with version 0xBC or 0x80 and the compressed flag
goes to "timport";
- everything else is refused before any backend call.
There is never a retry with the other command. A testnet (0xEF) WIF
is not accepted, because the Lite app is mainnet-only.
- New fixed, untranslated reasons: "uncompressed transparent keys are
not supported by the Lite wallet", a full-node incoming viewing key
(zivks) is explained as such, and anything else is "unrecognized key
format". The key is never echoed.
- The backend's refusal while the wallet is encrypted now maps to "wallet
is encrypted - remove encryption to import a private key". It is
checked before "locked", because unlocking would not help.
- base58Decode reserves its buffers up front, and it and
decodeBase58Check wipe their copies of the decoded bytes with
sodium_memzero on every return path, since the input can be a private
key.
- exportSeed rejects the all-zero-entropy phrase (23 x "abandon" + "art").
A backend built before the seed-export fix returns it for a wallet
whose lock failed halfway (seed wiped, wallet still unlocked); it is
never a real backup and must not seed a chat identity.
- An open that the backend recovered from the .bak ("OK: recovered from
backup; damaged file kept as <name>") is written to the lite log.
Tests: the retry control now expects one "import" and no "timport", and
"typo-key" makes no backend call; the controller's transparent-import
test uses a real compressed WIF. A new classifier test covers both WIF
versions, the shielded and viewing keys, a single call on failure, the
uncompressed, zivks, testnet, bad-flag and bad-checksum refusals, the
encrypted reason and its lite-log line, the zero-entropy seed and the
recovery log line.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
litelib_initialize_new, litelib_initialize_new_from_phrase,
litelib_initialize_existing and litelib_check_server_online had no
catch_unwind (only litelib_execute did), so any panic below them
unwound across the C ABI into the GUI. With rustc 1.63 that is
undefined behaviour; in practice the process aborts with "fatal runtime
error: failed to initiate panic, error 5". The simplest trigger is a
malformed user-entered server URL: get_server_or_default unwraps the
http::Uri parse before anything else runs.
- ffi_guard (next to error_cstring) runs a body under catch_unwind and
turns a panic into "Error: Rust panic in <name>", which the C++ bridge
already treats as a failed call. The three init entry points keep
their extern signatures and #[no_mangle] and call ffi_guard; their old
bodies are now private *_impl functions.
- litelib_check_server_online wraps its body the same way and returns
false on a panic.
- litelib_initialize_existing opens the wallet through
read_from_disk_reporting. After a recovery from the .bak it returns
"OK: recovered from backup; damaged file kept as <name>" instead of
"OK". The C++ side treats any non-empty, non-error string as a
successful open, so this is ABI-compatible.
No ABI or symbol change: the exported symbol inventory
(lite-backend-symbols.txt) is identical. panic = "unwind" stays in the
release profile, and every guard depends on it.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
do_save flushed the temp wallet and renamed it over the wallet with no
fsync of the temp file, the .bak or the directory, so after a power cut
the rename could be on disk while the data was not, leaving a torn
wallet. The readers then panicked on malformed bytes instead of
returning an error (the gzip Decoder and WalletTx unwraps in
LightWallet::read, the version asserts in data.rs and walletzkey.rs,
from_utf8/diversifier unwraps, and vec![0; len] on a corrupt length
prefix, which can abort the process outright), so read_from_disk's .bak
fallback was never reached. And the fallback itself was destructive: it
copied the .bak over the wallet before reading it, on ANY error, so the
damaged file was lost, a transient I/O error replaced a good newer
wallet with an older .bak, and a wallet written by a newer app ("Don't
know how to read wallet version") was overwritten too, dropping imported
keys that are not in the seed.
- do_save: sync_all the temp file (removed on error), copy the .bak,
tighten and sync it through a write handle (FlushFileBuffers needs
write access on Windows), rename, sync the copy-fallback destination,
then fsync the directory on unix. No MoveFileEx write-through on
Windows.
- read_from_disk: each parse runs in read_wallet_file under
catch_unwind (panic = "unwind" is kept for release builds). The .bak
is used only when the wallet fails with InvalidData, UnexpectedEof or
InvalidInput, and it is read first, so a bad backup leaves both files
as they were. A damaged wallet whose header says encrypted is not
replaced by a plaintext .bak (only an older build leaves one): the
open fails with an explanation and nothing changes. Otherwise the
damaged wallet is moved (or, failing that, copied) to
<wallet>.dat.corrupt-<unix secs>, with a -N suffix while that name is
taken (a dangling symlink counts as taken), and is never deleted. The
recovered wallet is tightened as before.
- read_from_disk_reporting returns the same client plus "recovered from
backup; damaged file kept as <name>" after a recovery, for the FFI;
read_from_disk wraps it. Both stages are also written to stderr,
since the logger is not initialised yet when the wallet is opened.
- Readers return Err: a newer wallet version is ErrorKind::Unsupported
(was InvalidData), and so is a newer WalletTx or key version;
Decoder::new and WalletTx::read propagate with ?; a bad Utxo version,
a negative or non-UTF-8 or non-R Utxo address, non-UTF-8 metadata
addresses, an invalid diversifier and a viewing key with no default
address are InvalidData. Every length-prefixed string and address is
read through read_bytes_bounded (take(len).read_to_end, no cap, std's
own short-read error), so a corrupt length is never allocated up
front.
Flagged behaviour changes:
- fsync order: tmp, then .bak, then rename, then the dir (unix); saves
at exit, encrypt and decrypt still run on the UI thread and get
slower on slow disks;
- the readers return Err instead of panicking;
- the .bak fallback runs only for corrupt files, and the damaged file
is kept as .dat.corrupt-<ts> (every copy is kept; one from a
plaintext era keeps its seed, inside the 0700 dir);
- a wallet with a newer format errors (Unsupported) and is never
replaced;
- a damaged encrypted wallet whose backup predates encryption is not
opened.
Wallet format v9 is unchanged; valid files read and write byte for byte
as before. No ABI or symbol change.
Tests: a wallet whose parse panicked recovers from the .bak, the
garbage is kept as .dat.corrupt-<ts> and the report names it, and the
next open reports nothing; a newer-version wallet is Unsupported and
both files are unchanged (this fails when only the version error kind is
reverted); an unreadable (0000) wallet is a PermissionDenied error that
changes nothing either; a corrupt wallet with
a corrupt or no .bak changes nothing; a damaged encrypted wallet with a
pre-encryption plaintext .bak is refused with both files unchanged and
nothing moved aside; a recovery never overwrites an earlier
.dat.corrupt-<ts> or follows a dangling symlink; every truncation of a
wallet and of a WalletTx is an Err, crafted Utxo, metadata, note and key
records are the expected Err kinds, and a u64::MAX length returns at
once.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
On Linux and macOS the Lite data dir (~/.silentdragonxlite) was created
with create_dir_all (0755 under the default umask) on every
get_zcash_data_path() call, even by the startup wallet_exists() probe.
do_save wrote the wallet through File::create (0644) and renamed it into
place, and fs::copy gave the .bak the same mode, so any local account
could read the wallet file, and the plaintext seed with it when the
wallet was not encrypted. The debug log was 0644 and its roller pattern
was relative, so rolled logs landed in the process's working directory.
A crash-leftover .dat.tmp was truncated and reused: an fd another
account had opened on it read the next wallet. And after encrypt(), the
first save copied the previous, plaintext wallet to .bak, where the seed
stayed until the next save, or for good if the app quit right away.
- New helpers, each an item-level #[cfg(unix)] / #[cfg(not(unix))]
pair: create_private_dir_all (0700 components), tighten (strips only
the group/other bits of an existing dir or file, keeps the owner bits,
never follows a symlink), create_private_file_new (unlinks a stale
file, then create_new with 0600), and a once-per-process warning when
a chmod fails (drvfs, FAT, NFS).
- get_zcash_data_path creates the default data dir 0700 and tightens an
existing one (for test/regtest also its parent). A caller-supplied
data_dir is created as before and never chmodded, and the params dir
is left alone.
- do_save writes a fresh 0600 temp file and tightens the .bak after the
copy. At the plaintext -> encrypted transition (the wallet is
encrypted and the on-disk header says plaintext) the .bak is copied
from the new, encrypted file. wallet_header_encrypted reads the flag
byte for versions 4 and 6-9 only (v5 is gzip) and returns None when it
cannot tell, which keeps the old behaviour.
- read_from_disk tightens a wallet restored from .bak, and the wallet
and .bak after every successful read.
- get_log_config pre-creates the log 0600 on unix and anchors the
roller in the data dir; a data path containing "{}" keeps the old
relative pattern. log4rs re-creates the log with the umask mode after
a 5 MB roll, so the 0700 data dir is the lasting control.
Flagged behaviour changes:
- on unix, the data dir is 0700 and existing files lose their
group/other bits (owner bits kept, symlinks and a caller-supplied
data_dir untouched); a shared group or a backup user under another uid
loses read access;
- a fresh 0600 temp file is used;
- the first encrypted save backs up the encrypted file;
- the log rolls inside the data dir (old rolled logs in former working
directories are not cleaned up).
On Windows the file modes are unchanged; the encrypted first .bak and
the log-roll location apply there too. Wallet format v9 is unchanged;
no ABI or symbol change.
Tests: the wallet and .bak are 0600 after every save and after an open
from 0644, and a wallet restored from a loose .bak is 0600, while the
caller's 0755 data dir is unchanged; a stale 0644 .dat.tmp is replaced
by a new inode and an fd held on it still reads the stale bytes; tighten
keeps owner bits (0555 -> 0500), ignores the other kind and symlinks,
create_private_dir_all makes every component 0700, and
create_private_file_new never reuses or follows a stale file or link;
after encrypt the first save's .bak is encrypted, byte-identical to the
wallet and unlocks to the seed; the header probe answers only for v4 and
v6-v9; the log is 0600, the roller pattern and a real roll stay in the
data dir, and a "{}" path falls back. An opt-in test
(DRAGONX_LITE_SCRATCH_HOME=1, temp HOME) checks the default data dir,
its testnet parent, a symlinked data dir and the params dir.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The lightwalletd server controls the compact ciphertext length, the
block, prev-block and tx hash lengths, and (through the chain) the full
txs' epks. Each of these could wedge or poison a sync:
- try_sapling_compact_note_decryption asserts a 52-byte ciphertext. A
shorter (or full 580-byte) one panicked a pool job in
scan_output_internal while the original sender stayed alive, so the
collector waited forever with sync_lock, txs.write() and a wallet read
guard held.
- scan_taddress_txids kept its own sender alive too: one panicking
t-address job (scan_full_tx) left crx.iter().take(n) blocked forever.
- CompactBlock::hash()/prev_hash() assert 32 bytes when no header is
sent, and every vtx hash is copied into a [u8; 32], one of them under
txs.write(): a 31-byte tx hash poisoned the wallet for good, and a bad
hash in the block's txid list panicked after the witnesses had moved.
- scan_full_tx and scan_full_mempool_tx unwrapped as_prime_order() on
the epk. Consensus rejects only a small-order epk, so a mixed-order
one can be mined: an own tx then failed every sync, and the mempool
receiver thread died silently.
Changes:
- append_leaf(node: Node, ..) commits a leaf to the existing, block and
new witnesses and the tree; the epk-error path, the new
short-ciphertext path and the normal path all use it (a
behaviour-neutral extraction).
- A compact ciphertext under 52 bytes is skipped with its cmu still
committed; a longer one is truncated to 52 bytes, which is exact (the
compact plaintext is those bytes and the cmu re-check validates it).
- Each decryption job catches a panic and sends exactly one message;
drop(tx) after the dispatch, and the collector logs a vanished or
panicked job and treats the output as not ours.
- scan_taddress_txids runs through the private seam
scan_taddress_txids_with(job): catch_unwind per job, drop(ctx), and a
count check, so a dead job is an error, never a hang or a silent
partial success.
- scan_block_with_pool checks the block, prev-block and vtx hash lengths
right after the parse and returns Err(-1), as for an unparsable block,
before any change (Err(height) would refetch the same block forever
under sync_lock).
- scan_full_tx and scan_full_mempool_tx skip an output whose epk is not
in the prime-order subgroup (one check per output, used by every
decryption and the outgoing recovery).
- The mempool receiver thread catches a panic of scan_full_mempool_tx.
Flagged behaviour changes:
- a compact ciphertext under 52 bytes is skipped (its cmu is still
committed); one over 52 bytes is truncated;
- pool-job panics are reported (logged; the output is treated as not
ours, which a rescan repairs) instead of hanging the sync;
- bad hash lengths return Err(-1) before any change;
- outputs with a non-prime-order epk are skipped;
- the mempool thread survives a bad tx.
Wallet format v9 is unchanged; no ABI or symbol change. Honest data
takes the same path as before (an old-vs-new run over generated honest
chains is byte-identical).
Tests (each on its own thread under a 15 s watchdog):
0/51/52/53/580-byte ciphertexts in one block on the inline and deferred
witness paths (the 51-byte notes are skipped, the 53- and 580-byte notes
are found, the tree and every witness match the reference); a 31-byte tx
hash, an empty block hash, a 31-byte prev hash and a 33-byte foreign tx
hash each return Err(-1) with the txs lock usable and
last_scanned_height, trees and witnesses unchanged; a mixed-order epk in
scan_full_tx and scan_full_mempool_tx does not panic and decrypts
nothing while the real epk still decrypts; a t-address job panicking for
1 of 3 addresses is an Err. The oracle generator gains short
(0..=51-byte) and full (580-byte) ciphertext outputs without an extra
random draw, so each seed keeps its sequence of blocks, values and
keys.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
lock() zeroed the seed first and then locked the keys one by one. A key
with no encrypted copy (an import into an encrypted wallet by an older
build) failed halfway: the seed was all zeros, the earlier keys were
wiped, and the wallet still reported unlocked. Seed export then showed
the all-zero-entropy phrase ("abandon ... art") as the backup.
unlock() had the mirror problem. When the passphrase was right but a
later key failed (a zaddress/fvk mismatch, a bad sealed key), the keys
decrypted so far stayed in memory while the wallet reported locked, and
write() saves a locked wallet as is, so the next save put them in
plaintext into the encrypted file. unlock also unwrapped the nonce and
the sealed key, so a malformed or foreign file panicked under the wallet
write lock and poisoned it.
remove_encryption() on an unlocked wallet never looked at the
passphrase: any string, even "", removed the encryption for good, and
the next save wrote the seed and every key in plaintext.
- WalletTKey/WalletZKey get can_lock(), which mirrors lock()'s
precondition. lock() checks every key first and changes nothing when
one cannot be locked; the keys are locked next and the seed is zeroed
last.
- unlock() runs the t-key loop, then the z-key loop; on any error it
re-locks every key that can be locked before it returns. This drops
only plaintext copies that can be re-derived or re-decrypted.
- The nonce and enc_key unwraps in LightWallet::unlock and in
WalletTKey/WalletZKey::unlock return "Wallet encryption data is
corrupt" (InvalidData) instead.
- get_seed_phrase() returns "" when the wallet is encrypted and its seed
is all zeros.
- remove_encryption() opens the sealed seed with the given passphrase
first, locked or unlocked, before it changes anything.
Flagged behaviour changes:
- lock() checks every key before it changes anything;
- a failed unlock re-wipes;
- corrupt encryption data returns an error, not a panic;
- seed export never returns the all-zero phrase;
- remove_encryption verifies the passphrase in both lock states (a wrong
passphrase now fails with "Decryption failed. Is your password
correct?").
Wallet format v9 is unchanged; no ABI or symbol change. The KDF is
unchanged; upgrading it is left for later.
Tests: lock is atomic for an unsealed t-key and an unsealed imported
z-key (seed, unlocked flag and keys unchanged, then a clean lock and
unlock); a failed unlock leaves no t-key or z-key secret in memory or in
the written file, and with the z-key's real derivation index restored
the same passphrase unlocks every key; a key with no sealed copy, a
missing nonce or a nonce of the wrong length is an error in WalletTKey,
WalletZKey and LightWallet, and remove_encryption refuses it too;
remove_encryption with a wrong or empty passphrase fails locked and
unlocked, and the right one round-trips an unencrypted v9 file; an
encrypted wallet with a zero seed exports "".
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
import_taddr decoded a WIF with bs58::decode(..).unwrap(), a [2..66]
slice of its hex and SecretKey::from_slice(..).unwrap(), all under the
wallet write guard that do_import_tk holds. A z-key, a typo or any short
string panicked there and poisoned the wallet until restart, and the
checksum was never verified, so a mistyped WIF that was still valid
base58 imported a different key.
A private key imported into an encrypted wallet was stored without an
enc_key. The import then failed in do_save's lock(), which had already
zeroed the seed while the wallet stayed "unlocked": seed export showed
the all-zero-entropy phrase.
- decode_taddr_wif replaces the old decode: Base58Check with the
checksum verified, version 0xBC (the config prefix) or 0x80, and the
0x01 compressed flag. It decodes into a buffer it owns and wipes it
with sodiumoxide::utils::memzero on every path, including a failed
checksum. Errors are fixed strings that never contain the key.
- import_taddr and add_imported_sk refuse while the wallet is encrypted,
as add_taddr and add_zaddr already do.
- do_import_tk and do_import_sk check is_encrypted() before the
"locked" check, so a locked wallet does not send the user to unlock
and retry.
Flagged behaviour changes:
- Base58Check, version 0xBC/0x80 and the compressed flag are required;
- uncompressed and foreign-network WIFs are rejected with fixed
messages (an uncompressed key used to import under the compressed
address, where its funds are not);
- no panic and no poisoned lock;
- private-key import (t and z) is refused while the wallet is
encrypted, with a reason that says to remove encryption, import, then
encrypt again; viewing keys still import;
- the key is never echoed.
No ABI, symbol or wallet format change.
Tests: garbage, z-keys, bad checksums, a transposed or flipped
character, a zero scalar, a wrong flag, uncompressed, 0x05 and 0xEF WIFs
are rejected with their message and leave tkeys usable; the wallet's own
export, a 0x80 WIF and the published key-1 vector still import; the
encrypted refusal in the wallet and the client, locked and unlocked,
leaves lock, save and seed export intact; a viewing key still imports
into an encrypted wallet; a bad timport leaves the wallet lock unpoisoned.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
clear_state_from wiped the wallet's blocks, txs and mempool maps without
taking sync_lock. The console `clear` and `rescan` commands run
concurrently with the refresh worker's sync (litelib_execute drops the
client mutex before a command runs), so a clear could land after a sync
had already taken its nullifier snapshot. scan_block then unwrapped a
note that was no longer in the wallet and panicked under the txs write
guard, which poisons the wallet until restart.
- clear_state_from now takes sync_lock first (lock order sync_lock ->
wallet, as in do_save and do_sync). No caller holds sync_lock, so it
cannot self-deadlock; do_rescan holds no lock when it clears.
- clear_state reads the birthday into a local before calling
clear_state_from. Before, the wallet read guard was a temporary that
lived across the call, which read-locks the wallet again: with a
writer (a save, an import, a new address) queued in between, the
writer-preferring RwLock on Linux and Windows could deadlock. Holding
it while waiting for sync_lock would also have inverted the lock order
against do_save.
- scan_block resolves each spent note with and_then instead of three
unwraps. A spend of a note that is no longer in the wallet logs a
warning and is skipped.
Flagged behaviour changes:
- console clear/rescan and the GUI rescan wait for an in-flight sync or
save to finish instead of racing it;
- the recursive wallet read lock that could deadlock a concurrent save
is gone;
- a spend of a vanished note now warns and is skipped instead of
panicking; it is counted after the next rescan.
No ABI, symbol or wallet format change.
Tests: clear_state_from waits for sync_lock, clear_state holds no wallet
guard while it waits, neither leaks the lock, and a 200x clear/write
loop finishes within a 20 s watchdog (a regression guard only).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The transparent-address txid scan in the sync loop and the mempool
monitor both hard-coded no_cert = true, so they connected to the
lightwalletd through get_client's NoCertificateVerification path in
every Lite build, while the app always passes dangerous = false. Block
sync, info, broadcast and the full-tx fetcher already honour
LightClientConfig::no_cert_verification. An active man in the middle on
those two channels could hide or invent transparent balance, UTXOs and
pending transactions (signing stays local, so no funds can be taken).
Both calls now take the setting from the config:
- the sync loop passes self.config.no_cert_verification to
scan_taddress_txids;
- start_mempool_monitor reads config.no_cert_verification once and
passes it to monitor_mempool.
Flagged behaviour change: a lightwalletd with an invalid or self-signed
certificate now fails the t-address stage (do_sync retries, then returns
the error) and the mempool monitor (it logs and retries), as block sync
already does. `dangerous` is the only bypass. No ABI, symbol or wallet
format change.
A tripwire test checks that lightclient.rs never again hard-codes the
bypass for either call; its needles are joined at runtime so the test
cannot match its own source.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The qtlib wrapper's Cargo.toml, the workspace root of the backend build,
had no release profile, so libsilentdragonxlite.a was built with cargo's
defaults: no LTO and 16 codegen units, with the objects of every
dependency copied into the archive.
The release profile now sets opt-level 3, fat LTO and one codegen unit.
panic stays "unwind", written out with a comment: litelib_execute turns
a panic into an error through catch_unwind, and the full-tx fetcher and
the threadpool turn scan-job panics into errors by unwinding. There is
no strip setting: rustc does not link a staticlib, and build.sh strips
the final executable.
Measured, building the same source both ways:
- archive: Linux 126.7 -> 71.5 MB with 1580 -> 1 Rust object files;
Windows 105.9 -> 65.8 MB with 1560 -> 1;
- stripped ObsidianDragonLite: Linux 84,283,856 -> 75,366,184 bytes
(-8.9 MB, -10.6%); Windows exe 87,436,288 -> 79,379,968 bytes
(-8.1 MB, -9.2%). On Linux .text went from 20.0 to 14.8 MB and
.eh_frame from 2.67 to 1.19 MB;
- exported symbols unchanged (blake3_PW and the 8 litelib_* functions);
- a forced panic in the send command's JSON parsing still returns
"Error: Rust panic in litelib_execute", and the next call works;
- lite_smoke --restore-recent, --keys and --encrypt pass with identical
output apart from chain tip and birthday; restore CPU time is
unchanged within noise (115.8-117.2 s before, 111.7-118.8 s after)
and peak RSS dropped from 38.5 to 31.6 MB;
- backend build time: Linux 70 -> 118 s, Windows 54 -> 102 s.
This has no effect on releases until the backend artifacts are rebuilt:
build.sh links the prebuilt build/lite-backend/<platform> archives,
which are still the old non-LTO ones (the Windows one also predates the
recent backend commits). Rebuild them with
scripts/build-lite-backend-artifact.sh from a clean, committed tree.
Not verifiable here: on Windows only the link was checked, with no panic
probe, smoke run, sync or send (inlining can grow stack frames, and
mingw threads get about 2 MB of stack); the macOS artifact was not built
(CARGO_PROFILE_RELEASE_LTO=thin is the fallback if fat LTO is a problem
there); and a send, with its proving, needs a funded wallet.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
reloadThemeImages() replaced gradient_tex_ in each of its three branches
(image loaded, load failed, no background) without freeing the old
texture, so every skin activation, including the one at startup, leaked
one texture.
The logo and coin textures were freed immediately instead, although the
current frame's draw lists can still reference them: the window backdrop
records its images at the start of the frame and skin switches happen
mid-frame (Settings, Ctrl+arrow keys), and the screenshot sweep switches
skins after RenderDrawData() but before the secondary viewports are
rendered. That hands the renderer a freed texture.
Textures replaced mid-frame are now retired to a queue (retireTexture()
also zeroes the member) that the next preFrame() frees first, after the
previous frame's draw data has been rendered. The old gradient is
retired at the top of reloadThemeImages(), and the logo and coin where
they used to be freed. ensureLogoTexture() still frees directly, since
it runs at the top of render(), before this frame draws a logo.
Shutdown does not drain the queue.
Measured with a temporary live-texture counter over a screenshot sweep
of all 10 skins: before, 6, 7, ... 15 live textures (one more per skin);
now a constant 5. The first screenshot after each skin switch is
pixel-identical to before on all 10 skins, and the full sweep matches
within noise.
Not verifiable here: DX11 at runtime.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Turning blur off (blur 0), switching to low-spec mode or enabling
reduced transparency after the acrylic targets existed left the capture
FBO and both blur ping-pong targets allocated for the rest of the
session, although nothing draws with them while the effect is skipped.
They are now released at a frame boundary and rebuilt on demand:
- ImGuiAcrylic::ReleaseIdleBuffers() runs right after app.preFrame(),
before NewFrame(), when the previous frame's draw data has been
rendered, and once in the resize-watcher frame (GL and DX11). While
the effect is skipped it frees the targets; when the effect is wanted
again it reallocates them once, with no per-frame retry if that fails;
- resize() while skipped only records that a reallocation is needed;
- the release checks the GL names (and each DX11 texture) directly, so a
half-allocated ping-pong pair is freed too; DX11 also resets the
ping-pong index;
- the capture paths and applyBlur() return early without a capture
buffer, which would otherwise blit the default framebuffer onto
itself;
- each release logs "released targets (effect skipped)".
With blur on, the path and the output are unchanged (screenshot sweeps
within noise). After re-enabling, the first frame draws the tinted
fallback until a fresh capture exists.
Measured with software GL, toggling every 30 frames for 1200 frames: the
targets were released exactly one frame after each switch-off (22 of 22)
and rebuilt on each switch-on (21 of 21), with no GL or framebuffer
errors. While off at 1280x720, with a fixed malloc mmap threshold, the
process holds 4.62 MB less (the 1280x720 RGBA8 capture plus two 320x180
RGBA16F blur targets) and RssAnon is about 3.1-3.5 MB lower. With
glibc's default threshold the freed memory stays in the heap and RssAnon
does not visibly drop.
Known issue: every off/on pair now frees and reallocates those targets.
Under software GL (llvmpipe) with the default glibc allocator the freed
texture memory is not reused: toggling every 2 frames grew RssAnon from
49 to 906 MB over 240 pairs, every 8 frames from 56 to 179 MB, and every
30 frames it levels off near 120 MB. It is not a GL object leak (with a
fixed mmap threshold the mapped bytes stay flat), and on a hardware
driver (WSLg d3d12) RssAnon stayed between 67.5 and 74.9 MB under the
2-frame churn (94.3 MB without the release). Users reach fast toggling
by holding Ctrl+Shift+Down (key repeat, about 20 Hz) or by dragging the
blur slider back and forth across 0. A short delay before releasing
would avoid it if software-rendered users matter.
Not verifiable here: the DX11 release and reallocation at runtime, and
macOS.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The GL window asked for a 24-bit depth and 8-bit stencil buffer, and
every acrylic Framebuffer attached a DEPTH24_STENCIL8 renderbuffer the
size of its color texture. Nothing uses them: no code enables depth or
stencil tests (the ImGui GL3 backend disables both), every blit and
readback is color-only, and only Framebuffer::clear() also cleared the
depth buffer, which nothing read. The window and every FBO, including
the full-size capture FBO, carried that memory, and the FBO
renderbuffers were reallocated on every resize.
Now:
- the window requests depth 0 and stencil 0 explicitly (SDL3 requests a
depth buffer by default when the attributes are simply removed);
alpha 8 stays for the transparent window;
- Framebuffer has no renderbuffer, and clear() clears color only;
- a permanent startup log line,
"GL default framebuffer: depth=N stencil=N", reports what the driver
provided; "(no attachment)" marks a failed query, which is how some
drivers report a buffer that does not exist.
The log shows depth=0 stencil=0 both offscreen and on WSLg X11, where
"Window transparency enabled by compositor" is still logged, with no
framebuffer-incomplete errors. Screenshot sweeps at 1280x720 and
2560x1440, and a glass run with ui_opacity 0.6 and blur on (so panels go
through the FBO blur), match the previous build within noise. The
driver-side memory saving itself was not measured. DX11 is unaffected:
its swap chain has no depth buffer.
Not verifiable here: macOS (an NSOpenGL 3.2 core context with a
transparent window).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Skin handling did more work than it needed:
- the bundled-skin scan fully parsed each of the 9 bundled skin files to
read name, author, dark and images from their first 327-345 bytes. The
build copies the rest of ui.toml into every skin, so each file is
51-54 KB;
- setActiveSkin() reloaded ui.toml right after main() had loaded it, a
second full parse with nothing merged in between;
- in the Windows single-file exe, every getBundledSkinsDirectory() call
(skin refresh, the dragonx entry, each image-dir lookup) re-extracted
all embedded themes with a blind overwrite: more than once per launch,
and again on skin changes.
Now:
- bundled skins are parsed only up to the first table header after
[theme] other than [theme.images]. Anything unusual (no [theme],
images defined later, a parse error in the slice) falls back to the
full parse, and user skins are still parsed in full;
- UISchema tracks whether its state is exactly the base (the last change
was a base load and no hot-reload is pending), and setActiveSkin()
skips reloadBase() in that case;
- the bundled-skins directory is resolved once per process (re-checked
with exists()), and theme extraction rewrites a file only when its
bytes differ, through the new resources::writeFileIfDifferent(). It
compares bytes, not sizes, so a same-length palette edit still
updates.
Measured on the Lite build (median of 5 runs):
- bundled-skin scan: 15 -> 1 ms;
- from "Initializing ObsidianDragon" to the activated skin: 29 -> 12 ms;
- ui.toml is loaded once per launch instead of twice, and the light skin
merges its overlay exactly once.
A Python mirror of the header-slice parse agrees with a full parse on
name, author, dark and images for all 18 source and build-expanded skin
files, and full screenshot sweeps over all 10 skins match the previous
build within noise. New unit tests cover writeFileIfDifferent(): missing
file, identical bytes (mtime untouched), same-length change, and a
longer file truncated.
Behaviour changes:
- a bundled skin with a TOML error after its header is now listed
instead of skipped; the error surfaces only when it is selected;
- deleting a single file inside the extracted bundled-themes dir
mid-session is repaired only at the next launch;
- the "Extracted N embedded bundled themes" count includes files that
were already current.
Not verifiable here: Linux has no embedded themes, so the new
writeFileIfDifferent() ran only in the unit tests and the extraction
path was only compiled, in the Windows cross-build. That a second
Windows launch leaves the theme files' mtimes untouched is still to be
checked on Windows.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
main() built the font atlas before App::init() loaded ui.toml, so the
first build used the hard-coded fallback sizes (H1 24 px, Body2 12 px).
App::init() then rebuilt for a saved font scale above 1.0, still before
the schema load and so still at the fallback sizes. Only the color-emoji
rebuild requested for the first frame reached the ui.toml sizes, so
color-emoji users built the atlas twice at font scale 1.0 and three
times at 1.5.
Users with chat_emoji_color=false never got that last rebuild. They
built the atlas once or twice and ran the whole session at the fallback
sizes, e.g. Body2 at 12 px instead of 15 and H1 at 24 px instead of 20.
Now:
- App::loadBaseUiSchema(), the ui.toml load moved out of App::init()
unchanged, runs in main() before both InitImGui paths (DX11 and GL),
together with the saved font scale and emoji style from the early
settings read. It is idempotent, so the call left in App::init() is a
no-op;
- the two rebuild triggers in App::init() are gone;
- Typography records what the atlas was built from (the 15 schema sizes,
the schema's font scale, the effective emoji style and the schema
generation), and preFrame() rebuilds once when they differ. While the
schema generation and the emoji style are unchanged this costs two
compares per frame.
Measured on the Lite build (color and mono emoji, font scale 1.0 and
1.5, English and Japanese): every run builds the atlas exactly once, at
the ui.toml sizes times the scale (H1 20 px and Body2 15 px at 1.0,
30 px and 22 px at 1.5), and ui.toml is loaded before the window and GL
context are created. The packaged Lite zip and AppImage log one atlas
build instead of two. Color-emoji screenshot sweeps match the previous
build within noise.
Visible changes:
- users with chat_emoji_color=false had been rendering the whole UI at
the fallback font sizes; they now get the designed ui.toml sizes, so
every mono-emoji screen differs from before and matches the
color-emoji layout;
- a user skin whose [fonts] differ from ui.toml now rebuilds the atlas
once when it is activated or left; on a runtime switch the atlas used
to keep the old sizes until a restart. With a test skin that sets
Body2 to 18 px, startup builds twice and ends at 18 px; switching to a
bundled skin rebuilds once, switching among the 9 bundled skins
rebuilds nothing (they carry an identical copy of [fonts]), and
switching back rebuilds once;
- editing ui.toml while running still gives exactly one rebuild.
Not verifiable here: the Windows DX11 path with the schema loaded before
its first FreeType build, macOS, and the interactive font-scale slider,
Alt+scroll and emoji toggle, which were checked by reading the code.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Typography copied every font blob into a fresh heap buffer for each face
it added (IM_ALLOC + memcpy, FontDataOwnedByAtlas = true): the Ubuntu
weight, the CJK subset merge, the emoji merge, and the icon and pickaxe
fonts. The blobs are INCBIN symbols in read-only memory that live for
the whole process, so the copies (16 faces, each with its own CJK copy
and, for 8 of them, an emoji copy) only cost memory. Styles with the
same weight and size also built separate, identical ImFonts.
Now:
- every font is added straight from the embedded data with
FontDataOwnedByAtlas = false. ImGui 1.92 frees only data it owns, so
the blobs survive atlas clears and DPI or skin reloads;
- load() walks a style table and shares the ImFont between styles with
the same (weight, scaled size, emoji) key. Emoji is part of the key,
so no style gains or loses the emoji fallback. With the current
ui.toml sizes H3/H4, H6/ButtonLg and Body1/Overline share a face: 13
ImFonts instead of 16 (12 text faces plus Mono), CJK merged 13 times
instead of 16 and emoji 7 times instead of 8.
Measured on the Lite build, RssAnon 15 s after start, median of 3 runs:
- color emoji: 108.6 -> 78.8 MiB (-29.8 MiB);
- monochrome emoji: 102.8 -> 78.6 MiB (-24.2 MiB).
RssFile is also 2.4-5.1 MiB lower, because the blobs are no longer read
end to end for copying. Full screenshot sweeps (610 screens per run; color
and mono emoji at 1280x720 in English and Japanese, color at 2560x1440)
match the previous build within run-to-run noise. The only content
differences are live values such as the mining tab's RAM readout (233 MB
before, 201 MB after).
Also fixed: when AddFontFromMemoryTTF failed, ImGui had already freed
the owned copy and the caller freed it again, a latent double free;
those IM_FREE calls are gone. The emoji-merge failure log now prints the
blob size instead of passing the float font size to %u.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
CMake turned every res/lang/*.json into an xxd header that i18n.cpp
compiled in as a fallback, on every platform. Only the Windows
single-file exe needs it: the Linux zip and AppImage and the macOS .app
always ship res/lang, and loadLanguage() reads the loose file first. On
Linux the embedded copy added about 1.5 MB to the binary, and every
translation edit recompiled i18n.cpp.
A new option, DRAGONX_EMBED_LANG, defaults to ON for WIN32 and OFF
elsewhere:
- ON without xxd is now a configure error (it used to warn, then fail to
compile i18n.cpp on the missing headers);
- i18n.cpp compiles the embedded includes and the fallback only when ON;
- outside Windows, loadLanguage() also tries
<exe>/../share/<app>/res/lang (the cmake --install layout and the
full-node AppImage) and <exe>/../Resources/res/lang (the macOS .app
binary run directly), so those layouts find the loose files whatever
the working directory; before, only the embedded copy covered that.
Measured:
- full-node Linux binary: .data 1,699,696 -> 168,592 bytes; stripped
19,983,136 -> 18,456,128 bytes (-1.53 MB);
- with the ImGui demo stubs, the Linux zips and AppImages are
0.50-0.55 MB smaller, with identical file lists including all 8
language files;
- a Lite build started from an unrelated working directory loads the
ja.json next to the binary (2422 strings), and the packaged Lite zip
and AppImage load ja.json with no new not-found or failed lines;
- Windows keeps the embedded copy: the 8 language headers are generated
and the ja.json bytes are in the exe.
A new test loads "de" from the loose files and checks a translated
string; with bin/res/lang moved away exactly its two assertions fail.
Behaviour changes on Linux and macOS: a missing or corrupt res/lang now
falls back to English instead of the embedded copy, and a translation
edit needs only the copy_langs step instead of a recompile.
Not verifiable here: the macOS .app in a non-English language, and the
Windows single-file exe at runtime.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Both Windows zips shipped a loose xmrig.exe next to the wallet exe. The
exe already embeds xmrig (HAS_EMBEDDED_XMRIG), and getXmrigPath() uses,
and re-extracts when needed, the copy under %APPDATA%\<app>\dragonx; it
looks next to the exe only when that extraction fails.
build_release_win now copies it only when the generated header does not
define HAS_EMBEDDED_XMRIG, or there is no header.
Measured: each Windows zip loses exactly one entry, xmrig.exe; with the
ImGui demo stubs, the full-node zip went from 167,515,430 to 164,554,177
bytes.
The loose dragonxd, dragonx-cli, dragonx-tx, wallet-rebuild helper,
params and asmap stay in the full-node zip. The daemon copy under
%APPDATA% is written once and never refreshed, so without the loose copy
existing zip users would silently run their first-install daemon.
Dropping them needs a migration first.
Not verifiable here: mining from an unzipped Windows build without the
loose xmrig.exe.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The Windows release embeds its resources into the exe through a
generated INCBIN header. The Lite build got the same param and asmap
entries as the full node, so ObsidianDragonLite.exe carried
sapling-spend.params, sapling-output.params and asmap.dat (about 53 MB)
that it never reads: the SDXL backend embeds its own copy of the params,
and Lite already skips extracting full-node-only resources.
build_release_win now writes the param and asmap INCBINs only for the
full node (should_bundle_full_node_assets). Lite gets null/zero stubs
for those three symbols and still embeds images, themes and xmrig. Two
fixes make that safe:
- getEmbeddedResource() stopped at the first table entry with null data,
so with the params stubbed out the Lite lookup ended before xmrig:
mining would report itself available but never find the embedded
binary. It now walks to the end of the table and skips null payloads.
This also fixes a full-node build without asmap, whose lookup ended at
the null asmap entry and hid the daemon and xmrig entries.
- full and Lite builds share build/windows. When the full-node build
cannot find the params, the generated header is now deleted, so a Lite
header left behind by an earlier Lite build is not compiled into it
(the node would then never start, because the startup job would take
the embedded-extraction path and never copy the loose params).
Measured on release builds:
- ObsidianDragonLite.exe: 159,788,544 -> 106,120,192 bytes (-53.7 MB, of
which about 0.28 MB is the ImGui demo stubs);
- the Lite header has no param or asmap INCBINs and the resource object
holds only the xmrig payload; a sample of sapling-spend.params occurs
once in the exe instead of twice (the remaining copy is the
backend's);
- the full-node header is byte-identical to before.
Not verifiable here (Windows runtime): the Lite exe starting without
embedded params, and mining through the embedded xmrig. Lite installs
from older versions may still hold about 53 MB of extracted params and
asmap in %APPDATA%\ObsidianDragonLite\dragonx; nothing deletes them.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The ImGui demo window cannot be opened in the wallet (its show flag is
never set), but imgui_demo.cpp was compiled and linked in full.
It is now compiled with IMGUI_DISABLE_DEMO_WINDOWS, which reduces it to
empty stubs. ShowDemoWindow, ShowStyleEditor, ShowAboutWindow,
ShowUserGuide and ShowStyleSelector stay defined, and imgui.h declares
them unconditionally, so a per-file define is ODR-safe.
IMGUI_DISABLE_DEBUG_TOOLS is deliberately not set: it changes imgui.h
declarations and would drop ImGui's duplicate-ID highlight.
Measured:
- imgui_demo.cpp.o: 755,392 -> 1,704 bytes;
- stripped full-node Linux binary: 20,267,680 -> 19,983,136 bytes
(-284,544);
- full-node Windows release exe: 282,112 bytes smaller;
- the "Dear ImGui Demo" string is gone and the stubs are still defined.
With Makefile generators the per-file define lands in the target's
flags.make, so each existing build tree recompiles the whole app once
(153 files, about 71 s at -j8 here). No runtime or visual change.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
HushChatFixtureCheck is a dev-only CLI for the HushChat fixture files.
Nothing runs it (no ctest entry, not linked into the app or the tests),
yet every dev and test build compiled and linked it.
It is now EXCLUDE_FROM_ALL, with a usage note next to the target:
cmake --build build/linux --target HushChatFixtureCheck
A dry run of `all` no longer mentions it (4 lines before) while
ObsidianDragonTests stays in `all`, and building the target explicitly
still produces a working binary (`--help` exits 0). The fixture template
and the chat sources are unchanged.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Release builds ran a plain `cmake --build .`, which builds `all`: on top
of the app and the wallet-rebuild helper, every --*-release run also
compiled HushChatFixtureCheck, ObsidianDragonTests where BUILD_TESTING
is on (Linux, macOS) and, in Lite backend trees, lite_smoke and
lite_send_smoke. None of them ship.
A new `dragonx_release` custom target depends on ObsidianDragon plus
dragonx-wallet-rebuild, copy_langs and copy_themes where those targets
exist, and the three release paths in build.sh build only that target.
The dev build (./build.sh) still builds `all`.
BUILD_TESTING is deliberately left alone: release and dev builds share
build/<platform>, and writing BUILD_TESTING=OFF into that cache would
leave ctest running a stale test binary.
Measured:
- a dry run of dragonx_release schedules none of the test, smoke-tool or
fixture-CLI builds that `all` schedules, in a full-node, a
Lite+backend and a mingw tree; the wallet-rebuild helper is still
built;
- all four release builds (Linux and Windows, full node and Lite) log
the dragonx_release target and no ObsidianDragonTests build, and the
Linux zip and AppImage file lists are unchanged.
Consequences: a release run no longer refreshes the test binary or the
lite smoke tools in build/<platform>/bin, so run ./build.sh or
`cmake --build` before ctest, and build the Windows smoke tools with
`--target lite_smoke lite_send_smoke`. If a release step starts copying
another CMake-built file out of bin/, its target must be added to
dragonx_release.
Not verifiable here: macOS --mac-release (the .app should still contain
the app, dragonx-wallet-rebuild and Contents/Resources/res).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The release functions always built in build/<platform> and wrote to
release/<platform>. On Linux and macOS build/<platform> is also the dev
build tree, so a release run reconfigures it (--linux-release turns a
Lite+backend dev tree into a full-node one), and building a baseline to
compare a change against meant overwriting the existing release/
artifacts.
build_release_linux, build_release_win and build_release_mac now take
their build and output roots from DRAGONX_BUILD_ROOT and
DRAGONX_RELEASE_ROOT, defaulting to build/ and release/ as before:
DRAGONX_BUILD_ROOT=/tmp/rel-build DRAGONX_RELEASE_ROOT=/tmp/rel \
./build.sh --win-release
With both unset nothing changes. The dev build (./build.sh) and the lite
backend artifact dir (build/lite-backend/<platform>) do not read them.
A full release set (Linux and Windows, full node and Lite) built into
override roots left the release/ listings (file sizes and timestamps)
and the dev tree's CMakeCache.txt unchanged (same checksums before and
after).
Known gaps:
- the end-of-run summary still prints release/ as the output dir;
- configuring a Windows build still writes res/ObsidianDragon.manifest
into the source tree, so the last configured variant's version ends up
there;
- a fresh build root re-downloads the FetchContent dependencies and
appimagetool, so it needs network access;
- the macOS paths changed the same way but were not run here.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
After broadcasting, the send path looked up the spent notes' and
UTXOs' transactions with unwrap. A reorg while the transaction was
being proved could remove one of them, so the backend panicked and
poisoned the wallet lock although the transaction was already
broadcast. The lookups are now `if let` with a warning.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>