The hashrate sparkline guessed the age of its oldest sample from the
sample count, labelling the left edge "5m ago", "1m ago" or "start"
regardless of how fast samples actually arrived. Solo and pool samples
come in at different intervals, and a slow or stalled poll made those
labels wrong.
- Each hashrate sample now carries a wall-clock timestamp, kept in
lockstep with the value as the history fills and rolls over, for both
the solo and pool histories.
- The chart's left-edge label is derived from the oldest sample's actual
timestamp, falling back to "start" only when no stamp is available.
Tests: a mining refresh records a timestamp per sample, and the
timestamp history stays the same length as the value history once the
history is capped.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The Mining tab shows earnings derived from transaction history, but it
was not treated as a wallet-data page, so newly mined or paid-out
earnings did not appear until the user left the tab and came back. A
coinbase row also changes category in place (immature becomes generate
at maturity, or orphan on a reorg), and the Recent merge kept both the
old and the new row, double-counting the earnings.
- The Mining tab now gets the cheap between-blocks Recent poll: one
listtransactions per new block, bounded by the 15 s transaction-age
timer, once history has loaded. It never runs a full routine scan and
never pre-empts an owed dirty cycle; tracked sends still take their
normal cycle on any page.
- The shielded-payout probe runs only while the pool miner is running,
since pool payouts are shielded receives while a solo miner's earnings
are transparent coinbase rows.
- The Recent merge drops the previous row of the other coinbase category
when a coinbase transitions, so maturing or reorged rewards are no
longer counted twice. A non-coinbase row sharing the txid is left
alone.
Behaviour change: mining earnings update on the Mining tab per block
instead of only on tab re-entry, and maturing or reorged coinbase rows
no longer double-count.
Tests: the per-block poll gate (one poll per block, due only after
history loads, held while dirty or addresses are dirty), the Mining-page
cycle shaping, and the coinbase transition merge for immature to
generate, generate to orphan, and a shared-txid non-coinbase row.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
WalletState had no change signal for its transaction list, so a UI
cache had to fingerprint all N transactions every frame to notice an
update. It now carries tx_revision, a counter that only increases;
clear() on disconnect bumps it instead of resetting it, so a cache
built before a reconnect or wallet switch can never match again.
- Every writer of the list bumps it: the full-node refresh, loading the
cached history, adding, completing and removing a pending send, a
mining-address relabel, the lite refresh and the screenshot demo
rows.
- The full-node refresh and the lite refresh (every ~2 s) swap in the
new list only when it differs field by field, the amount compared
bit-exactly because -0.0 and +0.0 print differently. An unchanged
refresh keeps the list and the revision; the compare costs O(N) per
refresh, not per frame.
Nothing reads the counter yet.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
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>
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>
"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>
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>
- 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>
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>
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>
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>
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>
Connected-but-locked fell through to tryConnect() on every Core tick
while the lock screen sat idle: new sockets, getinfo and getwalletinfo,
a forced address scan, a CoinGecko fetch and a wallet-index rewrite.
Its wallet scans also held cs_main ahead of the user's
walletpassphrase.
- While locked, poll chain state only (refreshCoreData without the
balance). That keeps the sync height current and the 3-strike
lost-connection check armed, so a node that exits while locked is
still restarted. Pool mining keeps the lock screen down, so while it
runs the throttled balance, the peers and the lock state (for an
unlock done in the Console tab) keep refreshing too.
- Warmup polls count refused/reset connections toward the
lost-connection check, so a node that died during warmup is
restarted instead of leaving the loading or lock screen over it.
- The wallet salvage/degraded detectors run at connect and again when
warmup ends: for encrypted wallets they relied on the repeated
tryConnect.
- restartDaemonAfterEncryption records the wallet as encrypted and
locked itself (the wizard's deferred path never did), and marks the
main client disconnected so the reconnect path picks up the restarted
node, aborting any in-flight call first.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
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>
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>
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>
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>
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
Immediate-mode render functions re-run every frame; these rebuilt O(N)
state each time even when nothing changed. From a 6-lens perf audit, each
finding verified on a hot-path basis:
- Console: ConsoleModel gains revision(); the full-model filter scan and the
glyph-by-glyph text-layout pass (BuildConsoleLayout) rebuild only when the
model / filter / wrap-width / zoom change — previously it re-shaped up to
10,000 lines every frame even when idle/scrolled. clear() force-invalidates
the memo mid-render (no OOB on the just-emptied visible set).
- Transactions: the summary-card totals memoize behind the tab's existing
FNV-1a fingerprint (also folds away a now-duplicate O(N) display-key pass).
- Send / Receive recent lists: early-exit the prefix scan (state.transactions
is kept newest-first) instead of filtering the whole tx history every frame.
- network_refresh_service: O(new x total) txid find-and-replace -> hash map.
- Sidebar unconfirmed-tx badge cached on last_tx_update + tx count; the
daemon-memory probe (/proc scan on Linux, popen on macOS) throttled to ~1.5s.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
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>
On a fully-shielded (ac_private=1) chain, z_gettotalbalance is O(mapWallet) and
holds the daemon's cs_main for its whole duration — ~20s on a ~5k-tx wallet. The
Overview refresh polled it every ~2s (twice: minconf 0 and 1), so cs_main was
held almost continuously, starving the single block-connection thread: the node
connected blocks only in the gaps between polls and could fall further behind
the tip than it caught up (observed live: gap growing 58→100 blocks while the
GUI was open, one core pegged on GetFilteredNotes, 22 idle, ~17 B/s download).
Two hardening changes on top of the existing "skip balance while syncing" guard:
- Hysteresis: keep the low-impact sync profile (and balance suppression) for a
short settle window after catching up, so a large-wallet scan can't
immediately re-starve connection and bounce the node back into syncing. Armed
only on the syncing→caught-up edge, so a wallet synced from the start is never
throttled at connect (effectivelySyncing()).
- Adaptive balance cadence: time each z_gettotalbalance scan and require the
next poll to wait at least (cost / 10%), so balance scanning never occupies
more than ~10% of wall-clock. Cheap wallets are unaffected (the tab's Core
timer stays the cadence); a ~20s scan backs off to ~200s. Wallet mutations
(send/shield) force the next poll through so the user's own action updates the
balance immediately (balanceRefreshDue()).
getblockchaininfo keeps its normal cadence throughout, so sync progress stays
live. Build + test_phase4 green.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Security audit remediation (15 confirmed findings from the codebase audit):
- H-02: scrub+delete the decrypt-flow plaintext key export on ALL exit paths
(RAII guard) and purge stale obsidiandecryptexport* files at startup.
- M-01/L-03/L-04/L-05/L-07: sodium_memzero the Set-PIN and encrypt-PIN worker
passphrase/PIN copies, the RPC Basic-auth string (auth_), the exported/imported
key buffers (App::wipeSecrets, called from ~App and before main's _Exit), and
the first-run wizard "Skip" buffers.
- M-03/M-04/M-05/L-06: return locked COPIES from XmrigManager/EmbeddedDaemon
getters (dedicated error_mutex_; DaemonController::lastError now by value),
route xmrig last_error_ writes through a locked setter, and wrap
shutdown_status_/wizard_stop_status_ in a locking GuardedStatus
(wizard_stopping_external_ -> std::atomic).
- M-02: persist after a console send/shield/import in the lite backend.
- L-01: require the confirm click for z_shieldcoinbase/z_mergetoaddress.
- L-02: quote/escape each Windows daemon argv per the MSDN CommandLineToArgvW rules.
- L-08: pin json/tomlplusplus/libwebp FetchContent to immutable commit SHAs.
- I-01: extract updater archives from the already-verified in-memory buffer
(no disk re-read TOCTOU).
Feature: warn once (full-node) when the active wallet loads empty while a sibling
wallet file in the datadir holds keys. A funded salvage wallet.<ts>.bak routes to
the recovery/Restore flow; a funded sibling .dat routes to the wallet manager.
Per-wallet-file dismissal; gated on synced + address-list-loaded to avoid false
positives on warm reconnect / spent-down wallets.
UX fixes:
- send: show the TOTAL balance (with a spendable "available" note) in the source
dropdown and keep pending-change addresses visible.
- chat: insert emoji at the cursor position; restrict new-chat recipients to
shielded (z) addresses.
- console: optional auto-focus of the command input on tab open (off by default).
- shutdown: when "stop external daemon" is on, keep the shutdown screen up until
the external node actually exits, showing live status.
Adversarially reviewed; verified across full-node, lite, and Windows builds; tests pass.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
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>
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>
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>
std::localtime shares a process-wide static tm; both sites are reachable from background threads (the RPC price-parse on the worker thread, and Logger::write from worker/monitor threads), so a concurrent localtime call could clobber the struct between the call and the read. Copy into a local std::tm via localtime_r / localtime_s, matching the codebase's existing convention (console_tab rpcTraceTimestamp, app_network, etc.). From the console-tab audit; benign (garbled/stale timestamp only), no crash or state impact.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Two causes of the "every message I send shows up again as a reply from the peer":
1. Harvest bug — the full-refresh path fed a SENT tx's outgoing memos (from
z_viewtransaction outgoing outputs) into the chat metadata extractor, and
ChatService::ingest marks everything Incoming. So each send was re-ingested as
a phantom "from peer" message. The recent-refresh path already omitted this, so
it was accidental. Drop the outgoing chat-harvest (keep the tx-history harvest);
genuine incoming still comes from z_listreceivedbyaddress, and our sends are
recorded by the local echo.
2. Own-identity ingest filter — a memo whose sender public key equals our own
identity is by definition something we sent (only we hold our key); it must
never be ingested as incoming. Skip those in ingest. This also collapses
same-seed self-chat (running the SAME wallet in the full node and Lite makes
them one chat identity, so sends land on an address we also own and loop back).
For a real two-party chat use two DIFFERENT wallets/seeds — same-seed wallets are
one identity and can't be distinct peers.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Rework the full-node Import Key dialog into a safer, Material-consistent, more
capable flow:
- Security: the key is masked by default (Password) with a reveal (eye) toggle,
a DialogWarningHeader "only import a key you own" note, and the buffer is
wiped with sodium_memzero on close and on successful import.
- Progress: import triggers a blocking full-chain rescan; the dialog now shows
"Importing & rescanning — this can take several minutes" with LoadingDots and
disables Import while it runs (no double-submit). Offline shows a "connect a
running node" note and disables Import up front.
- Viewing keys: the classifier gains isViewingKey / isRecognizedImportKey;
importPrivateKey auto-detects a shielded viewing key (zxviews…) and routes to
z_importviewingkey (watch-only) vs z_importkey / importprivkey. The live type
indicator shows Transparent / Shielded spending / Shielded viewing (watch-only).
- Result: on success the imported address is captured from the RPC result and
shown via AddressCopyField.
- Material restyle (OverlayDialogSpec/BlurFloat + TactileButton + Esc) and full
i18n (12 new keys x 8 languages; CJK subset rebuilt).
Verified: rendered on dark + light skins; a seeded zxviews… key shows the masked
field + "Shielded viewing key (watch-only)" indicator.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The app-wide "import-key pattern" fixes (missing guards / untrimmed input / no
re-entrancy protection), from the robustness audit:
- Import-key dialog: unify the type indicator and the RPC dispatch on one
classifier (classifyPrivateKey is now prefix-aware — an "SK..." shielded key no
longer misroutes to the transparent RPC), trim manual input (not just Paste),
and gate both the indicator and the Import button on a shared
isRecognizedPrivateKey() so unrecognized/empty input can't be submitted.
- Shield: guard the submit button on connected + !syncing (like Send), with a
disabled tooltip, instead of firing into a raw daemon error.
- Mining: disable the pool Mine button when the payout address is empty
("enter a payout address first"), and trim the pool URL/worker on persist.
- Console: track in-flight RPC commands (atomic counter + busy() override) so the
input is disabled while a command runs instead of piling up on the worker.
- Maintenance (rescan/repair/delete-chain/reinstall-daemon): re-entrancy guard —
bail with "already in progress" instead of launching a duplicate destructive op.
- Bootstrap dialog: re-attach to an in-flight download on reopen instead of
resetting state and orphaning the running worker.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Portfolio-group sparklines previously resampled the ~24-minute in-session price
buffer, so the hour/day/week/month intervals could never fill. Fetch real
historical USD series from CoinGecko /market_chart and back each interval with
appropriately-grained data.
- MarketInfo gains two timestamped series: price_chart_intraday (days=1, 5-min)
and price_chart_daily (days=365, daily), plus a fetch timestamp.
- App::refreshMarketChart() fetches both on the RPC worker via the TLS-verifying
util::httpGetString helper, self-throttled to ~30 min (historical data moves
slowly). Gated by getFetchPrices(); triggered on connect and each price tick.
- Pure NetworkRefreshService::parseCoinGeckoMarketChart() parses {"prices":
[[ms,price],...]} into (unix-seconds, price); malformed rows skipped. Unit-tested.
- market_tab pfSparklineSeries() maps interval -> series+bucket: minute = live
buffer; hour = intraday bucketed to 1h; day/week/month = daily bucketed to
1d/7d/30d. Falls back to the in-session buffer until the fetch populates.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Three P1 structural refactors from the audit; no behavior change.
1. Extract the ~180-line daemon-stdout rescan/witness parser out of the
~790-line App::update() into a pure, static, unit-testable
NetworkRefreshService::parseDaemonRescanOutput() returning a
DaemonRescanScan result struct (sits next to the existing static parse*
siblings). The scanning half moved verbatim; App::update() keeps the
state-application half, now reading scan.* fields. Adds
testParseDaemonRescanOutput() covering all six daemon signals + edge
cases against fixture log snippets — previously untestable without a
live daemon.
2. Extract the ~95-line global keyboard-shortcut block (Ctrl+, / theme
cycle / F5 / low-spec / effects / gradient / wizard) out of
App::update() into App::handleGlobalShortcuts().
3. Collapse the ImGuiAcrylic frontend fork: the GLAD and DX11 namespaces
were ~375 lines of semantically-identical code (the frontend makes zero
direct GL/DX calls — all backend work delegates to AcrylicMaterial, which
has a backend per API). Extended the single frontend's guard to
#if defined(DRAGONX_HAS_GLAD) || defined(DRAGONX_USE_DX11) and deleted the
DX11 duplicate, leaving the no-backend stub. Kills the Windows/DX11 drift
hazard (acrylic frontend changes now made once).
Verified: full-node + Lite build clean; ctest 1/1 (incl. the new parser
tests); source-hygiene clean; and the collapsed acrylic path was
compile-verified under DX11 via the mingw-w64 Windows cross-build
(ObsidianDragon.exe links).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The Market-tab portfolio (editor checklist + SumPortfolioBalance) reads the
combined WalletState::addresses view, but the full-node refresh path only ever
updated the authoritative z_addresses/t_addresses lists — rebuildAddressList()
was never called, so `addresses` stayed empty. Result: the manage-portfolio
modal showed no addresses to pick, and addresses added via the overview
right-click menu contributed no value to their entry (SumPortfolioBalance
found nothing in the empty combined list).
Rebuild the combined view in NetworkRefreshService::applyAddressRefreshResult
(the sole bulk updater of the address lists), and push newly created addresses
into the combined view immediately in the full-node create paths for
zero-latency parity with the overview (matching the lite branch). This also
repairs two other silently-broken full-node consumers of state.addresses: the
auto-shield target-address finder and the pool-mining transparent fallback.
Add a regression test (testWalletStateAddressListRebuild) covering the
empty-before / union-after rebuild and pending-send delta reflection.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The peer count in the status bar is state_.peers.size(), refreshed only by
getpeerinfo — and the peers refresh interval was 0 (disabled) on every tab
except Peers. So the count never changed until you opened the Peers/Network
tab. Give peers a slow 20s cadence on all tabs (30s on Console); the Peers tab
keeps its fast 5s for the live list. During sync this is still overridden by
kSyncProfile (peers 0) so it can't contend with block download. Test updated to
the new intervals.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The opid poll called z_getoperationstatus(["opid"]) to check a specific
operation, but this daemon rejects the filtered form with "JSON value is not a
number/array as expected" (a UniValue error returned as an RPC error). The
poll's catch swallowed it, so every completed send stayed stuck on "Waiting
for operation" forever — confirmed via a Windows debug-log capture showing the
throw on every 2s cycle. The no-arg form works (verified in the console).
Call z_getoperationstatus with no arguments (returns ALL operations) and filter
to the opids we're tracking in parseOperationStatusPoll(). The parser now skips
any operation whose id isn't in the requested set, so unrelated/old operations
can't fire a spurious error toast or pollute send state. The stale-opid logic
is unchanged (the no-arg form still reports in-progress ops, so a genuinely
pending opid is never misread as stale).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The full-node wallet polled the daemon at the per-tab cadence regardless of sync state.
On the Peers/Network tab that meant getpeerinfo every 5s + core every 5s + a full
transaction scan on every new block — and blocks arrive fast during sync. Each of those
calls takes the daemon's cs_main lock, the same lock block connection needs, so the node
synced noticeably slower than on the lightweight Console tab (core 10s, no peer polling).
Make the refresh cadence sync-aware:
- RefreshScheduler::kSyncProfile {core 10s, transactions/addresses/peers disabled} is applied
to ALL tabs while state_.sync.syncing, and reverts to the per-tab profile when sync ends.
applyRefreshPolicy() picks the profile; update() re-applies it on the syncing<->synced
transition. This suppresses getpeerinfo and the per-block tx scan during sync (that data is
incomplete mid-sync anyway) — every tab now syncs as fast as Console.
- collectCoreRefreshResult(rpc, includeBalance): skip z_gettotalbalance (wallet lock + cs_main)
while syncing; only getblockchaininfo runs, which is also what drives sync-progress detection.
applyCoreRefreshResult already leaves the balance untouched when balanceOk is false.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Two issues shared one root cause: the shielded-receive scan marked each z-address "scanned
at the EXACT current tip," but a new block (~36s on DRGX) advances the tip and invalidates
every prior per-address scan. A wallet with more z-addresses than one refresh cycle can
scan therefore never reached "all scanned at tip" — so shieldedScanComplete stayed false
and transactions_dirty_ stayed true forever, which (a) kept the history-refresh banner lit
and the full rescan churning every cycle, and (b) blocked maybeFinishTransactionSendProgress
(it waited on transactions_dirty_), leaving the send-progress indicator stuck on.
Fix 1 — completion tolerance. Add TransactionRefreshSnapshot::shieldedScanTipTolerance: an
address counts as fresh if its last scan is within N blocks of the tip (0 = old strict
behavior, so existing tests are unchanged). The app scales N with the z-address count
(2 + count/96, capped at 50), so a multi-block pass can COMPLETE before its earliest scan
goes stale. This also throttles full rescans to ~N blocks instead of every block —
transactions_dirty_ clears, the banner stops, and CPU/RPC churn drops. Already-fresh
addresses are skipped, so the per-block cost falls back to just the (cheap) transparent
listtransactions.
Fix 2 — send-progress gate. maybeFinishTransactionSendProgress() no longer waits on the
transaction history scan (transactions_dirty_ / Transactions job): the sent tx is already
shown via the optimistic pending insert, and the spend is reflected once the balance
refresh lands, so it now finishes on the address/balance signal alone.
Test: a tolerant snapshot skips recently-scanned addresses (shieldedAddressesScanned == 0,
shieldedScanComplete) while a strict one re-scans them.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
resetJobs() cleared the in-progress flags but left generations_ untouched, so a
refresh WorkFn still executing on the worker when a disconnect cleared state_
could pass completeDispatch's generation check and apply last-connection data
onto the new session. Bump every job's generation in resetJobs() so any
pre-reset ticket is treated as stale.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
z_sendmany returns an opid immediately; the tx is built/signed/broadcast
asynchronously afterward. The send path showed "Transaction sent successfully!"
and cleared the form on opid receipt, so a later async failure contradicted it.
Shield/merge stored the opid only in a dialog-local static (never polled), and
auto-shield ran a blocking z_shieldcoinbase on the UI thread and discarded its
opid — async failures of all three were silently lost.
- Add App::trackOperation(opid) so shield/merge/auto-shield register with the
shared opid poller (failures surface, balances refresh on completion).
- Defer the full-node send's success/failure to the poller via per-opid callbacks
(parseOperationStatusPoll now exposes failureByOpid); the "Sending..." spinner
covers the finalizing window, and the form is kept until terminal status.
- Dispatch auto-shield through the worker thread and use the configured fee.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Preserve the previously-uncommitted lite wallet implementation and related dev WIP
under version control:
- src/wallet/ lite services: client bridge, bridge runtime, connection, lifecycle,
sync, gateway, result parsers, state mapper, artifact contract/resolver, refresh
services, UI adapters, wallet_backend/capabilities. (Includes two small M1 fixes:
lifecycle walletReady now parses the response; default chain name -> "main".)
- src/chat/ chat protocol; tests/fixtures/ (lite + hushchat); tools/hushchat_fixture_check.cpp;
scripts/build-lite-backend-artifact.sh.
- Pre-existing modified app_network/security/wizard, network_refresh_service, sidebar,
mining_tab, bootstrap dialog, and version headers captured as-is.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Add an encrypted SQLite transaction history cache with cached tip metadata and
per-address shielded scan progress so startup and full refreshes avoid
re-scanning every z-address while still invalidating on wallet/address/rescan
changes.
Improve wallet history loading by paging transparent transactions, preserving
cached shielded and sent rows, keeping recent/unconfirmed activity visible, and
classifying mining-address receives. Show z_sendmany opid sends immediately in
History and Overview, pin pending rows through refreshes, and apply optimistic
address/balance debits until opids resolve.
Add timestamped RPC console tracing by source/method without logging params or
results, reduce redundant refresh/RPC calls, and cache Explorer recent block
summaries in SQLite.
Expand focused tests for transaction cache encryption, scan-progress
persistence/invalidation, history preservation, operation-status parsing,
pending send visibility, and Explorer/RPC refresh behavior.
Replay cached outgoing viewtransaction entries during transaction refresh so shielded sends created from the wallet remain in the History tab after send tracking is cleared.
Keep incomplete tracked sends retryable, preserve cached send timestamp/confirmation metadata, and emit a send placeholder from gettransaction metadata when viewtransaction enrichment is not yet available.
Add regression coverage for cached sends, retryable empty entries, placeholder sends, and send txid cleanup behavior.