Persists the Balance-tab earnings settings: interval (0=Hour 1=Day 2=Month
3=Year, default Day), mode (0=Inflows 1=Net, default Inflows), display
format (0=Coin 1=Coin+fiat 2=Fiat, default Coin+fiat), a global show-all
toggle (default off), and an opt-in set of addresses whose earnings column
is shown when show-all is off. All values are clamped on load and saved,
mirroring the existing favorite/hidden address sets.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Adds src/data/earnings_series.h: a pure, header-only aggregator that sums
wallet inflows (received + mined) over the current calendar period (hour /
day / month / year), optionally net of sends, both per receiving address
and wallet-wide, at "as seen" values. Attribution is best-effort: inflows
map to the receiving address; in net mode a send debits from_address when
present (a send with no from_address still counts in the wallet total).
Includes a revision-keyed EarningsCache (mirrors ui::MiningEarningsCache)
and a 13-assertion unit test covering inflows vs net, per-address
attribution, period exclusion, period nesting, and cache invalidation.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The mining "Est. Daily" was a solo expected-value (your hashrate share x
blocks/day x a fixed block reward) rendered in the same green as the real
Today / Yesterday / All-Time earnings, off an instantaneous hashrate and
gross of pool fees. It read like a dependable daily income when it is a
long-run statistical average with huge (Poisson) variance.
Framing:
- The estimate is now drawn in a neutral colour, not the green "money
earned" colour (the actual mined totals stay green).
- A sub-label in BOTH modes marks it as an average ("avg", or "avg, net"
in pool mode when the fee is applied), replacing the pool-only
"before pool fee" note.
- A hover tooltip over the column explains the variance ("often nothing
for long stretches, then a lump - NOT a steady daily income") and the
fee status (after the N% fee / before the fee when unknown / nothing for
solo and 0%-fee pools).
Accuracy:
- Uses a trailing-average hashrate instead of the instantaneous / 10s
snapshot: pool prefers hashrate_15m (then 60s, then 10s); solo uses the
mean of hashrate_history once there are >=10 samples, else the instant
rate. This stops a spike or a momentary dip from swinging the figure.
- Subtracts the known pool fee so pool-mode figures are net; synthetic or
unmatched pools stay gross and the tooltip says the fee is unknown.
The static block reward and true pool-reported earnings are intentionally
out of scope (the latter needs a new pool-API field). New i18n keys are
translated into all eight locales; the CJK subset font is rebuilt for the
three new glyphs. Builds + test suites pass on both variants.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Adds a "View transactions" action that opens the History tab pre-filtered
to a chosen address. SetTransactionsAddressFilter() stores a pending filter
that RenderTransactionsTab consumes on its next frame — pre-filling the
search box and resetting the type filter to All — then the caller navigates
to NavPage::History. This reuses the existing "prefill + navigate" pattern
(cf. SetSendFromAddress) and all of History's rendering, detail and export.
Triggers:
- Contacts: a context-menu item and a 4th per-row action icon (copy /
view-transactions / edit / delete; the trailing width is widened to fit).
- Balance address list: a per-address context-menu item (own z/t addresses).
- Receive tab: the recent-received rows now carry their address into the
filter instead of jumping to History unfiltered.
The History search also now matches from_address, so filtering by one of your
own addresses catches sends FROM it, not only receives INTO it.
Scoping is honest, not exhaustive: transparent is well covered, but shielded
counterparties are private and the transparent sender of received funds is
never recorded on-chain, so this shows transactions *involving* the address.
On Lite, from_address is thin, so own-address send filtering is weaker.
New i18n keys view_transactions / tt_view_transactions translated into all
eight locales (no new glyphs). Builds + test suites pass on both variants.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Adds a "Favorite Highlight Opacity" slider (15-100%, default 100%) to
Settings > Appearance > Scale & Effects. It scales the alpha of the four
favorite gold tokens (fill/border/icon/outline) at their resolve site in
the Balance address list via material::ScaleAlpha, so the gold row outline
and the star button fade together. The multiplier is skin-agnostic (it
scales whatever alpha the theme resolved) and touches only favorited rows;
non-favorite rows and the white selection highlight are unchanged, and the
100% default is byte-identical to the previous look.
The preference lives in Settings (favorite_opacity, clamped 0.15-1.0 in both
the setter and on load), persists across restarts, updates live, and is not
low-spec gated (it's a pure color-alpha preference, independent of the
transparency sliders). New i18n keys favorite_opacity / tt_favorite_opacity
are translated into all eight locales; the CJK subset font is rebuilt to add
the two new glyphs the zh string needs.
Verified: full + lite builds clean, full + lite test suites pass, hygiene OK.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The portfolio-editor address list drew every row every frame inside the
fade-scroll child. It now runs through an ImGuiListClipper; the fixed
step (the InvisibleButton height plus item spacing) keeps each row's
cursor exactly where the unclipped loop put it, so the scroll extent for
the fade shader, the check marks, the selection and the "selected rows
float to the top" ordering are all unchanged.
Verified on DX11 (Windows) at normal and large window sizes and at
font scale 1.5, including skin switch and resize with the dialog open.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The contacts Cards/List row loop submitted every contact every frame.
It now runs through an ImGuiListClipper, so only the on-screen rows are
built; the fixed step (row height plus the item spacing pushed above)
matches the per-row cursor exactly, so the scroll extent, fade, hover
actions, selection and keyboard navigation are unchanged. Table view is
left unclipped (its rows can wrap).
Verified on DX11 (Windows) at normal and large window sizes and at
font scale 1.5, in both Cards and List views.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
While a blur overlay (a modal dialog or the lock screen) covers the
window, the theme backdrop effects kept requesting animation frames
even though they are not visible, so the app never went idle and
re-rendered continuously.
The ambient animation predicate now drops the backdrop-effects term
when a blur overlay was active last frame. Reduce Motion and the
unfocused/hidden throttle were already handled, and the overlay's own
moving elements (the lock screen's unlock dots, a spinner) still request
frames through the progress term, so nothing on screen freezes.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The change-gated refresh worker only runs `balance` on a full-refresh
cycle, so testLiteWalletControllerRetire could no longer reliably park
the worker inside a blocked `balance` (it passed only by timing). Bump
the fake scanned height to force a full-refresh cycle, which enters the
now-blocked `balance` deterministically.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The background 2 s refresh worker re-queried and re-copied the whole
wallet every cycle (a fresh TLS refresh plus a multi-megabyte JSON
build, copies and DOM parse), even when nothing had changed, so CPU and
the network never went idle.
The worker now skips the full refresh and the re-publish on a cycle that
is provably unchanged, while never letting the UI show stale confirmed
balance or transactions:
- The lite bridge keeps a monotonic mutation epoch, bumped after any
command not on a read-only allow-list, so a local change made on any
thread (send, shield, import, new address, encrypt/unlock/lock,
console) is detected at one choke point.
- Each cycle the worker runs one cheap sync probe (one connection, the
mandatory block ingest) and a local height read, and refreshes only
when the scanned height advanced, the mutation epoch moved, the
encryption state changed, a sync is in progress, or a ~10 s heartbeat
elapsed; otherwise it skips. Direct/synchronous callers are not gated.
- A failed probe backs off instead of running a second sync, so a dead
server is not retried back-to-back.
Confirmed funds can never be stale beyond one cycle (every confirmed
change advances the height or bumps the epoch); only unconfirmed mempool
rows can lag up to the heartbeat, and the spend path re-queries the
backend directly rather than reading the gated model. Average idle cost
drops from three data connections per cycle to about one.
Tests: a bridge mutation-epoch test and a worker change-gate test
(skip-when-unchanged; refresh on height advance, local mutation and
encryption change) against the fake backend.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
New Lite wallets now encrypt their seed and keys with a salted Argon2id
key instead of the unsalted double-SHA256 of the passphrase, which was
cheap to brute-force from a stolen wallet file.
- A shared derive_wallet_key() replaces double-SHA256 across encrypt,
unlock, remove-encryption and seed recovery. KDF 0 is the old
double-SHA256 (unchanged); KDF 1 is Argon2id (moderate: 3 passes,
256 MiB), using sodiumoxide's existing pwhash -- no new dependency.
- The KDF id, salt and parameters are stored in the wallet. A wallet is
written at version 10 only while it is actually Argon2id-encrypted;
every pre-existing wallet (plaintext or double-SHA256) is read and
written byte-for-byte as version 9.
- New-wallets-only: a freshly created or restored wallet encrypts with
Argon2id; a wallet read from disk never changes its KDF, and removing
encryption never changes it. Set DRAGONX_LITE_KDF=legacy to keep new
wallets on double-SHA256.
- An older build (or SilentDragonXLite) opening a version-10 wallet
fails cleanly with "don't know how to read wallet version 10" before
parsing anything, never a silent misread; the seed phrase stays the
cross-wallet recovery path.
Compatibility break (by design, opt-in): a wallet newly encrypted with
Argon2id cannot be opened by older ObsidianDragon or SilentDragonXLite.
Tests: the existing encryption tests now exercise the Argon2id path,
plus the version-10 header, legacy byte-identity and derive_wallet_key
parameter bounds. Validated by the lite oracle suite and the old-vs-new
serialization differential (legacy wallets byte-identical; version-10
round-trips).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Deriving the next HD transparent address read the key count under a
read lock, released it, derived the address, and only then took a
write lock to append it. Between reading the position and appending,
another caller - for instance a scan deriving an address at the same
time - could read the same position, derive the same address, and
append it too, leaving a duplicate transparent key in the wallet.
- The whole read-modify-write now runs under a single write guard, so
the position a caller derives from reflects every key already
added. The slow seed stretch is computed once before the guard is
taken, so holding the lock does not block on it.
- As a further guard, derivation skips a position whose address is
already present and advances to the next, so no path can produce a
duplicate even if positions were ever to overlap.
- The starting index is computed exactly as before (it still counts
imported keys too), so a wallet with no contention derives the same
addresses in the same order.
Uncontended derivation produces the same addresses in the same order,
verified against the differential harness. The wallet file format is
unchanged.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Saving the wallet serializes it to a temporary file under the sync
lock, but it took a write guard on the wallet to do so. Serialization
only reads wallet state, so the write guard needlessly excluded other
readers for the whole duration of the write, buffering, and flush.
- The save path now takes a read guard to serialize. A read guard
still excludes writers, so nothing can mutate the wallet mid-write;
it only lets other readers proceed in parallel. The surrounding
sync lock still serializes saves against syncs, and the atomic
temp-file-then-rename sequence is unchanged.
The serialized bytes are identical, verified against the differential
harness. The wallet file format is unchanged.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Opening a wallet reads each shielded note from disk, and every note
record embeds a 169-byte extended full viewing key that was decoded
from scratch for every note. That decode is an elliptic-curve parse;
a wallet with many notes sharing a handful of keys paid for it once
per note, which dominated open time.
- Note reads now go through a small content-addressed cache keyed on
the exact 169 raw key bytes. The first note with a given key
decodes it; the rest reuse the result. Because the cache is keyed
on the raw bytes and the decoder is deterministic, the cached key
is identical to a fresh parse.
- The cache is thread-local and naturally bounded by the few distinct
keys a wallet holds, so it adds no cross-thread state and cannot
grow without bound.
The bytes on disk are read exactly as before - this only avoids
re-parsing identical bytes - so the loaded wallet is byte-identical,
verified against the differential harness. The wallet file format is
unchanged.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Each block range is streamed over one gRPC connection into a channel
that a worker thread drains, encodes, and scans. That channel was
unbounded, so when the server delivered blocks faster than the
scanner consumed them the backlog piled up in memory with no ceiling
- on a fast link against a large range it could grow without limit.
- The channel is now a bounded synchronous channel holding at most
2048 blocks. When it fills, the fetch side waits, applying
backpressure up the stream instead of buffering endlessly, so peak
memory is capped no matter how far ahead the server runs.
- The batch size is untouched: it still streams a whole range over a
single connection, which is what keeps the connection count low
enough to avoid socket-buffer exhaustion on Windows. Only the
in-flight depth between fetch and scan is now bounded.
The same blocks are fetched and scanned in the same order, so the
resulting wallet is byte-identical, verified against the differential
harness. The wallet file format is unchanged.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
When sync hit a reorg it stepped the scan back to just before the
invalidated block, then set the top of the re-scan range to that
point plus the full forward batch size. That window is far larger
than any reorg can be: the batch size is tuned for forward streaming
(tens of thousands of blocks), while a reorg is capped at the maximum
reorg depth and the pass aborts once it is exceeded. So the recovery
pass re-fetched and re-scanned a large span of already-good blocks
before it could resume moving forward.
- The recovery range now reaches only the maximum reorg depth (plus
one) above the rewind point, which is the most a reorg can ever
disturb. Forward syncing still uses the full batch size; only the
pass immediately after a reorg is bounded this way.
- The set of blocks that ultimately get scanned is unchanged - the
next pass picks up from where recovery stops - so the resulting
wallet is identical, just reached with far less redundant fetching
and scanning.
The wallet file format is unchanged.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
On quit the lite controller's destructor never runs, because main()
ends the process with _Exit(). Any state the background refresh had
scanned since the last save was lost: the destructor is where that
final save would normally happen, and it was being skipped on the
one path that always runs.
- shutdown() now calls a new flushOnExit() that persists once on the
way out, bounded to 4 s so the existing 15 s shutdown watchdog
budget still holds. On timeout it abandons the save thread and lets
_Exit follow.
- It is a plain save through the same backend path, which writes a
temp file and renames it into place. That cannot corrupt the wallet
if it is cut off or abandoned, and it does not change the wallet
file format - it only persists sooner on the quit path.
- It reuses the destructor's guards and also skips when a wallet op
is already in progress: shutdown() has already waited for ops to go
idle, and a still-pending op holds the sync lock and issues its own
save, so flushOnExit would only block the budget on a save that
cannot take the lock.
Tests cover a save on the open, synced, idle path (exactly once), a
blocked save returning within the budget, and the retired and
never-opened guards skipping the save.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The sidebar unconfirmed-tx badge was recomputed only when the tx list
changed, keyed on last_tx_update plus the list size. That missed the
one case that matters most: a transaction confirming without the list
count changing. last_tx_update did not move for a same-second content
change, and lite never stamped it at all, so the badge stuck on a
stale 0-conf count until some unrelated refresh happened to shift the
list size.
- The badge now keys on tx_revision, which the state bumps on every
content change - including a confirmation flip at an unchanged tx
count - and which lite stamps too, so the count tracks confirmations
on both the full node and the lite wallet.
- The sentinel starts at 0, which tx_revision never takes (it starts
at 1), so the first frame always computes once.
- The recompute still runs only when the revision actually moved, so
the per-frame work the previous guard avoided is still avoided.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Transparent history is gathered by skip-paging listtransactions. The
underlying list can shift between the per-page RPCs - a new
transaction arriving pushes one page's tail into the next page's head
- so a row could be collected twice and show up as a duplicate in
History.
- Pages are now de-duplicated on the daemon's stable per-output
identity: txid, send/non-send class, and vout. Keying on the class
rather than the exact category means a coinbase that matures
(immature to generate) between two fetches is still recognised as
the same row; keeping vout means two equal-value outputs of one
transaction stay distinct. No real transaction row is dropped or
duplicated.
- Rows with no txid or vout ("move") are never keyed and pass through
(they are dropped downstream anyway), so the de-dup fails open.
- The raw page size still drives the stop condition and the next-run
row hint, independent of how many rows were unique.
Tests cover a shifted page whose head repeats the prior page's last
row (dropped), a sibling output of the same tx (kept), and an
un-keyable row (kept).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
listtransactions was called with the default "" account, which
returns only rows in the unnamed account. Transactions tied to a
labelled address were left out of the History tab entirely.
- All three listtransactions call sites now pass "*", which spans
every account, so labelled-address rows appear alongside the rest.
- With "*" the daemon also emits "move" accounting rows that have no
txid, address or vout. Those are not wallet transactions, so they
are skipped and never become blank History rows. No real
transaction row is dropped by this filter.
Tests cover that a "move" row is skipped while a labelled receive row
is kept, and that the transparent collectors now page with "*".
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
parseRpcResult copied the "result" node out of the parsed reply,
because indexing the JSON object yields an lvalue reference. On large
replies - full transaction lists, unspent-note sets - that duplicated
the entire subtree for no reason, and it also meant a secret-bearing
result then existed in two trees at once.
- The result node is now moved out of the parse tree instead of
copied, so the secret lives only in the returned value that the
calling code already wipes. The leftover result slot is moved-from
(null), so the existing secret-scrub of the discarded tree still
runs harmlessly.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Loopback HTTP was still subject to the process environment's proxy
settings (http_proxy / all_proxy). That routed the local daemon's
basic-auth credentials and wallet data - and the miner's 127.0.0.1
stats API - through whatever proxy the user's shell happened to
export, and could break the connection outright.
- Local daemon RPC now sets curl's no-proxy to "*" when the host is
loopback, so it always connects directly. This also overrides a
no_proxy list. Remote daemons keep full proxy support unchanged.
- The xmrig stats fetch against the local miner API does the same.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The RPC worker loop only caught std::exception. A task that threw
anything else - a non-std exception propagating out of a posted job -
escaped the loop and tore down the worker thread, so every later RPC
silently went unanswered.
- The worker loop now also has a catch-all that swallows non-std
throws and keeps the thread running. Such a job still loses its
main-thread callback, exactly as the std::exception path already
did; the only change is that the worker survives.
- RPCClient's connected_ and warming_up_ flags are now atomic. They
are read from the UI thread while the worker updates them, so the
plain bools were a data race.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Starting the daemon polls for an already-running node holding the
datadir, sleeping up to three 100 ms intervals to let a shutting-down
node clear out. When a start runs on the UI thread, that is up to 300 ms
of blocking sleep on the render thread.
- The lock-wait poll count is now a parameter. A start invoked on the UI
thread passes 0, which does a single check and never sleeps; every
other caller keeps the three-poll wait. The default is unchanged, so
background starts behave exactly as before.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
At quit the UI thread drained and joined the mining-control thread and
then called the miner's blocking stop with a 3 s budget. Both ran on the
UI thread, so the window could stall for up to that long before the
shutdown screen rendered.
- Quit now only signals the mining-control thread (no join) on the UI
thread. The blocking teardown — join the control thread, then stop the
miner — moves to a background thread, so xmrig still has a single
driver but the UI thread never blocks on it.
- On the daemon-stop path that teardown runs first on the shutdown
thread, so the miner is fully stopped before the daemon stop begins.
On the disconnect-only quit paths it runs on its own thread, which
the final shutdown joins before tearing xmrig down, so there is never
a second concurrent join of the control thread.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The daemon monitor thread drained output and then slept a fixed short
interval on every tick, waking several times a second for the life of
the node even long after startup, when the node emits almost nothing.
Stopping it also meant waiting out the current sleep before it saw the
stop flag.
- The monitor now waits on a condition variable instead of a plain
sleep, so a stop request wakes it immediately rather than after the
interval. The stop flag is set under the monitor's mutex so the wake
cannot be lost.
- The poll interval stays fast for the first 30 s after the thread
starts and for 3 s after any output, and otherwise doubles per idle
tick up to a 500 ms cap. Any new output drops it straight back to
fast, so responsiveness during startup, rescan and shutdown is
unchanged while a quiet node is polled far less often. The interval
choice is a pure function.
Tests cover the interval function across the warmup window, the
recent-data window, the reset on new output, the doubling, and the cap.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The miner's stats and pool-API fetches are blocking HTTP calls on the
monitor thread with multi-second timeouts. When the miner was stopped,
stop() still had to wait out an in-flight request before the monitor
thread could join.
- Both fetches now install a curl progress callback that aborts the
transfer as soon as a stop is requested, so a request in flight ends
promptly instead of running down its timeout.
- An abort triggered by stop is treated as a clean cancellation, not a
fetch failure, so it is not logged or counted against the failure
warning.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Stopping the miner closed the stdout pipe before joining the monitor
thread, and the UI thread's stats poll also drained stdout. Either path
could read or close a descriptor the other side was still using, so a
read could land on a closed or already-reused fd.
- stop() now joins the monitor thread first and only then closes the
process handle and the stdout pipe, so nothing is draining the pipe
when it is closed.
- The stdout drain is serialized under its own mutex, and the UI
thread's stats poll no longer drains stdout at all: the monitor thread
is the sole reader. The process handle, pid and fds are atomic so they
can be observed safely from the memory probe.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The wallet pins a -dbcache value on the daemon command line. A
command-line flag overrides the same key in DRAGONX.conf, so a user who
set their own dbcache in the conf never got it: the wallet's pin always
won.
- Before spawning, a normal start now reads DRAGONX.conf once and, if it
sets dbcache, drops the wallet's own -dbcache pin from the arguments
so the user's value takes effect. An isolated start keeps its own
datadir and conf and is left alone.
- The conf is now read a single time for both this check and the
existing rpcthreads check instead of twice.
Tests cover dropping the pin when the conf sets dbcache (in various
forms), and leaving the default arguments untouched when it is
commented out, empty, or sets something else.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
When the daemon was restarted, the output-parse offset was reset to 0.
The daemon's output buffer is retained across runs, so the next poll
re-read the whole retained tail of the previous run and fed it to the
busy tracker as if it were fresh. A witness rebuild or rescan line left
over from the old run could then be mistaken for activity in the new
one.
- On restart the offset is now reset to the absolute end of the output
at that moment, captured on the lifecycle thread after the stop and
before the start, so the UI resumes parsing only the new run's output
rather than replaying the old tail.
- The capture is handed to the UI thread through a pending flag and
applied there before the next read, both on the normal poll and when
skipping the backlog, so the offset is only ever moved on the thread
that owns it.
Tests cover reading from the captured watermark (only the new run is
seen) versus the old reset-to-0 behaviour (the previous run replays).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The embedded daemon and the xmrig manager each kept their own copy of
the console output ring: append new bytes, trim the front to a cap at a
newline boundary, and hand a reader everything since its offset. The two
copies had drifted. The daemon tracked an absolute byte total so a
reader's offset stayed valid across a front-trim, while the miner keyed
its reader off the live buffer size, so a trim could silently rewind the
console feed.
- The append/trim and the since-offset logic now live in one small
header, outputWindowSince and appendBounded, included by both. The
trim keeps the newline-boundary cut and the guard that stops a lone
trailing newline from erasing the whole buffer.
- The xmrig manager now carries the same absolute byte total, so its
reader resolves against the window the same way and survives a trim
(and a process restart that clears the buffer but keeps the total).
Tests cover feeding the ring well past the trim point while a reader
polls each chunk (the feed is continuous and never dies at the trim),
the lone-trailing-newline guard, and a cleared buffer with a retained
total.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
ui.toml and the default banlist were embedded by a CMake function that
read each file and emitted a C array header one byte at a time in a
configure-time loop. That is slow to configure, and because the result
is a header, editing either resource recompiled every translation unit
that included it.
- Both files are now embedded with INCBIN from a small generated
translation unit, the same pattern already used for the fonts. There
is no generated header, so editing a resource re-assembles one tiny
object file instead of triggering a wide recompile, and configure does
no per-byte work.
- Consumers include a hand-written header declaring the symbols and
always use the embedded data (the __has_include fallback is gone). The
data is not NUL-terminated, so every consumer passes an explicit size.
- The old EmbedResources.cmake script is removed, and the generated unit
is added to the app and test builds.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Ingesting a scan's worth of chat messages persisted each new message
with its own append() call, so a batch of fresh messages opened and
committed one SQLite transaction per row.
- A new appendBatch persists many messages in a single transaction,
using the same INSERT OR IGNORE dedup key as append() and preserving
input order so load()'s rowid ordering is unchanged.
- Every message is serialized and encrypted before the transaction
opens, so a message with a non-UTF-8 body is skipped on its own
without leaving a transaction open; the valid siblings still persist.
- Ingest collects the newly stored messages and writes them once after
the loop instead of per message. It is inert (returns 0) while locked
or on empty input.
Tests cover a batch insert, dedup on re-insert, empty input, bad-UTF-8
isolation with a valid sibling and a following append(), insert order,
and the locked no-op.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Opening the transaction-history cache always derived its encryption key
with Argon2id, even for an unencrypted wallet. For those wallets the
passphrase is just a value derivable from the database itself, so the
~50-100 ms Argon2id stall bought no additional secrecy and ran on the
UI thread at open.
- Unencrypted wallets now derive the cache key with a cheap,
domain-separated BLAKE2b KDF over the wallet identity, keyed by the
per-wallet salt. The secret is derivable from the DB, so this is
security-equivalent for the unencrypted case and avoids the stall.
- Passphrase-encrypted wallets are unchanged: they keep the Argon2id
key derivation. The two paths use separate domain labels, so the
fast-path key and the Argon2id key never collide for the same DB and
salt; a cache written under one cannot be read under the other.
Tests cover the fast-path round-trip and reopen, and assert that an
Argon2id unlock of the same DB produces a different key and fails to
read the fast-path snapshot.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Drawing the tiled background noise built a viewport-sized pre-tiled
texture and re-created it whenever the viewport changed, copying the
256x256 noise tile across the whole screen in memory.
- On GL the draw now samples the resident 256x256 tile directly with
GL_REPEAT, computing UVs from the screen rect over the tile size with
the phase anchored at the viewport origin. The result is pixel-
identical to the old pre-tiled texture (same generator, same origin)
with no per-viewport copy.
- DX11 keeps the pre-tiled viewport-sized texture, since its sampler is
CLAMP and cannot repeat.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Theme images were decoded straight to RGBA at their full source size
and uploaded to a GPU texture as-is. A large or over-sized image wasted
VRAM and could exceed the GL maximum texture size, and each code path
also hand-managed the decoded pixel buffer with its own free calls.
- Loading from file and from memory now goes through the shared
size-aware decoder with a 2048-pixel cap on the long side. An image
larger than that is downscaled with its aspect ratio kept; an image
whose area is absurd is rejected; a small image is left untouched.
- The decoder owns the pixel buffer for the life of the upload, so the
manual free calls are gone.
Tests cover downscaling a wide image to a 2048 long side with the aspect
kept, rejecting an over-area image, and leaving an under-cap image as-is,
plus the fit-dims math at the theme-art cap.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Adding a lite server from the network tab restarted the background
server probe immediately. Starting a probe while one is still running
joins the previous worker thread, and that join happened on the UI
thread, so the frame could stall until the outstanding probe finished.
- Adding a server now sets a dirty flag instead of restarting the probe
inline; the next frame starts a fresh probe once the current pass has
finished, so the UI thread never blocks on a join.
- The probe's libcurl transfers honor the cancel flag through a progress
callback and abort cleanly, so a probe in flight stops promptly.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Before verifying a downloaded archive, both updaters slurped it into a
string with an istreambuf_iterator, which grows the buffer repeatedly
and reallocates as it reads a multi-megabyte file. It also placed no
upper bound on the size it would read into memory.
- A shared readFileExact helper sizes the file first and reads it in a
single allocation, failing closed if the file is missing, empty,
larger than a caller-supplied cap, or comes up short. A truncated read
can never reach the SHA-256 / signature verifier.
- Both updaters use it with the existing max-archive cap.
Tests cover a good read, a read exactly at the cap, over the cap, an
empty file, and a missing file.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The daemon and miner updaters fetch release metadata with a blocking
HTTP GET that ignored the updater's cancel flag. Closing the dialog set
the flag, but an in-flight metadata request kept running to completion
on the worker thread, so the dialog could not tear down promptly on a
slow or stalled connection.
- The metadata GET now passes the updater's cancel flag through to the
HTTP helper, so closing the dialog aborts the request in progress.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Several settings were read with json's value()/get<T>() helpers that
assume the stored type. A hand-edited settings file, or one re-saved
after an inf/NaN float was written back as JSON null, could therefore
hold a value of the wrong type and throw during load. The whole load is
wrapped so a throw quarantines the entire settings file to a
.corrupt- copy, meaning one bad field reset every unrelated setting.
- ui_opacity is accepted only when it is actually a number, and it is
clamped to the same 0.3..1.0 range the setter enforces; the legacy
reduced_transparency migration likewise runs only when that key is a
boolean.
- A non-string name in a blocked chat conversation, and a non-string
scope in a portfolio entry, now fall back to an empty string instead
of throwing.
The file loads with the offending field defaulted, and the rest of the
settings survive.
Tests cover ui_opacity as a string/null/int/float/bool, a non-string
blocked-conversation name and portfolio scope, and the legacy
reduced_transparency migration, asserting no quarantine file is written.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The single-instance lock lived in /tmp, which is world-writable. Creating
it with mode 0666 there gets masked to 0644, so a second local user
cannot open it O_RDWR and is wrongly blocked from running the wallet at
all. A world-writable directory also lets another user pre-create or
symlink the lock path.
- The lock now lives in a per-user directory: $XDG_RUNTIME_DIR when it is
an absolute directory the user owns, otherwise the per-user, per-variant
config directory that startup has already created. Never /tmp.
- It is created mode 0600 with O_NOFOLLOW, and after opening we fstat it
and refuse anything that is not a regular file we own, rejecting a
planted symlink or a foreign-owned file.
- The lock file is no longer unlinked on release; flock drops on close,
and leaving the file in place avoids an unlink/relock race (a stale
file is harmless).
Tests cover acquiring and conflicting on the lock, the 0600 mode, that
release leaves the file in place, and that a symlinked lock path is
refused.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Both the HEAD size probe and the archive download followed redirects
with no restriction on where they could go. A redirect could therefore
downgrade the transfer to plain http or point at a file:// URL, defeating
the TLS verification the initial https request was relying on.
- Redirects on both requests are now limited to https, so a redirect can
no longer leave TLS or reach a local path.
- The limit is set with the string-based curl option where available and
the protocol-mask option on older libcurl.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Resuming a partial download leaned entirely on the remote size from a
HEAD request. When that size could not be determined the code played it
safe by deleting the partial file and starting over, so a flaky HEAD (or
an offline mirror) threw away gigabytes that were fine to resume. It also
could not tell a real size from an error page, and if the server answered
a ranged request with the whole file (200, not 206) libcurl could append
it to the partial and silently corrupt the archive.
- The HEAD now returns transport status, HTTP code, and size together,
and a size counts as authoritative only on a 2xx reply with a positive
Content-Length.
- A pure helper decides what to do from the existing bytes and that
authoritative flag: start fresh, truncate a stale oversized file, reuse
a complete one, or resume with a ranged GET. When the size is not
authoritative the partial is kept and a ranged GET is attempted rather
than discarded.
- A ranged request that comes back as 200 instead of 206 now restarts
cleanly from scratch instead of appending to the partial.
- The free-space pre-check runs only when the size is authoritative, and
a failed download reports how many bytes were kept for the retry.
Tests cover the resume-decision helper across the fresh, stale, complete,
resuming, and non-authoritative cases.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Any verification failure deleted the downloaded archive and forced a
full multi-GB re-download on the next attempt. But most failures are not
a corrupt archive: a missing or unreadable checksum sidecar, or a fetch
blip while pulling the checksum, would throw away a perfectly good file.
On top of that the checksum parser took the first token of whatever came
back, so an HTTP error page could be mistaken for a digest.
- The archive is now removed only on a genuine digest mismatch. A
missing sidecar, an unusable one, or a transient fetch error keeps the
file so the next attempt can reuse or resume it; the no-checksum case
still fails closed and keeps the archive.
- The checksum parser now accepts a token only if it is exactly the
expected length and all hex, lowercasing it; anything else (an error
page, a truncated or wrong-width digest) is rejected.
- The small-file fetch moved onto the shared capped HTTP helper, and MD5
is consulted only as a fallback when no SHA-256 is published.
Tests cover the checksum parser across good, wrong-length, non-hex, and
error-page inputs.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The checksum and MD5 passes measured the file for their progress bar
with fseek/ftell. On Windows a long is 32-bit, so ftell overflows on
any archive larger than 2 GiB and hands back a wrong (often negative)
size, which makes the verify progress percentage meaningless on exactly
the multi-gigabyte downloads this code exists for.
- Both passes now take the size from std::filesystem::file_size, which
is 64-bit on every platform, through a small helper that returns 0 if
the size can't be read.
- No seek is performed just to size the file.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
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>