fbc429f914b2ee80b7a80f88b94e0ca97c377ca4
100
Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
fbc429f914 |
perf(effects): keep the theme animation clock in double precision
The theme effects stored their animation time in a 32-bit float taken from the ImGui clock. After a few days of uptime a float can no longer resolve a single frame step, so every oscillation, drift and sweep began to stutter, snapping forward a frame at a time instead of moving smoothly. - The clock is now held as a double, and the phase/oscillator maths runs through a small set of shared helpers that reduce the argument in double before returning a float. A float clock at 30 days steps by a quarter second per ULP; the double clock stays frame-accurate. - Hue cycling, the rainbow and gradient borders, shimmer, specular glare, glow pulse, edge trace, embers, the sandstorm and the viewport wash all use the shared helpers, so their timing no longer drifts with uptime. Tests: the helpers advance smoothly after seven days, pin the legacy float breakdown at 30 days, and stay in range for negative offsets. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> |
||
|
|
944fafa1c7 |
perf(explorer): fetch each block with one RPC and back off on failure
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> |
||
|
|
ebbeb846e8 |
feat(explorer): indexed block search with a bounded cache
The explorer block cache searched with a LIKE query that had to escape wildcards, grew without any limit, and retried a failed open on every frame, including a two-second busy wait on the UI thread. - Search now uses an instr() substring test on a lowercased needle, and only checks the height text when the query is all digits, since a non-numeric needle can never appear inside a decimal height. - The table is pruned to the highest 20000 blocks, once per open and periodically as blocks are stored, so the file stops growing at its high-water mark. - A failed open is negative-cached (30 s by default) instead of retried every frame, and the busy timeout drops to 250 ms. - When the cache database is unavailable or refuses a write, the block spills to a small bounded in-memory map that both the list and search read, so a spilled block never appears in one but not the other; the spill is cleared when the chain tip regresses. - A batch of blocks is written under one savepoint instead of one commit per block. Tests: case-insensitive hash and height search, the negative-cache and spill paths with tip-regression eviction, and batch store plus prune. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> |
||
|
|
c3f981b52e |
fix(history): keep the open detail row across paging and filtering
The inline detail panel was tracked by the filtered list index. The display list is rebuilt and re-sorted on every data change, sort switch or filter edit, so a stored index pointed at a different transaction after the rebuild, and a page reset was triggered by any change in the row count, bouncing the view back to page 1. - The open row is now tracked by a stable content key (txid, display type, address and the amount's exact bits) so the right row stays open across rebuilds. - The page resets and the open row collapses only when a filter input changes (type, sort or the full search text). A new or removed row is data and now just clamps the current page. - The open row is also cleared on page navigation and when the list empties, so a wallet switch does not resurrect an old expansion. Adds pure helpers rowKey/filterInputsChanged to transactions_tab_helpers.h. Tests: rowKey distinguishes each field and separates +0.0 from -0.0, and filterInputsChanged latches each input change but not the first frame. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> |
||
|
|
a5e4fd5c02 |
perf(history): group transaction rows by txid once per change
The History tab built a txid-to-indices map twice when its inputs changed, once for the summary-card totals and once for the display list, each a full pass over every transaction with string-keyed hashing. - The rows are now grouped by txid a single time whenever the transaction list changes, into an index-only structure (first/next chains with no string copies), and both the summary pass and the display pass reuse it. A sort-combo change re-sorts without regrouping. Adds a pure, unit-testable groupByTxid helper in transactions_tab_helpers.h. Tests: the chains match a reference map over a random id stream, and the empty and single-row cases. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> |
||
|
|
01b59d24db |
perf(image-picker): cull off-screen thumbnail cells
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> |
||
|
|
3ca7a5c90e |
perf(explorer): cull off-screen rows in the block detail list
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> |
||
|
|
b8a4a11f90 |
fix(console): keep JSON folding active under channel filters
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> |
||
|
|
c1b289cc27 |
perf(console): cull off-screen wrapped rows
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> |
||
|
|
f7ed1ae115 |
perf(console): tail-cap large RPC results without a full copy
A large RPC result was fully split into lines and copied before being handed to the console, even though only the last few lines could survive the model's line cap. - The formatter now takes a line cap and keeps only the last N lines, located by a backward newline scan, so a huge result is never fully split or copied. JSON classification still keys off the first character of the whole result. - It works over a string view and moves line text through, and fold spans are computed over the formatted lines directly instead of a separate vector of copies. - getline semantics are preserved: a trailing newline adds no empty line and an empty result yields no lines. Tests: getline parity for empty, trailing-newline and blank-line inputs, the backward tail cap, and JSON classification and fold spans over a capped result. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> |
||
|
|
116a997a3a |
fix(console): bound the pending queue by lines and bytes
The console's pending, not-yet-drained queue was unbounded, so a hidden Console page that kept receiving verbose daemon-log chunks could grow without limit. - Pending lines are now bounded by both a line count and a byte budget. The oldest pending lines are dropped once either is exceeded, while the newest line is always kept so one huge line is never lost. - A formatted RPC result is ingested as one block under a single lock, so lines from other producers can never interleave inside it. The front-sequence accounting is also corrected: a pending line dropped by the cap never entered the visible buffer, so it no longer advances the front sequence. Only a front eviction or a clear does. Tests: the line and byte caps keep the newest lines, a single oversized line survives, a batch appends contiguously, and the front sequence advances only on a front eviction or clear. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> |
||
|
|
70e96f75f0 |
perf(console): case-insensitive filtering without per-line copies
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> |
||
|
|
6a24355c79 |
fix(console): release busy gate when the fast worker stops
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> |
||
|
|
5376681af5 |
fix(schema): parse node-status banner and layout UI sections
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> |
||
|
|
8255923f33 |
fix(settings): scope portfolio groups by wallet identity
Market-tab portfolio groups were tied to a wallet by a hash of its address set. That hash changed whenever an address was added, so a group built for a wallet could silently drop out of view once the wallet gained a new address. - Groups are now scoped by the wallet's stable id, which is persisted and never derived from the address set, so adding an address can no longer orphan a group. - An older build's address-hash scope fails open (the group stays visible) and is migrated once per wallet per session to the stable id. A legacy group is claimed when its old hash matches, when one of its own addresses belongs to this wallet, or when only one wallet is known; anything ambiguous is left legacy and stays visible. - Scope filtering waits until the wallet's addresses are loaded, so a group never flashes an empty 0.0000 balance and Add stays disabled until the wallet is resolved. This works on Lite, which never stamps an address-update time. Behaviour: an older build opened after this change hides groups tagged with a stable "w:" scope (they are not deleted and return on the next upgrade) - worth a release note. Tests: group visibility across global, stable and legacy scopes with the wallet resolved and not; the migration rules (hash match, address overlap, single-wallet claim, ambiguous left alone, idempotent); and a save/load round trip where an address-set change cannot orphan the scope. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> |
||
|
|
c3ffc97b31 |
fix(ui): finish a stranded bootstrap even when its dialog is closed
A Settings bootstrap was only wrapped up inside the dialog's own draw path. If the dialog finished downloading, verifying or extracting while the user had clicked away or minimized the window, completion was never processed: the connect and reconcile gate stayed held and the post-bootstrap rescan was never armed. - Completion is now a pure decision driven from a pump() that both the per-frame update and the dialog's render call, so a finished bootstrap is handled every tick whether or not the dialog is drawn. - With the dialog open the behaviour is unchanged: it advances to the Done or Failed screen and those screens still own the gate. With the dialog closed, pump() releases the gate itself and shows a success or failure toast; a user-cancelled run is released silently. User-visible: a bootstrap started from Settings now completes, arms its rescan and reports its result even if the dialog is closed or the window is minimized when it finishes. Tests: the completion decision for an unfinished worker, a completed run with the dialog open and closed, a failed run, and a user-cancelled run. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> |
||
|
|
d4339be94b |
fix(ui): keep a chosen send-from address from auto-select overwrite
The Send tab auto-selected the highest-balance address on first load. When another tab had already pre-filled the source with "Send from this address", that first-load pick overwrote the user's choice, and the dropdown's selected index could drift out of step with the address that would actually be debited when the address list changed. - The send-from string is now the single source of truth. The one-shot auto-pick only fills an empty source and never replaces one the user or another tab set, and the list index is re-derived from the string each frame so it cannot desync. - An explicit "Send from this address" choice latches the auto-pick off, so the first visit cannot undo it. - A chosen source the dropdown does not list (zero balance, view-only, or the list still loading) now previews the address that will be debited instead of the "Select a source" placeholder. User-visible: a send-from address chosen from another tab survives the first load and later refresh ticks. Tests: the source resolver keeps a pre-fill on first visit, fills an empty source with the best spendable address, keeps a source missing from a short list with index -1, latches nothing while disconnected or with an empty list, and re-derives a stale index from the string. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> |
||
|
|
579accd0c6 |
fix(ui): validate shielded addresses with the correct RPC
The Validate address dialog chose its RPC from the first character of the input, then asked the transparent-only validateaddress for every case. That RPC is Base58-only, so a valid Sapling (z) address came back isvalid=false and was reported invalid. - The input is now classified with the checksum-verified recognizers, and a shielded address is sent to z_validateaddress while a transparent (mining) address keeps validateaddress. - Anything that is not an address - a typo, garbage, or a pasted viewing/spending key - is judged invalid on the spot and never sent to the node, so a key pasted into the field is not leaked to an RPC. - On Lite there is no node to ask, so the dialog shows the checksum verdict alone and hides the "owned by this wallet" row. Tests: validateRpcMethod maps shielded to z_validateaddress, transparent to validateaddress, and a key or empty string to no RPC. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> |
||
|
|
abc82136c4 |
fix(util): use the POSIX daemon binary name, scan versions off-thread
The installed-daemon lookup built the binary path from the Windows
resource key "dragonxd.exe", which never exists on Linux or macOS, so
the installed full node was never found there. The version scan also
read the whole binary, now around 200 MB, into memory on the UI thread.
- A new DAEMON_BINARY_NAME gives the daemon's file name on this platform
("dragonxd" on POSIX, "dragonxd.exe" on Windows); the old resource
constant stays only as the embedded-table key. The installed daemon is
now found on POSIX.
- The version-stamp scan streams the file in 4 MiB chunks with bounded
memory, carrying bytes across each boundary so a stamp straddling it is
neither cut short nor missed, and stops at the first match. The result
is cached by path, size and mtime, so only a changed binary is
rescanned.
- Settings stats the binary on the UI thread but runs the streamed scan
on a worker, guarded so a new scan is not started while one is still
running (the future blocks in its destructor), and the version fills
in when it lands.
Tests: the POSIX path and binary name, a version stamp in a small file,
a stamp straddling the 4 MiB chunk boundary, and a file with no stamp.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
|
||
|
|
21dc874988 |
fix(core): refresh transaction history on the Mining tab
The Mining tab shows earnings derived from transaction history, but it was not treated as a wallet-data page, so newly mined or paid-out earnings did not appear until the user left the tab and came back. A coinbase row also changes category in place (immature becomes generate at maturity, or orphan on a reorg), and the Recent merge kept both the old and the new row, double-counting the earnings. - The Mining tab now gets the cheap between-blocks Recent poll: one listtransactions per new block, bounded by the 15 s transaction-age timer, once history has loaded. It never runs a full routine scan and never pre-empts an owed dirty cycle; tracked sends still take their normal cycle on any page. - The shielded-payout probe runs only while the pool miner is running, since pool payouts are shielded receives while a solo miner's earnings are transparent coinbase rows. - The Recent merge drops the previous row of the other coinbase category when a coinbase transitions, so maturing or reorged rewards are no longer counted twice. A non-coinbase row sharing the txid is left alone. Behaviour change: mining earnings update on the Mining tab per block instead of only on tab re-entry, and maturing or reorged coinbase rows no longer double-count. Tests: the per-block poll gate (one poll per block, due only after history loads, held while dirty or addresses are dirty), the Mining-page cycle shaping, and the coinbase transition merge for immature to generate, generate to orphan, and a shared-txid non-coinbase row. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> |
||
|
|
fbe09c6a4c |
fix(security): follow the daemon's lock state on Set-PIN and expiry
Setting a PIN always unlocked the wallet with a 5 s probe and then re-locked it, even when the wallet was already unlocked, so a PIN check silently re-locked an unlocked wallet. And once the daemon's absolute unlock window ran out the GUI still showed "unlocked", so the next send failed with nothing on screen explaining it. - The Set-PIN check now decides on the main thread: an unlocked wallet is verified with the time it has left (capped at seven days) and is not re-locked, so the GUI and the daemon keep agreeing; a locked wallet still uses the 5 s probe and re-locks. Either way the daemon's lock state is re-read afterwards, which self-heals any drift. - When the daemon's unlock window has expired but the GUI still says "unlocked", a bounded reconcile (once per expiry, 30 s back-off) re-reads getwalletinfo so the lock screen and the transaction-cache lock follow the daemon. Lite wallets, which have no daemon timer, are left alone. Behaviour change: with auto-lock disabled the daemon still re-locks at its window, and the GUI now lands on the lock screen instead of appearing unlocked until a send fails. Tests: the Set-PIN verify plan (unlocked keeps its remaining time and skips the re-lock, locked or near-expiry falls back to the 5 s probe and re-lock, long windows are capped) and the unlock-expiry predicate across encrypted, locked, no-deadline and not-yet-expired cases. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> |
||
|
|
2fce9a7860 |
fix(security): reconcile the encryption-pending flag on every connect
The wizard persists an "encryption requested" flag so a quit or crash before the wallet is actually encrypted is not silent. That flag was only reconciled on the slow-path getwalletinfo refresh, so it could be left stale, and the clear-and-warn logic lived inline in two places. - A single reconcileEncryptionPending now runs on every connect and on every encryption-state refresh, keyed on the parsed getwalletinfo reply. It clears the flag whenever the wallet is encrypted, however it got there (deferred request, Settings, another client), and warns once per session when encryption was requested but the wallet is not encrypted and nothing is pending or in flight. - A failed or warm-up (-28) RPC decides nothing, so a 15 s timeout that forces encrypted=false can no longer mis-clear or mis-warn. - The flag is also cleared at encryptwallet success and when the user removes encryption, so neither path leaves a stale flag that would warn on the next connect. No settings format change. Tests: the pure decision helper over the flag, RPC-ok, encrypted, deferred-pending, in-flight and already-warned combinations (clear when encrypted, warn once when lost, nothing on a failed RPC or no flag). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> |
||
|
|
0aaf33ecf0 |
fix(core): drive auto-lock idle from real input, not widget focus
The auto-lock idle clock was reset whenever any ImGui item was active, so a text field left focused (a half-filled send form, say) counted as continuous activity and kept the wallet unlocked indefinitely, well past the configured idle timeout. - The idle clock is now fed by real input events (key, text, mouse, touch, pen, gamepad button) as they are ingested on the main thread, plus a still-held mouse button so a drag or scrollbar grab that emits no new event still counts. A focused but untouched widget no longer does. - A new header-only IdleTracker owns the clock and never moves backwards, so an event stamped before the previous one cannot shorten the idle time. Behaviour change: a form left focused and idle past the timeout now auto-locks. The form's buffers are app state and survive the lock and unlock, so nothing typed is lost. Tests: a new IdleTracker case covers idle time growing with no events (enough to trip a 900s lock), activity resetting it, the clock never moving backwards, and a query before the last activity reading zero rather than a negative value. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> |
||
|
|
1ea584311e |
fix(lite): honor the backend sync result with retry and a failed state
A failed backend sync was silently swallowed: the reply was never inspected, so a sync that errored out left the wallet showing a "syncing" state that never completed and never reported a problem. - The sync and rescan replies are now parsed. On failure the partial progress is saved and the sync is retried with exponential back-off (5s doubling up to 60s, six attempts in all), then an explicit failed state is published: done, sync not complete, and the backend's error text carried through. - A failed rescan is retried as a plain sync, because a rescan has already cleared the wallet and re-running it would throw away the partial progress just saved. - A terminal error, such as a reorg deeper than the wallet can unwind, stops the retries immediately instead of waiting out the attempts. - Recovery is observed, not guessed: once a later refresh's server-bound reads all succeed, the failed state clears itself. Behaviour change: a sync failure now surfaces to the user as a failed state with a message, instead of a frozen "syncing", and the client retries on its own with back-off. Tests: a parser case for the success, failure, and terminal replies, and fake-backend controller cases for retrying after a failure, giving up after the attempt cap, a terminal reorg stopping at once, a failed rescan retrying as sync, cancel stopping the back-off, and the failed state self-healing on a good refresh. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> |
||
|
|
3f7af2d028 |
fix(lite-backend): fix t-scan rewind underflow and retry the owed scan
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> |
||
|
|
5ae5e5213b |
fix(lite): sort lite transactions newest-first to match the UI panels
The lite backend lists transactions oldest-first, but every consumer of the transaction list assumes newest-first and takes a prefix of it, so the Recent Transactions / Recent Sends / Recent Received panels and the CSV export showed the oldest records instead of the latest. - The UI copy is now sorted pending (0-conf) first, then newest-first by timestamp, the same order as the History tab's default. - The sort is stable, so several records that share a txid (a send entry plus its per-note entries, or same-block transactions) keep a deterministic order across refreshes. That keeps the order-sensitive change check reporting "unchanged" and avoids bumping the transaction revision every couple of seconds. Tests: a new case covers the pending-first, newest-first ordering, a stable order within one txid, and the revision staying unchanged when the identical model is re-applied. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> |
||
|
|
e7f084e3eb |
fix(bootstrap): harden archive names, the cancel window and quit wait
The bootstrap preflight's protected names could be bypassed by names
that resolve onto them, and its cancel and quit paths had two timing
gaps.
- Entry names are judged as the file system resolves them: '\' is a
separator, "." components are dropped and each component loses its
trailing dots and spaces, as Win32 does ("./wallet.dat" and
"DRAGONX.conf." name protected files). The whole archive is refused
for an absolute or drive-rooted name, a ".." component, any ':'
(drive letter or NTFS alternate data stream, "DRAGONX.conf::$DATA"),
an 8.3 short name ("DRAGON~1.CON"), a component of only dots or
spaces (other than "."), and any byte >= 0x80 or ASCII control
character: macOS folds or ignores some Unicode (U+017F, U+212A,
U+200C) onto a protected name, and the published bootstrap archive
uses plain ASCII names. A refused name is echoed
with \xNN escapes. A member is written at the path it was checked
under, and the protected-name check runs on the resolved name.
- The user's Cancel (Settings dialog and first-run wizard) goes through
requestCancel(). It is refused once the old chain data starts being
replaced, and the start of that window re-checks a cancel under the
same lock, so a click lands before the old chain is touched or not at
all.
- Quit cancels the wizard's and the Settings bootstrap when the
shutdown starts; the HEAD and checksum requests now end at once on
cancel, and a cancelled HEAD keeps the partial download. A run
cancelled before the window leaves the old chain as it was. A run
inside it stops extracting and removes its partial chain while the
shutdown screen shows "Cleaning up...", so it ends with no chain data
(wallet.dat untouched; Retry reuses the zip, or the node resyncs).
That wait is bounded: at most 2 minutes from the start of the
shutdown, then 3 s in shutdown(), after which a worker still running
is left to the process exit. Only a cleanup that overruns that cap
can leave a partial chain, which dragonxd then answers with its
reindex offer.
Tests: refusal of the Windows-resolved, non-ASCII and control-character
names with the escaped echo; the protected-name list incl. the resolved
forms and a planted -wallet file, conf and database/ that stay
byte-identical; the user-cancel window; the bounded quit wait and
safeToAbandon(); a cancel ends a HEAD request that never answers.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
||
|
|
a5185737e6 |
refactor(app): retire logo textures through the deferred-destroy queue
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> |
||
|
|
a581f42a33 |
fix(settings): disable the unimplemented Tor option and tell its users
The "Use Tor" checkbox was saved but never read: no build routed any traffic (node, RPC, price/update/bootstrap/pool HTTP or Lite gRPC) through Tor, so users who ticked it believed they were anonymous. - The checkbox is disabled and always shown unticked, on the Settings page and in the legacy Settings window. Its tooltip says it is not available and gives the alternatives: for the node, proxy=127.0.0.1:9050 in DRAGONX.conf with Tor running; otherwise a system-wide VPN/Tor (Lite builds get the short version). The "Use Tor" label is kept. - Behaviour change: a stored use_tor=true is cleared once at startup (Settings::consumeLegacyUseTor + a synchronous save before the async writer and any Settings page state), with a 20 s warning in the user's language. The cleared key is the "already told" flag, so the notice never repeats. No settings format change. - 3 new strings (tt_tor_unavailable, tt_tor_unavailable_lite, tor_never_applied_notice) in all 8 languages; the tooltip wraps at tor-tooltip-wrap-width from ui.toml. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> |
||
|
|
ae66b54694 |
fix(miner): keep an updated miner, don't re-extract the bundled one
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> |
||
|
|
81733f5603 |
fix(rpc): run rescans and key imports without the 30 s timeout
A rescan or key import holds the node's cs_main for the whole wallet scan. On the shared clients it hit the 30 s curl timeout, reported a failure that wasn't one, abandoned a daemon RPC slot and flipped the app into "node busy". - One long wallet RPC at a time (rescan, importprivkey, importaddress, importwallet, z_importkey, z_importviewingkey, z_importwallet) runs on its own ungated client with no client timeout (rpc/long_call_lane.h). It can be aborted from any thread, and an abort is never lost, even one that lands before the client opens. - The GUI rescan, key import and sweep use it. The sweep's fund-moving body is unchanged; only the client it calls differs. runtimeRescan() returns bool, and a refused post-bootstrap reconcile stays armed. - The console routes rescan and the import commands through the lane; its input stays usable meanwhile, and a refusal is printed like any other result. RPCClient gains callRaw(.., timeoutSec). - Restart, wallet switch, delete-chain, bootstrap (now also from Settings) and the other node-stopping flows are refused while it runs, with a message saying a key import or rescan is still running; quit asks for confirmation. The lane is aborted at quit. - A second import or rescan is refused before the key is copied anywhere. An import cut off after the request reached the node (a lost reply, or a proxy's 502/504 while the node still imports) says the key may already be in the wallet and to run Settings -> Rescan. Only the node's own HTTP-layer refusals (401, 503, work queue full, unhandled request) say that the key was not imported. A pure classifyLongCallCutOff() makes that choice. - New strings in all 8 languages; console results stay English. Tests: the long-method list and the console's routing; one lane at a time; an abort lands whether it comes before the client opens or during the call; a call with no client timeout outlasts a 3 s scan, where a 1 s gated client times out and marks the node busy; classifyLongCallCutOff() for every failure kind with and without a sent request, and from raw HTTP replies (502, 504, a plain 500 -> cut off; 401, 503, queue full, unhandled -> refused). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> |
||
|
|
b3a57b07ad |
test(fake-daemon): add slow rescan and key-import methods
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> |
||
|
|
78e38570a8 |
fix(bootstrap): check archive and free space before deleting chain data
The bootstrap removed blocks/, chainstate/ and notarizations/ first and only then opened the archive. An unsafe entry, a full disk or a corrupt member therefore left no chain, or a half-written one for dragonxd to start on. - Preflight before anything is deleted: every archive entry must be safe (no absolute path, no "..") and readable, and the disk must hold the extracted size plus max(256 MiB, 5%), counting what removing the old chain frees (a symlinked chain dir counts 0). Free space counts as unknown only when the probe errors, the capacity is 0 or the available size is -1; a known disk with 0 bytes free is a refusal. The old chain is kept on every preflight failure. - Protected members are skipped, compared case-insensitively: any path component starting with "wallet", any "database" component, a .cookie or *.conf file, and top-level *.dat files other than fee_estimates, peers and banlist. The number of non-critical member failures is logged. - The removal of the old chain is verified (a locked file fails the run instead of mixing old and new chain files). If the extraction fails or is aborted, the partial chain is removed again before the failure is published; isDone() turns true only after the worker's last filesystem change. - Cancel is disabled while the old chain data is being replaced, and quit aborts a running bootstrap. - A complete zip from an earlier attempt is reused on Retry; its checksum is re-verified. A download that cannot fit fails early, and a disk-full write error says so. Tests: the space rule (boundaries, unknown vs. full disk, saturation); planArchive sizes, zip-slip, garbage and truncated zips; the protected-name list; reclaimableBytes with symlinks; each preflight refusal keeps the old chain and wallet.dat byte-identical; a corrupt critical member removes the partial chain before the failure is published. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> |
||
|
|
70d8b0f687 |
fix(daemon): supervise a daemon restarted after a crash
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> |
||
|
|
f6e5038043 |
fix(security): don't penalise a correct PIN after a passphrase change
A passphrase change left the PIN vault sealing the old passphrase. The next PIN unlock opened the vault, walletpassphrase answered RPC -14, and the generic catch ran the lockout curve on a correct PIN, while Settings kept showing the PIN as active. - Passphrase change: when the active wallet's vault exists at completion (same vault path as at dispatch, passphrase actually changed), the vault is marked out of date for the session and kept on disk: PIN entry is no longer offered, a warning toast says so, and Settings shows "PIN out of date" with a tooltip on how to fix it. The Change Passphrase dialog notes beforehand that a PIN will go out of date. - PIN unlock: RPC -14 after the vault opened is treated as "PIN out of date": passphrase entry, a message, no attempt counted, no lockout, and the vault is not deleted (-14 is only the daemon's answer; the vault may hold the only copy of a passphrase that opens an older backup). After a restart Settings shows the PIN as active until the first PIN attempt. - A malformed vault (zero-filled by a removeVault() whose unlink failed, truncated, unknown version) is treated as missing: passphrase entry, "PIN file unreadable", no penalty. New SecureVault::isWellFormed(). - Race guards: Set/Change/Remove PIN are disabled while a passphrase change runs, and Change Passphrase while a PIN operation runs. - Setting or removing the PIN, or switching wallets, clears the mark; setting a PIN offers PIN entry again. Change PIN keeps the mark (the vault still seals the same passphrase). - Pure decisions in data/pin_vault_policy.h, unit-tested. New strings in all 8 languages. No vault, wallet or settings format change. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> |
||
|
|
62a5577491 |
fix(sweep): keep the UI sweep away from real wallet data
The full UI sweep (dev builds only) ran its demo surfaces against the
real profile. Its teardown deleted wallet-{cold,trading,mining,donations,
2023,payroll}.dat from the real datadir, it created zero-filled wallet
files and picker-demo/extra-wallets folders, its demo wallet-index upsert
re-minted the active wallet's scopeId (hiding "w:"-scoped contacts), the
Contacts tab could re-attach legacy-scoped contacts to such a throwaway
id in addressbook.json, and it persisted the demo active wallet file and
Mining pool worker.
- Preflight: a full sweep is refused (toast + log line) while a real
workflow is in flight: a pending seed_migration_* setting or open
migrate dialog, a wallet switch, an encryption/import workflow, an
encrypt/PIN/lock operation, the seed-backup dialog, or a bootstrap.
The DRAGONX_FULL_SWEEP hook then exits as for an empty catalog.
- Persistence latches: AddressBook (snapshot on the first sweepBegin,
save/load latched while a demo-contacts surface is shown), WalletIndex
and Settings (active wallet file, contacts view mode, pool URL/worker)
for the whole sweep. Settings::sweepEnd() is the last statement of
clearDemoWalletData and flushes once only if a save was suppressed; a
quit mid-sweep writes nothing to the profile (the 0700 sandbox, with
zero-filled demo files only, may stay in the temp dir).
- Scope ids: during a full sweep activeWalletScopeId() returns the
persisted id or "" and never mints one, so the Contacts tab cannot
move legacy-scoped contacts to a throwaway id. The only address-book
write a sweep can still cause is the one-time legacy re-attach to the
wallet's persisted id, which opening Contacts does anyway.
- Sandbox: demo wallet files and folders go to a fresh random 0700 dir
under the temp dir (exclusive create, a pre-existing path or symlink
is refused), removed at sweep end. The teardown that deleted those six
wallet files is gone.
- Cleanup: the migrate-to-seed state, wizard phase, demo chat
conversations and a fabricated demo chat identity are reset at sweep
end; the seed-backup surface always shows the demo phrase and a real
z_exportmnemonic result is dropped while the sweep runs.
- i18n: one new key (screenshot_sweep_refused) in i18n.cpp and the 8
language files.
Tests: the address book snapshots only on the first sweepBegin, keeps
addressbook.json byte-identical while latched and restores the real
book; the wallet index keeps wallets.json and the real scope id, also
with the background writer attached; Settings restores the latched
values and flushes once only when a save was suppressed; the sandbox
helper refuses a pre-existing path or symlink and removes only a
prefixed dir, never a symlink's target.
Release note: v2.0.x UI sweeps could delete wallet-{cold,trading,mining,
donations,2023,payroll}.dat from the data directory and re-scope
contacts; this cannot be undone automatically.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
||
|
|
3ac46abe1a |
fix(security): compile the UI sweep only into dev builds
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> |
||
|
|
eac0993512 |
fix(lite): require a confirmed 8+ character passphrase to encrypt
The Lite "Encrypt wallet" field and the Create/Restore passphrase had no confirmation and no minimum length; only an empty passphrase was refused. Encrypting locks the wallet at once, and a locked wallet cannot reveal its seed, so a typo there could cost a user without a seed backup their funds. The full-node encrypt dialog already requires 8 characters and a confirmation. This applies to new passphrases only. Opening and unlocking are unchanged: an existing wallet with a shorter passphrase still opens and unlocks. - A pure liteEncryptPassphraseValid(pass, confirm): at least 8 bytes and equal to the confirmation. It takes string_views, so the page's secret buffers are never copied. - liteLifecyclePassphraseAcceptable(op, pass, confirm) gates the Settings create/open/restore request. Open is never checked, because its passphrase field also unlocks an existing wallet. Create and Restore are checked only when a passphrase was entered; an empty one still creates an unencrypted wallet. - Encrypt gets a Confirm field. The button stays disabled until the passphrase is valid, and a hint says why (too short, with the count, or not matching). - Create/Restore show a Confirm row once a passphrase is typed, with the same hint. Validate stays disabled while the passphrase is invalid, so a click never wipes a typed seed over a passphrase typo. The request builder re-checks the rule. - The confirmation buffers are wiped with their passphrase: after a run or an encrypt, and when the Confirm row is hidden. - Once the wallet is closed, the Settings page wipes the Encrypt, Confirm and Decrypt passphrase buffers whole every time, also when no secret was revealed; before, closing the wallet wiped them only while clearing a revealed backup or secret. They are zeroed in full because an emptied field can still hold the old tail past its first NUL. - New copy under Encrypt: "Anyone with the wallet file can try passphrases offline. Back up your seed phrase first and use a long passphrase." - i18n: three new keys (lite_confirm_label, tt_lite_confirm_pass, lite_encrypt_offline_warning) in i18n.cpp and the 8 language files, added additively. The hints reuse the wizard's wiz_pass_too_short and wiz_pass_mismatch. No new CJK glyphs, so the subset font is unchanged. Tests: the validator rejects empty, short (7) and mismatched or unconfirmed passphrases, and accepts 8 or more with a match. The request gate always accepts Open, even "abc" with no confirmation, accepts Create/Restore without a passphrase, and applies the rule to them otherwise. An encrypted, locked wallet still opens and unlocks through the controller with the 3-character passphrase "abc". Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> |
||
|
|
e52c9e1b92 |
fix(lite): classify imported keys before calling the backend
importKeyCore guessed the key type from its first character, then
retried a failed import with the other command. That retry is what sent
shielded keys to the backend's transparent-key decoder, which panicked
on them and poisoned the wallet lock. A WIF typo that was still
valid base58 also reached the backend unchecked.
- The key is classified first, and exactly one backend command runs, or
none:
- secret-extended-key-... and zxview... go to "import";
- a Base58Check WIF with version 0xBC or 0x80 and the compressed flag
goes to "timport";
- everything else is refused before any backend call.
There is never a retry with the other command. A testnet (0xEF) WIF
is not accepted, because the Lite app is mainnet-only.
- New fixed, untranslated reasons: "uncompressed transparent keys are
not supported by the Lite wallet", a full-node incoming viewing key
(zivks) is explained as such, and anything else is "unrecognized key
format". The key is never echoed.
- The backend's refusal while the wallet is encrypted now maps to "wallet
is encrypted - remove encryption to import a private key". It is
checked before "locked", because unlocking would not help.
- base58Decode reserves its buffers up front, and it and
decodeBase58Check wipe their copies of the decoded bytes with
sodium_memzero on every return path, since the input can be a private
key.
- exportSeed rejects the all-zero-entropy phrase (23 x "abandon" + "art").
A backend built before the seed-export fix returns it for a wallet
whose lock failed halfway (seed wiped, wallet still unlocked); it is
never a real backup and must not seed a chat identity.
- An open that the backend recovered from the .bak ("OK: recovered from
backup; damaged file kept as <name>") is written to the lite log.
Tests: the retry control now expects one "import" and no "timport", and
"typo-key" makes no backend call; the controller's transparent-import
test uses a real compressed WIF. A new classifier test covers both WIF
versions, the shielded and viewing keys, a single call on failure, the
uncompressed, zivks, testnet, bad-flag and bad-checksum refusals, the
encrypted reason and its lite-log line, the zero-entropy seed and the
recovery log line.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
||
|
|
b6cffdf942 |
fix(lite-backend): catch panics at the wallet-open C entry points
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> |
||
|
|
31304a1a63 |
fix(lite-backend): durable wallet saves, non-destructive backup recovery
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>
|
||
|
|
39333f80b7 |
fix(lite-backend): keep wallet files and logs private to the owner
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>
|
||
|
|
a5db2e9a3d |
fix(lite-backend): never hang or panic a sync on malformed server data
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> |
||
|
|
55431cd606 |
fix(lite-backend): make lock/unlock atomic, check passphrase to decrypt
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>
|
||
|
|
20b63bf45d |
fix(lite-backend): validate WIFs, refuse key import while encrypted
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> |
||
|
|
026d0e92a6 |
fix(lite-backend): serialize clear and rescan with a running sync
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> |
||
|
|
0a72b47ca6 |
fix(lite-backend): verify TLS for the t-address scan and mempool monitor
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> |
||
|
|
1848025a95 |
build(lite-backend): fat LTO and one codegen unit for release builds
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> |
||
|
|
1bf803f107 |
fix(app): free the replaced gradient texture and defer logo frees
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> |
||
|
|
261883ca21 |
perf(acrylic): release blur render targets while the effect is skipped
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> |
||
|
|
f970cae193 |
perf(gl): drop the unused depth/stencil buffers from window and FBOs
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> |
||
|
|
73f7f33a4b |
perf(skins): skip redundant skin parsing, base reloads and theme writes
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> |
||
|
|
b50d5ac31f |
perf(startup): build the font atlas once at the final ui.toml sizes
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> |
||
|
|
e0f9d36780 |
perf(typography): use font blobs in place and share identical faces
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> |
||
|
|
1f162ec830 |
build(i18n): embed language JSON only on Windows, add lookup fallbacks
CMake turned every res/lang/*.json into an xxd header that i18n.cpp compiled in as a fallback, on every platform. Only the Windows single-file exe needs it: the Linux zip and AppImage and the macOS .app always ship res/lang, and loadLanguage() reads the loose file first. On Linux the embedded copy added about 1.5 MB to the binary, and every translation edit recompiled i18n.cpp. A new option, DRAGONX_EMBED_LANG, defaults to ON for WIN32 and OFF elsewhere: - ON without xxd is now a configure error (it used to warn, then fail to compile i18n.cpp on the missing headers); - i18n.cpp compiles the embedded includes and the fallback only when ON; - outside Windows, loadLanguage() also tries <exe>/../share/<app>/res/lang (the cmake --install layout and the full-node AppImage) and <exe>/../Resources/res/lang (the macOS .app binary run directly), so those layouts find the loose files whatever the working directory; before, only the embedded copy covered that. Measured: - full-node Linux binary: .data 1,699,696 -> 168,592 bytes; stripped 19,983,136 -> 18,456,128 bytes (-1.53 MB); - with the ImGui demo stubs, the Linux zips and AppImages are 0.50-0.55 MB smaller, with identical file lists including all 8 language files; - a Lite build started from an unrelated working directory loads the ja.json next to the binary (2422 strings), and the packaged Lite zip and AppImage load ja.json with no new not-found or failed lines; - Windows keeps the embedded copy: the 8 language headers are generated and the ja.json bytes are in the exe. A new test loads "de" from the loose files and checks a translated string; with bin/res/lang moved away exactly its two assertions fail. Behaviour changes on Linux and macOS: a missing or corrupt res/lang now falls back to English instead of the embedded copy, and a translation edit needs only the copy_langs step instead of a recompile. Not verifiable here: the macOS .app in a non-English language, and the Windows single-file exe at runtime. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> |
||
|
|
04ad1462d5 |
build(win-release): drop the unused loose xmrig.exe from Windows zips
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> |
||
|
|
abb63d9441 |
build(win-release): stop embedding Sapling params/asmap in the Lite exe
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> |
||
|
|
70eacd90ad |
build(imgui): compile imgui_demo.cpp to its empty stubs
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> |
||
|
|
8b094595b1 |
build(cmake): exclude HushChatFixtureCheck from the default build
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>
|
||
|
|
e65be1d24b |
build(cmake): add a dragonx_release target that builds only what ships
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> |
||
|
|
25ceb2cfe1 |
build(build.sh): add DRAGONX_BUILD_ROOT/DRAGONX_RELEASE_ROOT overrides
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>
|
||
|
|
22e3f104d6 |
fix(lite-backend): don't panic when a reorg lands during a send
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> |
||
|
|
7c0afd06a8 |
fix(lite-backend): record the mined height of mempool-first transactions
When the mempool monitor saw a transaction first, its wallet entry kept the monitor's height (often 0) after the transaction was mined, because scanning the block reused the entry without updating its block. A later reorg between the two heights then left a phantom spend of our own send and a change note with an empty witness list until a rescan. Scanning a block now sets the entry's block to the block height. This applies to every monitor-first transaction, including payments received from others: their reported block height, the verified/spendable timing that depends on it, and the wallet birthday now use the real mined height instead of the monitor's. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> |
||
|
|
24a90ae532 |
fix(lite-backend): keep witnesses current for notes in a pending send
Notes spent by a send still in the mempool stopped getting witness updates. After a restart (pending flags are not saved), or a reorg that un-mined the send, those notes were spendable again but with stale witnesses, so the next send from them used an anchor that is not on chain and failed until a rescan. Deterministic note selection made this sticky: the stale note was picked every time it was the best fit. Witnesses now advance for every note that is not spent on chain, pending or not. Witness clearing still keys on the mined spend only, so pending notes are never cleared. The wallet file format is unchanged; an old build reading a file written while a send was pending can now spend from it (verified offline). The oracle reloads and reorgs inside the pending window and compares every unspent note's witnesses byte for byte; reverting the fix fails 9 tests. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> |
||
|
|
db8d5359a4 |
fix(lite-backend): don't panic on a reorg while a send is pending
invalidate_block unwrapped a note's `spent` field for notes that only had `unconfirmed_spent` set, so any reorg while one of our sends was still in the mempool panicked the backend (and poisoned the wallet lock). The branch could only panic or do nothing; it is gone, and a reorg now leaves `unconfirmed_spent` as it is: the send can still be mined on the new branch, because the chain checks the anchor's root, not its height. The oracle no longer steers reorgs around pending sends: it now reorgs above, across and below the sent tip, including popping the anchor block, and the reproducer runs by default. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> |
||
|
|
c233d4d503 |
perf(lite-backend): fetch full transactions over one shared gRPC channel
At the end of a sync the wallet fetches the full transaction of each new wallet tx, together with every other transaction in the blocks that contain them, as decoys. Every txid got its own pool job that built a fresh Tokio runtime (one thread per CPU), a new rustls config and a new TCP + TLS + HTTP/2 connection for a single GetTransaction call, so a restore or rescan opened one connection per fetched transaction. The first error returned from the sync while the remaining jobs kept fetching and scanning in the background, and a stalled connection, an unparsable transaction or a panicking scan could hang the sync forever with its lock held. fulltx_fetch.rs now works through the same (txid, height)-sorted, de-duplicated list on the sync's runtime, with as many workers as the sync pool has threads (2-8, the same fan-out as before), over one gRPC channel that is dialed lazily and re-dialed on retry. Parsing and scanning stay on the sync pool, and the call returns once the whole batch has finished. Behaviour changes: - a failure on a decoy no longer fails the sync; it fails only when one of the wallet's own txs could not be fetched and scanned; - new error texts "Error reading transaction: ...", "full tx scan job panicked" and "fetched only X of Y wallet txs", all after the existing "Error fetching all txns for memos: " prefix; - each tx gets up to 3 fetch attempts with a 120 s timeout each, where the old code could wait forever; a request that legitimately takes longer now fails; - nothing keeps running in the background after the sync returns; - all GetTransaction calls share one TLS/HTTP-2 connection (up to one dial per worker at the start, plus a re-dial per retry); the request list and its order are unchanged; - if the network silently drops packets mid-batch, every attempt waits out its timeout until 16 consecutive failed txs stop the batch: up to about 12 minutes with 8 workers (48 minutes with 2) with the sync lock held, where the old code gave up at its first failed connection. Which txs are requested, in what order, how often they are retried and when the batch stops do not depend on which txs are the wallet's own; only the final result does. A server that keeps failing one tx can see that result through the sync's retry, but it could already tell, because an own tx that failed is requested again on the next sync without its decoys; nothing new leaks. tests/fulltx_fetch.rs drives the engine with a fake fetcher: the old request list, each item fetched and scanned once, bounded concurrency, a request sequence independent of ownership, retries, timeouts, the breaker, panicking and unparsable scans, and a saved wallet byte-identical to the old serial fill. The gRPC fetcher itself is not covered by these tests. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> |
||
|
|
ba08cab95a |
perf(lite-backend): share unchanged witness states between blocks
Each scanned block deep-copied the newest witness of every note with history to start that block's entry, although a block without shielded outputs changes no witness, and spent or pending notes never change theirs. A note's 100-entry list was 100 separate copies, mostly identical. The list now holds Arc-shared witnesses. The per-block duplicate is an Arc::clone, and Arc::make_mut copies the newest entry only for unspent, non-pending notes and only in blocks with at least one shielded output. Every entry is still written in full, so the file is unchanged, and a spend takes its own copy of the witness. A loaded wallet is not de-duplicated, so the saving builds up over the next 100 blocks. No output change: the saved bytes are identical; notes use less memory. With 1000 live notes x 100 witnesses (a 70 MB file), RssAnon after 100 blocks without outputs is 18 MiB instead of 189 MiB and such a block scans in 1.0 ms instead of 2.1-2.7 ms; when every block has an output, the live bytes stay about the same. The witness tests, the 1000-seed oracle and a differential run against the unmodified backend (every witness entry and the whole saved file after every operation; only the cleared lists and the chosen spend notes may differ) all pass, and the measured wallet saves to the same SHA-256 as with the old build. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> |
||
|
|
6f1ae874fb |
feat(lite-backend): select spend notes deterministically, max 250 inputs
send_to_address walked the wallet's notes in HashMap order and took them until the target was covered, cloning the witness and spending key of each note it visited, zero-value notes included. So which notes were spent, how many and which address got the change were random: a send could prove zero-value notes (each spend is a Groth16 proof of about 0.5-2 s plus 384 bytes) and far more notes than needed, and a send needing 251-489 notes ground through minutes of proving to build a transaction whose flat fee is below DragonX's default minimum relay rate. Selection is now a pure function (note_selection.rs) over the value and nullifier of the eligible non-zero notes: - the smallest single note that covers the target; otherwise the fewest notes, largest first, with the last pick swapped for the smallest note that still covers. Ties break on the nullifier, so map order no longer matters; - at most 250 spends, and fewer when the estimated size would pass 190,000 bytes (the consensus limit is 200,000); - the notes cover the whole target whenever they can; only when they cannot do the keyed UTXOs, which are still spent in full, reduce what the notes must cover; - only the chosen notes get their witness and key cloned. Behaviour changes: - which notes are spent, and so which address receives the change, is deterministic: the smallest single covering note, otherwise the fewest notes, largest first; - zero-value notes are never spent; - new errors before any proving: "Too many inputs: ..." (more than 250 spends needed; it states the maximum sendable amount in DRGX) and "Transaction too large: ..." (it suggests shielding transparent funds first). Neither contains "Insufficient", so the GUI does not retry them. Sends that need 251-489 notes, which succeed today after minutes of proving, are refused; - UTXOs alone fund a send only when no eligible non-zero note exists, or when the size budget leaves no room for a shielded input; then, as for a wallet without notes before, the change goes to the first z-address and is listed as a receive. Otherwise the smallest non-zero note is still spent, so the change is recorded as change; - the "Insufficient verified funds" text is unchanged, but its "have" amount no longer counts UTXOs whose key the wallet lacks; - duplicate nullifiers (corrupt state only) are de-duplicated; - new info log: "Selected N of M spendable notes". A note left with a stale witness by the pre-existing pending-send bug is now picked every time it is the best fit, rather than by chance. Pure tests check that the selection never uses more notes than the old walk (3000 random cases, shuffled), that the count is minimal (brute force) and independent of order, and the cap, size budget and fallbacks; wallet tests cover best fit, a chat-fee send, the cap refused before proving and UTXO funding. In the 1000-seed oracle every send verifies offline at anchor offsets 0 and 4 and the largest send dropped from 7 inputs to 2; a differential run against the unmodified backend has identical anchors and byte-identical non-selection state apart from the cleared lists. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> |
||
|
|
965f36cecb |
perf(lite-backend): batch a block's witness appends across threads
The block scan appended each shielded output's leaf to the witness of every live (unspent, not pending) note as it went, one witness at a time on the sync thread, while holding the wallet's txs write lock: live notes x outputs appends per block, all on one core. With 16 or more live notes, the scan now collects the block's leaves in output order and appends them to those witnesses once, at the end of the block, still under the same lock and before anything reads them. When that is at least 128 appends, the witnesses are split into disjoint chunks over min(8, cores, live notes / 8) scoped threads; otherwise the appends run inline. Every witness receives the same leaves in the same order, including a leaf whose epk does not parse; an output whose cmu does not parse still adds nothing. The hashing work is the same, only spread over cores; the speed-up has not been benchmarked yet. No output change. Each qualifying block starts up to min(8, cores) short-lived threads. Tests check that the threaded helper matches the plain double loop byte for byte for 0-64 witnesses, and that the deferred path handles bad-epk and bad-cmu outputs. The 1000-seed oracle passes, as do the suite and 125 oracle seeds pinned to 2 cores, and a differential run against the unmodified backend is byte-identical, apart from the lists cleared two commits earlier, for wallets below and above 16 live notes and blocks of 0, 1 and 50 outputs. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> |
||
|
|
e7ebc568b0 |
perf(lite-backend): trim per-note witness history in place
The block scan replaced each note's witness list with the result of split_off(), which allocates a new buffer on every call: every block, each note with history paid an allocation and, once its list was full, a copy of all 100 entries. The duplicate of the newest witness is now pushed and, when the list exceeds 100 entries, the oldest is drained in place; notes with no history are skipped. The list keeps the same entries in the same order, so nothing in the output changes. The witness tests and the oracle pass, and a differential run against the unmodified backend is byte-identical (every witness entry and the whole saved file after every operation) apart from the lists the previous commit clears. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> |
||
|
|
7f99fbb9c6 |
perf(lite-backend): drop witness history of long-spent notes
Every note keeps a list of up to 100 witnesses, one per block, so that a reorg can roll it back; a full list takes roughly 40-90 KB of the wallet file. The block scan kept duplicating and trimming that list for every note that had one, spent notes included, although a spent note never reads its history again once its spend can no longer be rolled back. So every note the wallet ever spent kept costing memory, per-block scan time and file size. Once the block window is full (101 blocks), a spent note whose spending transaction's block is below the window's first block now gets an empty witness list. This runs before each block's witness update and once when a wallet is loaded. Such a spend can only be undone by a reorg that pops the whole window, and that already empties every witness list, so reorgs behave exactly as before. Behaviour changes: - notes whose spend lies below a full reorg window lose their witness history in memory, at load and in the next save; existing large wallets shrink on their first save (which, like every save, copies the previous file to wallet.dat.bak); - the wallet format (v9) is unchanged; old builds and SilentDragonXLite read an empty list as an unspendable note, which a spent note already is; - new info log at load: "Dropped witness history of N notes whose spend is deeper than 100 blocks". The oracle now also accepts an empty list for a spent note that was cleared before a later partial reorg. New witness tests compare every block's lists with a model of the old algorithm, including reorgs across the clear point and an old file with full lists, which loads and re-saves to the new bytes. The 1000-seed oracle run passes (12,207 reorgs, 993 sends verified offline), and a differential run against the unmodified backend is byte-identical apart from exactly the cleared lists; the old build reads the new files, and 17 sends it built from them verified offline. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> |
||
|
|
6de9b5cc90 |
perf(lite-backend): borrow embedded Sapling params instead of copying
Every LightClient copied the Sapling spend and output parameters, which rust-embed already compiles into the binary, onto the heap: 51,551,256 bytes (49.2 MiB) of private memory per live client, whether or not it ever sent anything. LightClient::sapling_output and sapling_spend are now Cow<'static, [u8]> and borrow the embedded image. set_sapling_params still stores an owned copy, with its is-empty guard, hash checks and disk write unchanged. The prover receives the same bytes. Behaviour changes: - about 49 MiB less anonymous memory per live LightClient; the wallet file and JSON are unchanged; - the first send or shield pages the 51.5 MB of embedded params in from the binary, where client creation used to copy them (an estimated 0.3-1 s from the AppImage); - the public fields sapling_output and sapling_spend change type from Vec<u8> to Cow<'static, [u8]>: a Rust API change, with no C ABI or exported-symbol change. tests/sapling_params.rs checks that a restored, a new and a reloaded client all borrow the embedded image (same pointer, pinned lengths and SHA-256), that set_sapling_params keeps its guard and hash checks, and that the borrowed params parse like a heap copy. An ignored test measures 576 KiB of RssAnon growth for a second client, against 46.5 MiB with the old heap copy. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> |
||
|
|
bd4c06dfd0 |
perf(lite-backend): encode note addresses from pk_d instead of deriving
note_address() re-derived a note's payment address from its viewing key and diversifier (a group hash, an ivk hash and a 252-bit scalar multiplication) for every note in `notes` and `list` and for each new note in the block scan. The z-balance sums did the same derivation in their address filter, so `balance`, which sums four balances for each of Z z-addresses, cost Z x (N + 2U) derivations for N notes, U of them unspent. do_list_notes also encoded up to Z + Z^2 addresses to find the spendable ones and cloned that set once per transaction. Now: - note_address() encodes the note's stored pk_d with its diversifier. Both decryption and wallet load build that pk_d from the same key and diversifier, so the string is byte-identical; - the three z-balance sums share one address filter. The per-address sums behind `balance` compare (diversifier, pk_d) with each z-address directly; any other address string keeps the old exact string compare; - those sums work from a copy of the z-addresses whose lock is released before any note is read, so the txs-then-zkeys lock order that sync uses is unchanged; - do_list_notes builds the spendable-address set with Z encodes and borrows it. No output changes: balances and the notes/list/balance JSON are byte-identical. The address filter no longer contains an unwrap (it could not fail). Tests compare against verbatim copies of the old functions over randomized chains (note addresses, also after a reload; every filtered balance for own, diversified, foreign and junk address strings; the per-address balances; the spendable set), and a concurrency test fails with a deadlock if the zkeys lock is held across the sums. A differential run against the unmodified backend compared per-address balances and the JSON after every operation, through reorgs and reloads, and the old build re-saves the new files byte for byte. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> |
||
|
|
d28cc0da31 |
test(lite-backend): add a seeded chain oracle and offline spend verifier
No existing test checks that a send's anchor, nullifiers and proofs are valid, or that note witnesses stay right through reorgs and reloads, and that is what the following witness and note-selection changes touch. oracle_tests.rs drives a LightWallet through its public entry points over seeded random fake chains that start at real mainnet checkpoints: receives to HD, diversified, imported, view-only and foreign keys, zero-value and dust notes, outputs with an unparseable epk, compact spends with change, real sends that are mined later or never (mempool expiry), reorgs of 1-100 blocks in one call or block by block (re-mining spends on the new branch), and save/reload. An independent reference keeps its own commitment tree, every block's root and every note's true witness at each height. After each step it checks the tree and block window, every note's fields and spent state, witness-list lengths, every retained witness of a live note byte for byte (sampled ones also by root, position and auth path), the balances and the mempool. Sends are captured in the broadcast closure, never sent, and verified offline: the anchor must be the reference root at tip minus the anchor offset, every nullifier must belong to an eligible note, and the Sapling verifier must accept every spend and output proof and the binding signature (the first send of a run also checks that it rejects a wrong anchor and a wrong nullifier). The default run (20 seeds x 300 blocks) is part of the normal suite; a 1000-seed x 400-block run and a 20-seed x 5000-block soak are #[ignore]. Every run prints its seed, and a single seed can be re-run. Two pre-existing bugs are documented as #[ignore] reproducers and kept out of the green runs: - a reorg while a send is pending panics the backend (invalidate_block unwraps `spent` on a note that only has `unconfirmed_spent`); - a pending send's notes stop receiving witness updates, so after a restart (the send never mined) or a reorg that undoes the block that mined it, they are spendable again with stale witnesses, and later sends from them fail until a rescan. On the unchanged production code the default and full runs pass (1000 seeds: 12,194 reorgs up to 100 deep, 5,893 reloads, 993 proving sends verified offline at anchor offsets 0 and 4) and the reproducers fail as described. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> |
||
|
|
8895038c40 |
test(lite-backend): port the core-crate tests to the current API
The lite backend core's in-crate tests no longer compiled (119 errors in
lightwallet/tests.rs and the lightclient and walletzkey test modules):
they still used the old LightWallet::new and send_to_address signatures,
the removed extfvks/extsks/zaddress fields and the old new_from_phrase
and do_send arities. So `cargo test` built nothing and no backend change
could be checked against them.
Port them, and the commands and checkpoints tests, to the current API,
touching only #[cfg(test)] code:
- LightWallet::new takes 4 arguments, send_to_address 7 (with an
explicit default fee, returning (txid, raw tx)), new_from_phrase 5 and
do_send 2; keys are read through zkeys and tkeys;
- every config uses the "main" chain: creating a wallet on the "test"
chain panics in base58_pubkey_address, so LightClient::unconnected
(which hard-codes "test") is no longer used;
- every LightClient lives in a temporary data dir, so a test cannot
write to the real ~/.silentdragonxlite or ~/.zcash-params;
- ten send_to_address recipient lists garbled by an upstream change in
2020 are restored from the upstream history.
Expectations that contradicted deliberate current behaviour are updated;
none is ignored:
- a pending send's mempool entry sits at the tip, not tip+1, and
zbalance no longer counts a note with a pending spend;
- keys cannot be added to an encrypted wallet, so the encrypted-receive
tests derive their second address before encrypting;
- the t-address vectors are m/44'/141'/0'/0/{0,1}, checked against an
independent BIP32 derivation; the z-address vectors record the current
output;
- test_addresses and test_checkpoints follow the current address count
and checkpoint tables.
All 43 tests pass on the unchanged production code (three runs) and
leave the real wallet and params directories untouched; that list is the
regression baseline for the following commits. They cover balances,
address derivation, bookkeeping, the mempool, encryption, serialization
and rollback, but none verifies a proof, an anchor or a witness.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
||
|
|
3f13d79ccb |
perf(ui): memoize the node-status banner ellipsis
While the node-status banner shows, its detail line was fitted every frame by popping one character at a time and re-measuring the whole "· " + detail + "…" string: O(L^2) per frame in the detail length L, which adds up on the ~1.5 KB Windows crash report. The pop also left a dangling UTF-8 lead byte before the "…", so a cut in non-ASCII text drew replacement characters in place of the ellipsis. - util::textfit::ellipsizeTail builds the same string in O(L + m log m), m being the length of the line the cut lands in: ImGui measures multi-line text as its widest line, so the old loop could only stop inside the first line that overflows on its own. Byte-identical for ASCII; the cut now lands on a code-point boundary. - EllipsisFitCache keeps the result until the detail, budget, font size, font (by FontId) or rasterizer density changes, so an unchanged frame costs one string compare. - When ImGui would draw a line wider than the per-line split predicts (a malformed byte joined with the following newline, as in CP-1252 output from the Windows daemon), the old whole-string loop is used for that text. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> |
||
|
|
8c0a22b7bc |
fix(ui): cut ellipsis truncation on UTF-8 code-point boundaries
TruncateToWidth, the middle "front...back" ellipsis for addresses, pool URLs, paths, labels and the status bar, cut at byte offsets: a multi-byte character at either cut was split into invalid UTF-8 and drawn as replacement characters (e.g. a translated Cyrillic or CJK status). It also tried front lengths from n/2 down until one fit, building and measuring a new string for each: up to O(n^2) per truncated label per frame. util::textfit::middleEllipsis keeps the same contract and is byte-identical for ASCII, but snaps the front cut down and the back cut up to code-point boundaries, and finds the largest fitting front length by bisection into one reused buffer: O(log n) measures for single-line text. Text containing a newline keeps the linear scan, because its measured width (the widest line) does not grow steadily with the cut. text_fit.h also gains largestFittingPrefix, a bisection over code-point boundaries; its first caller is the node-status banner in a following change. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> |
||
|
|
e025a314d1 |
perf(ui): memoize typography specs on the schema generation
Every Typography text, textWrapped and textColored call rebuilt all 15 type specs to read one uppercase flag: 28 ui.toml element lookups, 13 of them copying the element with its string map. With M such labels on screen, that was 28M lookups and 13M copies per frame. The specs are now built once per UI-schema generation, which every ui.toml load, hot reload and skin or theme switch bumps. DPI, font scale and language are not inputs, so nothing else invalidates them. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> |
||
|
|
1ced49d622 |
perf(console): extend the filtered view incrementally
With any console filter on, each batch of new output rescanned all N lines to rebuild the visible set. A text filter lowercase-copied the needle and every line to test it: O(total characters) and 2N allocations per batch. When the filter is unchanged and the model only appended or evicted lines since the last pass, the visible indices drop the evicted lines and shift down, and only the new lines are tested. The result equals a full rescan, since line text and channel never change and the filtered view has no folds. A filter change, Clear, or a model window that doesn't follow on from the last one still rescans. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> |
||
|
|
ee0a486676 |
perf(console): wrap only new console lines via a per-line cache
Each batch of new output re-wrapped all N visible lines glyph by glyph, allocating a fresh segment vector per line; a line evicted at the 10,000-line cap shifted every index, so nothing could be kept. And on every frame, idle or not, seeding the scanline shading summed the wrap rows of every line above the view. - The model numbers its lines and the view keeps one wrap per line, aligned by that number, so an eviction drops a prefix instead of forcing a rebuild. New output wraps only the new lines; the layout is then a prefix sum over the N visible lines, with no measuring and no per-line allocation. - The wraps are kept while the wrap width, line height, font size, font (by ImFont::FontId) and rasterizer density are exactly unchanged. A line-spacing change only re-sums heights; filters and fold toggles reuse the cached wraps. A resize drag still re-wraps every visible line per frame, as before, but reuses their storage. - The scanline seed is read from the prefix sum: O(1) per frame. Heights, offsets and segments are bit-identical to the old rebuild. A font reload at an unchanged size (the colour-emoji toggle) now re-wraps the console at once; before, it waited for the next line of output. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> |
||
|
|
c2c2a8325b |
perf(overview): clip the address list to the visible rows
The Overview address list built all N rows on every frame (icons, labels, balances, buttons, an InvisibleButton and a context-menu check per address) and allocated a vector of N row positions, however few rows fit on screen; the edge mask then walked all their vertices. An ImGuiListClipper now submits only the rows that intersect the list's clip rect, plus: - one guard row on each side, so dividers and anti-aliased edges at the clip boundary are drawn as before; - the row being dragged and the row whose context menu is open, so drag-to-reorder and the menu keep working when they scroll away; - the rows keyboard navigation targets (kept by the clipper itself). Before each row the cursor is put back exactly where the unclipped loop left it, so the density-scaled spacing, which depends on the cursor position, and every row's geometry are unchanged. Row tops are computed from the index, so the per-frame vector is gone, and the scroll extent is the same. The row model (sort, filter) is still rebuilt over all addresses each frame. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> |
||
|
|
bb973a1007 |
perf(ui): skip the scroll-edge mask when no edge fades
ApplyScrollEdgeMask, which fades a scroll area's content at its top and bottom edges, walked every vertex the area emitted on every frame and tested both edges per vertex, even when the content fit without scrolling and no vertex could ever be written. It now returns before the walk when neither edge fades. The fade maths moved unchanged into scroll_edge_fade.h and is tested against the old inline expression, so rendering is identical. All six callers benefit without an edit: the Overview address list, Mining's recent blocks, Console, History, Explorer and Peers. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> |
||
|
|
88fa13dd1a |
perf(mining): sample system RAM at most once a second
The Mining tab's memory bar read used system RAM and the wallet's own memory on the UI thread every frame: synchronous OS queries, on Linux an open and parse of /proc/meminfo and /proc/self/status per frame. Both are now sampled at most once a second; the first frame after more than a second idle resamples at once. Total RAM was already cached and daemon RAM already comes from a background sampler. The used-RAM and wallet-memory readings in the bar and its tooltip now refresh at most once a second; what they show is unchanged. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> |
||
|
|
888a41ddc2 |
perf(mining): memoize the earnings card on tx_revision
The Mining tab's earnings card walked all N wallet transactions on every frame to total Today / Yesterday / All time and pick the 4 most recent rows: four string compares per transaction plus a memo search on receives. It also ran localtime and mktime every frame to find local midnight. The pass is now a pure function behind a one-slot cache keyed on WalletState::tx_revision, the transaction count, the filter pill, the Solo/Pool mode, solo support and local midnight, with a 2 s maximum age as a safety net. It reruns only when one of those changes or the 2 s age runs out, instead of every frame. The totals are bit-identical (same loop, same summation order), and crossing local midnight still moves Today into Yesterday at once. Local midnight is recomputed only when the clock second changes, through the shared util::localTime; a failed localtime now yields a zeroed struct instead of an uninitialised one. The "x ago" labels and Est. Daily are still computed per frame. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> |
||
|
|
3069151e7f |
fix(chat): bound the selection copy range to the message body
The copy button of a text selection in a chat bubble copied body.substr(start, len) with byte offsets kept from earlier frames. Those offsets can outlive a thread change (an older message inserted above shifts the message indices, so the selection can land on a shorter body), and a start past the end made substr throw std::out_of_range out of the chat render; the main loop catches it, but the rest of that frame's UI is dropped. The copy now runs only when the start is not past the end of the body. substr already clamps the length, so the copied text is the same in every case that did not throw before. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> |
||
|
|
4a95d9ecb7 |
perf(chat): lay out and draw only the visible chat thread rows
Every frame the open chat thread handled all N messages: grouping flags from two day keys each, a measuring layout of every body, then the draw, which laid every body out a second time and ran the bubble, hover and popup code, however few rows were on screen. The thread now keeps a layout cache: grouping flags, each body's wrapped size (keyed by message, so only new or re-wrapped bodies are measured) and each row's height, with prefix offsets. A frame binary- searches the visible range and submits those rows plus any failed send (Retry stays reachable with Tab), the row being drag-selected and the row whose context menu is open; one spacer stands in for each skipped run, and scrolling to the newest message works as before. - Rows are rebuilt when the chat store changes or when the wrap width, fonts, font size, DPI scale, rasterizer density or compact mode change. Fonts are keyed by FontId, so a font reload at the same size (the colour-emoji toggle) re-measures. A rebuild is O(N), about 0.5-1 ms at 3000 messages. - Grouping flags are recomputed on a data change and once a minute, together with util::refreshTimezone(), so a system timezone change reaches chat timestamps within a minute. Rows above the visible range are now one spacer of their summed heights, so the visible rows no longer move by a pixel each time a row above them crosses the top of the app window. Debug builds log once if a cached wrap width or row height ever disagrees with the live layout. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> |
||
|
|
a69380817e |
perf(chat): use reentrant local time for chat timestamps
The open chat thread called std::localtime at least twice per message per frame (two day keys for grouping), and more for day separators and time labels. On glibc every call re-checks the timezone with a stat of /etc/localtime. The chat time helpers now use util::localTime() from the new util/local_time.h: localtime_r (localtime_s on Windows), with no per-call timezone check and no shared static result buffer. glibc's localtime_r reads the timezone only once, so local_time.h also adds util::refreshTimezone() (tzset) for caches to call when they rebuild. Nothing calls it yet; its first caller is the chat thread's grouping-flags cache in the next commit. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> |
||
|
|
e45f4c7d90 |
perf(history): key History caches on tx_revision and memoize the filter
Every frame the History tab: - hashed the txid, amount, confirmations, time, type and address of all N transactions (about 0.6-5 ms at 5k-20k) to decide whether its totals and its merged, sorted row list were stale; - copied every chat txid into a fresh set; - ran the type and search filter over all N rows, with up to three case-insensitive substring searches each. The totals and the row list now key on tx_revision (the row list also on the sort mode), the Message badge and the Chat filter use the chat store's txid index, and the filtered rows are rebuilt only when the row list, the type filter, the search text or, under the Chat filter, the chat store changes. A frame is now a few compares plus one txid lookup per row of the current page. An unchanged full-node refresh no longer rebuilds the totals and the row list either: the old fingerprint folded in the refresh time. On lite, a change to only a memo or a sender address now shows at once. The old fingerprint left those fields out and relied on a refresh time that lite never sets, so such a row stayed stale until something else in the list changed, such as the next block. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> |
||
|
|
d3fa09128e |
perf(chat): look up chat txids through a revision-keyed index
The History tab copies every chat txid into a fresh set each frame to badge and filter chat transactions: M string allocations for M chat messages. ChatStore gains hasChatTxid(), an O(1) lookup in a txid index that is rebuilt only after the store changed (append, delivery update, erase, clear) and never built for an empty store. clear() also drops the index, so no chat txids linger after a lock. The History tab moves to it in the next commit. The chat note-buffer pump, which runs every frame while chat is idle, built the whole conversation-id list just to see whether the store was empty; it now asks store().empty(), O(1) instead of O(M). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> |
||
|
|
860916d203 |
perf(history): add a transactions revision counter to WalletState
WalletState had no change signal for its transaction list, so a UI cache had to fingerprint all N transactions every frame to notice an update. It now carries tx_revision, a counter that only increases; clear() on disconnect bumps it instead of resetting it, so a cache built before a reconnect or wallet switch can never match again. - Every writer of the list bumps it: the full-node refresh, loading the cached history, adding, completing and removing a pending send, a mining-address relabel, the lite refresh and the screenshot demo rows. - The full-node refresh and the lite refresh (every ~2 s) swap in the new list only when it differs field by field, the amount compared bit-exactly because -0.0 and +0.0 print differently. An unchanged refresh keeps the list and the revision; the compare costs O(N) per refresh, not per frame. Nothing reads the counter yet. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> |
||
|
|
4a2a1bdd54 |
perf(image-picker): decode thumbnails off the UI thread
The image picker decoded up to 6 full-size images per frame on the UI thread to make its thumbnails (1.5-2.7 s per frame with 12 MP photos, until the visible grid filled) and read every .gif/.webp a second time just to see whether it animates. Thumbnails were freed only on navigate or close, so a large folder and every hovered animation (up to ~20 MB each) stayed resident. Thumbnails now decode on util::StartImageDecode workers (at most 2 at once, so the top of the visible grid fills first) and the UI thread uploads up to 8 small textures per frame. The animation badge comes from the same decode, with no second read. Hover previews get their own 2-slot lane, and the picker asks for a frame every 100 ms while anything is pending; under idle pacing a deferred thumbnail used to wait for input or the 2 s tick. Behaviour changes: - thumbnails and hover frames are LRU-capped at 64 MiB per folder; evicted ones re-decode when they are needed again; - leaving an animated thumbnail before its preview finishes decoding cancels it (re-hover restarts it); a finished preview is kept; - animated WebP files get a real thumbnail instead of the placeholder; - images above the decoder's limits show the placeholder. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> |
||
|
|
3af7b0ef44 |
perf(contacts): decode avatars off the UI thread
The Contacts tab read, decoded and downscaled custom avatars inside the draw call, two per frame, rows scrolled out of view included: a 12 MP photo froze the window for 0.25-0.45 s, a long GIF for up to 1.5 s, and the avatar cache was never evicted. "Add image" decoded the picked file and copied it inside the picker's confirm callback, freezing it again. Avatars now decode on util::StartImageDecode workers (at most 2 at once). The UI thread only uploads finished frames, at most about 4 MB per UI frame, and asks for a frame every 100 ms while anything is pending; under idle pacing a deferred avatar used to wait for input or the 2 s tick. Behaviour changes: - an avatar scrolled out of view is not decoded until it scrolls in (the badge shows for 0.1-0.3 s the first time); - avatar textures are LRU-capped at 128 MiB; the least recently drawn go first, never one drawn on-screen in the previous Contacts frame, and they re-decode on scroll-back; - an animated avatar starts playing once all its frames are uploaded; - an import is validated and copied on a worker: the avatar appears 0.1-0.5 s after confirming and Save/Add stays disabled until it does; a result only lands in the dialog session that started it; - the copy goes to a temporary file renamed over the target (retried briefly), so a crash or a failed copy never leaves a truncated avatar or removes the existing library image; - imports get the decoder's limits, and an animated WebP can now be imported (it always failed with "couldn't be loaded"). Applying an import also no longer destroys an avatar texture the same frame has already drawn (a use-after-free on DX11). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> |
||
|
|
c6fc1ac168 |
fix(images): cap and stream image decoding in a GL-free decoder
Image decoding lived in the GL texture file and had no limits: a file of any size was read whole, stb and libwebp allocated whatever the header claimed (a WebP alone up to 1 GB), and a GIF was decoded at full size, every frame, before the 300-frame cap. A 720p 150-frame GIF avatar cost 0.2-1.5 s and 140-370 MB on the UI thread. stb's GIF loader also read before the start of its buffer on disposal-3 frames, and when a long GIF ran out of memory it freed the frame delays that the caller then freed again (an abort). Decoding, and the stb implementation, now live in the GL-free util/image_decode_core, safe on any thread: - the header is probed first; stills above 256 MP, any GIF or animated WebP canvas above 32 Mi px, and files above 256 MiB are rejected before anything is allocated (real photos, 200 MP phone shots and wide panoramas still load); - a GIF decodes one composited frame at a time, downscaled as it goes, and stops at the frame cap: peak memory is stb's working buffers, two canvases and the small frames; a truncated GIF keeps the frames decoded so far; - a header stb cannot read means "unsupported", never a blind decode; - decodes above 16 MP run one at a time; a decode can be cancelled between frames. util::StartImageDecode runs one decode on a detached worker that owns its job and its lane's in-flight counter, so nothing waits at exit; the next commits move avatars and the image picker onto it. The limits apply now to avatars and the picker's hover previews; thumbnails and avatar imports get them when they move to workers. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> |
||
|
|
971f2abae5 |
perf(startup): prepare first-run node files in the background
On a first run or after an upgrade, Windows release builds wrote the bundled Sapling params, asmap, node and miner (about 88 MB) in App::init, before the first frame: 0.5-3 s of frozen blank window, 5-30 s on a slow disk or under antivirus. Zip, AppImage and .app builds copied the bundled files into the daemon dir and hashed the 51.5 MB of params on the UI thread at the first node start, a 0.3-1.5 s freeze in the wizard or the connect loop. A stat-only check at startup decides whether anything is missing, stale or unverified; a normal launch starts no thread and shows nothing. Otherwise one background job extracts (Windows) or copies (Linux, macOS) the files, then verifies the params once: - The first frame paints at once. If the job takes longer than 0.4 s, a chip at the bottom shows "Preparing wallet files... N%", then "Verifying wallet files...". - A node start on the UI thread while the job runs is deferred and replayed when it ends (never into a bootstrap download, a restart or a live connection); worker threads wait, and give up on quit. After an upgrade that ships a new miner, the node starts after the miner is written instead of before. Pool-miner starts and the miner version check wait too, so the old miner is never run or reported while it is being replaced. - Each file goes to "<name>.partial", is flushed and renamed into place under one lock shared with the daemon start, the bundled-node reinstall and the miner extraction, and the job stops between 4 MB chunks at shutdown. An interrupted write used to leave a truncated binary that was trusted forever; a leftover .partial is now removed. Other behaviour changes: - Linux and macOS full-node builds copy the bundled node files (about 77 MB) into the daemon dir at first launch, even if the wallet ends up on an external node; before, this happened at the first embedded start. - Lite no longer writes the 53 MB of params and asmap it never reads. - A disk-full or permission failure that leaves the node files unusable shows a warning; a miner blocked by antivirus alone does not. - A corrupt or truncated param in a zip, AppImage or .app install is replaced from the bundle and verified again instead of trusted forever. - Reinstalling the bundled node holds off the connect loop and a deferred start for its whole run (either could launch the old binary while the new one was written), and is refused while another restart is running. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> |
||
|
|
47180ff840 |
perf(startup): stat dragonxd instead of reading it for the update offer
On Windows every full-node launch read the whole installed dragonxd.exe (about 16.7 MB) on the UI thread and byte-scanned it for a version string, to decide whether to offer the bundled node, which only needs whether the file exists and its size. getInstalledDaemonInfo() takes a scanVersion flag, and the update offer now only stats the file. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> |
||
|
|
a700d2c7ee |
perf(wallets): walk added wallet folders off the UI thread
Every open of the Wallets dialog, Refresh, folder add or remove, and Open on a stale row walked each user-added folder recursively on the UI thread before the dialog drew: up to 40,000 entries per folder, roughly 50-150 ms warm on Linux and seconds on a cold disk, a network share or Windows. The datadir listing (a few top-level files) stays synchronous, so a wallet just created shows at once. The recursive walk runs on a detached worker with the same depth, hit and entry limits; its rows are merged at the start of a later frame and only the new rows are probed. - The dialog opens at once with the datadir rows. The external rows arrive when the walk ends, with "Scanning folders..." beside the scanned-folders caption, and the card grows once. - Reopening within 60 s with the same folders reuses the last walk without touching the disk, so its rows and sizes can be up to a minute stale: a wallet copied into a folder in that minute needs Refresh. Refresh and Open on a stale row always walk again. - While a walk or a wallet probe runs (up to 30 s) the dialog keeps frames coming, so rows and badges appear without moving the mouse. - The 200-row cap now holds across all folders; each extra folder could add one row past it. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> |
||
|
|
1658950386 |
fix(wallets): don't rebuild the wallet list inside its own row loop
Open on a wallet row whose file was moved or deleted after the list was built rebuilt the list from inside the dialog's row loop. The loop still held a reference to that row and walked a sort order sized for the old list, so the click read freed memory and could index past the end. Open now flags a rescan for the next frame; the vanished row drops out one frame later, with no crash. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> |
||
|
|
bb84243114 |
fix(lite): keep a rejected import key out of the log and Settings
The backend's error for a key it doesn't recognize repeats the key
("'<key>' was not recognized as either a spending key or a viewing
key"). Import passed that text on as its error, so a mistyped private
key was written to the lite log (shown in the Console tab) and to the
Settings status line. A failed import now carries a fixed reason
(unrecognized format, wallet locked, key already exists, could not be
decoded, or rejected), which is all the log records. Settings shows
"Couldn't import the key — check it, unlock the wallet (if encrypted)
and try again." instead, translated in all eight languages.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
||
|
|
7c24400c83 |
fix(lite): ignore a click on the already-connected server
Clicking the Network-tab card of the server the wallet was already pinned to and connected to tore the controller down and reopened the wallet against the same server. That click is now a no-op. A wallet that failed over to another server still reconnects, which retries the pinned one. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> |
||
|
|
55975901a2 |
perf(lite): move wallet ops and server switches off the UI thread
New address, Import key, Encrypt and Remove encryption called the
backend on the UI thread. Each ends in the backend's save, which waits on
the lock a scan holds for its whole duration and every refresh read holds
briefly, so during a scan the window froze until the scan ended. A create
or restore with a passphrase encrypted and saved just after its first
scan had started: for a restore, a freeze for the whole initial scan. A
server switch destroyed the old controller on the UI thread and joined
its refresh worker, which can sit for minutes in a read against a dead
server (each balance, notes or list read retries for ~62 s).
- The four ops run one at a time on a background lane and their results
are applied on the main thread; Settings shows "Working…" meanwhile.
Key and passphrase copies are wiped after the call, or when a queued
op is dropped.
- A create or restore encrypts and saves (an open unlocks) before the
first scan starts, so the save takes milliseconds and the scan runs on
the encrypted file.
- Import, Encrypt, Remove encryption and Receive's New address are
disabled while a scan runs ("Wait for blockchain to sync"); Redownload
blocks is disabled while an op is queued.
- A switch retires the old controller without blocking (queued ops
dropped, pending ones reported failed, its client shut down) and
destroys it on a background thread. It no longer saves on the way out:
the new client re-scans the blocks since the last save, as after a
restart.
- A switch waits for an op in flight, at most 15 s, so the op can't
finish and save on the old client while the new one owns the wallet
file. The Network tab already shows the new selection meanwhile.
- The refresh worker stops between backend commands, skips the remaining
server reads once one fails, and then retries after 4, 8, then 15 s
instead of every 2 s.
- Quit waits up to 2.5 s for an op in flight, e.g. Encrypt's save.
Encrypt and Remove encryption now report a failed save: the backend
returns it as a JSON error that the old check never saw, so the failure
was reported as success. The trimmed copy of a key typed into Import is
now wiped too, and stopping the refresh worker can no longer miss its
wakeup.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|