adf61e43d6bddca43bc20f58dd353f9f7f1a94fa
1084
Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
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> |