5fcab14e4e38d421b15597f0dc331d4552884985
1052
Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
2fce9a7860 |
fix(security): reconcile the encryption-pending flag on every connect
The wizard persists an "encryption requested" flag so a quit or crash before the wallet is actually encrypted is not silent. That flag was only reconciled on the slow-path getwalletinfo refresh, so it could be left stale, and the clear-and-warn logic lived inline in two places. - A single reconcileEncryptionPending now runs on every connect and on every encryption-state refresh, keyed on the parsed getwalletinfo reply. It clears the flag whenever the wallet is encrypted, however it got there (deferred request, Settings, another client), and warns once per session when encryption was requested but the wallet is not encrypted and nothing is pending or in flight. - A failed or warm-up (-28) RPC decides nothing, so a 15 s timeout that forces encrypted=false can no longer mis-clear or mis-warn. - The flag is also cleared at encryptwallet success and when the user removes encryption, so neither path leaves a stale flag that would warn on the next connect. No settings format change. Tests: the pure decision helper over the flag, RPC-ok, encrypted, deferred-pending, in-flight and already-warned combinations (clear when encrypted, warn once when lost, nothing on a failed RPC or no flag). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> |
||
|
|
0aaf33ecf0 |
fix(core): drive auto-lock idle from real input, not widget focus
The auto-lock idle clock was reset whenever any ImGui item was active, so a text field left focused (a half-filled send form, say) counted as continuous activity and kept the wallet unlocked indefinitely, well past the configured idle timeout. - The idle clock is now fed by real input events (key, text, mouse, touch, pen, gamepad button) as they are ingested on the main thread, plus a still-held mouse button so a drag or scrollbar grab that emits no new event still counts. A focused but untouched widget no longer does. - A new header-only IdleTracker owns the clock and never moves backwards, so an event stamped before the previous one cannot shorten the idle time. Behaviour change: a form left focused and idle past the timeout now auto-locks. The form's buffers are app state and survive the lock and unlock, so nothing typed is lost. Tests: a new IdleTracker case covers idle time growing with no events (enough to trip a 900s lock), activity resetting it, the clock never moving backwards, and a query before the last activity reading zero rather than a negative value. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> |
||
|
|
1ea584311e |
fix(lite): honor the backend sync result with retry and a failed state
A failed backend sync was silently swallowed: the reply was never inspected, so a sync that errored out left the wallet showing a "syncing" state that never completed and never reported a problem. - The sync and rescan replies are now parsed. On failure the partial progress is saved and the sync is retried with exponential back-off (5s doubling up to 60s, six attempts in all), then an explicit failed state is published: done, sync not complete, and the backend's error text carried through. - A failed rescan is retried as a plain sync, because a rescan has already cleared the wallet and re-running it would throw away the partial progress just saved. - A terminal error, such as a reorg deeper than the wallet can unwind, stops the retries immediately instead of waiting out the attempts. - Recovery is observed, not guessed: once a later refresh's server-bound reads all succeed, the failed state clears itself. Behaviour change: a sync failure now surfaces to the user as a failed state with a message, instead of a frozen "syncing", and the client retries on its own with back-off. Tests: a parser case for the success, failure, and terminal replies, and fake-backend controller cases for retrying after a failure, giving up after the attempt cap, a terminal reorg stopping at once, a failed rescan retrying as sync, cancel stopping the back-off, and the failed state self-healing on a good refresh. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> |
||
|
|
3f7af2d028 |
fix(lite-backend): fix t-scan rewind underflow and retry the owed scan
When a sync pass's transparent (t-address) scan failed, the backend mishandled it three ways: the retry computed its start height as the block height minus the batch size, an unsigned subtraction that wrapped to a huge value for low heights; the block range left un-scanned was not remembered, so a transient failure left a permanent hole in the transparent history that nothing ever revisited; and the block times needed to date the transactions in that range were discarded. - The owed range and the block times seen so far are now kept and folded into the next pass, which resumes from the lower of its own first block and any owed range. - The scan start never subtracts, so it can no longer underflow. - A t-address scan owed by an earlier failed pass is paid on the next sync even when no new block is pending. Behaviour change: a transparent scan that fails is retried in-process on the next sync rather than being lost. The owed range lives only in memory and is never written to the wallet file, so the file format is unchanged and remains interchangeable with other builds. The debt is lost if the process exits or the server is switched before a retry completes; Re-download blocks (rescan) recovers it, as today. Tests: library tests on the extracted t-scan seam cover a failed pass keeping its range and block times, a later success clearing the debt, and the scan start never exceeding the pass start across the batch-size boundary (the old formula wrapped there). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> |
||
|
|
5ae5e5213b |
fix(lite): sort lite transactions newest-first to match the UI panels
The lite backend lists transactions oldest-first, but every consumer of the transaction list assumes newest-first and takes a prefix of it, so the Recent Transactions / Recent Sends / Recent Received panels and the CSV export showed the oldest records instead of the latest. - The UI copy is now sorted pending (0-conf) first, then newest-first by timestamp, the same order as the History tab's default. - The sort is stable, so several records that share a txid (a send entry plus its per-note entries, or same-block transactions) keep a deterministic order across refreshes. That keeps the order-sensitive change check reporting "unchanged" and avoids bumping the transaction revision every couple of seconds. Tests: a new case covers the pending-first, newest-first ordering, a stable order within one txid, and the revision staying unchanged when the identical model is re-applied. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> |
||
|
|
e7f084e3eb |
fix(bootstrap): harden archive names, the cancel window and quit wait
The bootstrap preflight's protected names could be bypassed by names
that resolve onto them, and its cancel and quit paths had two timing
gaps.
- Entry names are judged as the file system resolves them: '\' is a
separator, "." components are dropped and each component loses its
trailing dots and spaces, as Win32 does ("./wallet.dat" and
"DRAGONX.conf." name protected files). The whole archive is refused
for an absolute or drive-rooted name, a ".." component, any ':'
(drive letter or NTFS alternate data stream, "DRAGONX.conf::$DATA"),
an 8.3 short name ("DRAGON~1.CON"), a component of only dots or
spaces (other than "."), and any byte >= 0x80 or ASCII control
character: macOS folds or ignores some Unicode (U+017F, U+212A,
U+200C) onto a protected name, and the published bootstrap archive
uses plain ASCII names. A refused name is echoed
with \xNN escapes. A member is written at the path it was checked
under, and the protected-name check runs on the resolved name.
- The user's Cancel (Settings dialog and first-run wizard) goes through
requestCancel(). It is refused once the old chain data starts being
replaced, and the start of that window re-checks a cancel under the
same lock, so a click lands before the old chain is touched or not at
all.
- Quit cancels the wizard's and the Settings bootstrap when the
shutdown starts; the HEAD and checksum requests now end at once on
cancel, and a cancelled HEAD keeps the partial download. A run
cancelled before the window leaves the old chain as it was. A run
inside it stops extracting and removes its partial chain while the
shutdown screen shows "Cleaning up...", so it ends with no chain data
(wallet.dat untouched; Retry reuses the zip, or the node resyncs).
That wait is bounded: at most 2 minutes from the start of the
shutdown, then 3 s in shutdown(), after which a worker still running
is left to the process exit. Only a cleanup that overruns that cap
can leave a partial chain, which dragonxd then answers with its
reindex offer.
Tests: refusal of the Windows-resolved, non-ASCII and control-character
names with the escaped echo; the protected-name list incl. the resolved
forms and a planted -wallet file, conf and database/ that stay
byte-identical; the user-cancel window; the bounded quit wait and
safeToAbandon(); a cancel ends a HEAD request that never answers.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
||
|
|
a5185737e6 |
refactor(app): retire logo textures through the deferred-destroy queue
ensureLogoTexture() still freed the chat emoji, header logo and coin logo textures it replaced at once, while every other theme-driven replacement already goes through retireTexture() and is freed at the next preFrame(). It runs at the top of render(), so nothing referenced them yet; this is hygiene, so that the same rule covers every replaced texture. - ensureLogoTexture(): the three direct DestroyTexture() calls become retireTexture(). Cost: the old textures live one extra frame on a theme or accent change. - setGradientTexture() (unused today) retires the texture it replaces instead of dropping it. - No drain at shutdown: the process exits and the live logo and gradient textures are not freed at exit either. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> |
||
|
|
a581f42a33 |
fix(settings): disable the unimplemented Tor option and tell its users
The "Use Tor" checkbox was saved but never read: no build routed any traffic (node, RPC, price/update/bootstrap/pool HTTP or Lite gRPC) through Tor, so users who ticked it believed they were anonymous. - The checkbox is disabled and always shown unticked, on the Settings page and in the legacy Settings window. Its tooltip says it is not available and gives the alternatives: for the node, proxy=127.0.0.1:9050 in DRAGONX.conf with Tor running; otherwise a system-wide VPN/Tor (Lite builds get the short version). The "Use Tor" label is kept. - Behaviour change: a stored use_tor=true is cleared once at startup (Settings::consumeLegacyUseTor + a synchronous save before the async writer and any Settings page state), with a 20 s warning in the user's language. The cleared key is the "already told" flag, so the notice never repeats. No settings format change. - 3 new strings (tt_tor_unavailable, tt_tor_unavailable_lite, tor_never_applied_notice) in all 8 languages; the tooltip wraps at tor-tooltip-wrap-width from ui.toml. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> |
||
|
|
ae66b54694 |
fix(miner): keep an updated miner, don't re-extract the bundled one
Builds that embed xmrig (Windows release) re-extracted the bundled miner whenever its size differed from the file on disk. An "Update miner..." install always differs, so the next start silently put the bundled build back while settings still showed the updated version. - The bundled miner is now placed only when it is missing, like the node binaries (kXmrigRefreshOnSizeMismatch = false in the provision table). Params and asmap still follow the bundled size. - Behaviour change: a wallet upgrade no longer refreshes a miner the user never updated; "Update miner..." is the way to get a newer build. - The uncalled needsXmrigExtraction() is removed. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> |
||
|
|
81733f5603 |
fix(rpc): run rescans and key imports without the 30 s timeout
A rescan or key import holds the node's cs_main for the whole wallet scan. On the shared clients it hit the 30 s curl timeout, reported a failure that wasn't one, abandoned a daemon RPC slot and flipped the app into "node busy". - One long wallet RPC at a time (rescan, importprivkey, importaddress, importwallet, z_importkey, z_importviewingkey, z_importwallet) runs on its own ungated client with no client timeout (rpc/long_call_lane.h). It can be aborted from any thread, and an abort is never lost, even one that lands before the client opens. - The GUI rescan, key import and sweep use it. The sweep's fund-moving body is unchanged; only the client it calls differs. runtimeRescan() returns bool, and a refused post-bootstrap reconcile stays armed. - The console routes rescan and the import commands through the lane; its input stays usable meanwhile, and a refusal is printed like any other result. RPCClient gains callRaw(.., timeoutSec). - Restart, wallet switch, delete-chain, bootstrap (now also from Settings) and the other node-stopping flows are refused while it runs, with a message saying a key import or rescan is still running; quit asks for confirmation. The lane is aborted at quit. - A second import or rescan is refused before the key is copied anywhere. An import cut off after the request reached the node (a lost reply, or a proxy's 502/504 while the node still imports) says the key may already be in the wallet and to run Settings -> Rescan. Only the node's own HTTP-layer refusals (401, 503, work queue full, unhandled request) say that the key was not imported. A pure classifyLongCallCutOff() makes that choice. - New strings in all 8 languages; console results stay English. Tests: the long-method list and the console's routing; one lane at a time; an abort lands whether it comes before the client opens or during the call; a call with no client timeout outlasts a 3 s scan, where a 1 s gated client times out and marks the node busy; classifyLongCallCutOff() for every failure kind with and without a sent request, and from raw HTTP replies (502, 504, a plain 500 -> cut off; 401, 503, queue full, unhandled -> refused). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> |
||
|
|
b3a57b07ad |
test(fake-daemon): add slow rescan and key-import methods
fake_dragonxd now answers rescan, importprivkey, z_importkey and z_importviewingkey, so the GUI's long wallet RPCs can be reproduced without a real node. - Each one holds cs_main for FAKE_RESCAN_SECS (default 0). The lock-free getrescaninfo reports the scan while it runs, and, like v1.3.0, the scan stops early when a shutdown is requested. - Argument checks, error codes and return values follow dragonxd (z_importkey returns null, importprivkey the t-address, the default "whenkeyisnew" skips a known key, z_importviewingkey needs its zaddr). Keys are only prefix-checked and never stored or logged; an imported spending key gets a made-up address that the address lists then show. - FAKE_ENCRYPTED=1 / FAKE_WALLET_PASSPHRASE: an encrypted wallet that starts locked, with walletpassphrase (-14 on a wrong passphrase), walletpassphrasechange, walletlock and getwalletinfo unlocked_until; the imports answer -13 while it is locked. As in dragonxd, the right passphrase with a non-integer timeout unlocks first and only then fails (-1): the wallet stays unlocked with no relock timer of its own. A timer pending from an earlier unlock keeps running; otherwise only walletlock locks it again. Without FAKE_ENCRYPTED, those three methods answer -15 like dragonxd. - FAKE_METHODS_ALL call events show keys and passphrases as <redacted>. A plain run is unchanged: the same 63 GUI calls before and after, and identical replies to every one of them. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> |
||
|
|
78e38570a8 |
fix(bootstrap): check archive and free space before deleting chain data
The bootstrap removed blocks/, chainstate/ and notarizations/ first and only then opened the archive. An unsafe entry, a full disk or a corrupt member therefore left no chain, or a half-written one for dragonxd to start on. - Preflight before anything is deleted: every archive entry must be safe (no absolute path, no "..") and readable, and the disk must hold the extracted size plus max(256 MiB, 5%), counting what removing the old chain frees (a symlinked chain dir counts 0). Free space counts as unknown only when the probe errors, the capacity is 0 or the available size is -1; a known disk with 0 bytes free is a refusal. The old chain is kept on every preflight failure. - Protected members are skipped, compared case-insensitively: any path component starting with "wallet", any "database" component, a .cookie or *.conf file, and top-level *.dat files other than fee_estimates, peers and banlist. The number of non-critical member failures is logged. - The removal of the old chain is verified (a locked file fails the run instead of mixing old and new chain files). If the extraction fails or is aborted, the partial chain is removed again before the failure is published; isDone() turns true only after the worker's last filesystem change. - Cancel is disabled while the old chain data is being replaced, and quit aborts a running bootstrap. - A complete zip from an earlier attempt is reused on Retry; its checksum is re-verified. A download that cannot fit fails early, and a disk-full write error says so. Tests: the space rule (boundaries, unknown vs. full disk, saturation); planArchive sizes, zip-slip, garbage and truncated zips; the protected-name list; reclaimableBytes with symlinks; each preflight refusal keeps the old chain and wallet.dat byte-identical; a corrupt critical member removes the partial chain before the failure is published. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> |
||
|
|
70d8b0f687 |
fix(daemon): supervise a daemon restarted after a crash
After an unexpected exit the monitor thread returned on its own but stayed joinable, and start() only spawned a monitor when none was joinable. A node restarted after a crash (no stop() in between) then ran unwatched: a second crash was never detected, its output was never drained, and the dead run's stdout pipe leaked into the next fork. - start() joins a monitor that returned on its own (only when joinable, at the top, after the "already running" early returns), closes the dead run's stdout pipe (Windows: process and pipe handles), and always starts a new monitor for the process it launched. crash_count_ is kept. - New lifecycle_mutex_ serializes start(), beginStop(), stop()'s flag writes and joins, and endStop(), so two threads can never join the same monitor. The monitor thread and the state callback never take it; a beginStop() racing a start() waits for it to return (its longest wait is the ~300 ms datadir-lock poll). - start() is refused (returns false, state unchanged) while a stop is in progress, i.e. between beginStop() and endStop(). - The daemon tests now start the stand-in only in isolation (temp -datadir override, port check skipped): they no longer write the real ~/.hush/DRAGONX/obsidiandragon-dragonxd.pid or tail the real debug.log. New tests: restart after a crash is supervised, a restarted run stops cleanly, no fd leak over crash+restart cycles (Linux), and start() is refused while stopping. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> |
||
|
|
f6e5038043 |
fix(security): don't penalise a correct PIN after a passphrase change
A passphrase change left the PIN vault sealing the old passphrase. The next PIN unlock opened the vault, walletpassphrase answered RPC -14, and the generic catch ran the lockout curve on a correct PIN, while Settings kept showing the PIN as active. - Passphrase change: when the active wallet's vault exists at completion (same vault path as at dispatch, passphrase actually changed), the vault is marked out of date for the session and kept on disk: PIN entry is no longer offered, a warning toast says so, and Settings shows "PIN out of date" with a tooltip on how to fix it. The Change Passphrase dialog notes beforehand that a PIN will go out of date. - PIN unlock: RPC -14 after the vault opened is treated as "PIN out of date": passphrase entry, a message, no attempt counted, no lockout, and the vault is not deleted (-14 is only the daemon's answer; the vault may hold the only copy of a passphrase that opens an older backup). After a restart Settings shows the PIN as active until the first PIN attempt. - A malformed vault (zero-filled by a removeVault() whose unlink failed, truncated, unknown version) is treated as missing: passphrase entry, "PIN file unreadable", no penalty. New SecureVault::isWellFormed(). - Race guards: Set/Change/Remove PIN are disabled while a passphrase change runs, and Change Passphrase while a PIN operation runs. - Setting or removing the PIN, or switching wallets, clears the mark; setting a PIN offers PIN entry again. Change PIN keeps the mark (the vault still seals the same passphrase). - Pure decisions in data/pin_vault_policy.h, unit-tested. New strings in all 8 languages. No vault, wallet or settings format change. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> |
||
|
|
62a5577491 |
fix(sweep): keep the UI sweep away from real wallet data
The full UI sweep (dev builds only) ran its demo surfaces against the
real profile. Its teardown deleted wallet-{cold,trading,mining,donations,
2023,payroll}.dat from the real datadir, it created zero-filled wallet
files and picker-demo/extra-wallets folders, its demo wallet-index upsert
re-minted the active wallet's scopeId (hiding "w:"-scoped contacts), the
Contacts tab could re-attach legacy-scoped contacts to such a throwaway
id in addressbook.json, and it persisted the demo active wallet file and
Mining pool worker.
- Preflight: a full sweep is refused (toast + log line) while a real
workflow is in flight: a pending seed_migration_* setting or open
migrate dialog, a wallet switch, an encryption/import workflow, an
encrypt/PIN/lock operation, the seed-backup dialog, or a bootstrap.
The DRAGONX_FULL_SWEEP hook then exits as for an empty catalog.
- Persistence latches: AddressBook (snapshot on the first sweepBegin,
save/load latched while a demo-contacts surface is shown), WalletIndex
and Settings (active wallet file, contacts view mode, pool URL/worker)
for the whole sweep. Settings::sweepEnd() is the last statement of
clearDemoWalletData and flushes once only if a save was suppressed; a
quit mid-sweep writes nothing to the profile (the 0700 sandbox, with
zero-filled demo files only, may stay in the temp dir).
- Scope ids: during a full sweep activeWalletScopeId() returns the
persisted id or "" and never mints one, so the Contacts tab cannot
move legacy-scoped contacts to a throwaway id. The only address-book
write a sweep can still cause is the one-time legacy re-attach to the
wallet's persisted id, which opening Contacts does anyway.
- Sandbox: demo wallet files and folders go to a fresh random 0700 dir
under the temp dir (exclusive create, a pre-existing path or symlink
is refused), removed at sweep end. The teardown that deleted those six
wallet files is gone.
- Cleanup: the migrate-to-seed state, wizard phase, demo chat
conversations and a fabricated demo chat identity are reset at sweep
end; the seed-backup surface always shows the demo phrase and a real
z_exportmnemonic result is dropped while the sweep runs.
- i18n: one new key (screenshot_sweep_refused) in i18n.cpp and the 8
language files.
Tests: the address book snapshots only on the first sweepBegin, keeps
addressbook.json byte-identical while latched and restores the real
book; the wallet index keeps wallets.json and the real scope id, also
with the background writer attached; Settings restores the latched
values and flushes once only when a save was suppressed; the sandbox
helper refuses a pre-existing path or symlink and removes only a
prefixed dir, never a symlink's target.
Release note: v2.0.x UI sweeps could delete wallet-{cold,trading,mining,
donations,2023,payroll}.dat from the data directory and re-scope
contacts; this cannot be undone automatically.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
||
|
|
3ac46abe1a |
fix(security): compile the UI sweep only into dev builds
The screenshot sweep and the full UI sweep are dev tools. The full sweep injects demo data into the live wallet state and the real data and config dirs, and the tab sweep screenshots real balances and addresses. Both were compiled into every build: the Settings buttons were reachable in full-node releases, and the DRAGONX_FULL_SWEEP env hook worked in every build, Lite releases included. A default-OFF option alone would fail open: dev and release builds share build/linux (and build/mac), so a release configured without --clean would inherit a cached ON from the last dev configure. - CMake: option(DRAGONX_ENABLE_UI_SWEEP ... OFF), passed to ObsidianDragon and ObsidianDragonTests as a compile definition. - build.sh passes it explicitly: ON in the dev build, OFF in every release configure (Linux, Windows, macOS osxcross and native). - Packaging guard: while the cache says ON, dragonx_release depends on dragonx_release_sweep_guard, which fails, so a hand-configured ON tree cannot package a release. - startSweepImpl is a no-op unless the option is ON (an undefined macro counts as 0, so it fails closed). The enabled branch logs the sentinel "[Sweep] DRAGONX_UI_SWEEP_COMPILED_IN", so a release can be checked with `strings <bin> | grep -c DRAGONX_UI_SWEEP_COMPILED_IN` (must be 0). - main.cpp: the DRAGONX_FULL_SWEEP hook is compiled out (autoSweep is false). It must be: with the sweep a no-op, the hook would quit the app once its warm-up frames ran out. - Settings > Node > Debug options: both sweep buttons, open-dir, the seed-demo-chat button and the current-theme checkbox are compiled only into dev builds. The daemon debug categories are unchanged. No i18n change: the existing strings stay in the tables. Verified with an isolated release (DRAGONX_BUILD_ROOT/DRAGONX_RELEASE_ROOT in a scratch dir) whose tree was first configured by hand with ON: build.sh --linux-release without --clean leaves the cache OFF, and the sentinel count is 0 in the built binary, the zip and the AppImage. With the cache set back to ON by hand, --target dragonx_release fails at the guard before compiling anything. Dev trees configured ON keep the sweep (sentinel present; a one-surface DRAGONX_FULL_SWEEP run against the fake daemon captured and exited cleanly). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> |
||
|
|
eac0993512 |
fix(lite): require a confirmed 8+ character passphrase to encrypt
The Lite "Encrypt wallet" field and the Create/Restore passphrase had no confirmation and no minimum length; only an empty passphrase was refused. Encrypting locks the wallet at once, and a locked wallet cannot reveal its seed, so a typo there could cost a user without a seed backup their funds. The full-node encrypt dialog already requires 8 characters and a confirmation. This applies to new passphrases only. Opening and unlocking are unchanged: an existing wallet with a shorter passphrase still opens and unlocks. - A pure liteEncryptPassphraseValid(pass, confirm): at least 8 bytes and equal to the confirmation. It takes string_views, so the page's secret buffers are never copied. - liteLifecyclePassphraseAcceptable(op, pass, confirm) gates the Settings create/open/restore request. Open is never checked, because its passphrase field also unlocks an existing wallet. Create and Restore are checked only when a passphrase was entered; an empty one still creates an unencrypted wallet. - Encrypt gets a Confirm field. The button stays disabled until the passphrase is valid, and a hint says why (too short, with the count, or not matching). - Create/Restore show a Confirm row once a passphrase is typed, with the same hint. Validate stays disabled while the passphrase is invalid, so a click never wipes a typed seed over a passphrase typo. The request builder re-checks the rule. - The confirmation buffers are wiped with their passphrase: after a run or an encrypt, and when the Confirm row is hidden. - Once the wallet is closed, the Settings page wipes the Encrypt, Confirm and Decrypt passphrase buffers whole every time, also when no secret was revealed; before, closing the wallet wiped them only while clearing a revealed backup or secret. They are zeroed in full because an emptied field can still hold the old tail past its first NUL. - New copy under Encrypt: "Anyone with the wallet file can try passphrases offline. Back up your seed phrase first and use a long passphrase." - i18n: three new keys (lite_confirm_label, tt_lite_confirm_pass, lite_encrypt_offline_warning) in i18n.cpp and the 8 language files, added additively. The hints reuse the wizard's wiz_pass_too_short and wiz_pass_mismatch. No new CJK glyphs, so the subset font is unchanged. Tests: the validator rejects empty, short (7) and mismatched or unconfirmed passphrases, and accepts 8 or more with a match. The request gate always accepts Open, even "abc" with no confirmation, accepts Create/Restore without a passphrase, and applies the rule to them otherwise. An encrypted, locked wallet still opens and unlocks through the controller with the 3-character passphrase "abc". Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> |
||
|
|
e52c9e1b92 |
fix(lite): classify imported keys before calling the backend
importKeyCore guessed the key type from its first character, then
retried a failed import with the other command. That retry is what sent
shielded keys to the backend's transparent-key decoder, which panicked
on them and poisoned the wallet lock. A WIF typo that was still
valid base58 also reached the backend unchecked.
- The key is classified first, and exactly one backend command runs, or
none:
- secret-extended-key-... and zxview... go to "import";
- a Base58Check WIF with version 0xBC or 0x80 and the compressed flag
goes to "timport";
- everything else is refused before any backend call.
There is never a retry with the other command. A testnet (0xEF) WIF
is not accepted, because the Lite app is mainnet-only.
- New fixed, untranslated reasons: "uncompressed transparent keys are
not supported by the Lite wallet", a full-node incoming viewing key
(zivks) is explained as such, and anything else is "unrecognized key
format". The key is never echoed.
- The backend's refusal while the wallet is encrypted now maps to "wallet
is encrypted - remove encryption to import a private key". It is
checked before "locked", because unlocking would not help.
- base58Decode reserves its buffers up front, and it and
decodeBase58Check wipe their copies of the decoded bytes with
sodium_memzero on every return path, since the input can be a private
key.
- exportSeed rejects the all-zero-entropy phrase (23 x "abandon" + "art").
A backend built before the seed-export fix returns it for a wallet
whose lock failed halfway (seed wiped, wallet still unlocked); it is
never a real backup and must not seed a chat identity.
- An open that the backend recovered from the .bak ("OK: recovered from
backup; damaged file kept as <name>") is written to the lite log.
Tests: the retry control now expects one "import" and no "timport", and
"typo-key" makes no backend call; the controller's transparent-import
test uses a real compressed WIF. A new classifier test covers both WIF
versions, the shielded and viewing keys, a single call on failure, the
uncompressed, zivks, testnet, bad-flag and bad-checksum refusals, the
encrypted reason and its lite-log line, the zero-entropy seed and the
recovery log line.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
||
|
|
b6cffdf942 |
fix(lite-backend): catch panics at the wallet-open C entry points
litelib_initialize_new, litelib_initialize_new_from_phrase, litelib_initialize_existing and litelib_check_server_online had no catch_unwind (only litelib_execute did), so any panic below them unwound across the C ABI into the GUI. With rustc 1.63 that is undefined behaviour; in practice the process aborts with "fatal runtime error: failed to initiate panic, error 5". The simplest trigger is a malformed user-entered server URL: get_server_or_default unwraps the http::Uri parse before anything else runs. - ffi_guard (next to error_cstring) runs a body under catch_unwind and turns a panic into "Error: Rust panic in <name>", which the C++ bridge already treats as a failed call. The three init entry points keep their extern signatures and #[no_mangle] and call ffi_guard; their old bodies are now private *_impl functions. - litelib_check_server_online wraps its body the same way and returns false on a panic. - litelib_initialize_existing opens the wallet through read_from_disk_reporting. After a recovery from the .bak it returns "OK: recovered from backup; damaged file kept as <name>" instead of "OK". The C++ side treats any non-empty, non-error string as a successful open, so this is ABI-compatible. No ABI or symbol change: the exported symbol inventory (lite-backend-symbols.txt) is identical. panic = "unwind" stays in the release profile, and every guard depends on it. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> |
||
|
|
31304a1a63 |
fix(lite-backend): durable wallet saves, non-destructive backup recovery
do_save flushed the temp wallet and renamed it over the wallet with no
fsync of the temp file, the .bak or the directory, so after a power cut
the rename could be on disk while the data was not, leaving a torn
wallet. The readers then panicked on malformed bytes instead of
returning an error (the gzip Decoder and WalletTx unwraps in
LightWallet::read, the version asserts in data.rs and walletzkey.rs,
from_utf8/diversifier unwraps, and vec![0; len] on a corrupt length
prefix, which can abort the process outright), so read_from_disk's .bak
fallback was never reached. And the fallback itself was destructive: it
copied the .bak over the wallet before reading it, on ANY error, so the
damaged file was lost, a transient I/O error replaced a good newer
wallet with an older .bak, and a wallet written by a newer app ("Don't
know how to read wallet version") was overwritten too, dropping imported
keys that are not in the seed.
- do_save: sync_all the temp file (removed on error), copy the .bak,
tighten and sync it through a write handle (FlushFileBuffers needs
write access on Windows), rename, sync the copy-fallback destination,
then fsync the directory on unix. No MoveFileEx write-through on
Windows.
- read_from_disk: each parse runs in read_wallet_file under
catch_unwind (panic = "unwind" is kept for release builds). The .bak
is used only when the wallet fails with InvalidData, UnexpectedEof or
InvalidInput, and it is read first, so a bad backup leaves both files
as they were. A damaged wallet whose header says encrypted is not
replaced by a plaintext .bak (only an older build leaves one): the
open fails with an explanation and nothing changes. Otherwise the
damaged wallet is moved (or, failing that, copied) to
<wallet>.dat.corrupt-<unix secs>, with a -N suffix while that name is
taken (a dangling symlink counts as taken), and is never deleted. The
recovered wallet is tightened as before.
- read_from_disk_reporting returns the same client plus "recovered from
backup; damaged file kept as <name>" after a recovery, for the FFI;
read_from_disk wraps it. Both stages are also written to stderr,
since the logger is not initialised yet when the wallet is opened.
- Readers return Err: a newer wallet version is ErrorKind::Unsupported
(was InvalidData), and so is a newer WalletTx or key version;
Decoder::new and WalletTx::read propagate with ?; a bad Utxo version,
a negative or non-UTF-8 or non-R Utxo address, non-UTF-8 metadata
addresses, an invalid diversifier and a viewing key with no default
address are InvalidData. Every length-prefixed string and address is
read through read_bytes_bounded (take(len).read_to_end, no cap, std's
own short-read error), so a corrupt length is never allocated up
front.
Flagged behaviour changes:
- fsync order: tmp, then .bak, then rename, then the dir (unix); saves
at exit, encrypt and decrypt still run on the UI thread and get
slower on slow disks;
- the readers return Err instead of panicking;
- the .bak fallback runs only for corrupt files, and the damaged file
is kept as .dat.corrupt-<ts> (every copy is kept; one from a
plaintext era keeps its seed, inside the 0700 dir);
- a wallet with a newer format errors (Unsupported) and is never
replaced;
- a damaged encrypted wallet whose backup predates encryption is not
opened.
Wallet format v9 is unchanged; valid files read and write byte for byte
as before. No ABI or symbol change.
Tests: a wallet whose parse panicked recovers from the .bak, the
garbage is kept as .dat.corrupt-<ts> and the report names it, and the
next open reports nothing; a newer-version wallet is Unsupported and
both files are unchanged (this fails when only the version error kind is
reverted); an unreadable (0000) wallet is a PermissionDenied error that
changes nothing either; a corrupt wallet with
a corrupt or no .bak changes nothing; a damaged encrypted wallet with a
pre-encryption plaintext .bak is refused with both files unchanged and
nothing moved aside; a recovery never overwrites an earlier
.dat.corrupt-<ts> or follows a dangling symlink; every truncation of a
wallet and of a WalletTx is an Err, crafted Utxo, metadata, note and key
records are the expected Err kinds, and a u64::MAX length returns at
once.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
||
|
|
39333f80b7 |
fix(lite-backend): keep wallet files and logs private to the owner
On Linux and macOS the Lite data dir (~/.silentdragonxlite) was created
with create_dir_all (0755 under the default umask) on every
get_zcash_data_path() call, even by the startup wallet_exists() probe.
do_save wrote the wallet through File::create (0644) and renamed it into
place, and fs::copy gave the .bak the same mode, so any local account
could read the wallet file, and the plaintext seed with it when the
wallet was not encrypted. The debug log was 0644 and its roller pattern
was relative, so rolled logs landed in the process's working directory.
A crash-leftover .dat.tmp was truncated and reused: an fd another
account had opened on it read the next wallet. And after encrypt(), the
first save copied the previous, plaintext wallet to .bak, where the seed
stayed until the next save, or for good if the app quit right away.
- New helpers, each an item-level #[cfg(unix)] / #[cfg(not(unix))]
pair: create_private_dir_all (0700 components), tighten (strips only
the group/other bits of an existing dir or file, keeps the owner bits,
never follows a symlink), create_private_file_new (unlinks a stale
file, then create_new with 0600), and a once-per-process warning when
a chmod fails (drvfs, FAT, NFS).
- get_zcash_data_path creates the default data dir 0700 and tightens an
existing one (for test/regtest also its parent). A caller-supplied
data_dir is created as before and never chmodded, and the params dir
is left alone.
- do_save writes a fresh 0600 temp file and tightens the .bak after the
copy. At the plaintext -> encrypted transition (the wallet is
encrypted and the on-disk header says plaintext) the .bak is copied
from the new, encrypted file. wallet_header_encrypted reads the flag
byte for versions 4 and 6-9 only (v5 is gzip) and returns None when it
cannot tell, which keeps the old behaviour.
- read_from_disk tightens a wallet restored from .bak, and the wallet
and .bak after every successful read.
- get_log_config pre-creates the log 0600 on unix and anchors the
roller in the data dir; a data path containing "{}" keeps the old
relative pattern. log4rs re-creates the log with the umask mode after
a 5 MB roll, so the 0700 data dir is the lasting control.
Flagged behaviour changes:
- on unix, the data dir is 0700 and existing files lose their
group/other bits (owner bits kept, symlinks and a caller-supplied
data_dir untouched); a shared group or a backup user under another uid
loses read access;
- a fresh 0600 temp file is used;
- the first encrypted save backs up the encrypted file;
- the log rolls inside the data dir (old rolled logs in former working
directories are not cleaned up).
On Windows the file modes are unchanged; the encrypted first .bak and
the log-roll location apply there too. Wallet format v9 is unchanged;
no ABI or symbol change.
Tests: the wallet and .bak are 0600 after every save and after an open
from 0644, and a wallet restored from a loose .bak is 0600, while the
caller's 0755 data dir is unchanged; a stale 0644 .dat.tmp is replaced
by a new inode and an fd held on it still reads the stale bytes; tighten
keeps owner bits (0555 -> 0500), ignores the other kind and symlinks,
create_private_dir_all makes every component 0700, and
create_private_file_new never reuses or follows a stale file or link;
after encrypt the first save's .bak is encrypted, byte-identical to the
wallet and unlocks to the seed; the header probe answers only for v4 and
v6-v9; the log is 0600, the roller pattern and a real roll stay in the
data dir, and a "{}" path falls back. An opt-in test
(DRAGONX_LITE_SCRATCH_HOME=1, temp HOME) checks the default data dir,
its testnet parent, a symlinked data dir and the params dir.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
||
|
|
a5db2e9a3d |
fix(lite-backend): never hang or panic a sync on malformed server data
The lightwalletd server controls the compact ciphertext length, the block, prev-block and tx hash lengths, and (through the chain) the full txs' epks. Each of these could wedge or poison a sync: - try_sapling_compact_note_decryption asserts a 52-byte ciphertext. A shorter (or full 580-byte) one panicked a pool job in scan_output_internal while the original sender stayed alive, so the collector waited forever with sync_lock, txs.write() and a wallet read guard held. - scan_taddress_txids kept its own sender alive too: one panicking t-address job (scan_full_tx) left crx.iter().take(n) blocked forever. - CompactBlock::hash()/prev_hash() assert 32 bytes when no header is sent, and every vtx hash is copied into a [u8; 32], one of them under txs.write(): a 31-byte tx hash poisoned the wallet for good, and a bad hash in the block's txid list panicked after the witnesses had moved. - scan_full_tx and scan_full_mempool_tx unwrapped as_prime_order() on the epk. Consensus rejects only a small-order epk, so a mixed-order one can be mined: an own tx then failed every sync, and the mempool receiver thread died silently. Changes: - append_leaf(node: Node, ..) commits a leaf to the existing, block and new witnesses and the tree; the epk-error path, the new short-ciphertext path and the normal path all use it (a behaviour-neutral extraction). - A compact ciphertext under 52 bytes is skipped with its cmu still committed; a longer one is truncated to 52 bytes, which is exact (the compact plaintext is those bytes and the cmu re-check validates it). - Each decryption job catches a panic and sends exactly one message; drop(tx) after the dispatch, and the collector logs a vanished or panicked job and treats the output as not ours. - scan_taddress_txids runs through the private seam scan_taddress_txids_with(job): catch_unwind per job, drop(ctx), and a count check, so a dead job is an error, never a hang or a silent partial success. - scan_block_with_pool checks the block, prev-block and vtx hash lengths right after the parse and returns Err(-1), as for an unparsable block, before any change (Err(height) would refetch the same block forever under sync_lock). - scan_full_tx and scan_full_mempool_tx skip an output whose epk is not in the prime-order subgroup (one check per output, used by every decryption and the outgoing recovery). - The mempool receiver thread catches a panic of scan_full_mempool_tx. Flagged behaviour changes: - a compact ciphertext under 52 bytes is skipped (its cmu is still committed); one over 52 bytes is truncated; - pool-job panics are reported (logged; the output is treated as not ours, which a rescan repairs) instead of hanging the sync; - bad hash lengths return Err(-1) before any change; - outputs with a non-prime-order epk are skipped; - the mempool thread survives a bad tx. Wallet format v9 is unchanged; no ABI or symbol change. Honest data takes the same path as before (an old-vs-new run over generated honest chains is byte-identical). Tests (each on its own thread under a 15 s watchdog): 0/51/52/53/580-byte ciphertexts in one block on the inline and deferred witness paths (the 51-byte notes are skipped, the 53- and 580-byte notes are found, the tree and every witness match the reference); a 31-byte tx hash, an empty block hash, a 31-byte prev hash and a 33-byte foreign tx hash each return Err(-1) with the txs lock usable and last_scanned_height, trees and witnesses unchanged; a mixed-order epk in scan_full_tx and scan_full_mempool_tx does not panic and decrypts nothing while the real epk still decrypts; a t-address job panicking for 1 of 3 addresses is an Err. The oracle generator gains short (0..=51-byte) and full (580-byte) ciphertext outputs without an extra random draw, so each seed keeps its sequence of blocks, values and keys. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> |
||
|
|
55431cd606 |
fix(lite-backend): make lock/unlock atomic, check passphrase to decrypt
lock() zeroed the seed first and then locked the keys one by one. A key
with no encrypted copy (an import into an encrypted wallet by an older
build) failed halfway: the seed was all zeros, the earlier keys were
wiped, and the wallet still reported unlocked. Seed export then showed
the all-zero-entropy phrase ("abandon ... art") as the backup.
unlock() had the mirror problem. When the passphrase was right but a
later key failed (a zaddress/fvk mismatch, a bad sealed key), the keys
decrypted so far stayed in memory while the wallet reported locked, and
write() saves a locked wallet as is, so the next save put them in
plaintext into the encrypted file. unlock also unwrapped the nonce and
the sealed key, so a malformed or foreign file panicked under the wallet
write lock and poisoned it.
remove_encryption() on an unlocked wallet never looked at the
passphrase: any string, even "", removed the encryption for good, and
the next save wrote the seed and every key in plaintext.
- WalletTKey/WalletZKey get can_lock(), which mirrors lock()'s
precondition. lock() checks every key first and changes nothing when
one cannot be locked; the keys are locked next and the seed is zeroed
last.
- unlock() runs the t-key loop, then the z-key loop; on any error it
re-locks every key that can be locked before it returns. This drops
only plaintext copies that can be re-derived or re-decrypted.
- The nonce and enc_key unwraps in LightWallet::unlock and in
WalletTKey/WalletZKey::unlock return "Wallet encryption data is
corrupt" (InvalidData) instead.
- get_seed_phrase() returns "" when the wallet is encrypted and its seed
is all zeros.
- remove_encryption() opens the sealed seed with the given passphrase
first, locked or unlocked, before it changes anything.
Flagged behaviour changes:
- lock() checks every key before it changes anything;
- a failed unlock re-wipes;
- corrupt encryption data returns an error, not a panic;
- seed export never returns the all-zero phrase;
- remove_encryption verifies the passphrase in both lock states (a wrong
passphrase now fails with "Decryption failed. Is your password
correct?").
Wallet format v9 is unchanged; no ABI or symbol change. The KDF is
unchanged; upgrading it is left for later.
Tests: lock is atomic for an unsealed t-key and an unsealed imported
z-key (seed, unlocked flag and keys unchanged, then a clean lock and
unlock); a failed unlock leaves no t-key or z-key secret in memory or in
the written file, and with the z-key's real derivation index restored
the same passphrase unlocks every key; a key with no sealed copy, a
missing nonce or a nonce of the wrong length is an error in WalletTKey,
WalletZKey and LightWallet, and remove_encryption refuses it too;
remove_encryption with a wrong or empty passphrase fails locked and
unlocked, and the right one round-trips an unencrypted v9 file; an
encrypted wallet with a zero seed exports "".
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
||
|
|
20b63bf45d |
fix(lite-backend): validate WIFs, refuse key import while encrypted
import_taddr decoded a WIF with bs58::decode(..).unwrap(), a [2..66] slice of its hex and SecretKey::from_slice(..).unwrap(), all under the wallet write guard that do_import_tk holds. A z-key, a typo or any short string panicked there and poisoned the wallet until restart, and the checksum was never verified, so a mistyped WIF that was still valid base58 imported a different key. A private key imported into an encrypted wallet was stored without an enc_key. The import then failed in do_save's lock(), which had already zeroed the seed while the wallet stayed "unlocked": seed export showed the all-zero-entropy phrase. - decode_taddr_wif replaces the old decode: Base58Check with the checksum verified, version 0xBC (the config prefix) or 0x80, and the 0x01 compressed flag. It decodes into a buffer it owns and wipes it with sodiumoxide::utils::memzero on every path, including a failed checksum. Errors are fixed strings that never contain the key. - import_taddr and add_imported_sk refuse while the wallet is encrypted, as add_taddr and add_zaddr already do. - do_import_tk and do_import_sk check is_encrypted() before the "locked" check, so a locked wallet does not send the user to unlock and retry. Flagged behaviour changes: - Base58Check, version 0xBC/0x80 and the compressed flag are required; - uncompressed and foreign-network WIFs are rejected with fixed messages (an uncompressed key used to import under the compressed address, where its funds are not); - no panic and no poisoned lock; - private-key import (t and z) is refused while the wallet is encrypted, with a reason that says to remove encryption, import, then encrypt again; viewing keys still import; - the key is never echoed. No ABI, symbol or wallet format change. Tests: garbage, z-keys, bad checksums, a transposed or flipped character, a zero scalar, a wrong flag, uncompressed, 0x05 and 0xEF WIFs are rejected with their message and leave tkeys usable; the wallet's own export, a 0x80 WIF and the published key-1 vector still import; the encrypted refusal in the wallet and the client, locked and unlocked, leaves lock, save and seed export intact; a viewing key still imports into an encrypted wallet; a bad timport leaves the wallet lock unpoisoned. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> |
||
|
|
026d0e92a6 |
fix(lite-backend): serialize clear and rescan with a running sync
clear_state_from wiped the wallet's blocks, txs and mempool maps without taking sync_lock. The console `clear` and `rescan` commands run concurrently with the refresh worker's sync (litelib_execute drops the client mutex before a command runs), so a clear could land after a sync had already taken its nullifier snapshot. scan_block then unwrapped a note that was no longer in the wallet and panicked under the txs write guard, which poisons the wallet until restart. - clear_state_from now takes sync_lock first (lock order sync_lock -> wallet, as in do_save and do_sync). No caller holds sync_lock, so it cannot self-deadlock; do_rescan holds no lock when it clears. - clear_state reads the birthday into a local before calling clear_state_from. Before, the wallet read guard was a temporary that lived across the call, which read-locks the wallet again: with a writer (a save, an import, a new address) queued in between, the writer-preferring RwLock on Linux and Windows could deadlock. Holding it while waiting for sync_lock would also have inverted the lock order against do_save. - scan_block resolves each spent note with and_then instead of three unwraps. A spend of a note that is no longer in the wallet logs a warning and is skipped. Flagged behaviour changes: - console clear/rescan and the GUI rescan wait for an in-flight sync or save to finish instead of racing it; - the recursive wallet read lock that could deadlock a concurrent save is gone; - a spend of a vanished note now warns and is skipped instead of panicking; it is counted after the next rescan. No ABI, symbol or wallet format change. Tests: clear_state_from waits for sync_lock, clear_state holds no wallet guard while it waits, neither leaks the lock, and a 200x clear/write loop finishes within a 20 s watchdog (a regression guard only). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> |
||
|
|
0a72b47ca6 |
fix(lite-backend): verify TLS for the t-address scan and mempool monitor
The transparent-address txid scan in the sync loop and the mempool monitor both hard-coded no_cert = true, so they connected to the lightwalletd through get_client's NoCertificateVerification path in every Lite build, while the app always passes dangerous = false. Block sync, info, broadcast and the full-tx fetcher already honour LightClientConfig::no_cert_verification. An active man in the middle on those two channels could hide or invent transparent balance, UTXOs and pending transactions (signing stays local, so no funds can be taken). Both calls now take the setting from the config: - the sync loop passes self.config.no_cert_verification to scan_taddress_txids; - start_mempool_monitor reads config.no_cert_verification once and passes it to monitor_mempool. Flagged behaviour change: a lightwalletd with an invalid or self-signed certificate now fails the t-address stage (do_sync retries, then returns the error) and the mempool monitor (it logs and retries), as block sync already does. `dangerous` is the only bypass. No ABI, symbol or wallet format change. A tripwire test checks that lightclient.rs never again hard-codes the bypass for either call; its needles are joined at runtime so the test cannot match its own source. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> |
||
|
|
1848025a95 |
build(lite-backend): fat LTO and one codegen unit for release builds
The qtlib wrapper's Cargo.toml, the workspace root of the backend build, had no release profile, so libsilentdragonxlite.a was built with cargo's defaults: no LTO and 16 codegen units, with the objects of every dependency copied into the archive. The release profile now sets opt-level 3, fat LTO and one codegen unit. panic stays "unwind", written out with a comment: litelib_execute turns a panic into an error through catch_unwind, and the full-tx fetcher and the threadpool turn scan-job panics into errors by unwinding. There is no strip setting: rustc does not link a staticlib, and build.sh strips the final executable. Measured, building the same source both ways: - archive: Linux 126.7 -> 71.5 MB with 1580 -> 1 Rust object files; Windows 105.9 -> 65.8 MB with 1560 -> 1; - stripped ObsidianDragonLite: Linux 84,283,856 -> 75,366,184 bytes (-8.9 MB, -10.6%); Windows exe 87,436,288 -> 79,379,968 bytes (-8.1 MB, -9.2%). On Linux .text went from 20.0 to 14.8 MB and .eh_frame from 2.67 to 1.19 MB; - exported symbols unchanged (blake3_PW and the 8 litelib_* functions); - a forced panic in the send command's JSON parsing still returns "Error: Rust panic in litelib_execute", and the next call works; - lite_smoke --restore-recent, --keys and --encrypt pass with identical output apart from chain tip and birthday; restore CPU time is unchanged within noise (115.8-117.2 s before, 111.7-118.8 s after) and peak RSS dropped from 38.5 to 31.6 MB; - backend build time: Linux 70 -> 118 s, Windows 54 -> 102 s. This has no effect on releases until the backend artifacts are rebuilt: build.sh links the prebuilt build/lite-backend/<platform> archives, which are still the old non-LTO ones (the Windows one also predates the recent backend commits). Rebuild them with scripts/build-lite-backend-artifact.sh from a clean, committed tree. Not verifiable here: on Windows only the link was checked, with no panic probe, smoke run, sync or send (inlining can grow stack frames, and mingw threads get about 2 MB of stack); the macOS artifact was not built (CARGO_PROFILE_RELEASE_LTO=thin is the fallback if fat LTO is a problem there); and a send, with its proving, needs a funded wallet. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> |
||
|
|
1bf803f107 |
fix(app): free the replaced gradient texture and defer logo frees
reloadThemeImages() replaced gradient_tex_ in each of its three branches (image loaded, load failed, no background) without freeing the old texture, so every skin activation, including the one at startup, leaked one texture. The logo and coin textures were freed immediately instead, although the current frame's draw lists can still reference them: the window backdrop records its images at the start of the frame and skin switches happen mid-frame (Settings, Ctrl+arrow keys), and the screenshot sweep switches skins after RenderDrawData() but before the secondary viewports are rendered. That hands the renderer a freed texture. Textures replaced mid-frame are now retired to a queue (retireTexture() also zeroes the member) that the next preFrame() frees first, after the previous frame's draw data has been rendered. The old gradient is retired at the top of reloadThemeImages(), and the logo and coin where they used to be freed. ensureLogoTexture() still frees directly, since it runs at the top of render(), before this frame draws a logo. Shutdown does not drain the queue. Measured with a temporary live-texture counter over a screenshot sweep of all 10 skins: before, 6, 7, ... 15 live textures (one more per skin); now a constant 5. The first screenshot after each skin switch is pixel-identical to before on all 10 skins, and the full sweep matches within noise. Not verifiable here: DX11 at runtime. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> |
||
|
|
261883ca21 |
perf(acrylic): release blur render targets while the effect is skipped
Turning blur off (blur 0), switching to low-spec mode or enabling reduced transparency after the acrylic targets existed left the capture FBO and both blur ping-pong targets allocated for the rest of the session, although nothing draws with them while the effect is skipped. They are now released at a frame boundary and rebuilt on demand: - ImGuiAcrylic::ReleaseIdleBuffers() runs right after app.preFrame(), before NewFrame(), when the previous frame's draw data has been rendered, and once in the resize-watcher frame (GL and DX11). While the effect is skipped it frees the targets; when the effect is wanted again it reallocates them once, with no per-frame retry if that fails; - resize() while skipped only records that a reallocation is needed; - the release checks the GL names (and each DX11 texture) directly, so a half-allocated ping-pong pair is freed too; DX11 also resets the ping-pong index; - the capture paths and applyBlur() return early without a capture buffer, which would otherwise blit the default framebuffer onto itself; - each release logs "released targets (effect skipped)". With blur on, the path and the output are unchanged (screenshot sweeps within noise). After re-enabling, the first frame draws the tinted fallback until a fresh capture exists. Measured with software GL, toggling every 30 frames for 1200 frames: the targets were released exactly one frame after each switch-off (22 of 22) and rebuilt on each switch-on (21 of 21), with no GL or framebuffer errors. While off at 1280x720, with a fixed malloc mmap threshold, the process holds 4.62 MB less (the 1280x720 RGBA8 capture plus two 320x180 RGBA16F blur targets) and RssAnon is about 3.1-3.5 MB lower. With glibc's default threshold the freed memory stays in the heap and RssAnon does not visibly drop. Known issue: every off/on pair now frees and reallocates those targets. Under software GL (llvmpipe) with the default glibc allocator the freed texture memory is not reused: toggling every 2 frames grew RssAnon from 49 to 906 MB over 240 pairs, every 8 frames from 56 to 179 MB, and every 30 frames it levels off near 120 MB. It is not a GL object leak (with a fixed mmap threshold the mapped bytes stay flat), and on a hardware driver (WSLg d3d12) RssAnon stayed between 67.5 and 74.9 MB under the 2-frame churn (94.3 MB without the release). Users reach fast toggling by holding Ctrl+Shift+Down (key repeat, about 20 Hz) or by dragging the blur slider back and forth across 0. A short delay before releasing would avoid it if software-rendered users matter. Not verifiable here: the DX11 release and reallocation at runtime, and macOS. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> |
||
|
|
f970cae193 |
perf(gl): drop the unused depth/stencil buffers from window and FBOs
The GL window asked for a 24-bit depth and 8-bit stencil buffer, and every acrylic Framebuffer attached a DEPTH24_STENCIL8 renderbuffer the size of its color texture. Nothing uses them: no code enables depth or stencil tests (the ImGui GL3 backend disables both), every blit and readback is color-only, and only Framebuffer::clear() also cleared the depth buffer, which nothing read. The window and every FBO, including the full-size capture FBO, carried that memory, and the FBO renderbuffers were reallocated on every resize. Now: - the window requests depth 0 and stencil 0 explicitly (SDL3 requests a depth buffer by default when the attributes are simply removed); alpha 8 stays for the transparent window; - Framebuffer has no renderbuffer, and clear() clears color only; - a permanent startup log line, "GL default framebuffer: depth=N stencil=N", reports what the driver provided; "(no attachment)" marks a failed query, which is how some drivers report a buffer that does not exist. The log shows depth=0 stencil=0 both offscreen and on WSLg X11, where "Window transparency enabled by compositor" is still logged, with no framebuffer-incomplete errors. Screenshot sweeps at 1280x720 and 2560x1440, and a glass run with ui_opacity 0.6 and blur on (so panels go through the FBO blur), match the previous build within noise. The driver-side memory saving itself was not measured. DX11 is unaffected: its swap chain has no depth buffer. Not verifiable here: macOS (an NSOpenGL 3.2 core context with a transparent window). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> |
||
|
|
73f7f33a4b |
perf(skins): skip redundant skin parsing, base reloads and theme writes
Skin handling did more work than it needed: - the bundled-skin scan fully parsed each of the 9 bundled skin files to read name, author, dark and images from their first 327-345 bytes. The build copies the rest of ui.toml into every skin, so each file is 51-54 KB; - setActiveSkin() reloaded ui.toml right after main() had loaded it, a second full parse with nothing merged in between; - in the Windows single-file exe, every getBundledSkinsDirectory() call (skin refresh, the dragonx entry, each image-dir lookup) re-extracted all embedded themes with a blind overwrite: more than once per launch, and again on skin changes. Now: - bundled skins are parsed only up to the first table header after [theme] other than [theme.images]. Anything unusual (no [theme], images defined later, a parse error in the slice) falls back to the full parse, and user skins are still parsed in full; - UISchema tracks whether its state is exactly the base (the last change was a base load and no hot-reload is pending), and setActiveSkin() skips reloadBase() in that case; - the bundled-skins directory is resolved once per process (re-checked with exists()), and theme extraction rewrites a file only when its bytes differ, through the new resources::writeFileIfDifferent(). It compares bytes, not sizes, so a same-length palette edit still updates. Measured on the Lite build (median of 5 runs): - bundled-skin scan: 15 -> 1 ms; - from "Initializing ObsidianDragon" to the activated skin: 29 -> 12 ms; - ui.toml is loaded once per launch instead of twice, and the light skin merges its overlay exactly once. A Python mirror of the header-slice parse agrees with a full parse on name, author, dark and images for all 18 source and build-expanded skin files, and full screenshot sweeps over all 10 skins match the previous build within noise. New unit tests cover writeFileIfDifferent(): missing file, identical bytes (mtime untouched), same-length change, and a longer file truncated. Behaviour changes: - a bundled skin with a TOML error after its header is now listed instead of skipped; the error surfaces only when it is selected; - deleting a single file inside the extracted bundled-themes dir mid-session is repaired only at the next launch; - the "Extracted N embedded bundled themes" count includes files that were already current. Not verifiable here: Linux has no embedded themes, so the new writeFileIfDifferent() ran only in the unit tests and the extraction path was only compiled, in the Windows cross-build. That a second Windows launch leaves the theme files' mtimes untouched is still to be checked on Windows. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> |
||
|
|
b50d5ac31f |
perf(startup): build the font atlas once at the final ui.toml sizes
main() built the font atlas before App::init() loaded ui.toml, so the first build used the hard-coded fallback sizes (H1 24 px, Body2 12 px). App::init() then rebuilt for a saved font scale above 1.0, still before the schema load and so still at the fallback sizes. Only the color-emoji rebuild requested for the first frame reached the ui.toml sizes, so color-emoji users built the atlas twice at font scale 1.0 and three times at 1.5. Users with chat_emoji_color=false never got that last rebuild. They built the atlas once or twice and ran the whole session at the fallback sizes, e.g. Body2 at 12 px instead of 15 and H1 at 24 px instead of 20. Now: - App::loadBaseUiSchema(), the ui.toml load moved out of App::init() unchanged, runs in main() before both InitImGui paths (DX11 and GL), together with the saved font scale and emoji style from the early settings read. It is idempotent, so the call left in App::init() is a no-op; - the two rebuild triggers in App::init() are gone; - Typography records what the atlas was built from (the 15 schema sizes, the schema's font scale, the effective emoji style and the schema generation), and preFrame() rebuilds once when they differ. While the schema generation and the emoji style are unchanged this costs two compares per frame. Measured on the Lite build (color and mono emoji, font scale 1.0 and 1.5, English and Japanese): every run builds the atlas exactly once, at the ui.toml sizes times the scale (H1 20 px and Body2 15 px at 1.0, 30 px and 22 px at 1.5), and ui.toml is loaded before the window and GL context are created. The packaged Lite zip and AppImage log one atlas build instead of two. Color-emoji screenshot sweeps match the previous build within noise. Visible changes: - users with chat_emoji_color=false had been rendering the whole UI at the fallback font sizes; they now get the designed ui.toml sizes, so every mono-emoji screen differs from before and matches the color-emoji layout; - a user skin whose [fonts] differ from ui.toml now rebuilds the atlas once when it is activated or left; on a runtime switch the atlas used to keep the old sizes until a restart. With a test skin that sets Body2 to 18 px, startup builds twice and ends at 18 px; switching to a bundled skin rebuilds once, switching among the 9 bundled skins rebuilds nothing (they carry an identical copy of [fonts]), and switching back rebuilds once; - editing ui.toml while running still gives exactly one rebuild. Not verifiable here: the Windows DX11 path with the schema loaded before its first FreeType build, macOS, and the interactive font-scale slider, Alt+scroll and emoji toggle, which were checked by reading the code. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> |
||
|
|
e0f9d36780 |
perf(typography): use font blobs in place and share identical faces
Typography copied every font blob into a fresh heap buffer for each face it added (IM_ALLOC + memcpy, FontDataOwnedByAtlas = true): the Ubuntu weight, the CJK subset merge, the emoji merge, and the icon and pickaxe fonts. The blobs are INCBIN symbols in read-only memory that live for the whole process, so the copies (16 faces, each with its own CJK copy and, for 8 of them, an emoji copy) only cost memory. Styles with the same weight and size also built separate, identical ImFonts. Now: - every font is added straight from the embedded data with FontDataOwnedByAtlas = false. ImGui 1.92 frees only data it owns, so the blobs survive atlas clears and DPI or skin reloads; - load() walks a style table and shares the ImFont between styles with the same (weight, scaled size, emoji) key. Emoji is part of the key, so no style gains or loses the emoji fallback. With the current ui.toml sizes H3/H4, H6/ButtonLg and Body1/Overline share a face: 13 ImFonts instead of 16 (12 text faces plus Mono), CJK merged 13 times instead of 16 and emoji 7 times instead of 8. Measured on the Lite build, RssAnon 15 s after start, median of 3 runs: - color emoji: 108.6 -> 78.8 MiB (-29.8 MiB); - monochrome emoji: 102.8 -> 78.6 MiB (-24.2 MiB). RssFile is also 2.4-5.1 MiB lower, because the blobs are no longer read end to end for copying. Full screenshot sweeps (610 screens per run; color and mono emoji at 1280x720 in English and Japanese, color at 2560x1440) match the previous build within run-to-run noise. The only content differences are live values such as the mining tab's RAM readout (233 MB before, 201 MB after). Also fixed: when AddFontFromMemoryTTF failed, ImGui had already freed the owned copy and the caller freed it again, a latent double free; those IM_FREE calls are gone. The emoji-merge failure log now prints the blob size instead of passing the float font size to %u. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> |
||
|
|
1f162ec830 |
build(i18n): embed language JSON only on Windows, add lookup fallbacks
CMake turned every res/lang/*.json into an xxd header that i18n.cpp compiled in as a fallback, on every platform. Only the Windows single-file exe needs it: the Linux zip and AppImage and the macOS .app always ship res/lang, and loadLanguage() reads the loose file first. On Linux the embedded copy added about 1.5 MB to the binary, and every translation edit recompiled i18n.cpp. A new option, DRAGONX_EMBED_LANG, defaults to ON for WIN32 and OFF elsewhere: - ON without xxd is now a configure error (it used to warn, then fail to compile i18n.cpp on the missing headers); - i18n.cpp compiles the embedded includes and the fallback only when ON; - outside Windows, loadLanguage() also tries <exe>/../share/<app>/res/lang (the cmake --install layout and the full-node AppImage) and <exe>/../Resources/res/lang (the macOS .app binary run directly), so those layouts find the loose files whatever the working directory; before, only the embedded copy covered that. Measured: - full-node Linux binary: .data 1,699,696 -> 168,592 bytes; stripped 19,983,136 -> 18,456,128 bytes (-1.53 MB); - with the ImGui demo stubs, the Linux zips and AppImages are 0.50-0.55 MB smaller, with identical file lists including all 8 language files; - a Lite build started from an unrelated working directory loads the ja.json next to the binary (2422 strings), and the packaged Lite zip and AppImage load ja.json with no new not-found or failed lines; - Windows keeps the embedded copy: the 8 language headers are generated and the ja.json bytes are in the exe. A new test loads "de" from the loose files and checks a translated string; with bin/res/lang moved away exactly its two assertions fail. Behaviour changes on Linux and macOS: a missing or corrupt res/lang now falls back to English instead of the embedded copy, and a translation edit needs only the copy_langs step instead of a recompile. Not verifiable here: the macOS .app in a non-English language, and the Windows single-file exe at runtime. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> |
||
|
|
04ad1462d5 |
build(win-release): drop the unused loose xmrig.exe from Windows zips
Both Windows zips shipped a loose xmrig.exe next to the wallet exe. The exe already embeds xmrig (HAS_EMBEDDED_XMRIG), and getXmrigPath() uses, and re-extracts when needed, the copy under %APPDATA%\<app>\dragonx; it looks next to the exe only when that extraction fails. build_release_win now copies it only when the generated header does not define HAS_EMBEDDED_XMRIG, or there is no header. Measured: each Windows zip loses exactly one entry, xmrig.exe; with the ImGui demo stubs, the full-node zip went from 167,515,430 to 164,554,177 bytes. The loose dragonxd, dragonx-cli, dragonx-tx, wallet-rebuild helper, params and asmap stay in the full-node zip. The daemon copy under %APPDATA% is written once and never refreshed, so without the loose copy existing zip users would silently run their first-install daemon. Dropping them needs a migration first. Not verifiable here: mining from an unzipped Windows build without the loose xmrig.exe. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> |
||
|
|
abb63d9441 |
build(win-release): stop embedding Sapling params/asmap in the Lite exe
The Windows release embeds its resources into the exe through a generated INCBIN header. The Lite build got the same param and asmap entries as the full node, so ObsidianDragonLite.exe carried sapling-spend.params, sapling-output.params and asmap.dat (about 53 MB) that it never reads: the SDXL backend embeds its own copy of the params, and Lite already skips extracting full-node-only resources. build_release_win now writes the param and asmap INCBINs only for the full node (should_bundle_full_node_assets). Lite gets null/zero stubs for those three symbols and still embeds images, themes and xmrig. Two fixes make that safe: - getEmbeddedResource() stopped at the first table entry with null data, so with the params stubbed out the Lite lookup ended before xmrig: mining would report itself available but never find the embedded binary. It now walks to the end of the table and skips null payloads. This also fixes a full-node build without asmap, whose lookup ended at the null asmap entry and hid the daemon and xmrig entries. - full and Lite builds share build/windows. When the full-node build cannot find the params, the generated header is now deleted, so a Lite header left behind by an earlier Lite build is not compiled into it (the node would then never start, because the startup job would take the embedded-extraction path and never copy the loose params). Measured on release builds: - ObsidianDragonLite.exe: 159,788,544 -> 106,120,192 bytes (-53.7 MB, of which about 0.28 MB is the ImGui demo stubs); - the Lite header has no param or asmap INCBINs and the resource object holds only the xmrig payload; a sample of sapling-spend.params occurs once in the exe instead of twice (the remaining copy is the backend's); - the full-node header is byte-identical to before. Not verifiable here (Windows runtime): the Lite exe starting without embedded params, and mining through the embedded xmrig. Lite installs from older versions may still hold about 53 MB of extracted params and asmap in %APPDATA%\ObsidianDragonLite\dragonx; nothing deletes them. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> |
||
|
|
70eacd90ad |
build(imgui): compile imgui_demo.cpp to its empty stubs
The ImGui demo window cannot be opened in the wallet (its show flag is never set), but imgui_demo.cpp was compiled and linked in full. It is now compiled with IMGUI_DISABLE_DEMO_WINDOWS, which reduces it to empty stubs. ShowDemoWindow, ShowStyleEditor, ShowAboutWindow, ShowUserGuide and ShowStyleSelector stay defined, and imgui.h declares them unconditionally, so a per-file define is ODR-safe. IMGUI_DISABLE_DEBUG_TOOLS is deliberately not set: it changes imgui.h declarations and would drop ImGui's duplicate-ID highlight. Measured: - imgui_demo.cpp.o: 755,392 -> 1,704 bytes; - stripped full-node Linux binary: 20,267,680 -> 19,983,136 bytes (-284,544); - full-node Windows release exe: 282,112 bytes smaller; - the "Dear ImGui Demo" string is gone and the stubs are still defined. With Makefile generators the per-file define lands in the target's flags.make, so each existing build tree recompiles the whole app once (153 files, about 71 s at -j8 here). No runtime or visual change. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> |
||
|
|
8b094595b1 |
build(cmake): exclude HushChatFixtureCheck from the default build
HushChatFixtureCheck is a dev-only CLI for the HushChat fixture files.
Nothing runs it (no ctest entry, not linked into the app or the tests),
yet every dev and test build compiled and linked it.
It is now EXCLUDE_FROM_ALL, with a usage note next to the target:
cmake --build build/linux --target HushChatFixtureCheck
A dry run of `all` no longer mentions it (4 lines before) while
ObsidianDragonTests stays in `all`, and building the target explicitly
still produces a working binary (`--help` exits 0). The fixture template
and the chat sources are unchanged.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
||
|
|
e65be1d24b |
build(cmake): add a dragonx_release target that builds only what ships
Release builds ran a plain `cmake --build .`, which builds `all`: on top of the app and the wallet-rebuild helper, every --*-release run also compiled HushChatFixtureCheck, ObsidianDragonTests where BUILD_TESTING is on (Linux, macOS) and, in Lite backend trees, lite_smoke and lite_send_smoke. None of them ship. A new `dragonx_release` custom target depends on ObsidianDragon plus dragonx-wallet-rebuild, copy_langs and copy_themes where those targets exist, and the three release paths in build.sh build only that target. The dev build (./build.sh) still builds `all`. BUILD_TESTING is deliberately left alone: release and dev builds share build/<platform>, and writing BUILD_TESTING=OFF into that cache would leave ctest running a stale test binary. Measured: - a dry run of dragonx_release schedules none of the test, smoke-tool or fixture-CLI builds that `all` schedules, in a full-node, a Lite+backend and a mingw tree; the wallet-rebuild helper is still built; - all four release builds (Linux and Windows, full node and Lite) log the dragonx_release target and no ObsidianDragonTests build, and the Linux zip and AppImage file lists are unchanged. Consequences: a release run no longer refreshes the test binary or the lite smoke tools in build/<platform>/bin, so run ./build.sh or `cmake --build` before ctest, and build the Windows smoke tools with `--target lite_smoke lite_send_smoke`. If a release step starts copying another CMake-built file out of bin/, its target must be added to dragonx_release. Not verifiable here: macOS --mac-release (the .app should still contain the app, dragonx-wallet-rebuild and Contents/Resources/res). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> |
||
|
|
25ceb2cfe1 |
build(build.sh): add DRAGONX_BUILD_ROOT/DRAGONX_RELEASE_ROOT overrides
The release functions always built in build/<platform> and wrote to
release/<platform>. On Linux and macOS build/<platform> is also the dev
build tree, so a release run reconfigures it (--linux-release turns a
Lite+backend dev tree into a full-node one), and building a baseline to
compare a change against meant overwriting the existing release/
artifacts.
build_release_linux, build_release_win and build_release_mac now take
their build and output roots from DRAGONX_BUILD_ROOT and
DRAGONX_RELEASE_ROOT, defaulting to build/ and release/ as before:
DRAGONX_BUILD_ROOT=/tmp/rel-build DRAGONX_RELEASE_ROOT=/tmp/rel \
./build.sh --win-release
With both unset nothing changes. The dev build (./build.sh) and the lite
backend artifact dir (build/lite-backend/<platform>) do not read them.
A full release set (Linux and Windows, full node and Lite) built into
override roots left the release/ listings (file sizes and timestamps)
and the dev tree's CMakeCache.txt unchanged (same checksums before and
after).
Known gaps:
- the end-of-run summary still prints release/ as the output dir;
- configuring a Windows build still writes res/ObsidianDragon.manifest
into the source tree, so the last configured variant's version ends up
there;
- a fresh build root re-downloads the FetchContent dependencies and
appimagetool, so it needs network access;
- the macOS paths changed the same way but were not run here.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
||
|
|
22e3f104d6 |
fix(lite-backend): don't panic when a reorg lands during a send
After broadcasting, the send path looked up the spent notes' and UTXOs' transactions with unwrap. A reorg while the transaction was being proved could remove one of them, so the backend panicked and poisoned the wallet lock although the transaction was already broadcast. The lookups are now `if let` with a warning. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> |
||
|
|
7c0afd06a8 |
fix(lite-backend): record the mined height of mempool-first transactions
When the mempool monitor saw a transaction first, its wallet entry kept the monitor's height (often 0) after the transaction was mined, because scanning the block reused the entry without updating its block. A later reorg between the two heights then left a phantom spend of our own send and a change note with an empty witness list until a rescan. Scanning a block now sets the entry's block to the block height. This applies to every monitor-first transaction, including payments received from others: their reported block height, the verified/spendable timing that depends on it, and the wallet birthday now use the real mined height instead of the monitor's. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> |
||
|
|
24a90ae532 |
fix(lite-backend): keep witnesses current for notes in a pending send
Notes spent by a send still in the mempool stopped getting witness updates. After a restart (pending flags are not saved), or a reorg that un-mined the send, those notes were spendable again but with stale witnesses, so the next send from them used an anchor that is not on chain and failed until a rescan. Deterministic note selection made this sticky: the stale note was picked every time it was the best fit. Witnesses now advance for every note that is not spent on chain, pending or not. Witness clearing still keys on the mined spend only, so pending notes are never cleared. The wallet file format is unchanged; an old build reading a file written while a send was pending can now spend from it (verified offline). The oracle reloads and reorgs inside the pending window and compares every unspent note's witnesses byte for byte; reverting the fix fails 9 tests. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> |
||
|
|
db8d5359a4 |
fix(lite-backend): don't panic on a reorg while a send is pending
invalidate_block unwrapped a note's `spent` field for notes that only had `unconfirmed_spent` set, so any reorg while one of our sends was still in the mempool panicked the backend (and poisoned the wallet lock). The branch could only panic or do nothing; it is gone, and a reorg now leaves `unconfirmed_spent` as it is: the send can still be mined on the new branch, because the chain checks the anchor's root, not its height. The oracle no longer steers reorgs around pending sends: it now reorgs above, across and below the sent tip, including popping the anchor block, and the reproducer runs by default. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> |
||
|
|
c233d4d503 |
perf(lite-backend): fetch full transactions over one shared gRPC channel
At the end of a sync the wallet fetches the full transaction of each new wallet tx, together with every other transaction in the blocks that contain them, as decoys. Every txid got its own pool job that built a fresh Tokio runtime (one thread per CPU), a new rustls config and a new TCP + TLS + HTTP/2 connection for a single GetTransaction call, so a restore or rescan opened one connection per fetched transaction. The first error returned from the sync while the remaining jobs kept fetching and scanning in the background, and a stalled connection, an unparsable transaction or a panicking scan could hang the sync forever with its lock held. fulltx_fetch.rs now works through the same (txid, height)-sorted, de-duplicated list on the sync's runtime, with as many workers as the sync pool has threads (2-8, the same fan-out as before), over one gRPC channel that is dialed lazily and re-dialed on retry. Parsing and scanning stay on the sync pool, and the call returns once the whole batch has finished. Behaviour changes: - a failure on a decoy no longer fails the sync; it fails only when one of the wallet's own txs could not be fetched and scanned; - new error texts "Error reading transaction: ...", "full tx scan job panicked" and "fetched only X of Y wallet txs", all after the existing "Error fetching all txns for memos: " prefix; - each tx gets up to 3 fetch attempts with a 120 s timeout each, where the old code could wait forever; a request that legitimately takes longer now fails; - nothing keeps running in the background after the sync returns; - all GetTransaction calls share one TLS/HTTP-2 connection (up to one dial per worker at the start, plus a re-dial per retry); the request list and its order are unchanged; - if the network silently drops packets mid-batch, every attempt waits out its timeout until 16 consecutive failed txs stop the batch: up to about 12 minutes with 8 workers (48 minutes with 2) with the sync lock held, where the old code gave up at its first failed connection. Which txs are requested, in what order, how often they are retried and when the batch stops do not depend on which txs are the wallet's own; only the final result does. A server that keeps failing one tx can see that result through the sync's retry, but it could already tell, because an own tx that failed is requested again on the next sync without its decoys; nothing new leaks. tests/fulltx_fetch.rs drives the engine with a fake fetcher: the old request list, each item fetched and scanned once, bounded concurrency, a request sequence independent of ownership, retries, timeouts, the breaker, panicking and unparsable scans, and a saved wallet byte-identical to the old serial fill. The gRPC fetcher itself is not covered by these tests. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> |
||
|
|
ba08cab95a |
perf(lite-backend): share unchanged witness states between blocks
Each scanned block deep-copied the newest witness of every note with history to start that block's entry, although a block without shielded outputs changes no witness, and spent or pending notes never change theirs. A note's 100-entry list was 100 separate copies, mostly identical. The list now holds Arc-shared witnesses. The per-block duplicate is an Arc::clone, and Arc::make_mut copies the newest entry only for unspent, non-pending notes and only in blocks with at least one shielded output. Every entry is still written in full, so the file is unchanged, and a spend takes its own copy of the witness. A loaded wallet is not de-duplicated, so the saving builds up over the next 100 blocks. No output change: the saved bytes are identical; notes use less memory. With 1000 live notes x 100 witnesses (a 70 MB file), RssAnon after 100 blocks without outputs is 18 MiB instead of 189 MiB and such a block scans in 1.0 ms instead of 2.1-2.7 ms; when every block has an output, the live bytes stay about the same. The witness tests, the 1000-seed oracle and a differential run against the unmodified backend (every witness entry and the whole saved file after every operation; only the cleared lists and the chosen spend notes may differ) all pass, and the measured wallet saves to the same SHA-256 as with the old build. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> |
||
|
|
6f1ae874fb |
feat(lite-backend): select spend notes deterministically, max 250 inputs
send_to_address walked the wallet's notes in HashMap order and took them until the target was covered, cloning the witness and spending key of each note it visited, zero-value notes included. So which notes were spent, how many and which address got the change were random: a send could prove zero-value notes (each spend is a Groth16 proof of about 0.5-2 s plus 384 bytes) and far more notes than needed, and a send needing 251-489 notes ground through minutes of proving to build a transaction whose flat fee is below DragonX's default minimum relay rate. Selection is now a pure function (note_selection.rs) over the value and nullifier of the eligible non-zero notes: - the smallest single note that covers the target; otherwise the fewest notes, largest first, with the last pick swapped for the smallest note that still covers. Ties break on the nullifier, so map order no longer matters; - at most 250 spends, and fewer when the estimated size would pass 190,000 bytes (the consensus limit is 200,000); - the notes cover the whole target whenever they can; only when they cannot do the keyed UTXOs, which are still spent in full, reduce what the notes must cover; - only the chosen notes get their witness and key cloned. Behaviour changes: - which notes are spent, and so which address receives the change, is deterministic: the smallest single covering note, otherwise the fewest notes, largest first; - zero-value notes are never spent; - new errors before any proving: "Too many inputs: ..." (more than 250 spends needed; it states the maximum sendable amount in DRGX) and "Transaction too large: ..." (it suggests shielding transparent funds first). Neither contains "Insufficient", so the GUI does not retry them. Sends that need 251-489 notes, which succeed today after minutes of proving, are refused; - UTXOs alone fund a send only when no eligible non-zero note exists, or when the size budget leaves no room for a shielded input; then, as for a wallet without notes before, the change goes to the first z-address and is listed as a receive. Otherwise the smallest non-zero note is still spent, so the change is recorded as change; - the "Insufficient verified funds" text is unchanged, but its "have" amount no longer counts UTXOs whose key the wallet lacks; - duplicate nullifiers (corrupt state only) are de-duplicated; - new info log: "Selected N of M spendable notes". A note left with a stale witness by the pre-existing pending-send bug is now picked every time it is the best fit, rather than by chance. Pure tests check that the selection never uses more notes than the old walk (3000 random cases, shuffled), that the count is minimal (brute force) and independent of order, and the cap, size budget and fallbacks; wallet tests cover best fit, a chat-fee send, the cap refused before proving and UTXO funding. In the 1000-seed oracle every send verifies offline at anchor offsets 0 and 4 and the largest send dropped from 7 inputs to 2; a differential run against the unmodified backend has identical anchors and byte-identical non-selection state apart from the cleared lists. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> |
||
|
|
965f36cecb |
perf(lite-backend): batch a block's witness appends across threads
The block scan appended each shielded output's leaf to the witness of every live (unspent, not pending) note as it went, one witness at a time on the sync thread, while holding the wallet's txs write lock: live notes x outputs appends per block, all on one core. With 16 or more live notes, the scan now collects the block's leaves in output order and appends them to those witnesses once, at the end of the block, still under the same lock and before anything reads them. When that is at least 128 appends, the witnesses are split into disjoint chunks over min(8, cores, live notes / 8) scoped threads; otherwise the appends run inline. Every witness receives the same leaves in the same order, including a leaf whose epk does not parse; an output whose cmu does not parse still adds nothing. The hashing work is the same, only spread over cores; the speed-up has not been benchmarked yet. No output change. Each qualifying block starts up to min(8, cores) short-lived threads. Tests check that the threaded helper matches the plain double loop byte for byte for 0-64 witnesses, and that the deferred path handles bad-epk and bad-cmu outputs. The 1000-seed oracle passes, as do the suite and 125 oracle seeds pinned to 2 cores, and a differential run against the unmodified backend is byte-identical, apart from the lists cleared two commits earlier, for wallets below and above 16 live notes and blocks of 0, 1 and 50 outputs. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> |
||
|
|
e7ebc568b0 |
perf(lite-backend): trim per-note witness history in place
The block scan replaced each note's witness list with the result of split_off(), which allocates a new buffer on every call: every block, each note with history paid an allocation and, once its list was full, a copy of all 100 entries. The duplicate of the newest witness is now pushed and, when the list exceeds 100 entries, the oldest is drained in place; notes with no history are skipped. The list keeps the same entries in the same order, so nothing in the output changes. The witness tests and the oracle pass, and a differential run against the unmodified backend is byte-identical (every witness entry and the whole saved file after every operation) apart from the lists the previous commit clears. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> |
||
|
|
7f99fbb9c6 |
perf(lite-backend): drop witness history of long-spent notes
Every note keeps a list of up to 100 witnesses, one per block, so that a reorg can roll it back; a full list takes roughly 40-90 KB of the wallet file. The block scan kept duplicating and trimming that list for every note that had one, spent notes included, although a spent note never reads its history again once its spend can no longer be rolled back. So every note the wallet ever spent kept costing memory, per-block scan time and file size. Once the block window is full (101 blocks), a spent note whose spending transaction's block is below the window's first block now gets an empty witness list. This runs before each block's witness update and once when a wallet is loaded. Such a spend can only be undone by a reorg that pops the whole window, and that already empties every witness list, so reorgs behave exactly as before. Behaviour changes: - notes whose spend lies below a full reorg window lose their witness history in memory, at load and in the next save; existing large wallets shrink on their first save (which, like every save, copies the previous file to wallet.dat.bak); - the wallet format (v9) is unchanged; old builds and SilentDragonXLite read an empty list as an unspendable note, which a spent note already is; - new info log at load: "Dropped witness history of N notes whose spend is deeper than 100 blocks". The oracle now also accepts an empty list for a spent note that was cleared before a later partial reorg. New witness tests compare every block's lists with a model of the old algorithm, including reorgs across the clear point and an old file with full lists, which loads and re-saves to the new bytes. The 1000-seed oracle run passes (12,207 reorgs, 993 sends verified offline), and a differential run against the unmodified backend is byte-identical apart from exactly the cleared lists; the old build reads the new files, and 17 sends it built from them verified offline. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> |