main() built the font atlas before App::init() loaded ui.toml, so the
first build used the hard-coded fallback sizes (H1 24 px, Body2 12 px).
App::init() then rebuilt for a saved font scale above 1.0, still before
the schema load and so still at the fallback sizes. Only the color-emoji
rebuild requested for the first frame reached the ui.toml sizes, so
color-emoji users built the atlas twice at font scale 1.0 and three
times at 1.5.
Users with chat_emoji_color=false never got that last rebuild. They
built the atlas once or twice and ran the whole session at the fallback
sizes, e.g. Body2 at 12 px instead of 15 and H1 at 24 px instead of 20.
Now:
- App::loadBaseUiSchema(), the ui.toml load moved out of App::init()
unchanged, runs in main() before both InitImGui paths (DX11 and GL),
together with the saved font scale and emoji style from the early
settings read. It is idempotent, so the call left in App::init() is a
no-op;
- the two rebuild triggers in App::init() are gone;
- Typography records what the atlas was built from (the 15 schema sizes,
the schema's font scale, the effective emoji style and the schema
generation), and preFrame() rebuilds once when they differ. While the
schema generation and the emoji style are unchanged this costs two
compares per frame.
Measured on the Lite build (color and mono emoji, font scale 1.0 and
1.5, English and Japanese): every run builds the atlas exactly once, at
the ui.toml sizes times the scale (H1 20 px and Body2 15 px at 1.0,
30 px and 22 px at 1.5), and ui.toml is loaded before the window and GL
context are created. The packaged Lite zip and AppImage log one atlas
build instead of two. Color-emoji screenshot sweeps match the previous
build within noise.
Visible changes:
- users with chat_emoji_color=false had been rendering the whole UI at
the fallback font sizes; they now get the designed ui.toml sizes, so
every mono-emoji screen differs from before and matches the
color-emoji layout;
- a user skin whose [fonts] differ from ui.toml now rebuilds the atlas
once when it is activated or left; on a runtime switch the atlas used
to keep the old sizes until a restart. With a test skin that sets
Body2 to 18 px, startup builds twice and ends at 18 px; switching to a
bundled skin rebuilds once, switching among the 9 bundled skins
rebuilds nothing (they carry an identical copy of [fonts]), and
switching back rebuilds once;
- editing ui.toml while running still gives exactly one rebuild.
Not verifiable here: the Windows DX11 path with the schema loaded before
its first FreeType build, macOS, and the interactive font-scale slider,
Alt+scroll and emoji toggle, which were checked by reading the code.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Typography copied every font blob into a fresh heap buffer for each face
it added (IM_ALLOC + memcpy, FontDataOwnedByAtlas = true): the Ubuntu
weight, the CJK subset merge, the emoji merge, and the icon and pickaxe
fonts. The blobs are INCBIN symbols in read-only memory that live for
the whole process, so the copies (16 faces, each with its own CJK copy
and, for 8 of them, an emoji copy) only cost memory. Styles with the
same weight and size also built separate, identical ImFonts.
Now:
- every font is added straight from the embedded data with
FontDataOwnedByAtlas = false. ImGui 1.92 frees only data it owns, so
the blobs survive atlas clears and DPI or skin reloads;
- load() walks a style table and shares the ImFont between styles with
the same (weight, scaled size, emoji) key. Emoji is part of the key,
so no style gains or loses the emoji fallback. With the current
ui.toml sizes H3/H4, H6/ButtonLg and Body1/Overline share a face: 13
ImFonts instead of 16 (12 text faces plus Mono), CJK merged 13 times
instead of 16 and emoji 7 times instead of 8.
Measured on the Lite build, RssAnon 15 s after start, median of 3 runs:
- color emoji: 108.6 -> 78.8 MiB (-29.8 MiB);
- monochrome emoji: 102.8 -> 78.6 MiB (-24.2 MiB).
RssFile is also 2.4-5.1 MiB lower, because the blobs are no longer read
end to end for copying. Full screenshot sweeps (610 screens per run; color
and mono emoji at 1280x720 in English and Japanese, color at 2560x1440)
match the previous build within run-to-run noise. The only content
differences are live values such as the mining tab's RAM readout (233 MB
before, 201 MB after).
Also fixed: when AddFontFromMemoryTTF failed, ImGui had already freed
the owned copy and the caller freed it again, a latent double free;
those IM_FREE calls are gone. The emoji-merge failure log now prints the
blob size instead of passing the float font size to %u.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
CMake turned every res/lang/*.json into an xxd header that i18n.cpp
compiled in as a fallback, on every platform. Only the Windows
single-file exe needs it: the Linux zip and AppImage and the macOS .app
always ship res/lang, and loadLanguage() reads the loose file first. On
Linux the embedded copy added about 1.5 MB to the binary, and every
translation edit recompiled i18n.cpp.
A new option, DRAGONX_EMBED_LANG, defaults to ON for WIN32 and OFF
elsewhere:
- ON without xxd is now a configure error (it used to warn, then fail to
compile i18n.cpp on the missing headers);
- i18n.cpp compiles the embedded includes and the fallback only when ON;
- outside Windows, loadLanguage() also tries
<exe>/../share/<app>/res/lang (the cmake --install layout and the
full-node AppImage) and <exe>/../Resources/res/lang (the macOS .app
binary run directly), so those layouts find the loose files whatever
the working directory; before, only the embedded copy covered that.
Measured:
- full-node Linux binary: .data 1,699,696 -> 168,592 bytes; stripped
19,983,136 -> 18,456,128 bytes (-1.53 MB);
- with the ImGui demo stubs, the Linux zips and AppImages are
0.50-0.55 MB smaller, with identical file lists including all 8
language files;
- a Lite build started from an unrelated working directory loads the
ja.json next to the binary (2422 strings), and the packaged Lite zip
and AppImage load ja.json with no new not-found or failed lines;
- Windows keeps the embedded copy: the 8 language headers are generated
and the ja.json bytes are in the exe.
A new test loads "de" from the loose files and checks a translated
string; with bin/res/lang moved away exactly its two assertions fail.
Behaviour changes on Linux and macOS: a missing or corrupt res/lang now
falls back to English instead of the embedded copy, and a translation
edit needs only the copy_langs step instead of a recompile.
Not verifiable here: the macOS .app in a non-English language, and the
Windows single-file exe at runtime.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Both Windows zips shipped a loose xmrig.exe next to the wallet exe. The
exe already embeds xmrig (HAS_EMBEDDED_XMRIG), and getXmrigPath() uses,
and re-extracts when needed, the copy under %APPDATA%\<app>\dragonx; it
looks next to the exe only when that extraction fails.
build_release_win now copies it only when the generated header does not
define HAS_EMBEDDED_XMRIG, or there is no header.
Measured: each Windows zip loses exactly one entry, xmrig.exe; with the
ImGui demo stubs, the full-node zip went from 167,515,430 to 164,554,177
bytes.
The loose dragonxd, dragonx-cli, dragonx-tx, wallet-rebuild helper,
params and asmap stay in the full-node zip. The daemon copy under
%APPDATA% is written once and never refreshed, so without the loose copy
existing zip users would silently run their first-install daemon.
Dropping them needs a migration first.
Not verifiable here: mining from an unzipped Windows build without the
loose xmrig.exe.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The Windows release embeds its resources into the exe through a
generated INCBIN header. The Lite build got the same param and asmap
entries as the full node, so ObsidianDragonLite.exe carried
sapling-spend.params, sapling-output.params and asmap.dat (about 53 MB)
that it never reads: the SDXL backend embeds its own copy of the params,
and Lite already skips extracting full-node-only resources.
build_release_win now writes the param and asmap INCBINs only for the
full node (should_bundle_full_node_assets). Lite gets null/zero stubs
for those three symbols and still embeds images, themes and xmrig. Two
fixes make that safe:
- getEmbeddedResource() stopped at the first table entry with null data,
so with the params stubbed out the Lite lookup ended before xmrig:
mining would report itself available but never find the embedded
binary. It now walks to the end of the table and skips null payloads.
This also fixes a full-node build without asmap, whose lookup ended at
the null asmap entry and hid the daemon and xmrig entries.
- full and Lite builds share build/windows. When the full-node build
cannot find the params, the generated header is now deleted, so a Lite
header left behind by an earlier Lite build is not compiled into it
(the node would then never start, because the startup job would take
the embedded-extraction path and never copy the loose params).
Measured on release builds:
- ObsidianDragonLite.exe: 159,788,544 -> 106,120,192 bytes (-53.7 MB, of
which about 0.28 MB is the ImGui demo stubs);
- the Lite header has no param or asmap INCBINs and the resource object
holds only the xmrig payload; a sample of sapling-spend.params occurs
once in the exe instead of twice (the remaining copy is the
backend's);
- the full-node header is byte-identical to before.
Not verifiable here (Windows runtime): the Lite exe starting without
embedded params, and mining through the embedded xmrig. Lite installs
from older versions may still hold about 53 MB of extracted params and
asmap in %APPDATA%\ObsidianDragonLite\dragonx; nothing deletes them.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The ImGui demo window cannot be opened in the wallet (its show flag is
never set), but imgui_demo.cpp was compiled and linked in full.
It is now compiled with IMGUI_DISABLE_DEMO_WINDOWS, which reduces it to
empty stubs. ShowDemoWindow, ShowStyleEditor, ShowAboutWindow,
ShowUserGuide and ShowStyleSelector stay defined, and imgui.h declares
them unconditionally, so a per-file define is ODR-safe.
IMGUI_DISABLE_DEBUG_TOOLS is deliberately not set: it changes imgui.h
declarations and would drop ImGui's duplicate-ID highlight.
Measured:
- imgui_demo.cpp.o: 755,392 -> 1,704 bytes;
- stripped full-node Linux binary: 20,267,680 -> 19,983,136 bytes
(-284,544);
- full-node Windows release exe: 282,112 bytes smaller;
- the "Dear ImGui Demo" string is gone and the stubs are still defined.
With Makefile generators the per-file define lands in the target's
flags.make, so each existing build tree recompiles the whole app once
(153 files, about 71 s at -j8 here). No runtime or visual change.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
HushChatFixtureCheck is a dev-only CLI for the HushChat fixture files.
Nothing runs it (no ctest entry, not linked into the app or the tests),
yet every dev and test build compiled and linked it.
It is now EXCLUDE_FROM_ALL, with a usage note next to the target:
cmake --build build/linux --target HushChatFixtureCheck
A dry run of `all` no longer mentions it (4 lines before) while
ObsidianDragonTests stays in `all`, and building the target explicitly
still produces a working binary (`--help` exits 0). The fixture template
and the chat sources are unchanged.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Release builds ran a plain `cmake --build .`, which builds `all`: on top
of the app and the wallet-rebuild helper, every --*-release run also
compiled HushChatFixtureCheck, ObsidianDragonTests where BUILD_TESTING
is on (Linux, macOS) and, in Lite backend trees, lite_smoke and
lite_send_smoke. None of them ship.
A new `dragonx_release` custom target depends on ObsidianDragon plus
dragonx-wallet-rebuild, copy_langs and copy_themes where those targets
exist, and the three release paths in build.sh build only that target.
The dev build (./build.sh) still builds `all`.
BUILD_TESTING is deliberately left alone: release and dev builds share
build/<platform>, and writing BUILD_TESTING=OFF into that cache would
leave ctest running a stale test binary.
Measured:
- a dry run of dragonx_release schedules none of the test, smoke-tool or
fixture-CLI builds that `all` schedules, in a full-node, a
Lite+backend and a mingw tree; the wallet-rebuild helper is still
built;
- all four release builds (Linux and Windows, full node and Lite) log
the dragonx_release target and no ObsidianDragonTests build, and the
Linux zip and AppImage file lists are unchanged.
Consequences: a release run no longer refreshes the test binary or the
lite smoke tools in build/<platform>/bin, so run ./build.sh or
`cmake --build` before ctest, and build the Windows smoke tools with
`--target lite_smoke lite_send_smoke`. If a release step starts copying
another CMake-built file out of bin/, its target must be added to
dragonx_release.
Not verifiable here: macOS --mac-release (the .app should still contain
the app, dragonx-wallet-rebuild and Contents/Resources/res).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The release functions always built in build/<platform> and wrote to
release/<platform>. On Linux and macOS build/<platform> is also the dev
build tree, so a release run reconfigures it (--linux-release turns a
Lite+backend dev tree into a full-node one), and building a baseline to
compare a change against meant overwriting the existing release/
artifacts.
build_release_linux, build_release_win and build_release_mac now take
their build and output roots from DRAGONX_BUILD_ROOT and
DRAGONX_RELEASE_ROOT, defaulting to build/ and release/ as before:
DRAGONX_BUILD_ROOT=/tmp/rel-build DRAGONX_RELEASE_ROOT=/tmp/rel \
./build.sh --win-release
With both unset nothing changes. The dev build (./build.sh) and the lite
backend artifact dir (build/lite-backend/<platform>) do not read them.
A full release set (Linux and Windows, full node and Lite) built into
override roots left the release/ listings (file sizes and timestamps)
and the dev tree's CMakeCache.txt unchanged (same checksums before and
after).
Known gaps:
- the end-of-run summary still prints release/ as the output dir;
- configuring a Windows build still writes res/ObsidianDragon.manifest
into the source tree, so the last configured variant's version ends up
there;
- a fresh build root re-downloads the FetchContent dependencies and
appimagetool, so it needs network access;
- the macOS paths changed the same way but were not run here.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
After broadcasting, the send path looked up the spent notes' and
UTXOs' transactions with unwrap. A reorg while the transaction was
being proved could remove one of them, so the backend panicked and
poisoned the wallet lock although the transaction was already
broadcast. The lookups are now `if let` with a warning.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
A FIFO of blocking jobs, run one at a time on a background thread that a
post starts when none is running and that exits once the queue is empty.
Each job returns a completion, and completions run only in pump(), on
the owner's thread. Unlike RPCWorker and AsyncTaskManager nothing ever
joins: abandon() (also run by the destructor) drops queued jobs and
undelivered completions and refuses later posts, and a job stuck in a
blocking call finishes on its own thread. It is meant for backend calls
that can wait out a long scan and may have to be given up. post()
reports a dropped job (abandoned lane, or no thread could be started),
and waitIdle() is bounded.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The daemon's RSS was read on the UI thread: a scan of every /proc entry
on Linux (1-7 ms), a forked ps on macOS (up to ~30 ms). The mining poll
probed every 1-5 s on every tab, and the Mining tab's memory bar probed
again every 1.5 s.
util::BackgroundSampler now runs the probe on one lazily started thread
and caches the result. getDaemonMemoryUsageMB() returns the last sample
and asks for a new one at most every 3 s, and only while the Mining tab
memory bar or the Peers tab Memory cell is drawn: nothing on other tabs
or while minimized. EmbeddedDaemon::memoryProbe() hands the sampler a
pid snapshot (on Windows the value read from the owned process handle),
so nothing off-thread touches daemon state. The mining poll does no I/O
any more; it carries the last sample. Both shutdown paths stop the
sampler.
Fixed along the way: an unreadable /proc made the directory iterator
throw on the UI thread; the scan now just stops.
User-visible:
- the Peers tab Memory cell keeps updating during sync and shows an
external daemon's memory (it showed the mining poll's value, which
pauses during sync and covers only the embedded daemon);
- the Mining tab memory bar refreshes every ~3 s (was 1.5 s).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>