7c24400c83fcd17265e55c1fdded26f16a0fff36
136
Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
55975901a2 |
perf(lite): move wallet ops and server switches off the UI thread
New address, Import key, Encrypt and Remove encryption called the
backend on the UI thread. Each ends in the backend's save, which waits on
the lock a scan holds for its whole duration and every refresh read holds
briefly, so during a scan the window froze until the scan ended. A create
or restore with a passphrase encrypted and saved just after its first
scan had started: for a restore, a freeze for the whole initial scan. A
server switch destroyed the old controller on the UI thread and joined
its refresh worker, which can sit for minutes in a read against a dead
server (each balance, notes or list read retries for ~62 s).
- The four ops run one at a time on a background lane and their results
are applied on the main thread; Settings shows "Working…" meanwhile.
Key and passphrase copies are wiped after the call, or when a queued
op is dropped.
- A create or restore encrypts and saves (an open unlocks) before the
first scan starts, so the save takes milliseconds and the scan runs on
the encrypted file.
- Import, Encrypt, Remove encryption and Receive's New address are
disabled while a scan runs ("Wait for blockchain to sync"); Redownload
blocks is disabled while an op is queued.
- A switch retires the old controller without blocking (queued ops
dropped, pending ones reported failed, its client shut down) and
destroys it on a background thread. It no longer saves on the way out:
the new client re-scans the blocks since the last save, as after a
restart.
- A switch waits for an op in flight, at most 15 s, so the op can't
finish and save on the old client while the new one owns the wallet
file. The Network tab already shows the new selection meanwhile.
- The refresh worker stops between backend commands, skips the remaining
server reads once one fails, and then retries after 4, 8, then 15 s
instead of every 2 s.
- Quit waits up to 2.5 s for an op in flight, e.g. Encrypt's save.
Encrypt and Remove encryption now report a failed save: the backend
returns it as a JSON error that the old check never saw, so the failure
was reported as success. The trimmed copy of a key typed into Import is
now wiped too, and stopping the refresh worker can no longer miss its
wakeup.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
||
|
|
b2a57a61a0 |
feat(util): add SerialJobLane
A FIFO of blocking jobs, run one at a time on a background thread that a post starts when none is running and that exits once the queue is empty. Each job returns a completion, and completions run only in pump(), on the owner's thread. Unlike RPCWorker and AsyncTaskManager nothing ever joins: abandon() (also run by the destructor) drops queued jobs and undelivered completions and refuses later posts, and a job stuck in a blocking call finishes on its own thread. It is meant for backend calls that can wait out a long scan and may have to be given up. post() reports a dropped job (abandoned lane, or no thread could be started), and waitIdle() is bounded. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> |
||
|
|
296c04386c |
perf(mining): sample daemon memory off the UI thread
The daemon's RSS was read on the UI thread: a scan of every /proc entry on Linux (1-7 ms), a forked ps on macOS (up to ~30 ms). The mining poll probed every 1-5 s on every tab, and the Mining tab's memory bar probed again every 1.5 s. util::BackgroundSampler now runs the probe on one lazily started thread and caches the result. getDaemonMemoryUsageMB() returns the last sample and asks for a new one at most every 3 s, and only while the Mining tab memory bar or the Peers tab Memory cell is drawn: nothing on other tabs or while minimized. EmbeddedDaemon::memoryProbe() hands the sampler a pid snapshot (on Windows the value read from the owned process handle), so nothing off-thread touches daemon state. The mining poll does no I/O any more; it carries the last sample. Both shutdown paths stop the sampler. Fixed along the way: an unreadable /proc made the directory iterator throw on the UI thread; the scan now just stops. User-visible: - the Peers tab Memory cell keeps updating during sync and shows an external daemon's memory (it showed the mining poll's value, which pauses during sync and covers only the embedded daemon); - the Mining tab memory bar refreshes every ~3 s (was 1.5 s). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> |
||
|
|
2618744e59 |
fix(rescan): probe the node's block range off the UI thread
Finding the lowest block a node still has (Settings > Rescan, and the automatic reconcile after a bootstrap) ran a binary search of about 42 synchronous getblockhash/getblock calls on the UI thread, each waiting for the RPC connection behind any wallet scan (10-20 s on a big wallet) and each with a 30 s timeout. The window froze for that long. It now runs as one task on the wallet RPC worker, bounded to 90 s, and the answer comes back on the UI thread; meanwhile the dialog shows "Checking which blocks..." with Rescan disabled. An answer from an earlier connection counts as "could not tell", and one for an earlier opening of the dialog is dropped. The search probes block 1 first, so a full-history node answers in one probe, and it answers "could not tell" at once while a runtime rescan holds the lane. Fixed along the way: any probe error (timeout, busy node) read as "block missing", so a busy node could get a bogus "bootstrapped, rescan from the tip"; and a non-string getblockhash reply never called back, leaving the dialog on "Checking..." for good. Only the daemon's "can't read block" error now counts as missing; anything else means "could not tell". User-visible: - after a bootstrap, a probe that cannot tell is tried up to 6 times, 30 s apart and only while the node is not busy, then a toast points to Settings > Rescan, instead of falling back to the -rescan restart, which fails on a bootstrapped node; - the Settings Rescan dialog warns when the probe could not tell. Two new strings, translated into all eight languages. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> |
||
|
|
ad0f60a747 |
perf(market): fetch price and market data on a dedicated HTTP lane
The CoinGecko price, tickers and market chart and the exchange candle GETs ran on the wallet's RPC worker, the same FIFO lane as balance, history and sends. A slow or blocked endpoint held that lane for up to 10-12 s per price or ticker fetch and 2 x 30 s per chart or candle job, and a wallet scan in turn delayed the price. At quit a hung GET held shutdown until the 15 s watchdog cut teardown short. They now run on their own RPCWorker thread; results are applied on the main thread and wake the idle loop. A new util::httpGet takes a cancel flag (checked by libcurl's progress callback), sets CURLOPT_NOSIGNAL and returns the HTTP status and error; httpGetString wraps it. Shutdown sets the flag, so queued GETs return at once and one in flight aborts within about a second. The price uses a plain in-flight flag instead of the connection-scoped refresh job, so a disconnect no longer throws away a good in-flight price and leaves it loading until the next tick. User-visible: - chart and candle GETs use a 5 s connect / 20 s total timeout (were 15 s / 30 s); - the price request goes through the shared GET: User-Agent ObsidianDragon/1.0 (was DragonX-Wallet/1.0) and a 16 MiB reply cap; - market fetches on connect start alongside the wallet refresh instead of queuing behind it. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> |
||
|
|
93b6871511 |
perf(history): write the history snapshot on change, off the UI thread
Every history refresh, address refresh, connect and lock-state check rewrote the encrypted tx-history snapshot on the UI thread: a JSON document of every row, its encryption, then INSERT OR REPLACE and a prune, an estimated 40-120 ms on a 5k-transaction wallet, even when nothing had changed. Now a signature of what would be stored (about 1 ms on 5k rows) decides whether to write. Confirmations count only by the thresholds the UI reads (conflicted, pending, 1-9, 10-99, 100+); per-address scan heights, exact confirmations and the tip drift every block and form a separate part. - Routine refreshes write a content change at most every 20 s (a deferred one is released from the update loop) and the per-block drift at most every 10 min. Nothing changed means no write. - A mining-address relabel, a passphrase change, a rescan, a disconnect, a wallet lock and exit write whatever is not on disk yet, at once. - The UI thread only copies the rows and the key into a job; a single background writer (newest job wins) builds, encrypts and stores it. A failed write is retried. - Exit queues the final write so it overlaps the daemon stop, then waits up to 3 s for the writer; a disk that hangs past that keeps the previous snapshot. - A loaded snapshot counts as stored, so the first identical refresh doesn't rewrite it. An address refresh re-keys the snapshot only when the address set changed. After a hard kill the snapshot can now be up to ~20 s behind for rows and 10 min for scan heights, exact confirmations and the tip (it was one refresh); the first refresh replaces it either way. Lite builds, which never read this cache, no longer write it on Refresh or a mining relabel, and the screenshot capture mode's demo rows never reach it. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> |
||
|
|
c0568eb930 |
refactor(history): make TransactionHistoryCache safe for a writer thread
The tx-history cache was only ever used from the UI thread and had no locking. To let snapshots be written from a background thread: - every SQL statement runs under one database mutex and the key fields under a second one, taken after it and never held across I/O or the key derivation; - a write is a self-contained, move-only SnapshotJob holding the rows and a private copy of the key, which is wiped after the write or when a job never runs; the JSON build and encryption hold no lock; - a job with an all-zero key is refused instead of overwriting a good snapshot with one nobody can read; - the prune after a write also keeps the wallet unlocked now, so a late job for the previous wallet can't delete the salt of the one just switched to; - keyId() gives a non-reversible tag of the installed key, so a passphrase change can be told from a re-unlock with the same one. replace() stays a synchronous wrapper with the same result. A failed INSERT is now logged. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> |
||
|
|
6c02e67c6d |
perf(mining): one save, at most one restart per thread-count gesture
Every thread-count change on the Mining tab saved settings.json and, with a miner running, stopped and restarted it: xmrig re-inits its RandomX dataset and reconnects to the pool, seconds of lost hashing each time. A drag across the grid did this once per tile crossed (up to ~24), with a "Starting pool miner" toast each; every -/+ click or Enter did it again. The tab now hands each pick to the App, which puts the newest in settings at once and applies it when the input settles: on drag release, or 0.5 s after the last click, even if the Mining tab was left in the meantime. The header, RAM estimate and tile fill still follow the drag live. - Pool: the restart is decided on the mining-control thread, after every earlier start or stop has run. It never starts a miner the user stopped, is a no-op when xmrig already runs at that count, and is no longer silently skipped when the pick lands in another restart's stop window (which left xmrig on the old count). - Solo: one setgenerate per gesture. A pick made while one is in flight now waits for it instead of being dropped (another stale count). - Clicking the tile that is already selected does nothing; it used to save settings and restart the miner. - With idle thread scaling on, the grid no longer snaps back to the old count while a pick is on its way to the miner. A pool auto-balance restart keeps a count still queued. - Idle thread scaling changes the pool count through the same retarget. Its correction after a start compares with the count last requested, not the xmrig API's lagging report, so it comes about 1 s in instead of after the first API report (3-5 s). - A retarget whose pool config no longer validates shows the same error and stops the miner, as the old stop-then-start did. - While a benchmark runs a pick is saved, but the miner change is dropped: the benchmark owns the count until it ends. A crash within 0.5 s of a pick, or mid-drag, loses that pick; every clean exit saves it. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> |
||
|
|
7649b1f8a3 |
perf(settings): write settings.json and wallets.json off the UI thread
Settings::save() and WalletIndex::save() ran the atomic write (temp file, fsync, rename, directory fsync) on the UI thread: on every preference change, and for wallets.json after every address-scan pass whose wallet metadata changed, i.e. repeatedly during sync, when the disk is busiest. Each took milliseconds to tens of milliseconds, and seconds under heavy disk load. The JSON is still built on the caller; the write is queued on an App-owned CoalescingWriter, latest wins per file, one write at a time. - After a fund- or security-critical setter (seed-migration stage, encryption pending, PIN enabled, active wallet file, wizard completed) the next save still waits until the file is on disk, so e.g. the sweep-submitted marker is durable before the sweep is broadcast. A failed durable save keeps the next one durable. - Saves before the writer is attached (the upgrade re-save at startup), and in tests and tools, stay synchronous. - main() flushes the queue (at most 3 s) before and after App::shutdown(), before the single-instance lock is released, so a relaunch never reads a stale settings.json. A kill or power loss right after an ordinary save can now lose that last preference change; the previous file stays intact. WalletIndex saves report "queued" rather than "written" (no caller reads it). On a wedged disk, exit can wait up to 2 x 3 s longer, inside the 15 s watchdog. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> |
||
|
|
8f8dc4c922 |
perf(platform): launch URL and folder openers without waiting for them
On Linux and macOS openUrl/openFolder spawned xdg-open/open and waited for it on the UI thread: 50-300 ms per click where the opener forks and exits, and on generic xdg-open sessions (sway, i3, WSLg, ...), which run the browser or file manager as xdg-open's foreground child, the window froze until the user closed that app. The opener is now left running and a detached thread reaps it; the click returns at once. Its stdin, stdout and stderr go to /dev/null again. That redirect was lost in the switch to posix_spawnp, so a text-mode browser fallback could take over the wallet's terminal. The result now means "launched" rather than "exited 0" (no caller reads it), and a non-zero opener exit is reported on stderr instead of in the debug log. Windows (ShellExecute, which never waited) is unchanged. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> |
||
|
|
3f1194a84b |
feat(util): add CoalescingWriter for background file/snapshot writes
One background thread for blocking write jobs, for the settings.json / wallets.json saves and the tx-history snapshot that later commits move off the UI thread. Jobs are keyed (a file path, "tx-history"): - a submit for a key that is still queued replaces that job and keeps its place in line (latest wins); - keys run first in, first out, one job at a time, so two writes of one file never overlap and an older snapshot never lands after a newer one; - submitAndWait() blocks until that job (or a newer one for the key) has run and returns its result, for writes the caller needs on disk; - flush() waits with a bound; stop() runs everything queued, then joins; - the thread starts on first use and sleeps when idle; with no thread (none could be created, or after stop()) a job runs inline. persistFileAtomically() routes Platform::writeFileAtomically through a writer, or writes synchronously when there is none. Nothing uses it yet. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> |
||
|
|
cee8b9d7f6 |
feat(merge): run per-address consolidation as a paced background job
"Keep in each address" posted one task that called z_mergetoaddress for every batch of every address back to back; each call is a whole-wallet scan under cs_main, and the daemon runs the ops one at a time anyway. It is now an App-owned services::ConsolidationRun: - one RPC at a time; the next merge only after the previous batch left the queue and a gap as long as the last call took (2-120 s); - pauses while the node syncs, the wallet is locked, a send of ours is in progress, or another wallet scan is running; - failures classified: nothing that may have queued is ever resent; a breaker stops after 3 dead addresses or 3 failed ops in a row; - each merge is charged to the scan budget; one re-read and one forced balance at the end; - it keeps running when the dialog closes: a status-bar chip, a run view with progress, the wait reason and a Stop button. The "Into one" merge can no longer be confirmed twice while its call is in flight. fake_dragonxd gains FAKE_SCAN_SECS, FAKE_MERGE_SERIAL and FAKE_MERGE_FAIL. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> |
||
|
|
b29a09eaa0 |
perf(chat): sends re-read by origin, chat scans under the scan budget
A z_sendmany submit set off a burst of wallet scans before anything had changed. Ops now re-read the wallet when they finish, by who started them: the user's sends and dialog ops get a re-read plus one forced balance once none of their ops is pending; client auto-shield re-reads unforced; chat messages and splits refresh only the chat note view. A pending send no longer forces scans, and chat txids no longer enter the tracked-send set. Chat ingest skips stored messages before decrypting and caches undecryptable ones per identity. The inbox scan runs when getwalletinfo's txcount changed (a cheap probe, at most 2% duty) or a cost-scaled safety net is due; the note-buffer scan runs on need. Both are admitted by the scan budget, on a steady clock. The chat payer comes from a per-address view of verified notes, with debits re-applied across in-flight scans; splits are sized to one payer and only when they can refill the buffer. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> |
||
|
|
f86cc4d6b7 |
perf(net): hold wallet scans at connect, in the background and on tab entry
- Connect gate: after the node starts answering, wallet scans wait until the lock state is known and the node knows its tip (or the wallet has fewer than 500 transactions), at most 20 s. A node already behind latches the sync profile from the connect's getinfo. Meanwhile a list-only pass keeps the address list and the cached history up, and the session's first balance and per-address balances are read once even while catching up, so the wallet shows its last-synced values instead of 0. - Background: minimized, hidden, or covered without focus (3 s debounce, any input cancels) polls only the chain state every 15 s. Wallet scans, the mining poll, price, nudges and auto-lock interaction tracking pause; auto-lock now fires while minimized. Leaving a held profile marks every timer due again. - Tab entry marks the timers due instead of forcing scans; a stale list or a never-loaded history still loads on entry. - The scan entry points own the hard gates, failed enumerations keep the previous lists behind a retry hold, and failed balance calls never overwrite known balances with 0. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> |
||
|
|
323143e111 |
perf(net): one scan budget for all wallet scans, gated on what changed
The balance, address scan, history and probe were throttled one by one at 10% each, which added up to 30-40% of dragonxd's cs_main with all of them due. services::WalletScanGovernor runs one wallet scan at a time across both RPC lanes and keeps one shared quiet clock at 15% of wall-clock (cut when the node is behind). Waiters are served first in, first out, with a head start for what the page shows and a starvation floor of max(30 s visible / 900 s hidden, 15x the stream's cost). Explicit work (a dirty set, the user's send, a first load) is never refused but is charged. Routine scans run only when the tip, the mempool size (getmempoolinfo: no cs_main) or a wallet-mutation epoch changed since their last good scan, or a safety sweep is due. Pages that don't show the balance poll it at most every max(10 min, 50x its cost) (2 min with client-side auto-shield); coming back into view with an old value fetches it at once. The staleness badge counts from the last poll that confirmed nothing changed, and hides while the budget holds the balance within its guarantee. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> |
||
|
|
879de37d18 |
perf(history): scan shielded history in bounded passes, not every block
Every block re-ran z_listreceivedbyaddress for every z-address (O(wallet) each, under cs_main) and a whole-wallet z_listsentbyaddress. On a 40-address wallet that was ~80% of wall-clock. The history now advances in passes: - an address is rescanned when it is stale by a number of blocks scaled to its measured cost (8 to 500), never-scanned and hinted addresses first (a hint: z_listunspent shows a note the history doesn't); - z_listsentbyaddress covers only the blocks since the last good sweep (+100 reorg margin); the whole-wallet sweep runs once per connect, with a 10 min / 1 h / 6 h backoff after real failures; - listtransactions pages are sized from last time, and an unchanged tip reuses the transparent rows; - found-but-unenriched txids carry over as a backlog; only -5 is a definitive "not ours" for z_viewtransaction; - between blocks, a cheap recent poll plus a one-address probe. Duty model for a 40-address wallet at 10 s per scan: ~9% instead of ~80%. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> |
||
|
|
8b9a498eee |
perf(net): one balance scan per poll, filtered opstatus, a guarded merge scope
The Core poll ran z_gettotalbalance twice (minconf 0 and 1), each a whole- wallet scan under dragonxd's cs_main. The spendable (minconf=1) call now runs only after the display call succeeded, and only when something is unconfirmed or pending or the exact total moved: about 95% of polls make one scan instead of two. A forced post-send balance can no longer be lost to a refused enqueue, and pending-send debits aren't re-applied each tick. z_getoperationstatus is filtered to our opids with its real argument form ([[id,...]]; the old ["id"] was rejected), falling back to the unfiltered call if a daemon refuses the filter. The Shield/Merge dialog's own polls are filtered and session-guarded too, and finally reach Done; the dead RPCClient wrappers are deleted. The Merge/Consolidate scope scan becomes a tested state machine: one scan at a time, failures latch behind a Retry button, only "node not ready" retries by itself (10/30/60 s, at most 3), Lite never scans. Its 120 s calls are maintenance calls: every main-thread disconnect aborts one in flight first, so a lifecycle action no longer freezes the UI behind it. An aborted merge that was already sent says it may still go through. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> |
||
|
|
7fa7c3d6da |
fix(lite): drop the 15 s startup overlay and a TLS connection per refresh
- The refresh-model mapper marks the encryption state known when the backend reports it. The wallet tabs wait for that, so every Lite launch sat behind the loading overlay until the 15 s "assume unencrypted" fallback. - Each 2 s refresh skips the backend's `info` (a fresh TLS connection and GetLightdInfo, only a fallback for the chat tip, which the explicit height call already provides) and the gateway's `height` (replaced by that explicit call, which runs after the sync commands so it sees the newest block). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> |
||
|
|
7ceff1eaaa |
fix(market): back off failed market fetches instead of retrying every frame
The Market tab calls the exchange-list, price-history and candle fetches every frame, so a failed fetch (offline, rate-limited, a venue's API down) was retried on the very next frame, ~20 requests a second. - util::FetchBackoff: 30 s doubling to a 10 min cap, +/-20% jitter. The exchange list, the price history (a partial result counts as a failure) and the per-pair candles each back off on failure. - Candle backoff is per pair. Switching to another pair still loads at once, a failed refresh keeps the on-screen pair's last candles, and switching back to a pair that is backing off shows the aggregate chart rather than the previous pair's candles. - The Market tab's refresh button retries at once (at most every 5 s). A price fetch (60 s timer, no backoff) that succeeds after failing means the network is back, so it lifts all three backoffs. - A partial price history no longer marks the chart loaded: with no fetch timestamp, the 30-minute reload gate measured system uptime and blocked retries for the first 30 minutes after boot. - The exchange list goes through httpGetString (TLS verified, size capped) with short timeouts (5 s connect, 12 s total, as it runs on the shared RPC worker) instead of its own curl code. A failed fetch is retried; it used to be tried only once per session. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> |
||
|
|
16e0ee9947 |
perf: stop per-frame SQLite writes and console spam, make perf.log opt-in
- Explorer: the tip check runs every frame the Explorer tab is open, and it read both tip rows and rewrote them each time (two autocommit WAL writes per frame). A steady tip is now kept in memory and skips SQLite. A new tip writes both rows in one savepoint, so a crash between them can't pair a new height with the old hash. A test checks that 100 steady checks write nothing. - Console: while the daemon was in the Error state the Console tab added its error line on every poll, i.e. every frame, re-laying out the whole buffer each time. It prints once per error now: on entering Error, when the message changes, or after another crash (EmbeddedDaemon::getUnexpectedExitCount(), never reset). setState writes the message before the state, so a reader that sees Error sees that error's message. The stray " ===" suffix is gone. - perf.log: the profiler appended a block every 300 frames on every run and never rotated the file (tens of MB a day for a wallet left open). It is now off unless the app starts with DRAGONX_PERFLOG=1; otherwise its zone hooks are near no-ops. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> |
||
|
|
43fc0090d4 |
fix(wallet): retire tracked sends that only paid the wallet itself
A send stays tracked until z_viewtransaction shows an outgoing output. Shields, merges/consolidations and chat note splits pay only the wallet itself, so they never showed one. They stayed tracked for the whole session and forced a full history refresh past the scan throttle on every tab. - A tracked send whose view has no outgoing output is retired once it is mined (1+ confirmations) or conflicted. - Tracked sends are enriched first, and reaching the per-cycle view cap no longer ends the loop, so the cap can't starve them. - Backstop: a tracked send still unsettled 20 blocks after it was added (expired, dropped from the mempool) stops being tracked. - Tracked sends are cleared on disconnect: they belong to that connection's wallet. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> |
||
|
|
575abd4bcc |
perf(ui): render frames on demand instead of continuously
While theme effects were on (the default) the main loop drew every frame it could. Where vsync is off (offscreen, some drivers, remote sessions) an idle wallet spun at ~3,000 fps, 88% of a core; with vsync it still drew every refresh. Frames are now paced by what needs them: - full rate while there is input, a held key, a window appearing or settling, a smooth scroll or the UI sweep; - ambient (30 fps focused, 10 fps unfocused) for theme effects, the acrylic backdrop, avatar animations and notifications; - progress (10 fps focused, 4 fps unfocused) for UI that changes without input. The updater and bootstrap downloads, console commands, lite-wallet operations, seed migration, the loading overlay and the lock-screen countdowns ask for it with material::RequestProgressFrame(); - otherwise idle, as before: wait for events, with a housekeeping wake every 2 s (5 s unfocused). A software frame cap (0.9x the display refresh) bounds the loop where vsync is off, and hidden, occluded or minimized windows get no ambient frames. Reduce Motion freezes the theme-effect clock (ThemeEffects::setMotionFrozen), so it needs no ambient frames either. The displayed-balance roll goes through one clamped StepDisplayLerp (balance_lerp.h). An idle-length frame pushed dt*speed past 1, so the roll overshot, and under Reduce Motion's speed of 999 it diverged to inf/NaN. The classic layout had its own copy of the old code. Idle CPU, Lite build, offscreen (no vsync): 88% -> 5% of a core with effects on, and 91% -> 3% with effects saved off (with the previous commit). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> |
||
|
|
3961f84d16 |
Merge branch 'fix/audit-remediations' into dev
Every fix on this branch already reached dev through the port in
|
||
|
|
71ed92b9f1 |
feat(wallet): consolidate notes per address, keeping each address's funds in place
Users who keep funds apart under different labelled addresses can now shed notes (smaller wallet, faster scans/witness rebuilds) without the funds being merged into a single address. - Merge/Consolidate dialog: "Where should the funds go?" - Into one address (unchanged) or Keep in each address. The latter lists shielded addresses with 2+ spendable notes (label, note count, amount; too-small ones disabled) and merges each address's notes back into that address with z_mergetoaddress [addr] addr. Addresses with more notes than the batch size get several batches (the daemon locks a batch's notes before returning, so calls take disjoint notes); the dialog suggests a second run after those confirm. Per-address progress; ops keep running if the dialog is closed. The Consolidate (wallet-size) preset opens in this mode; Merge keeps "Into one". Shielded only: a transparent address can't take a merge's output. - services/note_consolidation: groupSpendableNotes, worthConsolidating, estimateBatches, consolidateInPlace (pure; driven by a merge-call function), with unit tests against a stand-in following dragonxd's rules. - fake_dragonxd: optional note wallet (FAKE_WALLET_NOTES, FAKE_MERGE_SECS) with z_mergetoaddress and its operations. - i18n: 17 new keys in all 8 languages; CJK subset rebuilt. - UI sweep: modal-consolidate target. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> |
||
|
|
31d8742ce7 |
feat(ui): explain mining addresses everywhere and stop offering them for payment
Follow-up to
|
||
|
|
5246898968 |
feat(ui): treat transparent addresses as mining-only addresses
DragonX is a private chain (-ac_private=1): consensus rejects any
non-coinbase output to a transparent address, so a t-address can only
ever receive mining rewards, and its coins can only move to a shielded
address. The wallet treated them like ordinary recipients.
Block paying one:
- Send: a transparent recipient shows "Mining address - can't be paid"
and disables Review Send (tooltip explains; ask for a zs... address).
Typing, paste, contacts and payment URIs all go through this field.
- Overview drag-to-transfer: a mining-address row is never a transfer
target (it only reorders).
- Pool payout must be shielded (pools pay with ordinary transactions):
field tooltip, disabled Start button and the start check say why.
- isValidRecipientAddress() is now shielded-only; new isDragonXAddress()
and classifyRecipient() (Shielded / MiningOnly / Invalid).
Call them mining addresses:
- Overview: "Mining rewards" card (amber "Not yet shielded" when there
is a balance; hover explains; click opens the Shield dialog), "New
mining address" button with a tooltip, rows typed "Mining".
- QR / label dialogs, portfolio filter, contact badge ("Mining only"),
Receive notifications.
i18n: 17 new keys in all 8 languages, 12 dead keys removed, CJK subset
rebuilt. Tests: recipient classification, translation completeness.
UI sweep: new "send-mining-recipient" target.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
||
|
|
a3361020d1 |
feat(daemon): recognise our detached node, tail POSIX debug.log, more RPC threads
S12 - our node across sessions: - The node the wallet launches is recorded in <datadir>/obsidiandragon- dragonxd.pid (pid + process start time + exe). A later session that finds it still running (after "Leave running" / keep_daemon_running) stops it gracefully on quit and waits for that exact process (no time limit, "Leave running" offered); never signals or kills it. A reused pid or a reboot never matches; stale records are deleted. - stop_external_daemon is now honoured when this session only connected (no controller); it was ignored. S13 - the node's own log on every platform: - No -printtoconsole on Linux/macOS: the node writes its timestamped debug.log (which outlives the GUI) and we tail it next to the stderr pipe. Tail path from the datadir or the isolated override (Windows had %APPDATA% hard-coded, wrong for migrate-to-seed starts). - After the node shrinks debug.log at startup, resume at this run's banner instead of re-reading the old run's tail. - A node we didn't launch (left running, or found running) feeds the busy tracker from its debug.log, tied to the live process (our record, or POSIX hushd.pid) and only from lines it wrote. A GUI attaching to a node mid-rebuild now says "Node busy: rebuilding note witnesses" and asks before stopping it. - A start that only finds a running node no longer resets the tracker; a found busy node shows the busy overlay at once. S14: launch our node with -rpcthreads=16 unless DRAGONX.conf sets it. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> |
||
|
|
e20b70d73f |
fix(daemon): never restart, delete or swap under a node that hasn't exited
Every operation that stops the node (restart, rescan, repair, delete chain data, reinstall, bootstrap, wallet switch/adopt/restore/rebuild, wizard restart, encrypt, decrypt) now: - refuses up front while the node is rebuilding witnesses or has not answered for 30s, with a "try again when it's done" warning, and sends no stop; - goes ahead only once the node's PROCESS has exited, not its RPC port. A stop that doesn't finish within 120s cancels the operation (warning; a cancelled rescan/repair disarms its flags); nothing is started over the old node, chain data isn't deleted, wallet.dat isn't swapped. Specifics: - stopDaemonForWalletSwitch() no longer reports "stopped" when the app is quitting (adopt could swap wallet.dat under a live node). - Decrypt waited a fixed 3s after "stop" before renaming wallet.dat; it now waits for the process to exit, and scrubs the key export if not. - Bootstrap waits for the process before cleanChainData and stops the node through the stop routine instead of the shared RPC client. - StopScope::Connected lets switch/reinstall/decrypt stop an adopted or external node through the same never-kill routine. - A bounded stop that can't reach the node (a foreign node refusing our credentials) gives up after 5s instead of 120s. - macOS: isDaemonProcessRunning() checks processes (libproc), not the RPC port. - rpcBusyWhileAlive() also reads the busy gate: since the gate, the first timeout usually lands on another lane and the 30s clock never started (also weakened the quit prompt for a silent rebuild). - Notifications render during the first-run wizard. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> |
||
|
|
1215ded1a0 |
feat(ui): say "Node busy" instead of "Launching dragonxd"; honest quit copy
A node rebuilding note witnesses for an hour was shown as "Starting DragonX node... Launching dragonxd", which invited closing it. - Overlay, a new NodeBusy banner and the status bar now say what the node is doing (rebuilding witnesses / catching up on blocks / up but not answering), with the elapsed time and Phase-1 block progress. A rebuild during startup warm-up replaces the stale "-28 Activating best chain" text, and the overlay re-applies its text once a second instead of only on a connect attempt (30s apart while the node is busy). - Quit prompt: elapsed time, "Keep node running & quit" is the default, "Stop anyway & quit" becomes "Stop node & wait" (it no longer kills). - A reinstall skipped because the node is still running now notifies. - Status bar: don't repeat the warm-up status on the right, and don't print "Connected" there in non-English locales. - i18n: 31 new keys in all 8 languages; 10 dead force-quit/old-modal keys removed; CJK subset rebuilt for the new glyphs. - Tests: chooseDaemonStatus, NodeBusy banner priority, and every new key present in all 8 JSONs with matching printf conversions. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> |
||
|
|
0f25c67600 |
feat(rpc): stop flooding a busy node's RPC queue; fix the reconnect storm
dragonxd serves RPC with 8 workers and a 16-deep queue. While a witness rebuild holds cs_main, every request needing it waits, and a request the client gave up on keeps its slot. The GUI kept polling and re-probing until all 24 slots were its own abandoned requests (even stop was then refused), treated the busy node as lost (tearing down the wallet state), and retried connect at frame rate on some pages. Busy gate (src/rpc/rpc_busy_gate.h), enforced in RPCClient::performCall for the shared rpc_ / fast_rpc_: - the first reply timeout or queue-full refusal marks the node busy; from then on nothing needing cs_main is sent (calls fail at once with the new RpcFailureKind::Deferred). stop, help and getrescaninfo are lock-free in dragonxd and always go through - one canary getinfo with no client timeout waits on its own client and thread; its answer clears the gate and triggers an immediate refresh (queue-full: retried after 2..30 s; no answer: back to normal handling) - the timer dispatch skips Core/Tx/Addresses/Peers/chat/mining/opid and connect attempts while busy - the 3-strike teardown counts only failures that put the connection in doubt, never busy ones; no balance scans follow a busy chain-info call Reconnect storm: retries were armed with setTimer(Core, kCoreDefault - 1), i.e. "4 s elapsed", which against Overview's 2 s interval meant every frame. RefreshScheduler::deferFor() is an absolute hold, and ConnectRetryBackoff picks the delay by failure kind (not listening 1/2/5 s, queue full 2..30 s, busy 30 s, else 4 s). Measured against the fake daemon: abandoned requests during a rebuild went from ~24 (every slot) to at most 2, with no teardown and recovery when the rebuild ends; a saturated queue sees about one canary per back-off step instead of ~190 requests/s. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> |
||
|
|
6b14098bb2 |
fix(daemon): make the "keep the node running?" prompt actually appear
Quitting during a witness rebuild is supposed to ask before stopping the node, because a stop throws the rebuild away and a killed node replays and redoes it. The guard almost never fired: - it returned false for any daemon >= v1.3.0, on the false premise that v1.3.0 checkpoints a rebuild (it does not; nothing survives a kill) - it read a 16 KB debug.log tail and compared the last progress line with the newest line, so any unrelated line during the rebuild's silent second phase turned it off; and on POSIX that file is stale - daemon_version, which the bypass needed, is zeroed when a busy node drops the connection Now a DaemonBusyTracker (src/daemon/daemon_busy_detect.h, pure) follows dragonxd's own markers in order: rebuilding (Phase-1 / legacy markers, ended by the completion line, an abort line or the next UpdateTip) and replaying (Activating best chain ... Done Loading), reset by a new banner or "Shutdown: done". It is fed from the owned output on the UI thread, including the backlog skipped after a restore. The quit guard prompts when the quit would stop the node and it is rebuilding, or has been alive but only timing out / refusing as busy for >= 30 s. A replay alone never prompts: stopping it is safe. The stop loop uses the same signal to never offer Kill during a rebuild. Also: - daemon output offsets are absolute, so a reader that falls behind a trim of the 1 MB buffer no longer skips new output (which could hide a start or end marker) - a quit deferred while minimized restores and raises the window, and the wizard renders the prompt, so it is never waiting unseen Verified live with the fake daemon: quitting mid-rebuild now shows the prompt for a v1.3.0 node and leaves the node alone; a silent busy node prompts after 30 s. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> |
||
|
|
5993488176 |
feat(daemon): deliver stop reliably and never kill the node unasked
Closing the wallet sent "stop" at most twice, gated on a getinfo probe that needs the daemon's cs_main, then killed the node after 20 s (Windows) or 30 s (POSIX) whether or not stop had arrived. A node busy with an hour-long witness rebuild therefore never got stop, was killed mid- rebuild, and replayed everything since its last flush on every start. Delivery (src/daemon/stop_delivery.h, RPCClient::open/trySendStop): - stop goes over a dedicated client with no probe, so it never waits on rpc_'s lock or a cs_main-bound getinfo - every attempt is classified, and StopDeliveryPolicy decides the next: Accepted / 503 = delivered; Queued (reply timeout) is resent at most every 30 s; QueueFull / not listening are retried every 1.5 s; a refusal after we were heard means the node is shutting down; a 401 on our own node tries the next credential source - POSIX: a group SIGTERM goes out as soon as an attempt fails, which bypasses the HTTP queue entirely Waiting (DaemonController::executeDaemonStop, pure, virtual-clock tested): - never kills on its own. Quitting waits as long as the node needs; the shutdown screen offers "Close wallet — leave node running" after 3 s, and "Kill node (unsafe)" (confirmed) only once stop was delivered and the node showed no CPU/I-O progress for ~10 min outside a rebuild - other callers (restart, rescan, reinstall, switch, ...) wait up to 120 s, let go once the app starts quitting, and keep watching a node that is still running; wallet switch refuses to go ahead then - EmbeddedDaemon gains stop-session primitives (beginStop, pollOwnedProcess, signalGracefulStop, forceKillOwnedProcess, ownedResourceUsage over the Linux process group / Windows handle, endStop) and only one stop session runs at a time - the Force Quit button and its stall heuristic are gone; "Leave running" aborts a pending stop attempt and closes at once Verified against the fake daemon with the real GUI: closing mid-rebuild now delivers stop (or SIGTERM 2 ms after a queue-full refusal) and the node exits cleanly after its rebuild, where it was SIGKILLed before. The shutdown screen, its "Leave running" button and the idle Kill offer (after ~11 min) were checked by screenshot. New strings are English- only for now. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> |
||
|
|
a091bbc52f |
fix(daemon): never kill a dragonxd the wallet didn't launch
On Windows, stop() with no live process of its own looked up the first "dragonxd.exe" by name and TerminateProcess'd it after the grace period: an old node still shutting down (how an in-progress shutdown was killed), a node on another datadir, or the user's main node. It was reached on a lock-gated close, for an external node, after the tracked process had exited, and from ~EmbeddedDaemon's stop(3000). - Windows stop(): drop the by-name branch; with no tracked process it logs and returns. findProcessByName is detection only. - ~EmbeddedDaemon no longer calls stop(); it stops watching and releases its handles. stop() returns whether the process exited on its own. - SeedWalletCreator never sent its isolated node an RPC stop, so on Windows that node was always force-killed holding the wallet.dat it had just created, and the stack object's destructor could then kill the main node by name. It now stops the node over the node's own RPC on every path. A node that still had to be force-killed fails the creation (seed wiped; nothing has been swept into it yet), so an uncleanly closed wallet is never adopted. - Reinstall waited only for the RPC port, which closes long before the process exits (locked exe on Windows, held datadir lock everywhere). It now waits for the process to exit and otherwise aborts without touching the binaries or starting a second node. - Linux isDaemonProcessRunning also matches /proc/<pid>/exe, so a node whose main thread is already renamed "hush-shutoff" (still holding the datadir lock) counts as running. Tests: a Windows real-process test (a fake dragonxd.exe on an isolated datadir survives stop() and the destructor), a POSIX test that the destructor leaves the process running, and the pure identity rule. scripts/test-windows.sh passes the stub path to the Windows suite. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> |
||
|
|
d8175b7dc1 |
feat(rpc): classify RPC failures from structured data, not message text
Every caller guessed what went wrong from what(): "Timeout" meant busy, "401" meant auth, and warmup was code -28 or one of six substrings. A non-JSON HTTP 500 lost its body, so "Work queue depth exceeded" read as a bare "HTTP 500", and libcurl reports connect- and reply-phase timeouts with the same code. New header-only src/rpc/rpc_failure.h: - RpcFailureKind (NotListening, ConnectTimeout, ReplyTimeout, QueueFull, ShuttingDown, Unhandled, Auth, Warmup, AppError, Aborted, Other) and a pure classifyRpcFailure(curl code, request-sent, HTTP status, JSON-RPC code, plain-text body) - formatHttpError keeps a short, printable plain-text body - RpcError (moved here) stays "the daemon answered with a JSON-RPC error". The new RpcTransportError means "no JSON-RPC answer": curl failures and HTTP-layer refusals (401, 503, queue full, unhandled). Keeping them separate matters: callers catch RpcError to mean "the daemon said no" (chat identity's no-mnemonic fallback, seed export), so a timeout must not be one. They now treat a queue-full 500 as transient rather than as "no". RPCClient: - performCall tells the two timeouts apart with CURLINFO_REQUEST_SIZE (0 = never reached the daemon; reset on every transfer, checked on libcurl 7.81), and callRaw now shares performCall and the HTTP-failure path. - connect() decides warmup by the -28 code only, so a plain-text body saying "Loading" can't pass for warmup, and records getLastConnectFailureKind(). - "Connection failed" names the kind and is logged once, then every 100th repeat (one incident logged it 60k times). - secret-bearing calls never echo a response body. tryConnect's "401"/"Timeout" checks now use the kind, and CoreRefreshResult carries blockchainErrKind/balanceErrKind for the busy handling to come. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> |
||
|
|
2c04b88e19 |
fix(daemon): let POSIX stop() see the daemon exit instead of always SIGKILLing
POSIX stop() set should_stop_, which made the monitor thread return without reaping the child, and then polled the state-based isRunning(), which stays true while the state is Stopping. So every Linux/macOS stop waited out its whole grace period, sent SIGTERM, waited 10 s more and SIGKILLed, even when the node had exited seconds earlier. Closing the GUI with an idle node took ~30 s; a node still flushing at 30 s was killed. - stop() joins the monitor first, then polls waitpid(pid, WNOHANG) on a cached pid, so it is the only reaper and sees the exit at once. - markStopRequested() (set by App::stopEmbeddedDaemon before the RPC stop, and by stop() itself; cleared by start()) makes an exit that lands before stop() runs a clean stop instead of a crash, on POSIX and in the Windows monitor. crash_count_ drives auto-restart, and a crash recorded before the request keeps its count. - The SIGTERM -> SIGKILL escalation stays for now, but only fires for a daemon that has really not exited. Replacing it with a wait policy and an explicit user choice is follow-up work. Verified against the fake daemon: an idle node that exits 3.4 s after stop used to be SIGTERMed at +20 s and SIGKILLed at +30 s (GUI closed in 30.6 s); it is now reaped as "exited cleanly" ~50 ms after it exits (GUI closes in 4.0 s). New POSIX tests cover the clean exit, the early requested exit and a graceful SIGTERM, each time-bounded so the old code fails fast. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> |
||
|
|
9339bb0663 |
test(windows): build and run the test suite for Windows from WSL
scripts/test-windows.sh cross-compiles ObsidianDragonTests.exe (and the fake_dragonxd stub) with mingw into build/windows-tests, reusing the toolchain and dependency sources from build/windows, and runs it from WSL through Windows interop (or wine). All tests pass on Windows. CMake: - Windows forced BUILD_TESTING OFF in the cache to keep curl's tests out, which also removed ObsidianDragonTests from every Windows build. Hide it from curl with a scoped variable instead, and restore our cache value afterwards (curl's cmake_dependent_option FORCE-writes it). Windows still defaults to OFF, so release builds are unchanged. - ObsidianDragonTests now depends on copy_langs: i18n.cpp includes the generated embedded/lang_*.h, so a tree that builds only the tests failed (latent on Linux too). - Link the Windows system libraries the tests need. Tests: - testTmpPath/fixtureDir/removeTree helpers: no hard-coded /tmp, a runtime fixture-dir override (the compiled-in path is a Linux one), and best-effort cleanup that can't abort the suite. - Close the explorer cache before deleting its directory (Windows can't delete an open file). - Compile out the address-book and wallet-index round-trips on Windows: getConfigDir() uses SHGetFolderPath there, which no environment variable redirects, so they would overwrite the real %APPDATA%\ObsidianDragon files. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> |
||
|
|
fae8b20fb7 |
fix: FAQ CJK glyphs, outgoing-tx history, lite encryption, migrate & sync safety
Bug-report fixes plus adversarial-audit follow-ups. Bumps full 2.0.1->2.0.2 and lite 1.1.0->1.1.1. i18n / fonts: - Rebuild the NotoSansCJK subset with the glyphs the FAQ back-fill introduced (什/么/门/做); Chinese FAQ titles rendered as "??" on zh (and ja/ko). Transaction history (enumeration gaps): - Surface outgoing shielded z->z sends via z_listsentbyaddress (listtransactions and z_listreceivedbyaddress never report them). - Surface z->t deshields by parsing z_listsentbyaddress transparentSends (which z_viewtransaction does not expose); dedupe t->t against listtransactions. - Key send-row dedup on address so equal-value multi-output sends aren't dropped. Lite wallet (key safety): - The async create/restore/open path the UI uses now applies the passphrase: encrypt a new/restored wallet, unlock an existing one. It previously discarded the passphrase, storing the seed/keys in PLAINTEXT (create/restore) or leaving an encrypted wallet locked (open). Migrate-to-seed (fund safety): - Persist a sweep-submitted marker at broadcast so a resumed migration can't treat a ~0 balance (an unconfirmed in-flight sweep) as an empty wallet and adopt (swap wallet.dat) before the sweep confirms; best-effort locate routes to the confirm gate. Sync / threading: - Gate isSynced() on a peer-derived tip (tip_known) so a peerless node isn't reported synced (Send against a stale balance); add a "no peers" status banner. - Guard daemon_status_ with a mutex (monitor-thread write vs UI-thread read). Tests: extends tests/test_phase4.cpp across all of the above; suite green. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BwycPDQSfEKtrTS2uC4JeT |
||
|
|
0a042df8e0 |
feat(chat): per-conversation delete (revive + block); memoize chat/badge render
Adds a per-conversation "delete" with two modes, and removes the per-frame rescans of the chat history that shared these files. Delete conversation (header trash icon → confirm dialog): - Delete (revive-on-new-message): clears local history and tombstones the messages (new chat_deleted table, keyed dedup hashes) so the every-few- seconds memo re-scan can't re-import them; a genuinely NEW message (new txid) revives the thread. - Delete & block: removes history WITHOUT a tombstone and records the cid as blocked (settings); ChatService::ingest drops that conversation's messages — old and future — until unblocked from the "Blocked" manager, which then re-imports the conversation from chain. - Local-only (messages remain on-chain; the peer keeps their copy). deleteConversation() deletes the DB rows FIRST and only then mutates the store, so a failed write can't leave the two diverged. Unit-tested (revive / tombstone-survives-reload / block / unblock) and adversarially reviewed (store/DB divergence, half-open DB, revive-unread). Performance (chat + badge hot paths, from the perf audit): - ChatStore gains revision(); the Chat unread badge is now a single O(N) no-alloc pass cached on it (was O(conversations x messages) copy+sort every frame). The conversation list and open thread are memoized on revision() (+ show-hidden and AddressBook::revision() for peer-name resolution). - AddressBook gains revision() so an in-place contact rename invalidates the chat memo (an edit keeps entries().size() constant). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> |
||
|
|
4492aa3425 |
fix(sync): prioritize getblockchaininfo and pause chat scans while behind
The node kept falling behind the network near the tip because sync DETECTION was starved: getblockchaininfo was queued behind the O(mapWallet) wallet RPCs (z_gettotalbalance / z_listunspent), so longestchain went stale, the wallet decided it was "synced", and it resumed hammering cs_main — a feedback loop. - Issue getblockchaininfo FIRST each cycle and skip the balance/address/ tx refresh entirely while behind, so sync state (and kSyncProfile) updates before any heavy wallet scan runs. - Gate the two chat note scans (refreshChatNoteBudgetNode / fastScanChatMemos) on effectivelySyncing() and the active page, so chat memo scanning no longer competes with block connection during sync. - Windows debug.log tailer: reset the read offset when dragonxd truncates the log on startup (it was stranding at Block:0 with no witness/rescan progress). - Tests cover the getblockchaininfo-first ordering and behind-skip. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> |
||
|
|
ea26c0cbbb |
fix(balance): stop the displayed balance cratering during a pending shielded send
Sending a small amount from an address holding a large balance made the displayed balance collapse to ~0 until the tx confirmed. A shielded spend consumes the whole source note; the change returns as a 0-confirmation note, and every balance query used the default minconf=1 — so the spent note dropped out and the change wasn't counted yet. Split every balance into two views: - DISPLAY (balance / privateBalance / transparentBalance / totalBalance) — now queried at minconf=0, so it INCLUDES the user's own pending change and no longer craters. This is what the Overview, balance tab, market portfolio and receive tab show. unconfirmedBalance is now populated (= total - spendable). - SPENDABLE (new spendableBalance / spendable*Balance) — confirmed (minconf>=1), what z_sendmany (run at minconf=1) can actually spend. The Send form's available/ Max/validation, the from-address selection, the drag-to-transfer dialog cap, the chat pay-from and the auto-shield gate all size off these, so they never offer 0-conf change the daemon would reject. Implementation: a single z_listunspent(0)/listunspent(0), partitioned per-note by "confirmations">=1; z_gettotalbalance called at minconf 0 (display) and 1 (spendable); the z_getbalance fallback queries both. applyPendingSendDelta (the optimistic post-send debit) now touches ONLY the spendable fields — debiting the display too would re-crater it on top of the honest minconf=0 RPC. Lite mirrors spendableBalance = balance (its per-address balance is already confirmed) so lite sends aren't zeroed. The confirmed-only gates (seed-migration/sweep z_gettotalbalance, sweep z_getbalance(addr,1), z_sendmany's minconf arg) are untouched. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> |
||
|
|
3216debc7d |
fix(node): detect a wallet salvage at startup, not only on connect
Reported: loading a BDB-inconsistent wallet silently renamed it and created a new
one — no recovery dialog. Two causes, both fixed:
1) Detection ran only in onConnected(). The salvage happens at STARTUP, and the
node may never connect (block-index abort, long sync, crash) — or a long sync
trims the salvage line out of the rolling output buffer before connect. Extract
detectWalletAutoRecovery() and run it every tryConnect() tick (every ~5s during
startup), so the salvage is caught the instant it appears, regardless of whether
the node connects. Also hold the crash-restart loop while a salvage is pending,
so the wallet can't be re-salvaged/shrunk while the Rebuild/Restore dialog is up.
2) walletAutoRecovered() only matched the SUCCESSFUL-salvage strings. A
BDB-inconsistent file makes aggressive salvage FAIL ("found no records"), which
prints different lines. Broaden the detector to the signals that fire in every
case: "CDBEnv::Salvage", the "Renamed <wallet> to wallet.<ts>.bak" rename, and
"found no records in wallet" — while still not matching normal startup or a
block-DB abort.
Adds the exact failed-salvage sequence to the detector test. Build clean, suite
green (1/1).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
|
||
|
|
bc183257b7 |
feat(node): in-app "Rebuild wallet database" recovery for a BDB-inconsistent wallet
Automates the manual recovery that fixed a wallet.dat with stale Berkeley DB extent metadata (the "main" subdb metapage records a low last_pgno while its live data spans thousands of pages beyond it). A tolerant page-walk reads every record, but the daemon's BDB verify rejects the file and auto-salvages it — finding nothing and shrinking the wallet to empty on each restart (the salvage cascade that looks like fund loss). Plain "Restore original" can't fix it (hands the same broken file back → re-salvage); a rebuild must produce a fresh, consistent DB. Pieces (Approach A from the design workflow — out-of-process helper keeps AGPL Berkeley DB out of the GPLv3 GUI): - util/wallet_file_probe.h: extractWalletBtreeRecords() — sibling to parseWalletBtree that collects raw (key,value) bytes (same bounds-checked, subdb-aware walk). Records copied verbatim → encrypted key material passes through as opaque ciphertext (no passphrase). Overflow-page values (only large tx history) are skipped + counted; a rescan rebuilds history — funds unaffected. - tools/wallet_rebuild/main.cpp: dragonx-wallet-rebuild CLI — reads via the tolerant reader, writes the records into a fresh BDB "main" btree via libdb (DB_EXCL, never overwrites), prints a JSON summary. New BDB-guarded CMake target. - App::rebuildWalletDatabase(): picks the largest readable wallet/.bak as source, stops the daemon, runs the helper, VERIFIES the output (readable BDB with keys) before swapping, moves the current wallet aside (kept, timestamped), installs the rebuilt one, clears the stale BDB env, sets -rescan, restarts. Copy/rename only — never deletes. Result surfaced via the existing pumpWalletRestore channel. - Wired as the preferred action on the existing wallet-auto-recovery dialog (shown only when the helper is present). Full-node only; lite-safe. Verified end-to-end against the real broken wallet: helper reads 3,808 t-keys + 1 z-key + HD seed and the daemon LOADS the rebuilt output with no salvage. Adds extractWalletBtreeRecords coverage. Build clean, suite green (1/1). Remaining (follow-up): release packaging — build.sh bundling the helper built against the vendored per-platform static libdb (DRAGONX_BDB_ROOT), and a macOS Berkeley DB port (no in-tree artifact). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> |
||
|
|
975650f11b |
fix(node): restore the LARGEST salvage backup, not the newest (salvage cascade)
The "Restore original wallet" action picked the newest wallet.<ts>.bak — but the daemon auto-salvages on every failed BDB verify, and each round SHRINKS the wallet (salvage keeps only readable records + drops the dead-page bloat). In a cascade the newest .bak is the most-degraded (seen in the wild as "Salvage found no records") while the original is the oldest and by far the largest. Pick by file SIZE instead: add largestWalletSalvageBak((name,size) pairs) — the largest wallet.<digits>.bak is the least-salvaged, i.e. the pristine original (an emptied salvage is tiny; a real wallet is large); ties break to the newest ts. Factor the shared parse into parseWalletSalvageBakTs(). restoreOriginalWallet() now gathers file sizes and uses it (still verifies the pick is a valid BDB before swapping). newestWalletSalvageBak kept for reference. Adds a cascade regression test (a 40KB emptied newest .bak must NOT win over the 194MB original). Suite green (1/1). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> |
||
|
|
3b7423f3a1 |
feat(node): warn when the daemon auto-recovers (salvages) wallet.dat
dragonxd auto-recovers a wallet.dat that fails BDB verification on startup — no flag needed (CWallet::Verify → CDBEnv::Verify(walletFile, CWalletDB::Recover)): it moves the original to wallet.<timestamp>.bak, salvages readable keys into a fresh wallet.dat, and keeps running. The salvage can be incomplete (or the whole thing a FALSE POSITIVE from stale/cross-platform BDB env state — __db.* / the database/ dir carried between machines), so the node silently comes up on a possibly-empty wallet. To the user that reads as fund loss, with no warning. Detect it and warn loudly instead: - daemon/daemon_startup_diagnosis.h: pure walletAutoRecovered() (the salvage / "Original wallet.dat saved as wallet.<ts>.bak" markers) + newestWalletSalvageBak() (picks the wallet.<unixtime>.bak the recovery just made). - onConnected() scans the node's captured output once per session; on a match it shows a warning dialog + notification: the ORIGINAL is safe in wallet.<ts>.bak, the shown balance may be incomplete, and here are the exact steps to restore it (rename the .bak back + delete the stale database/ + __db.* env). One-click "Open data folder" jumps straight there. Full-node only; lite-safe. Deliberately does NOT auto-swap the wallet files (untested per-platform file manipulation on a real wallet is not worth the risk) — it informs + guides. Adds walletAutoRecovered / newestWalletSalvageBak coverage to testBlockDbOutputDiagnosis. Suite green (1/1). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> |
||
|
|
d136916e80 |
feat(node): detect an unreadable block DB on startup and offer a one-click reindex
When a daemon update changes the block-index on-disk format (or the index is
corrupt), dragonxd aborts at startup — "non-canonical optional discriminant" →
"Error loading block database. Aborted." — and the wallet silently shows a zero
balance. Previously the connect loop just crash-restarted into the same abort up
to 3x and then reported a bare "Daemon crashed N times", with no path forward.
Now:
- daemon/daemon_startup_diagnosis.h: pure blockDbOutputLooksBroken() classifies
the crashed node's captured console output (the fatal block-DB markers).
- The connect loop detects it on the FIRST abort, STOPS crash-restarting into the
same failure (each retry reloads the whole index — wasteful), and offers a fix.
- A one-shot -reindex flag (EmbeddedDaemon::setReindexOnNextStart → DaemonController
forwarder → args) rebuilds the block index + chainstate from the intact raw
blocks; App::reindexBlockDatabase() arms it and un-gates the loop to restart.
- An auto-shown dialog (renderBlockDbReindexDialog) + a notification explain the
situation ("your coins are safe; the node just can't load the chain") and offer
a one-click "Rebuild block database". Full-node only (gated), lite-safe.
This is the exact trap behind a real "big wallet shows no funds" report: a
post-format-change daemon over pre-change chaindata. Reindex also fixes a plain
corrupt index.
Adds testBlockDbOutputDiagnosis (the abort sequence + individual markers trip it;
normal startup / wallet-corruption / asmap errors do not). Suite green (1/1).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
|
||
|
|
5296dd7ae5 |
fix(parsers): harden RPC/price/updater parsing against valid-but-unhandled input
Audit of the full-node RPC response parsers and updater release-body parsers (find -> adversarially-verify workflow) surfaced three worth fixing; three others guard formats the project doesn't emit and are backstopped by signature verification, so they're documented rather than churned. - Price (Medium): parseCoinGeckoPriceResponse used .value(key, 0.0), which throws type_error on a PRESENT null. CoinGecko emits null for usd_24h_change/ usd_24h_vol on illiquid tokens (DRGX is one) while still returning a valid spot price; the outer catch turned that into no price update at all. Read null-tolerantly so the valid usd/btc survives. - Daemon updater (Medium): parseDaemonChecksums blanked '|'/backtick but not markdown emphasis, so a bolded **archive.zip** checksum row was dropped and a valid, correctly-signed release would be refused. Also blank '*'/'_' (cannot cause a wrong-asset match; the 64-hex + .zip-suffix tests are unchanged). - Opid poll (Low, severe failure mode): parseOperationStatusPoll read id/status via .value() (throws on a present non-string) and the call site parsed OUTSIDE its try/catch, so a throw left opid_poll_in_progress_ stuck true and wedged all z-operation polling for the session. Type-check the reads and parse inside the guard. (dragonxd can't emit non-string id/status; this is defense-in-depth.) Regression tests: CoinGecko null field; opid non-string id/status; **bold** checksum row. Suite green (1/1). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> |
||
|
|
a1d3964e34 |
fix(lite): require exactly 24 words on first-run restore (crash on valid seed)
The lite first-run restore wizard enabled Restore for {12,15,18,21,24}-word
phrases, but the SDXL backend only accepts 24-word / 32-byte-entropy seeds:
LightWallet::new does copy_from_slice(&phrase.entropy()) into a [u8;32]
(lightwallet.rs:231), which panics on 16/20/24/28-byte entropy. Mnemonic::
from_phrase accepts the shorter valid phrase, and the restore FFI
litelib_initialize_new_from_phrase (lib.rs:127) has no catch_unwind (unlike
litelib_execute), so the panic unwinds across extern "C" -> process abort
(UB on the pinned rustc 1.63). A user restoring a legitimate 12-word seed
from another wallet crashed the app.
The Settings restore gate was already tightened to == 24 (
|
||
|
|
c3e81a5fa6 |
fix(send): accept P2SH/multisig recipients in the send + URI address gates
Same defect class as the import-key fix: a hardcoded prefix/length pre-filter
layered over the checksum validators rejected valid addresses before the daemon
saw them. The send-screen recipient gate required a[0]=='R', and the payment-URI
parser accepted only 'R'/'t' with rigid length bands — so every valid P2SH /
multisig address (DragonX SCRIPT_ADDRESS=85 → 'b…') was silently refused, leaving
the Send button disabled with no usable recipient.
Centralize recipient recognition in util/address_validation:
- isTransparentAddress: Base58Check with a 21-byte version+hash160 payload —
covers P2PKH ('R…', v60) AND P2SH ('b…', v85) on every network, rejects WIF
keys / typos by real checksum.
- isShieldedAddress: Bech32 + a Sapling payment-address HRP (zs / ztestsapling /
zregtestsapling), distinguishing a payment address from a viewing key.
- isValidRecipientAddress: either of the above.
send_tab's two validity helpers (the single choke point for all 5 call sites) and
the payment-URI format check now route through these. The URI parser now
checksum-validates the recipient (fail-fast on transcription errors) rather than
being prefix/length-only.
Tests use real checksummed vectors (P2PKH/P2SH/shielded, WIF- and typo-rejection);
testPaymentUri updated off its old fake fixed-char addresses. Suite green (1/1).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
|
||
|
|
d603a54618 |
fix(import): recognize real DragonX key formats in the import gate
The client-side pre-check rejected legitimate keys before the daemon ever
saw them, surfacing "Unrecognized key format" / a cryptic daemon "Invalid"
error. Two concrete defects plus the brittle heuristic behind them:
- Viewing keys: isViewingKey looked for Zcash's "zxview" extended-FVK
prefix, but DragonX's z_exportviewingkey emits a Sapling *incoming*
viewing key (HRP "zivks"), which z_importviewingkey is the only form the
daemon decodes. Every real DragonX viewing key was refused. (F1)
- Uncompressed transparent WIF: the length+first-char heuristic accepted
{5,K,L,U} only, but a version-188 uncompressed key starts with '7'. (F2)
Replace the heuristic with structural validation using the existing
checksum validators (F3): add util::decodeBase58Check (checksum-stripped
payload) and util::bech32Hrp (HRP of a valid Bech32 string). Transparent
keys are now accepted by decoding Base58Check and checking the payload is a
33/34-byte secret key with a DragonX SECRET_KEY version byte (188 main/
regtest, 128 testnet) — covering compressed and uncompressed, rejecting
addresses/typos by real checksum. Viewing keys are matched by the real
incoming-VK HRPs (zivks / zivktestsapling / zivkregtestsapling).
The Sweep gate and the dialog's live type indicator run off the same
predicates, so they are fixed too (F4). Messaging now names the likely
cause and appends a wrong-coin/network hint to the daemon's raw "Invalid"
error (F5).
Adds testPrivateKeyImportRecognition plus decodeBase58Check/bech32Hrp
coverage; suite green (1/1).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
|
||
|
|
32be868dbc |
feat(migrate): persist the sweep opid so a mid-sweep interruption can resume (W3-3)
Migrate-to-seed submits z_mergetoaddress -> an async opid, then only persists the resolved txid once the op completes. An app-close during Sweeping (opid submitted, txid not yet resolved) dropped the opid and resumed at the re-sweep gate, silently losing the tx. Now the opid is persisted and re-tracked on resume. If the daemon forgot it (restart — its op queue is in-memory only), the existing poller flags it stale and the callback falls back to the dismissable Sweep gate; it can never hang (a thrown RPC aborts the poll, so a stale classification only comes from a *successful* poll that omits the opid). - New seed_migration_sweep_opid setting; adopted atomically with clearing any prior txid in the SAME settings.save(), and only once the submit succeeds — so a failed "Sweep remaining" re-sweep keeps the already-mined first sweep's Confirming context, and txid/opid are never both authoritative (resume checks txid first; torn-write safe). - Resume routing extracted to a pure, unit-tested helper (data/seed_migration_resume.h::decideSeedMigrationResume): txid -> Confirming; opid AND connected -> re-track (Sweeping); else -> the dismissable Sweep gate. The connectivity gate keeps a disconnected resume out of the buttonless Sweeping spinner. - Shared makeSweepCompletionCallback(resumed): success -> Confirming; resumed-stale -> Sweep gate (re-fetch balance + "may have already completed" copy); fresh-fail -> Error. Fund safety unchanged: adopt still gated on legacy balance ~0 AND sweep tx mined; legacy wallet.dat only ever moved to a never-deleted timestamped .bak. Reviewed in two adversarial rounds (design + implementation) per the migration-code mandate; both safety facts (no fund loss, no hang) held, and the resume-UX traps they surfaced are fixed. Build-clean; ctest 1/1 (adds testSeedMigrationResume). See docs/wallet-hardening.md. *** Still requires a live mainnet interrupted-sweep run before release (human gate). *** Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> |