The header key-verify lock was an unexplained hover-only icon. It's now
clickable, opening a dialog that shows the contact's identity-key fingerprint
and explains comparing it out-of-band, with a Copy-key button and a clearer
tooltip. The crypto/verification logic is unchanged (the wording is careful that
the lock means "encrypted to this key", since there is no trusted/verified flag).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Peer ban actions only toasted on failure; success was silent. Added success
toasts for ban/unban/clear-all, and the bulk "Clear All Bans" now takes a
confirming second click (per-row unban stays one-click). The setban/clearbanned
RPC calls are unchanged.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
- First-run handoff: one info toast ("node starting, will keep syncing in the
background") at the completion point, instead of dropping the user onto a busy
blank window with no explanation.
- Daemon updater: a newer installed binary now sets a persistent "restart
pending" reminder (surfaced in Settings → Daemon Binary with a Restart button)
and the updater's Done screen leads with a warning-level "restart required"
headline — easy to miss before.
- Settings: the debug-categories "restart required" note is raised from ~35%
alpha to a readable color with the restart prompt moved directly beneath it;
the DEBUG OPTIONS header shows a lock + explanatory caption so it's clearly a
gated diagnostics section (the auth gate itself is unchanged); a couple of
hardcoded color/size literals now use material tokens / dpiScale.
- Removed the legacy duplicate settings_window.cpp/.h (only reachable via a dev
flag, never in production) so edits can't silently miss one of two settings UIs.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
- The failed-unlock error message now stays on screen until the next attempt /
mode switch / success, instead of fading after ~3s (the reason was often gone
before it could be read).
- The escalating failed-attempt lockout counter + expiry are now persisted
(keyed to the active wallet file) and restored once per lock-screen session,
so killing and relaunching the app can no longer reset the lockout curve — a
brute-force window. A forward clock change is capped; the PIN/passphrase is
never stored. The verification (walletpassphrase/decrypt) path is unchanged.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Migrate-to-seed is irreversible and fund-moving, so its gates are strengthened
(UI only — the sweep/adopt/wipe logic, z_mergetoaddress args, amounts and
destination are byte-identical):
- Continue now requires a second, consequence-stating acknowledgement checkbox
(funds move and are recoverable only via the seed phrase), not just "I wrote
it down".
- Discard, Sweep-all and Sweep-remaining each take a second confirming click
(stating the amount being moved) instead of firing on one click; the confirm
body runs verbatim only after confirmation, and arming never survives a
close/reopen.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The chat header's export button wrote a decrypted plain-text file immediately on
click, warning only via a hover tooltip. It now opens a confirm dialog that
states the file is unencrypted and shows the destination path before writing.
The export format, path and write call are unchanged.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
- The pool payout-address rule is no longer hover-only: an invalid payout
address now outlines the field in red and shows a short reason below it (a row
is reserved so it doesn't overlap the card beneath). The longer tooltip stays.
- The solo "Mining Addr" row gains a hover explainer and a "None yet" empty-state
(with a tooltip pointing to Receive → New mining address) instead of a blank.
- Replaced hardcoded English in the earnings/stats cards ("(%d txn)", uptime
"N/A"/"%lldm %llds"/"%lldh %lldm") with TR() keys.
Also carries the chat-export confirm strings used by the next commit.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
- Request-Payment no longer dead-ends with no shielded address: it shows a hint
and a "Create a shielded address" button (auto-selecting the new address), and
labels address entries with their contact/wallet names.
- The balance "Recent transactions" empty area uses a proper empty-state and now
distinguishes "still loading / syncing" from "no transactions yet" instead of a
dim one-liner that also showed mid-sync.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Send-tab UX pass (presentation/validation only — the z_sendmany submit path, its
amounts, fee, recipients and memo bytes are unchanged, and the Review button plus
the confirm-time live re-check still block any send until the wallet is synced):
- The form is now editable while syncing (so the recipient/amount/memo can be
pre-filled); only Review/Send stays gated, with an "available once synced" note.
- The recipient's red "Invalid address" no longer flashes from the first
keystroke — it waits until the field loses focus.
- The memo field stays visible (text preserved) with a "requires a shielded
recipient" explainer for transparent recipients, instead of vanishing + silently
dropping what was typed.
- "Cancel" (which only cleared the form) is relabeled "Clear form", disabled when
empty, and drops its redundant confirm (the timed Undo already covers it).
- The source picker shows address labels, and warns when the chosen source is
transparent (non-private on this chain).
Also adds the string keys used here and by the next commit.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
- The Shielded balance card gains the explanatory hover tooltip the mining card
already has (suppressed while the pending-chip tooltip is showing).
- Toasts get a severity-tinted background fill and a slightly thicker accent
border, so Success/Warning/Error/Info differ by more than hue alone (the
per-severity leading icon already existed). Tint opacity is a ui.toml token.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
First-run bootstrap polish:
- Failed card wraps the (often long) error instead of clipping it, and offers a
"Try mirror" button; Retry now keeps whichever source (main/mirror) was used.
- Bootstrap offer shows an honest size/time hint and relabels "Mirror" →
"Use mirror" with a clearer tooltip.
- Daemon status dot/label is keyed off the EmbeddedDaemon::State enum instead of
matching the English substring "Stopping" (which broke in every other
language), and the faint ~55%-alpha label is raised to ~86%.
- Bootstrap Done screen: for the embedded daemon it now offers "Start Daemon"
(and says the node will start) instead of telling the user to start it by hand.
Also carries the new string-table keys for these (and the shielded-card tooltip
used by the next commit).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The conversation list now marks a pending stranger request with a small
"Request" pill (bottom-right of the row), so it's distinguishable before you
open it; the preview text is clipped to the pill's left so they don't overlap.
Completes the request-affordance pair with the in-thread Accept/Block card.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Extends the labeled "Pending +X" chip (with its awaiting-confirmations tooltip)
to three more layouts via a shared DrawPendingChip() helper. It draws on the
ticker line (the USD value sits on the line below in these layouts, so there's no
overlap) and is skipped if it would overflow the card. The remaining draw-list
layouts with right-aligned content (Consolidated, Minimal, the grid variants)
are left for a follow-up.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
An unsolicited first message from a stranger (an incoming ContactRequest, with
nothing sent by us and the peer not in contacts) now shows an Accept / Block
card above the thread instead of looking like a normal conversation. Accept adds
the peer to the address book (which clears the request state); Block deletes and
blocks the conversation (reversible from the Blocked manager). ConvSummary gains
a derived pendingRequest flag. (The list-row "Request" pill and a header Block
button were left out to avoid the fragile hand-drawn header-toolbar geometry.)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Shield-coinbase replaced its free-text "*"-wildcard address box with a dropdown
of the wallet's mining (transparent) addresses and their balances ("All mining
addresses" default), and the confirm step now previews "Shield up to X DRGX from
N mining address(es)… to <label>? Network fee: Y" instead of only relabeling the
button. Also blocks submitting from an empty source or one with no funds. The
z_shieldcoinbase call (args/fee/limit/destination) is unchanged.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The unconfirmed balance was shown in only one layout as an unlabeled "+X" that
read as a mystery. It's now a labeled "Pending +X" chip with an "awaiting
confirmations" tooltip — the Classic card's badge is relabeled, and the shared
compact hero (Dashboard + Two-row layouts) now draws it too. (The remaining
hand-drawn layouts can reuse the same pattern as a follow-up.)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
- Bootstrap: Retry now reuses the source the user actually started (was always
the default), and a "Try mirror" button offers the alternate server on
failure (s_lastUrl remembered on start, cleared on a fresh Confirm).
- Validate Address: classification + checksum are local, so the dialog no
longer requires a node connection — offline/lite now validate and show the
type, with ownership reported as "Unknown — node offline / not checkable in
the lite wallet" instead of a dead "not connected" screen.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
- Sync ETA: a new pure util::SyncRateEstimator (App-owned) replaces the
function-local statics that went stale after a rescan/restart (producing the
"412h" ETA). It self-resets on a height drop and on connect/disconnect/rescan/
restart (via an atomic flag, since onConnected can run off-thread), formats
with days, and uses the Warning() token.
- Settings: the Explorer-tab URL fields + custom-fees/fetch-prices checkboxes
now persist on edit — they were the only controls still relying on the
About-only Save button that a user on this tab never sees.
- Export-all-keys: hidden in the lite build (which has its own key export),
instead of dead-ending on "Not connected to the daemon".
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
- A mining start/stop/pool failure now sets a persistent App::mining_last_error_
(cleared on the next attempt), rendered as a red caption with "Press Mine to
try again" below the Mine button (plus a Dismiss, and a Windows-Defender
"How to fix" on an AV block), so a crashed/blocked/failed miner is no longer
just a button that "did nothing" once its toast faded. Two more hardcoded
English strings (pool-payout-invalid, AV-blocked) moved to i18n.
- Pool connection status is now a persistent "Status" row in the pool stats
card (PoolMiningState.running_since / last_error): it flips from "Connecting…"
to "Can't reach pool — check the URL" after a 30s timeout instead of sitting
on red forever, and no longer vanishes once logs arrive. The old transient
banner in mining_tab.cpp was removed.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The shield/merge status line was amber only while in-flight and uncoloured for
every terminal state (success, failure, uncertain). It now tracks an OpOutcome
(display-only) and colours the terminal line Success/Error/Warning accordingly,
shows a LoadingDots spinner while the 120s scan runs, and maps the raw daemon
error through friendlyRpcError. The actual shield/merge submit logic is
unchanged (no "Try again" that could double-submit a fund move).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
When the node rejects the wallet's RPC login (bad rpcuser/rpcpassword), the
status bar showed developer jargon ("Auth failed — check rpcuser/rpcpassword")
and the only action was Reconnect, which just repeats the failure. Now a
dedicated AuthFailed banner reason surfaces "Wallet can't sign in to the node"
with a plain-language detail and a real fix: Restart node for the embedded
daemon (regenerates its cookie creds) or Open config folder for an external one
(to fix DRAGONX.conf). Driven by a new rpc_auth_failed_ flag (set on the auth
path, cleared on each connect attempt), with evaluator unit tests.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The address-transfer dialog (own-address moves) enabled Confirm on amount alone,
so a disconnected / syncing / locked wallet failed only at submit. It now
mirrors the Send tab's guards (strictly more restrictive — the transfer logic is
unchanged) and re-checks live state before broadcast, shows an inline reason
when blocked (enter amount / not connected / syncing / locked), and labels the
result as a "Transaction ID" (not "Operation ID") with a copy button.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
- Warmup/daemon status no longer appends a hardcoded English " (Block N)" — it
uses a translated warmup_block_fmt key.
- Recent-transaction type labels (Sent/Received/Mined) are now translated in the
UI consumer (recentTypeLabel in balance_recent_tx.cpp) with a generic
"Transaction" fallback, instead of the data layer's raw English getTypeDisplay().
- Mining: a disconnected solo miner hovering the dead Mine button now gets a
"not connected to the node" tooltip instead of a misleading "Start Mining"
(the tooltip chain had no branch for the solo !isConnected() disable).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
HiDPI geometry debt from the audit: 15 security-dialog buttons used a raw 40px
height (undersized vs the scaled label at 1.5x), the market "Updated" caption
used a raw SameLine(0,12), and the QR popup's 28px overlay-inset budget wasn't
scaled (wrapping the address at HiDPI). All now go through ui::Layout::dpiScale().
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
- Contacts: a "Message" context-menu item for shielded (z) contacts hands the
address to the Chat tab (new OpenChatNewConversation entry point) and switches
there with the New-conversation dialog prefilled; disabled with a reason for
transparent-only contacts. (Kept to the safe context-menu + picker path, not
the hand-drawn 4-slot row action cluster, to avoid mis-wiring the destructive
Delete slot.)
- Chat: the New-conversation picker no longer silently hides transparent
contacts — they're shown disabled with a "needs a shielded address to chat"
tooltip instead of vanishing.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The conversation header only copies a PEER's address, so a user had no way to
hand out their own chat address for others to message them. Add a "Copy my chat
address" link in the left pane (public App::chatMyAddress() forwards the stable
seed-derived reply z-address; copied on click only, with an "address copied"
toast and a warning if none exists yet).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The aggregate price is deliberately kept on a failed or old refresh (it's never
cleared), so the hero number could be hours out of date while still drawn in the
"live" green. It now turns amber with an "as of N min/h ago" caption and a hover
tooltip carrying the friendly fetch error, when the last refresh failed or the
aggregate is >=5 min old (only when the hero is the aggregate, not an exchange
pair with its own feed). The hardcoded "Price fetch failed" default is now i18n.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Three next-batch UX-audit fixes:
- Chat delivery: the sending/sent glyph has higher contrast and a tooltip
("Sent to the network — arrives after it confirms", so a check isn't mistaken
for "delivered"); a failed send now carries the real reason (ChatMessage::
fail_reason, set from onChatBroadcastResult via friendlyRpcError, in-memory
only) in the "not sent" hover tooltip.
- Explorer: a block height beyond the tip says so ("Block N is beyond the chain
tip (M)") instead of silently snapping to the top; the empty block list shows
Not connected / Syncing (N behind) / Loading instead of an endless "Loading".
- Settings: the misleading "Reset to Defaults" button (which only reloads the
last-saved values) is relabeled "Discard unsaved changes" with accurate copy.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
- The network fee and running total are now shown on the compose form (once an
amount is entered), instead of only being revealed in the confirm popup.
- When the pre-broadcast re-validation fails (balance dropped, sync restarted,
fee pushed total over available), the confirm popup now closes and surfaces a
warning toast — previously the reason was drawn on the form behind the still-
open popup, so the user was left re-clicking a button that did nothing.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
- Seed-backup dialog: a failed "Save to file" was drawn in the success (green)
colour; it's now red on failure, green on success (flag reset on close).
- Encrypt-wallet dialog: the disabled Encrypt button now says why once the user
starts typing ("Use at least 8 characters." / "The passphrases don't match."),
and a "Show passphrase" checkbox reveals both fields.
- Key-export dialog: the raw RPC exception (e.g. "...walletpassphrase first")
is routed through util::friendlyRpcError(), so a locked wallet reads
"Unlock your wallet first, then try again." instead of daemon jargon.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
New-user mining cluster from the UX audit:
- Solo/Pool toggle: hover tooltips on each half explaining the trade-off
(solo = no fees but long waits on a small CPU; pool = frequent small payouts,
needs the miner + a shielded payout address).
- Payout field: when it's empty (new wallets have no z-address, and a pool
payout must be shielded) the bookmark button is replaced by a one-click
"Generate a shielded payout address" button (createNewZAddress → fills the
field, persists, toasts), instead of only a hover hint to the Receive tab.
- Miner updater pill: when no miner is installed it becomes a prominent accent
"Download miner" call-to-action with a "pool mining needs DRG-XMRig" tooltip,
instead of a subtle grey "Update" pill that's easy to miss.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
A coinbase/transparent→shielded move (often auto-shield) was drawn with a red
"Sent" label and send arrow yet a green "+X" amount — an internally
contradictory row that reads as "money leaving" to anyone who auto-shielded. It
now renders as a neutral "Shielded" row (shield icon + the shielded accent
colour for label and amount), and since a shield is your own funds going
private — not a spend — it's excluded from the Sent card total and the Sent
filter (it merges into one row visible under "All").
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Finish the error-surfacing foundation across app_network.cpp (+ one in
app_wizard.cpp): ~21 toasts that were hardcoded English — or raw daemon/RPC
error text concatenated after an English prefix — now go through TR() keys, and
every raw-error one maps the detail through util::friendlyRpcError() (warming
up / locked / method-not-found / insufficient / unreachable → plain guidance,
raw text as fallback). Covers mining start/stop, pool start, peer ban/unban/
clear, key export, rescan, wallet-switch guards, and the lite-unavailable
notices.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
renderNodeStatusBanner suppresses itself during a UI sweep (the forced demo
state would otherwise paint a banner over every tab). Add a narrow opt-in
(sweep_show_node_banner_) so ONLY the overview-no-peers target shows it; every
other capture is unchanged. The target also clears connection_in_progress_ so
the evaluator classifies the state as no-peers rather than "connecting".
Verified in a full-node + DRAGONX_ENABLE_UI_SWEEP build: the banner renders as
"Connected, but no network peers — the node can't sync · Check your internet
connection and firewall, then restart the node to find peers." (Restart button
only appears with an embedded daemon, which the sweep has none of.)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Capture the states the earlier verification couldn't reach:
- overview-syncing: the balance "catching up — funds are safe" strip (P4).
- overview-no-peers: the node-status banner's no-peers detail (P2; full-node
only — renders as a plain Overview in the Lite sweep binary).
- chat-identity-preparing / -unavailable: the chat empty states that used to
all say "unlock your wallet" (P3). Setup clears the fabricated demo identity
(clearIdentity wipes only the key; the demo message store survives);
sweepEnsureDemoChatIdentity() re-fabricates on teardown so later targets keep
the populated chat UI.
Also make the demo baseline a genuinely synced node (tip_known=true) so the
no-peers banner only appears on its own target, and dismiss the lite first-run
welcome prompt during the demo so it stops occluding every lite screenshot
(snapshotted + restored). Verified: all four new captures render correctly.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Two of the systemic error-surfacing foundations from the UX audit:
- util::friendlyRpcError() maps the common raw daemon/JSON-RPC errors
(warming up / -28, locked / -13, method-not-found / -32601, insufficient
funds, unreachable node) to short translated guidance with a next step,
falling back to the trimmed raw text. Wired into the send (money) path: the
toast shows the friendly cause, the overlay keeps the raw text below it for
diagnostics. The helper is reusable for the remaining raw-error sites.
- Toasts now pause their auto-dismiss while hovered and ellipsize over-long
text with the full message in a hover tooltip, instead of hard-clipping it
mid-word (durations were already lengthened earlier).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
A syncing node skips the balance RPC, so the balance card paints a bare
0.00000000 / $-.-- that reads as lost funds after an update (a documented
support trap). Add one shared notice at the top of the Balance tab — above
every layout — shown only for the normal catching-up case (connected, network
tip known, still verifying, not a rescan/witness rebuild). Offline, no-peers,
a busy node and rescans stay the node-status banner's job, so the two never
stack.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
- Seed backup: the one-time 12s toast that marked itself "reminded" the instant
it fired (so a funded, unbacked wallet was never nudged again) is replaced by
an actionable alert with a "Back up now" link that opens the backup dialog and
persists in the bell panel. "Reminded" now means the user actually viewed the
phrase (set from the backup dialog), so the nudge re-fires each session until
they do, and escalates to a warning once a balance exists.
- Console: fund-moving / key-revealing commands (dumpprivkey, z_exportkey,
z_sendmany, z_mergetoaddress, …) now require a confirming re-entry when typed
or PASTED, honoring the command reference's `destructive` flag that was
previously enforced only by the reference dialog's Run button. (Output
masking of key dumps in the scrollback is still a follow-up.)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
- Chat tab no longer shows "Unlock your wallet" for every missing-identity
case. A new App::chatIdentityStatus() distinguishes Locked (real),
Preparing (unlocked, deriving the identity) and Unavailable (view-only or a
legacy wallet with no exportable secret — correctly latched). Each gets its
own icon + message, and the Unavailable prompt points at Back up / Migrate
instead of telling the user to unlock an already-unlocked wallet.
- A chat message that sits queued ("Sending") because the wallet is offline or
has no shielded funds for the fee now surfaces the reason (throttled, after a
short dwell), wiring the ready-made chat_toast_not_connected /
chat_toast_need_funds strings that were never shown. The send stays queued
and goes out when the condition clears.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
- send confirm: the FROM/TO cards now show the FULL address (wrapped) instead
of a middle-truncated preview, and the saved-contact label when the address
matches the address book ("TO · Alice") — the strongest human check against
a swapped recipient on the one screen meant to catch it. Cards auto-grow.
- peers tab: the "No connected peers" empty state now carries the same
actionable hint as the node-status banner instead of being a bare dead-end.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The no-peers banner headline ("Connected, but no network peers") was followed
by a detail line that just repeated connection_status ("Connected"/"Peers: 0"),
reading as a contradiction, and it offered no action at the exact moment the
wallet looks synced but is silently stalled at a stale height.
- NoPeers now clears the passthrough detail; App renders a translated,
actionable hint (node_banner_no_peers_detail) for this reason instead.
- When the embedded daemon is the node, offer a Restart button (re-triggers
peer discovery via the -addnode seeds); an external node stays action-less
since it is the user's own to restart.
- Update + extend the evaluator unit tests (external = no action/empty detail;
embedded = RestartNode).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Batch of safe, isolated fixes from the 10-surface UX audit:
- settings: Auto-shield / Save-z-transactions toggles now persist on change
(they discarded CB()'s return and relied on a Save button that only renders
on the About tab, so the choice was silently lost on relaunch).
- exports: show the resolved Platform::getConfigDir() instead of a hardcoded
~/.config path (wrong on Windows/macOS) in both the all-keys and CSV
dialogs; all-keys also reveals the file path on a partial export.
- notifications: error/warning toasts last 9s/7s (were 4s/3.5s) so failures
are readable.
- qr popup / tx-details: copy-to-clipboard now confirms with a toast.
- about: Escape-to-close + readable heading contrast via OnSurfaceMedium().
- sidebar: suppress single-key nav (J/K/arrows/[) while an overlay dialog or
ImGui popup is open, so it no longer switches the tab behind a modal.
- i18n: strip the stale hardcoded path out of file_save_location in i18n.cpp
and all eight res/lang/*.json locales (additive, one key each).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
A full z_listsentbyaddress holds the daemon's cs_main for the whole sweep --
~140 s on a large wallet on a daemon without the sent-history decrypt cache --
which starves block connection and makes every other RPC (getinfo, balances)
wait, so the node looks "busy". This runs the full sweep in depth bands so no
single call holds the lock for long.
- The full case now covers [0, tip] in bands: z_listsentbyaddress(z, minconf=a,
filter-2, maxDepth=b) returns exactly depth [a, b]. Newest band first (its
minconf=0 includes the mempool); results dedupe by txid across the overlap.
- Each band is a separate call, so cs_main is held only ~5 s at a time and the
node stays responsive between bands (block connection isn't starved, the busy
canary answers). Total CPU is unchanged -- just not in one long lock.
- sentSweepNextBandSize adapts the next band toward ~5 s: grows up to 4x in
sparse depth (empty regions covered fast), shrinks to 1/2x in dense depth.
- W advances only after every band succeeds; a mid-sweep failure re-sweeps
rather than marking a partial result. The windowed sweep is unchanged.
- Each band also fits the 120 s timeout on its own, so the seed completes even
on a daemon where the single whole-wallet call used to time out.
This removes the ~2.5 min hang on the first-run seed, the weekly safety-net
re-sweep, and any invalidation. Multi-band + band-sizing covered by tests.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
#4 wired the total-earnings figure into the compact hero and the classic
layout only. This adds it to the remaining layouts in each one's native
style: donut / shield / timeline / minimal beside the USD line, consolidated
to the right of the USD, vertical-stack left of the Total row's amount, and
vertical-2x2 as a second line under the Total cell (dashboard and two-row
already show it via RenderCompactHero). All go through FormatTotalEarnings,
which returns nothing unless the global earnings toggle is on, so every
layout's default appearance is unchanged.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Persisting the sent-sweep watermark means a relaunch runs a windowed sweep,
which can't see a change made outside the GUI (a Console z_importviewingkey,
an external same-seed restore) that adds an old send below the window. This
adds a periodic full re-sweep so that case is still caught within ~7 days.
- sentSweepSafetyNetDue(now, lastFull) = lastFull > 0 && now - lastFull > 7d
(a wallet that has never full-swept doesn't trigger one; the seeding sweep
records the time). lastFullTime / lastFullHeight ride in the sent_sweep
snapshot; any successful full sweep (seed, verify-mismatch or safety-net)
resets the clock. On load, a valid watermark without a clock starts it now,
so even watermarks seeded before this field eventually re-sweep.
- When due, the snapshot forces a full sweep for that cycle. Gated on: a
watermark exists, a routine (non-syncing) cycle, not the Mining page, and
not in full-sweep backoff -- so a full sweep that can't finish on a slow
daemon backs off instead of retrying every cycle.
Degrades gracefully: ~0.4 s on a note-cache daemon; callLong(120 s) with
backoff on an old one (the deferred banding would keep each cs_main hold
short so it fits there too). Helper + persistence covered by tests.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The Overview earnings controls (interval 1H/1D/1M/1Y, received/net mode, and the
global show-all toggle) were ad-hoc ImGui buttons. They now use
material::SegmentedControl -- the same rounded-track pill the Receive tab's
All/Shielded/Mining filter uses -- so the interval is a text segmented control,
the mode is two icon segments with per-segment tooltips, and the toggle is a
single matching segment (highlighted when on). Verified in a headless GL capture.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
On a wallet with a very large shielded note set, the daemon re-decrypts all
notes under cs_main on every z_gettotalbalance / z_listunspent (GetFilteredNotes),
~15-33s per pass. The GUI issued these roughly every 15s and timed them out at
the 30s RPC limit, so the node looked permanently "busy" and per-address
balances read 0 (nothing to carry on a fresh session) while the total was fine.
Two wallet-side mitigations:
- A cost-proportional floor on VISIBLE balance polls (wallet_scan_governor): the
adaptive backoff previously applied only when the balance was off-screen. It
now also holds a visible poll until ~3x the measured scan cost has elapsed, so
a wallet whose sweep costs tens of seconds can't re-poll before the node
recovers. Negligible for small wallets (cost ~ms); forced / first-read bypass it.
- The heavy balance RPCs (z_gettotalbalance, z_listunspent) now use a 120s reply
timeout via the existing callLong path, so a slow pass completes and populates
per-address balances instead of aborting at 30s and leaving them at 0.
The root fix is daemon-side (cache decrypted notes so GetFilteredNotes isn't
O(notes x decrypt) per call); specced separately. Adds governor test coverage.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
z_listsentbyaddress re-walks the whole wallet when there's no usable
watermark, which the daemon does under cs_main for every send -- hundreds
of seconds, 400+ s and ~19 GB RSS on a large wallet. The watermark lived
only in memory, so every relaunch, reconnect or daemon restart paid for a
full sweep. This persists it so those cases run a bounded windowed sweep
([z, 0, 2, tip - W + margin]) instead (SENT-HISTORY-SWEEP Part 1).
- The watermark (W = last swept tip), its anchor (A = W - margin) and anchor
hash (H_A = getblockhash(A)), plus the enrichment backlog and the full-
sweep backoff, ride in the encrypted history snapshot as an optional
`sent_sweep` object (schema stays v1; a missing/inconsistent one reads as
"no watermark" = today's behaviour, so older builds and payloads are safe).
- Before the first windowed sweep after a load, the anchor is verified
(getblockhash(A) == H_A). A match means any fork is inside the window; a
mismatch (reorg deeper than the margin, different chain/datadir) or an
inability to check drops the watermark and sweeps the whole wallet.
- The first sweep after a load uses a bigger margin (absorbs notifier lag),
and a watermark older than 2400 blocks uses the long timeout (callLong).
- The backlog is persisted WITH W: a pure z->z send's txid lives only in a
sweep result and would be lost if the window moved past an un-enriched one.
- Watermark changes reach disk (folded into the store signature), and every
hard case still forces a full sweep: reorg (anchor verify), GUI rescan /
zap / import / address change, restoreOriginalWallet, and disconnect.
Deferred (follow-ups, each only turns a correct-but-slow case faster, never a
correctness risk): the banded full sweep for seeding on a pre-note-cache
daemon, the external-import tripwire, and the weekly safety-net re-sweep.
Part 2 (the daemon's decrypt cost) is already on the daemon's
perf/sapling-note-cache branch.
Pure helpers + cache round-trip + anchor-verify/large-window covered by
tests; the getblockhash-after-sweep updates the pinned RPC sequences.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Surfaces the period-earnings aggregator in the Balance tab:
- Toolbar (shared address list): interval pills (1H/1D/1M/1Y), a received-
vs-net mode toggle, and a global show-all toggle, placed right of "Hide 0
Balances". The New Z-Address / New mining address buttons move to a footer
row across from "Showing X of Y" to make room.
- Per address: the row draws its earnings on line 2, left of the $USD value
(green positive, red negative in net mode), shown when the global toggle
is on or the address is opted in via a new "Show/Hide earnings" right-click
item. Works in every layout via the shared list.
- Total: FormatTotalEarnings (gated on the global toggle, so the default
appearance is unchanged) is wired into the compact hero (dashboard/minimal)
and the classic layout, left of / beside their USD figure. The remaining
layouts' totals are not wired yet.
- Settings > Appearance: an "Earnings Display Format" selector (Coin /
Coin + fiat / Fiat).
New i18n keys are translated into all eight locales; the CJK subset font is
rebuilt for the new glyphs. Builds + test suites pass on both variants.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Persists the Balance-tab earnings settings: interval (0=Hour 1=Day 2=Month
3=Year, default Day), mode (0=Inflows 1=Net, default Inflows), display
format (0=Coin 1=Coin+fiat 2=Fiat, default Coin+fiat), a global show-all
toggle (default off), and an opt-in set of addresses whose earnings column
is shown when show-all is off. All values are clamped on load and saved,
mirroring the existing favorite/hidden address sets.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Adds src/data/earnings_series.h: a pure, header-only aggregator that sums
wallet inflows (received + mined) over the current calendar period (hour /
day / month / year), optionally net of sends, both per receiving address
and wallet-wide, at "as seen" values. Attribution is best-effort: inflows
map to the receiving address; in net mode a send debits from_address when
present (a send with no from_address still counts in the wallet total).
Includes a revision-keyed EarningsCache (mirrors ui::MiningEarningsCache)
and a 13-assertion unit test covering inflows vs net, per-address
attribution, period exclusion, period nesting, and cache invalidation.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The mining "Est. Daily" was a solo expected-value (your hashrate share x
blocks/day x a fixed block reward) rendered in the same green as the real
Today / Yesterday / All-Time earnings, off an instantaneous hashrate and
gross of pool fees. It read like a dependable daily income when it is a
long-run statistical average with huge (Poisson) variance.
Framing:
- The estimate is now drawn in a neutral colour, not the green "money
earned" colour (the actual mined totals stay green).
- A sub-label in BOTH modes marks it as an average ("avg", or "avg, net"
in pool mode when the fee is applied), replacing the pool-only
"before pool fee" note.
- A hover tooltip over the column explains the variance ("often nothing
for long stretches, then a lump - NOT a steady daily income") and the
fee status (after the N% fee / before the fee when unknown / nothing for
solo and 0%-fee pools).
Accuracy:
- Uses a trailing-average hashrate instead of the instantaneous / 10s
snapshot: pool prefers hashrate_15m (then 60s, then 10s); solo uses the
mean of hashrate_history once there are >=10 samples, else the instant
rate. This stops a spike or a momentary dip from swinging the figure.
- Subtracts the known pool fee so pool-mode figures are net; synthetic or
unmatched pools stay gross and the tooltip says the fee is unknown.
The static block reward and true pool-reported earnings are intentionally
out of scope (the latter needs a new pool-API field). New i18n keys are
translated into all eight locales; the CJK subset font is rebuilt for the
three new glyphs. Builds + test suites pass on both variants.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Adds a "View transactions" action that opens the History tab pre-filtered
to a chosen address. SetTransactionsAddressFilter() stores a pending filter
that RenderTransactionsTab consumes on its next frame — pre-filling the
search box and resetting the type filter to All — then the caller navigates
to NavPage::History. This reuses the existing "prefill + navigate" pattern
(cf. SetSendFromAddress) and all of History's rendering, detail and export.
Triggers:
- Contacts: a context-menu item and a 4th per-row action icon (copy /
view-transactions / edit / delete; the trailing width is widened to fit).
- Balance address list: a per-address context-menu item (own z/t addresses).
- Receive tab: the recent-received rows now carry their address into the
filter instead of jumping to History unfiltered.
The History search also now matches from_address, so filtering by one of your
own addresses catches sends FROM it, not only receives INTO it.
Scoping is honest, not exhaustive: transparent is well covered, but shielded
counterparties are private and the transparent sender of received funds is
never recorded on-chain, so this shows transactions *involving* the address.
On Lite, from_address is thin, so own-address send filtering is weaker.
New i18n keys view_transactions / tt_view_transactions translated into all
eight locales (no new glyphs). Builds + test suites pass on both variants.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Adds a "Favorite Highlight Opacity" slider (15-100%, default 100%) to
Settings > Appearance > Scale & Effects. It scales the alpha of the four
favorite gold tokens (fill/border/icon/outline) at their resolve site in
the Balance address list via material::ScaleAlpha, so the gold row outline
and the star button fade together. The multiplier is skin-agnostic (it
scales whatever alpha the theme resolved) and touches only favorited rows;
non-favorite rows and the white selection highlight are unchanged, and the
100% default is byte-identical to the previous look.
The preference lives in Settings (favorite_opacity, clamped 0.15-1.0 in both
the setter and on load), persists across restarts, updates live, and is not
low-spec gated (it's a pure color-alpha preference, independent of the
transparency sliders). New i18n keys favorite_opacity / tt_favorite_opacity
are translated into all eight locales; the CJK subset font is rebuilt to add
the two new glyphs the zh string needs.
Verified: full + lite builds clean, full + lite test suites pass, hygiene OK.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The portfolio-editor address list drew every row every frame inside the
fade-scroll child. It now runs through an ImGuiListClipper; the fixed
step (the InvisibleButton height plus item spacing) keeps each row's
cursor exactly where the unclipped loop put it, so the scroll extent for
the fade shader, the check marks, the selection and the "selected rows
float to the top" ordering are all unchanged.
Verified on DX11 (Windows) at normal and large window sizes and at
font scale 1.5, including skin switch and resize with the dialog open.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The contacts Cards/List row loop submitted every contact every frame.
It now runs through an ImGuiListClipper, so only the on-screen rows are
built; the fixed step (row height plus the item spacing pushed above)
matches the per-row cursor exactly, so the scroll extent, fade, hover
actions, selection and keyboard navigation are unchanged. Table view is
left unclipped (its rows can wrap).
Verified on DX11 (Windows) at normal and large window sizes and at
font scale 1.5, in both Cards and List views.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
While a blur overlay (a modal dialog or the lock screen) covers the
window, the theme backdrop effects kept requesting animation frames
even though they are not visible, so the app never went idle and
re-rendered continuously.
The ambient animation predicate now drops the backdrop-effects term
when a blur overlay was active last frame. Reduce Motion and the
unfocused/hidden throttle were already handled, and the overlay's own
moving elements (the lock screen's unlock dots, a spinner) still request
frames through the progress term, so nothing on screen freezes.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The change-gated refresh worker only runs `balance` on a full-refresh
cycle, so testLiteWalletControllerRetire could no longer reliably park
the worker inside a blocked `balance` (it passed only by timing). Bump
the fake scanned height to force a full-refresh cycle, which enters the
now-blocked `balance` deterministically.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The background 2 s refresh worker re-queried and re-copied the whole
wallet every cycle (a fresh TLS refresh plus a multi-megabyte JSON
build, copies and DOM parse), even when nothing had changed, so CPU and
the network never went idle.
The worker now skips the full refresh and the re-publish on a cycle that
is provably unchanged, while never letting the UI show stale confirmed
balance or transactions:
- The lite bridge keeps a monotonic mutation epoch, bumped after any
command not on a read-only allow-list, so a local change made on any
thread (send, shield, import, new address, encrypt/unlock/lock,
console) is detected at one choke point.
- Each cycle the worker runs one cheap sync probe (one connection, the
mandatory block ingest) and a local height read, and refreshes only
when the scanned height advanced, the mutation epoch moved, the
encryption state changed, a sync is in progress, or a ~10 s heartbeat
elapsed; otherwise it skips. Direct/synchronous callers are not gated.
- A failed probe backs off instead of running a second sync, so a dead
server is not retried back-to-back.
Confirmed funds can never be stale beyond one cycle (every confirmed
change advances the height or bumps the epoch); only unconfirmed mempool
rows can lag up to the heartbeat, and the spend path re-queries the
backend directly rather than reading the gated model. Average idle cost
drops from three data connections per cycle to about one.
Tests: a bridge mutation-epoch test and a worker change-gate test
(skip-when-unchanged; refresh on height advance, local mutation and
encryption change) against the fake backend.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
New Lite wallets now encrypt their seed and keys with a salted Argon2id
key instead of the unsalted double-SHA256 of the passphrase, which was
cheap to brute-force from a stolen wallet file.
- A shared derive_wallet_key() replaces double-SHA256 across encrypt,
unlock, remove-encryption and seed recovery. KDF 0 is the old
double-SHA256 (unchanged); KDF 1 is Argon2id (moderate: 3 passes,
256 MiB), using sodiumoxide's existing pwhash -- no new dependency.
- The KDF id, salt and parameters are stored in the wallet. A wallet is
written at version 10 only while it is actually Argon2id-encrypted;
every pre-existing wallet (plaintext or double-SHA256) is read and
written byte-for-byte as version 9.
- New-wallets-only: a freshly created or restored wallet encrypts with
Argon2id; a wallet read from disk never changes its KDF, and removing
encryption never changes it. Set DRAGONX_LITE_KDF=legacy to keep new
wallets on double-SHA256.
- An older build (or SilentDragonXLite) opening a version-10 wallet
fails cleanly with "don't know how to read wallet version 10" before
parsing anything, never a silent misread; the seed phrase stays the
cross-wallet recovery path.
Compatibility break (by design, opt-in): a wallet newly encrypted with
Argon2id cannot be opened by older ObsidianDragon or SilentDragonXLite.
Tests: the existing encryption tests now exercise the Argon2id path,
plus the version-10 header, legacy byte-identity and derive_wallet_key
parameter bounds. Validated by the lite oracle suite and the old-vs-new
serialization differential (legacy wallets byte-identical; version-10
round-trips).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Deriving the next HD transparent address read the key count under a
read lock, released it, derived the address, and only then took a
write lock to append it. Between reading the position and appending,
another caller - for instance a scan deriving an address at the same
time - could read the same position, derive the same address, and
append it too, leaving a duplicate transparent key in the wallet.
- The whole read-modify-write now runs under a single write guard, so
the position a caller derives from reflects every key already
added. The slow seed stretch is computed once before the guard is
taken, so holding the lock does not block on it.
- As a further guard, derivation skips a position whose address is
already present and advances to the next, so no path can produce a
duplicate even if positions were ever to overlap.
- The starting index is computed exactly as before (it still counts
imported keys too), so a wallet with no contention derives the same
addresses in the same order.
Uncontended derivation produces the same addresses in the same order,
verified against the differential harness. The wallet file format is
unchanged.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Saving the wallet serializes it to a temporary file under the sync
lock, but it took a write guard on the wallet to do so. Serialization
only reads wallet state, so the write guard needlessly excluded other
readers for the whole duration of the write, buffering, and flush.
- The save path now takes a read guard to serialize. A read guard
still excludes writers, so nothing can mutate the wallet mid-write;
it only lets other readers proceed in parallel. The surrounding
sync lock still serializes saves against syncs, and the atomic
temp-file-then-rename sequence is unchanged.
The serialized bytes are identical, verified against the differential
harness. The wallet file format is unchanged.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Opening a wallet reads each shielded note from disk, and every note
record embeds a 169-byte extended full viewing key that was decoded
from scratch for every note. That decode is an elliptic-curve parse;
a wallet with many notes sharing a handful of keys paid for it once
per note, which dominated open time.
- Note reads now go through a small content-addressed cache keyed on
the exact 169 raw key bytes. The first note with a given key
decodes it; the rest reuse the result. Because the cache is keyed
on the raw bytes and the decoder is deterministic, the cached key
is identical to a fresh parse.
- The cache is thread-local and naturally bounded by the few distinct
keys a wallet holds, so it adds no cross-thread state and cannot
grow without bound.
The bytes on disk are read exactly as before - this only avoids
re-parsing identical bytes - so the loaded wallet is byte-identical,
verified against the differential harness. The wallet file format is
unchanged.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Each block range is streamed over one gRPC connection into a channel
that a worker thread drains, encodes, and scans. That channel was
unbounded, so when the server delivered blocks faster than the
scanner consumed them the backlog piled up in memory with no ceiling
- on a fast link against a large range it could grow without limit.
- The channel is now a bounded synchronous channel holding at most
2048 blocks. When it fills, the fetch side waits, applying
backpressure up the stream instead of buffering endlessly, so peak
memory is capped no matter how far ahead the server runs.
- The batch size is untouched: it still streams a whole range over a
single connection, which is what keeps the connection count low
enough to avoid socket-buffer exhaustion on Windows. Only the
in-flight depth between fetch and scan is now bounded.
The same blocks are fetched and scanned in the same order, so the
resulting wallet is byte-identical, verified against the differential
harness. The wallet file format is unchanged.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
When sync hit a reorg it stepped the scan back to just before the
invalidated block, then set the top of the re-scan range to that
point plus the full forward batch size. That window is far larger
than any reorg can be: the batch size is tuned for forward streaming
(tens of thousands of blocks), while a reorg is capped at the maximum
reorg depth and the pass aborts once it is exceeded. So the recovery
pass re-fetched and re-scanned a large span of already-good blocks
before it could resume moving forward.
- The recovery range now reaches only the maximum reorg depth (plus
one) above the rewind point, which is the most a reorg can ever
disturb. Forward syncing still uses the full batch size; only the
pass immediately after a reorg is bounded this way.
- The set of blocks that ultimately get scanned is unchanged - the
next pass picks up from where recovery stops - so the resulting
wallet is identical, just reached with far less redundant fetching
and scanning.
The wallet file format is unchanged.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
On quit the lite controller's destructor never runs, because main()
ends the process with _Exit(). Any state the background refresh had
scanned since the last save was lost: the destructor is where that
final save would normally happen, and it was being skipped on the
one path that always runs.
- shutdown() now calls a new flushOnExit() that persists once on the
way out, bounded to 4 s so the existing 15 s shutdown watchdog
budget still holds. On timeout it abandons the save thread and lets
_Exit follow.
- It is a plain save through the same backend path, which writes a
temp file and renames it into place. That cannot corrupt the wallet
if it is cut off or abandoned, and it does not change the wallet
file format - it only persists sooner on the quit path.
- It reuses the destructor's guards and also skips when a wallet op
is already in progress: shutdown() has already waited for ops to go
idle, and a still-pending op holds the sync lock and issues its own
save, so flushOnExit would only block the budget on a save that
cannot take the lock.
Tests cover a save on the open, synced, idle path (exactly once), a
blocked save returning within the budget, and the retired and
never-opened guards skipping the save.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The sidebar unconfirmed-tx badge was recomputed only when the tx list
changed, keyed on last_tx_update plus the list size. That missed the
one case that matters most: a transaction confirming without the list
count changing. last_tx_update did not move for a same-second content
change, and lite never stamped it at all, so the badge stuck on a
stale 0-conf count until some unrelated refresh happened to shift the
list size.
- The badge now keys on tx_revision, which the state bumps on every
content change - including a confirmation flip at an unchanged tx
count - and which lite stamps too, so the count tracks confirmations
on both the full node and the lite wallet.
- The sentinel starts at 0, which tx_revision never takes (it starts
at 1), so the first frame always computes once.
- The recompute still runs only when the revision actually moved, so
the per-frame work the previous guard avoided is still avoided.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Transparent history is gathered by skip-paging listtransactions. The
underlying list can shift between the per-page RPCs - a new
transaction arriving pushes one page's tail into the next page's head
- so a row could be collected twice and show up as a duplicate in
History.
- Pages are now de-duplicated on the daemon's stable per-output
identity: txid, send/non-send class, and vout. Keying on the class
rather than the exact category means a coinbase that matures
(immature to generate) between two fetches is still recognised as
the same row; keeping vout means two equal-value outputs of one
transaction stay distinct. No real transaction row is dropped or
duplicated.
- Rows with no txid or vout ("move") are never keyed and pass through
(they are dropped downstream anyway), so the de-dup fails open.
- The raw page size still drives the stop condition and the next-run
row hint, independent of how many rows were unique.
Tests cover a shifted page whose head repeats the prior page's last
row (dropped), a sibling output of the same tx (kept), and an
un-keyable row (kept).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
listtransactions was called with the default "" account, which
returns only rows in the unnamed account. Transactions tied to a
labelled address were left out of the History tab entirely.
- All three listtransactions call sites now pass "*", which spans
every account, so labelled-address rows appear alongside the rest.
- With "*" the daemon also emits "move" accounting rows that have no
txid, address or vout. Those are not wallet transactions, so they
are skipped and never become blank History rows. No real
transaction row is dropped by this filter.
Tests cover that a "move" row is skipped while a labelled receive row
is kept, and that the transparent collectors now page with "*".
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
parseRpcResult copied the "result" node out of the parsed reply,
because indexing the JSON object yields an lvalue reference. On large
replies - full transaction lists, unspent-note sets - that duplicated
the entire subtree for no reason, and it also meant a secret-bearing
result then existed in two trees at once.
- The result node is now moved out of the parse tree instead of
copied, so the secret lives only in the returned value that the
calling code already wipes. The leftover result slot is moved-from
(null), so the existing secret-scrub of the discarded tree still
runs harmlessly.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Loopback HTTP was still subject to the process environment's proxy
settings (http_proxy / all_proxy). That routed the local daemon's
basic-auth credentials and wallet data - and the miner's 127.0.0.1
stats API - through whatever proxy the user's shell happened to
export, and could break the connection outright.
- Local daemon RPC now sets curl's no-proxy to "*" when the host is
loopback, so it always connects directly. This also overrides a
no_proxy list. Remote daemons keep full proxy support unchanged.
- The xmrig stats fetch against the local miner API does the same.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The RPC worker loop only caught std::exception. A task that threw
anything else - a non-std exception propagating out of a posted job -
escaped the loop and tore down the worker thread, so every later RPC
silently went unanswered.
- The worker loop now also has a catch-all that swallows non-std
throws and keeps the thread running. Such a job still loses its
main-thread callback, exactly as the std::exception path already
did; the only change is that the worker survives.
- RPCClient's connected_ and warming_up_ flags are now atomic. They
are read from the UI thread while the worker updates them, so the
plain bools were a data race.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Starting the daemon polls for an already-running node holding the
datadir, sleeping up to three 100 ms intervals to let a shutting-down
node clear out. When a start runs on the UI thread, that is up to 300 ms
of blocking sleep on the render thread.
- The lock-wait poll count is now a parameter. A start invoked on the UI
thread passes 0, which does a single check and never sleeps; every
other caller keeps the three-poll wait. The default is unchanged, so
background starts behave exactly as before.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
At quit the UI thread drained and joined the mining-control thread and
then called the miner's blocking stop with a 3 s budget. Both ran on the
UI thread, so the window could stall for up to that long before the
shutdown screen rendered.
- Quit now only signals the mining-control thread (no join) on the UI
thread. The blocking teardown — join the control thread, then stop the
miner — moves to a background thread, so xmrig still has a single
driver but the UI thread never blocks on it.
- On the daemon-stop path that teardown runs first on the shutdown
thread, so the miner is fully stopped before the daemon stop begins.
On the disconnect-only quit paths it runs on its own thread, which
the final shutdown joins before tearing xmrig down, so there is never
a second concurrent join of the control thread.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The daemon monitor thread drained output and then slept a fixed short
interval on every tick, waking several times a second for the life of
the node even long after startup, when the node emits almost nothing.
Stopping it also meant waiting out the current sleep before it saw the
stop flag.
- The monitor now waits on a condition variable instead of a plain
sleep, so a stop request wakes it immediately rather than after the
interval. The stop flag is set under the monitor's mutex so the wake
cannot be lost.
- The poll interval stays fast for the first 30 s after the thread
starts and for 3 s after any output, and otherwise doubles per idle
tick up to a 500 ms cap. Any new output drops it straight back to
fast, so responsiveness during startup, rescan and shutdown is
unchanged while a quiet node is polled far less often. The interval
choice is a pure function.
Tests cover the interval function across the warmup window, the
recent-data window, the reset on new output, the doubling, and the cap.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The miner's stats and pool-API fetches are blocking HTTP calls on the
monitor thread with multi-second timeouts. When the miner was stopped,
stop() still had to wait out an in-flight request before the monitor
thread could join.
- Both fetches now install a curl progress callback that aborts the
transfer as soon as a stop is requested, so a request in flight ends
promptly instead of running down its timeout.
- An abort triggered by stop is treated as a clean cancellation, not a
fetch failure, so it is not logged or counted against the failure
warning.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Stopping the miner closed the stdout pipe before joining the monitor
thread, and the UI thread's stats poll also drained stdout. Either path
could read or close a descriptor the other side was still using, so a
read could land on a closed or already-reused fd.
- stop() now joins the monitor thread first and only then closes the
process handle and the stdout pipe, so nothing is draining the pipe
when it is closed.
- The stdout drain is serialized under its own mutex, and the UI
thread's stats poll no longer drains stdout at all: the monitor thread
is the sole reader. The process handle, pid and fds are atomic so they
can be observed safely from the memory probe.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The wallet pins a -dbcache value on the daemon command line. A
command-line flag overrides the same key in DRAGONX.conf, so a user who
set their own dbcache in the conf never got it: the wallet's pin always
won.
- Before spawning, a normal start now reads DRAGONX.conf once and, if it
sets dbcache, drops the wallet's own -dbcache pin from the arguments
so the user's value takes effect. An isolated start keeps its own
datadir and conf and is left alone.
- The conf is now read a single time for both this check and the
existing rpcthreads check instead of twice.
Tests cover dropping the pin when the conf sets dbcache (in various
forms), and leaving the default arguments untouched when it is
commented out, empty, or sets something else.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
When the daemon was restarted, the output-parse offset was reset to 0.
The daemon's output buffer is retained across runs, so the next poll
re-read the whole retained tail of the previous run and fed it to the
busy tracker as if it were fresh. A witness rebuild or rescan line left
over from the old run could then be mistaken for activity in the new
one.
- On restart the offset is now reset to the absolute end of the output
at that moment, captured on the lifecycle thread after the stop and
before the start, so the UI resumes parsing only the new run's output
rather than replaying the old tail.
- The capture is handed to the UI thread through a pending flag and
applied there before the next read, both on the normal poll and when
skipping the backlog, so the offset is only ever moved on the thread
that owns it.
Tests cover reading from the captured watermark (only the new run is
seen) versus the old reset-to-0 behaviour (the previous run replays).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The embedded daemon and the xmrig manager each kept their own copy of
the console output ring: append new bytes, trim the front to a cap at a
newline boundary, and hand a reader everything since its offset. The two
copies had drifted. The daemon tracked an absolute byte total so a
reader's offset stayed valid across a front-trim, while the miner keyed
its reader off the live buffer size, so a trim could silently rewind the
console feed.
- The append/trim and the since-offset logic now live in one small
header, outputWindowSince and appendBounded, included by both. The
trim keeps the newline-boundary cut and the guard that stops a lone
trailing newline from erasing the whole buffer.
- The xmrig manager now carries the same absolute byte total, so its
reader resolves against the window the same way and survives a trim
(and a process restart that clears the buffer but keeps the total).
Tests cover feeding the ring well past the trim point while a reader
polls each chunk (the feed is continuous and never dies at the trim),
the lone-trailing-newline guard, and a cleared buffer with a retained
total.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
ui.toml and the default banlist were embedded by a CMake function that
read each file and emitted a C array header one byte at a time in a
configure-time loop. That is slow to configure, and because the result
is a header, editing either resource recompiled every translation unit
that included it.
- Both files are now embedded with INCBIN from a small generated
translation unit, the same pattern already used for the fonts. There
is no generated header, so editing a resource re-assembles one tiny
object file instead of triggering a wide recompile, and configure does
no per-byte work.
- Consumers include a hand-written header declaring the symbols and
always use the embedded data (the __has_include fallback is gone). The
data is not NUL-terminated, so every consumer passes an explicit size.
- The old EmbedResources.cmake script is removed, and the generated unit
is added to the app and test builds.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Ingesting a scan's worth of chat messages persisted each new message
with its own append() call, so a batch of fresh messages opened and
committed one SQLite transaction per row.
- A new appendBatch persists many messages in a single transaction,
using the same INSERT OR IGNORE dedup key as append() and preserving
input order so load()'s rowid ordering is unchanged.
- Every message is serialized and encrypted before the transaction
opens, so a message with a non-UTF-8 body is skipped on its own
without leaving a transaction open; the valid siblings still persist.
- Ingest collects the newly stored messages and writes them once after
the loop instead of per message. It is inert (returns 0) while locked
or on empty input.
Tests cover a batch insert, dedup on re-insert, empty input, bad-UTF-8
isolation with a valid sibling and a following append(), insert order,
and the locked no-op.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Opening the transaction-history cache always derived its encryption key
with Argon2id, even for an unencrypted wallet. For those wallets the
passphrase is just a value derivable from the database itself, so the
~50-100 ms Argon2id stall bought no additional secrecy and ran on the
UI thread at open.
- Unencrypted wallets now derive the cache key with a cheap,
domain-separated BLAKE2b KDF over the wallet identity, keyed by the
per-wallet salt. The secret is derivable from the DB, so this is
security-equivalent for the unencrypted case and avoids the stall.
- Passphrase-encrypted wallets are unchanged: they keep the Argon2id
key derivation. The two paths use separate domain labels, so the
fast-path key and the Argon2id key never collide for the same DB and
salt; a cache written under one cannot be read under the other.
Tests cover the fast-path round-trip and reopen, and assert that an
Argon2id unlock of the same DB produces a different key and fails to
read the fast-path snapshot.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Drawing the tiled background noise built a viewport-sized pre-tiled
texture and re-created it whenever the viewport changed, copying the
256x256 noise tile across the whole screen in memory.
- On GL the draw now samples the resident 256x256 tile directly with
GL_REPEAT, computing UVs from the screen rect over the tile size with
the phase anchored at the viewport origin. The result is pixel-
identical to the old pre-tiled texture (same generator, same origin)
with no per-viewport copy.
- DX11 keeps the pre-tiled viewport-sized texture, since its sampler is
CLAMP and cannot repeat.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Theme images were decoded straight to RGBA at their full source size
and uploaded to a GPU texture as-is. A large or over-sized image wasted
VRAM and could exceed the GL maximum texture size, and each code path
also hand-managed the decoded pixel buffer with its own free calls.
- Loading from file and from memory now goes through the shared
size-aware decoder with a 2048-pixel cap on the long side. An image
larger than that is downscaled with its aspect ratio kept; an image
whose area is absurd is rejected; a small image is left untouched.
- The decoder owns the pixel buffer for the life of the upload, so the
manual free calls are gone.
Tests cover downscaling a wide image to a 2048 long side with the aspect
kept, rejecting an over-area image, and leaving an under-cap image as-is,
plus the fit-dims math at the theme-art cap.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Adding a lite server from the network tab restarted the background
server probe immediately. Starting a probe while one is still running
joins the previous worker thread, and that join happened on the UI
thread, so the frame could stall until the outstanding probe finished.
- Adding a server now sets a dirty flag instead of restarting the probe
inline; the next frame starts a fresh probe once the current pass has
finished, so the UI thread never blocks on a join.
- The probe's libcurl transfers honor the cancel flag through a progress
callback and abort cleanly, so a probe in flight stops promptly.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Before verifying a downloaded archive, both updaters slurped it into a
string with an istreambuf_iterator, which grows the buffer repeatedly
and reallocates as it reads a multi-megabyte file. It also placed no
upper bound on the size it would read into memory.
- A shared readFileExact helper sizes the file first and reads it in a
single allocation, failing closed if the file is missing, empty,
larger than a caller-supplied cap, or comes up short. A truncated read
can never reach the SHA-256 / signature verifier.
- Both updaters use it with the existing max-archive cap.
Tests cover a good read, a read exactly at the cap, over the cap, an
empty file, and a missing file.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The daemon and miner updaters fetch release metadata with a blocking
HTTP GET that ignored the updater's cancel flag. Closing the dialog set
the flag, but an in-flight metadata request kept running to completion
on the worker thread, so the dialog could not tear down promptly on a
slow or stalled connection.
- The metadata GET now passes the updater's cancel flag through to the
HTTP helper, so closing the dialog aborts the request in progress.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Several settings were read with json's value()/get<T>() helpers that
assume the stored type. A hand-edited settings file, or one re-saved
after an inf/NaN float was written back as JSON null, could therefore
hold a value of the wrong type and throw during load. The whole load is
wrapped so a throw quarantines the entire settings file to a
.corrupt- copy, meaning one bad field reset every unrelated setting.
- ui_opacity is accepted only when it is actually a number, and it is
clamped to the same 0.3..1.0 range the setter enforces; the legacy
reduced_transparency migration likewise runs only when that key is a
boolean.
- A non-string name in a blocked chat conversation, and a non-string
scope in a portfolio entry, now fall back to an empty string instead
of throwing.
The file loads with the offending field defaulted, and the rest of the
settings survive.
Tests cover ui_opacity as a string/null/int/float/bool, a non-string
blocked-conversation name and portfolio scope, and the legacy
reduced_transparency migration, asserting no quarantine file is written.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The single-instance lock lived in /tmp, which is world-writable. Creating
it with mode 0666 there gets masked to 0644, so a second local user
cannot open it O_RDWR and is wrongly blocked from running the wallet at
all. A world-writable directory also lets another user pre-create or
symlink the lock path.
- The lock now lives in a per-user directory: $XDG_RUNTIME_DIR when it is
an absolute directory the user owns, otherwise the per-user, per-variant
config directory that startup has already created. Never /tmp.
- It is created mode 0600 with O_NOFOLLOW, and after opening we fstat it
and refuse anything that is not a regular file we own, rejecting a
planted symlink or a foreign-owned file.
- The lock file is no longer unlinked on release; flock drops on close,
and leaving the file in place avoids an unlink/relock race (a stale
file is harmless).
Tests cover acquiring and conflicting on the lock, the 0600 mode, that
release leaves the file in place, and that a symlinked lock path is
refused.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Both the HEAD size probe and the archive download followed redirects
with no restriction on where they could go. A redirect could therefore
downgrade the transfer to plain http or point at a file:// URL, defeating
the TLS verification the initial https request was relying on.
- Redirects on both requests are now limited to https, so a redirect can
no longer leave TLS or reach a local path.
- The limit is set with the string-based curl option where available and
the protocol-mask option on older libcurl.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Resuming a partial download leaned entirely on the remote size from a
HEAD request. When that size could not be determined the code played it
safe by deleting the partial file and starting over, so a flaky HEAD (or
an offline mirror) threw away gigabytes that were fine to resume. It also
could not tell a real size from an error page, and if the server answered
a ranged request with the whole file (200, not 206) libcurl could append
it to the partial and silently corrupt the archive.
- The HEAD now returns transport status, HTTP code, and size together,
and a size counts as authoritative only on a 2xx reply with a positive
Content-Length.
- A pure helper decides what to do from the existing bytes and that
authoritative flag: start fresh, truncate a stale oversized file, reuse
a complete one, or resume with a ranged GET. When the size is not
authoritative the partial is kept and a ranged GET is attempted rather
than discarded.
- A ranged request that comes back as 200 instead of 206 now restarts
cleanly from scratch instead of appending to the partial.
- The free-space pre-check runs only when the size is authoritative, and
a failed download reports how many bytes were kept for the retry.
Tests cover the resume-decision helper across the fresh, stale, complete,
resuming, and non-authoritative cases.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Any verification failure deleted the downloaded archive and forced a
full multi-GB re-download on the next attempt. But most failures are not
a corrupt archive: a missing or unreadable checksum sidecar, or a fetch
blip while pulling the checksum, would throw away a perfectly good file.
On top of that the checksum parser took the first token of whatever came
back, so an HTTP error page could be mistaken for a digest.
- The archive is now removed only on a genuine digest mismatch. A
missing sidecar, an unusable one, or a transient fetch error keeps the
file so the next attempt can reuse or resume it; the no-checksum case
still fails closed and keeps the archive.
- The checksum parser now accepts a token only if it is exactly the
expected length and all hex, lowercasing it; anything else (an error
page, a truncated or wrong-width digest) is rejected.
- The small-file fetch moved onto the shared capped HTTP helper, and MD5
is consulted only as a fallback when no SHA-256 is published.
Tests cover the checksum parser across good, wrong-length, non-hex, and
error-page inputs.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The checksum and MD5 passes measured the file for their progress bar
with fseek/ftell. On Windows a long is 32-bit, so ftell overflows on
any archive larger than 2 GiB and hands back a wrong (often negative)
size, which makes the verify progress percentage meaningless on exactly
the multi-gigabyte downloads this code exists for.
- Both passes now take the size from std::filesystem::file_size, which
is 64-bit on every platform, through a small helper that returns 0 if
the size can't be read.
- No seek is performed just to size the file.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
While the seed migration sits on the Confirming gate it polled the
sweep's confirmations and a full wallet balance together on every pass.
That balance call is the expensive one, and it only matters once the
sweep is mined and we do not yet have a post-mine figure, yet it ran
every poll even mid-sync when it cannot move the gate.
- The confirmation count is still read on every poll.
- The balance scan now runs only when it can actually advance the adopt
gate: the sweep is mined, no post-mine balance has been recorded yet,
and no sync is in progress. The manual Refresh button forces it
regardless.
- The decision lives in a small pure helper, and the poller snapshots
the main-thread state it needs rather than reading members off the
worker thread.
- The Confirming screen now claims a leftover remainder (and offers the
re-sweep button) only once the balance has actually been measured; while
it is still unknown it keeps waiting instead of prompting on a sentinel.
Tests cover the gate helper across the forced, syncing, and mined cases.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Several HTTP fetches read a remote body into memory without a tight
bound. The price and exchange-ticker calls shared one large 16 MiB
metadata cap, and the bootstrap checksum download had no size limit at
all, so a hostile or MITM'd server streaming an unbounded (and
Content-Length-free) response could grow memory far past anything these
endpoints legitimately return.
- The HTTP GET helper now takes a per-call byte cap and redirect limit.
The write callback refuses bytes past the cap, which aborts the
transfer, and the same value is set as curl's max file size.
- The price call is capped at 256 KiB and the ticker list at 2 MiB,
each sized to its real payload rather than the shared default.
- The bootstrap checksum fetch caps its body at 64 KiB in both the
write callback and curl's max file size.
Tests cover the sink accept/abort boundary.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Loading chat history did more work than it needed to. Seeding the unread
watermark walked every conversation and copied each one's full message
list out of the store just to read the last timestamp, and the database
reload copied every message into the store one at a time with no
reservation.
- The watermark now comes from one pass over the stored messages that
returns the latest timestamp per conversation, with no per-
conversation copy or sort.
- The store gained a move-append and a reserve, so the reload pre-sizes
its vector and dedup set once and moves each row in instead of copying
it. The dedup key is computed before the move.
Behaviour is unchanged: the watermark values and the blocked-conversation
filter on reload are the same as before.
Tests cover the per-conversation latest-timestamp pass and move-append.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Two secure-delete paths overwrote a sensitive file and then unlinked it,
but neither reliably forced the overwrite to stable storage. The
plaintext key export used a buffered stream with only a flush, and the
PIN vault removal opened a second descriptor to fsync only on non-
Windows. On a crash, or on Windows, the zeroing write could stay in the
OS cache and the original bytes could survive on disk.
Both now go through one shared best-effort secure-delete helper:
- It overwrites the file in place in chunks, never truncating first, so
the original blocks are the ones rewritten.
- It flushes the data to the device before unlinking (full fsync on
macOS, write-through on Windows), then removes the file.
- It is safe on a missing file and reports whether the file is gone.
This is still best-effort: SSD wear-levelling and copy-on-write or
journaling filesystems may retain copies.
Tests exercise the overwrite-and-remove helper.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
A failed wallet unlock was always treated as a wrong guess: the lockout
counter was bumped and the back-off curve armed no matter why the RPC
failed. A busy or warming-up node, a transport timeout, or a wallet that
is not actually encrypted would all eat an attempt and could push a user
into a lockout without a single mistyped passphrase.
The failed call is now classified before it is acted on:
- Only the daemon's "passphrase incorrect" error counts as a wrong
guess and advances the lockout.
- A warming-up or unreachable node, a reply timeout, or a full queue is
reported as a transient busy-node condition with no attempt charged.
- A "wallet is not encrypted" reply refreshes the local encryption state
instead of penalising the user.
- Any unknown error surfaces its message without touching the counter.
The PIN path got the same treatment: when the vault decrypts correctly
but the unlock RPC then fails for a non-credential reason, the attempt
is no longer counted.
Tests cover the classification of each failure kind.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Alt + mouse wheel adjusts the font scale, but it was only handled in the
poll loop that runs after the app wakes. When the app was idle it blocks
in a single timed wait, and that first wheel event went straight to
ImGui, scrolling the window instead of changing the scale until a later
event arrived.
- The Alt+wheel handling is pulled into a shared helper used by both the
idle wait path and the poll loop, so the first event while idle is
consumed and triggers a redraw.
- Behaviour is otherwise unchanged: the visual scale updates smoothly and
the font atlas is rebuilt once scrolling stops.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The window always asked DWM for a full-window blur region and, on
Windows 11, an acrylic backdrop. When the user runs with an opaque
window (low-spec mode or full opacity) that desktop blur is never seen
but DWM still composites it every frame.
- An early read of the saved settings decides whether the window starts
opaque before the backdrop is configured.
- When it does, the full-window blur region is left as SDL's zero-area
default and the backdrop type is set to none instead of acrylic.
Windows-only.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The custom minimize / maximize / restore buttons are drawn from inside
app.render(), while an ImGui frame is open. Calling SDL to change the
window state there pushes resize events synchronously, which re-enters
the resize watcher and can nest a new frame inside the open one.
- A click now records the requested op instead of acting on it, and the
window state change is applied once the frame has closed, after the
pacing block so the follow-up redraw is not dropped.
Windows-only; it rides the borderless caption controls.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The live-resize event watcher redrew for every resize event, and each
resize tore down and recreated the DX11 render target even when the
pixel size had not actually changed. A single drag fires both a RESIZED
and a PIXEL_SIZE_CHANGED event reporting the same final size, so the
work ran twice.
- The watcher keys off the real pixel size and returns early when it has
not moved since the last pass, collapsing the duplicate event pair into
one redraw. It also bails out when called from inside an open ImGui
frame, which the caption buttons can trigger.
- The DX11 resize now checks the swap chain's current dimensions up
front and does nothing when the size already matches and the target is
live, skipping the destroy/recreate churn.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
On Windows stdout and stderr were reopened onto the same log path with
two separate freopen() calls. Each stream then had its own file handle
and its own write offset into the one file, so the two writers
overwrote each other and interleaved into a garbled log.
- stdout is reopened (truncating the file per run), stderr is pointed at
the same underlying handle via _dup2 so both share one offset and
append in order.
- stderr is first given a valid descriptor, since a GUI-subsystem
process may start with no fd 2, and is left unbuffered so crash
diagnostics are not lost.
Also corrects a stale comment that named the old log file.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Startup initialised SDL's gamepad subsystem and turned on ImGui gamepad
navigation, but the wallet is never driven by a gamepad. Initialising
the subsystem enumerates attached controllers and loads the mapping
database for nothing.
- SDL now starts with the video subsystem only.
- ImGui keeps keyboard navigation; the gamepad navigation flag is
removed from both the DX11 and the OpenGL setup paths.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
On Windows SDL3 raises the global timer resolution with
timeBeginPeriod(1) and holds it for the whole process lifetime, which
keeps the system on a 1 ms tick and costs power for no benefit here.
- A startup hint now asks SDL to leave the system timer alone. Nothing
in the app needs sub-15 ms waits: vsync paces drawing, the idle wait
is 200 ms, and background sleeps are 50 ms or longer.
The hint is Windows-only in effect and is ignored elsewhere; an
environment override that turns it off is harmless.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
A secret copied to the clipboard auto-clears after a timeout, but the
deadline was stored as an ImGui::GetTime() value. That clock only
advances while frames are being drawn, so while the app idled (no
redraws) it effectively stood still and the pending clear could be put
off well past the intended window.
- The clear deadline is now a steady_clock time_point, which keeps
counting whether or not the UI is rendering.
- The pump uses the armed hash as its "something is pending" flag rather
than a sentinel deadline value, and compares the real wall time.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
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>
When following the debug.log of a node this session did not launch, every
poll re-read the last 2 MB of the file and re-scanned it for the process
banner. If that node logs to its console instead, the banner is never
there, so the full re-scan repeated on every poll for the life of the
process.
- A miss is now remembered by pid and file size: if nothing has changed
since the last miss, or the file is growing but the backoff has not
elapsed, the scan is skipped.
- The retry schedule re-scans quickly during a fresh-node grace window
(the banner flushes a beat after the pid appears) and then backs off
from 4 s, doubling to a 60 s cap. The memo is cleared on a successful
tie and when the followed process goes away.
Tests: the retry delay is fast within the grace window and then steps
4, 8, 16 s up to the 60 s cap.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Hovering the Paste button in the import-key dialog read the OS clipboard
every frame to draw a ghost preview of the key, and the trimmed
plaintext was rebuilt each frame and never wiped. SDL's clipboard read
is a synchronous round-trip, and the pasted value is a secret private or
viewing key.
- The clipboard is now read only on hover entry and then at most twice a
second, and the classified preview is cached rather than recomputed
every frame.
- The cached preview is a dedicated secret buffer: it is wiped the moment
it stops being shown, wiped on secret wipe, and the transient trimmed
copy is zeroed right after it is classified.
- Trimming moves to a shared clipboard helper shared with the paste path.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
While no lite wallet is open the startup auto-open check called
walletExists() every frame. That call crosses into the backend and
touches the filesystem (and prints), so it ran a disk stat on every
frame the app idled at the first-run prompt.
- walletExists() now caches its result for about two seconds on the main
thread, and the cache is invalidated whenever an open or lifecycle
result is consumed, since the wallet file may have changed.
- The auto-open condition is reordered so the cheap timer checks gate the
call, and the click-time overwrite decision uses a new
walletExistsFresh() that bypasses the cache.
Tests: the cache returns the first queried value within the TTL and the
fresh read re-queries the backend and refreshes the cache.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The pool auto-balance step returned early whenever the pool selector was
in Manual mode, so a pending manual Refresh of the pool display stats was
dropped and the button did nothing unless Auto-balance was active.
- A pending refresh is now serviced in Manual mode too, re-querying the
pool display stats without auto-switching the pool or restarting xmrig.
- Auto-balance behaviour is unchanged; the manual path is display-only,
so it runs with no benchmark gate.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The global keyboard shortcuts tested keys without disabling key-repeat,
so holding one down fired it every frame: the theme cycle, balance and
market layout cycles, the effects and background toggles, the settings
and sidebar-collapse keys all re-triggered on a held key. Cycling themes
also stepped onto skins that had failed to load and wrote the broken
pick into settings.
- Every one-shot shortcut now checks for a fresh key press only, so a
held key fires once.
- Theme cycling skips over invalid skins and only applies, persists, and
shows the toast when a skin actually loads; if none can load, settings
are left untouched and no toast appears.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Once a shutdown was under way the app could still launch or restart the
full node and keep running the RPC connection state machine. That could
spawn a daemon process on the way out and surface a spurious connection
failure toast as the window tore down.
- Starting the embedded daemon now bails out immediately while a
shutdown is in progress, so the node is never (re)launched on exit.
- The connect attempt returns early during shutdown, so there is no
start/connect try and no stray failure toast on the exit screen.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The transaction export streamed rows straight into an ofstream. A write
that failed partway, on a full disk or removable media, left a truncated
CSV on disk behind a success toast. The CSV formatting also lived inline
in the dialog with no test coverage.
- The CSV is now built in memory and written atomically (temp file plus
rename), so a write failure is reported with an error toast and leaves
no partial file.
- Formatting moves into a reusable helper, and the timestamp conversions
use the thread-safe localtime variants and tolerate a conversion
failure rather than dereferencing a null result.
Tests: field escaping neutralises formula-injection prefixes and quotes
special characters, and the full CSV has the expected header and rows.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Renaming or adding a contact from the chat tab called the address-book
mutator and then saved again, even though the mutator already persists.
It also reported success unconditionally, so a failed rename still
showed a success toast.
- The redundant explicit save is removed; the mutator's own save stands.
- A rename now shows success only when the mutator succeeds and an error
toast when it does not.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Pinning a lite server while a connection to it was already being
established tore the in-flight connect down and restarted it, slowing
the very connection the user was waiting on.
- Pinning is now skipped while a connect is already underway, so the
existing attempt is left to finish.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The request-payment dialog kept its generated payment URI across opens
and closes. A dialog opened for an address with no payable shielded
address could still show the previous request's URI, and the string
lingered after the dialog closed.
- The payment URI is cleared when the dialog opens, when it closes, and
on the close cleanup path.
- The URI field is shown only when there is both a URI and a real
address to pay.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The send tab read the OS clipboard every frame the paste button was
hovered, to preview a pasted address. SDL's clipboard read is a
synchronous round-trip that can stall, and the preview can only ever
latch when the recipient field is empty, so most of those reads were
wasted work.
- The clipboard is now read only while the button is hovered and the
recipient field is empty, polled once on hover entry and then at most
twice a second.
- Trimming the clipboard text moves to a shared helper that also bounds
the copy so a pathological multi-megabyte clipboard is ignored.
Tests: the trim helper handles null, surrounding whitespace, an
all-whitespace string, and the length bound.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The chart fetch and the Market tab both resolved the saved exchange and
pair names into the registry, with their own near-identical scans and
slightly different fallbacks. Keeping two copies risked the chart and
the chips disagreeing about which pair was selected.
- A single resolver maps the saved names to a registry index, matching
the exchange name (any exchange when none is saved) then the pair
display name, and falling back to the registry's first pair, the UI
default. The chart fetch and the Market tab both call it.
- The Market tab now derives its selection indices from the saved names
against the current registry every frame, so the indices stay correct
when the live exchange list replaces the fallback; a steady-state fast
path skips the scan once the indices already name the saved selection.
Tests: the resolver returns the right index for a matched exchange/pair,
an empty-exchange match, and the first-pair fallback on misses.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The portfolio address picker's sort comparator looked up each address in
the selected-address set on every comparison, two hash lookups per call,
and the per-row draw repeated the lookup again. On a long address list
that is a lot of redundant hashing every frame.
- Each filtered candidate now carries its selected flag, looked up once
when the list is built. The comparator reads the stored flag, so the
sort produces the same ordering with no lookups of its own.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The contacts list carried its own lowercase-and-compare sort, its own
lowercase substring search, and an inline linear middle-truncation loop
for fitting an address to a column width. These duplicated logic that
belongs in the shared text helpers.
- A new allocation-free case-insensitive compare replaces the per-sort
lowercasing; it lowercases ASCII per byte and leaves bytes >= 0x80
alone, so UTF-8 labels sort exactly as before.
- Search now uses the shared case-insensitive contains helper.
- Middle-truncation to a pixel width moves to a shared template that
binary-searches the fit (about log2(n) width measurements instead of
up to n) while producing the same result as the old linear shrink.
Tests: the compare matches lowercase-then-compare across ASCII, UTF-8
bytes and length ties, and the fit helper matches the old linear loop
across lengths and widths.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The thread benchmark state machine was advanced from the Mining tab's
render path, so it only progressed while that tab was visible and the
window was on screen. Hiding the tab or minimizing the window froze the
benchmark mid-sweep.
- The benchmark now advances from the main update loop, ungated by
connectivity, so it keeps running when the tab is hidden or the window
is minimized.
- Its clock is driven from a steady wall-clock reading rather than the
render delta, and each step is clamped like the other logic timers so
a suspend/resume cannot leap a benchmark phase.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Cancelling a thread benchmark always stopped and restarted the pool
miner at its pre-benchmark thread count, even when the user had not been
mining before the benchmark began. That silently started the miner on a
plain cancel or a switch to solo mode.
- A cancel now always drops the candidate-thread miner, and restarts the
pool miner only when the user was mining before the benchmark, read
from a snapshot taken at the start rather than the live running state
(the benchmark sweep transiently stops the miner between candidates,
so a live check would drop the restore).
- Callers that must not restore the miner (leaving solo mode) pass the
new restore flag as false.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The status-bar notifications only expired the front of the queue, so an
expired newer toast could sit in front of a still-live older one and
hide it from view. The liveness check that drives continuous repaints
also disagreed with what render() actually showed.
- Expired toasts are now pruned wherever they sit in the queue, not just
at the front, so the newest remaining toast is always a live one.
- The visible toast is the newest live entry, skipping an expired newer
one. The liveness check reports active while a toast is visible plus
one trailing frame, so the final faded-out state is painted once and
then repaints stop; it self-expires if render() is skipped.
Tests: pruning removes expired entries from anywhere in the queue, the
visible toast skips an expired newest entry, and liveness tracks it.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The filled sparklines and the market price chart built their area fill
with PathFillConcave, which runs a per-scanline pass over the whole
filled region. For a simple area under an x-monotone curve that work is
unnecessary.
- A new helper fills the area between an x-monotone polyline and a
horizontal baseline directly as a triangle strip, in one pass over the
points. The market sparklines, the market price chart and the mining
hashrate sparkline all use it. The fill is flat and non-anti-aliased;
the anti-aliased curve drawn on top hides the stepped top edge, and
the faint side and bottom edges are imperceptible at these alphas.
Tests: the strip's vertex and index counts, index bounds, and that its
triangle area matches the trapezoid area under the polyline.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Glass cards draw their own grain layer over the surface overlay, but the
acrylic primitive beneath them was also drawing its grain. That lower
layer sits under the overlay at well below one grey level, so it is
invisible and only costs an extra noise pass per card.
- The card now asks the acrylic primitive for zero grain and keeps its
own visible grain layer, removing the redundant pass with no visible
change.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
If the scroll-edge fade shader failed to compile or link, the init path
left its ready flag clear and tried again on the very next frame. On a
driver that rejects the shader that meant a fresh compile attempt every
frame, for the life of the program.
- A failed init is now latched and treated as final until destroy(), so
the compile is attempted once, the failure is logged once, and the
fades stay disabled without further retries. Destroying the shader
clears the latch so a later re-init can try again.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The acrylic effect blits its backdrop from the default framebuffer using
the logical viewport size as the source rectangle. On a HiDPI display
the framebuffer is larger than the logical size, so the blur sampled
only the lower-left corner of the screen and the glass showed the wrong
region stretched across each panel.
- The capture blit source now uses the real framebuffer pixel size
(logical size times ImGui's DisplayFramebufferScale, truncated the
same way the GL backend does), while the capture and blur targets stay
at logical size. The framebuffer size is tracked separately from the
viewport size so quality or buffer-lifetime resets cannot lose it, and
moving the window between a Retina and a non-Retina display redoes the
capture. DX11 is unaffected; its scale is one.
- The debug screenshot sweep captures at framebuffer size too, so HiDPI
captures are no longer cropped.
Tests: the framebuffer-extent helper matches the GL backend's
truncation and falls back to the logical size when the scale is unknown.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The acrylic material carried a full composite shader — vertex and
fragment source, compile and link helpers, the program handle and seven
uniform locations — that nothing ever invoked. The acrylic is composited
on the ImGui draw list, not through this program.
- Remove the dead shader source, its compile/link helpers, the program
and uniform fields, and the compile/link/cleanup steps in init and
shutdown. No behaviour change; init no longer builds a program that is
never bound.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Every panel class samples one shared blur texture, so a menu asking for
a different blur radius than cards could not actually get it. All it did
was force a second full-screen blur pass each frame as the radius
flipped between the card and menu values, and let whichever class drew
last decide the radius for everything.
- Setting the acrylic theme now copies the card blur radius onto the
menu class, so menus and cards share one radius and the blur runs
once per frame. The first-use default path goes through the same
setter so it is covered too.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Glass cards scrolled out of view still ran their full effect path, and
the rainbow border always used a segment count derived from a fixed
per-pixel budget, so wide cards drew far more line segments than their
slowly changing gradient needed.
- A glass panel whose rectangle lies wholly outside the draw list's clip
rect now returns early, so a scrolled-out card draws nothing (its
border and ember passes are skipped along with the fill).
- The rainbow border is split into segments by perimeter arc length:
straight edges get one segment per ~32 dp, corner arcs get a few
chords each to stay round, and a fully transparent border is skipped
outright. A wide card now draws tens of segments instead of a couple
hundred, with no visible change.
Tests: the segment fractions are ascending and bounded, each corner arc
keeps at least three chords, and the rect-cull margin test.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The theme effects stored their animation time in a 32-bit float taken
from the ImGui clock. After a few days of uptime a float can no longer
resolve a single frame step, so every oscillation, drift and sweep began
to stutter, snapping forward a frame at a time instead of moving
smoothly.
- The clock is now held as a double, and the phase/oscillator maths runs
through a small set of shared helpers that reduce the argument in
double before returning a float. A float clock at 30 days steps by a
quarter second per ULP; the double clock stays frame-accurate.
- Hue cycling, the rainbow and gradient borders, shimmer, specular
glare, glow pulse, edge trace, embers, the sandstorm and the viewport
wash all use the shared helpers, so their timing no longer drifts with
uptime.
Tests: the helpers advance smoothly after seven days, pin the legacy
float breakdown at 30 days, and stay in range for negative offsets.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Each recent block was fetched with two round trips (getblockhash then
getblock), and a height that failed, such as one briefly above the tip
during a reorg, was refetched on every frame.
- getblock is now called once with the height directly, falling back to
the getblockhash plus getblock pair only when an older daemon answers
"not found" for the short string; any other error is a real answer.
- A failed fetch backs off with a doubling wait (2 s up to 32 s) before
any new fetch is scheduled, and a successful fetch resets the backoff.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The explorer block cache searched with a LIKE query that had to escape
wildcards, grew without any limit, and retried a failed open on every
frame, including a two-second busy wait on the UI thread.
- Search now uses an instr() substring test on a lowercased needle, and
only checks the height text when the query is all digits, since a
non-numeric needle can never appear inside a decimal height.
- The table is pruned to the highest 20000 blocks, once per open and
periodically as blocks are stored, so the file stops growing at its
high-water mark.
- A failed open is negative-cached (30 s by default) instead of retried
every frame, and the busy timeout drops to 250 ms.
- When the cache database is unavailable or refuses a write, the block
spills to a small bounded in-memory map that both the list and search
read, so a spilled block never appears in one but not the other; the
spill is cleared when the chain tip regresses.
- A batch of blocks is written under one savepoint instead of one commit
per block.
Tests: case-insensitive hash and height search, the negative-cache and
spill paths with tip-regression eviction, and batch store plus prune.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The inline detail panel was tracked by the filtered list index. The
display list is rebuilt and re-sorted on every data change, sort switch
or filter edit, so a stored index pointed at a different transaction
after the rebuild, and a page reset was triggered by any change in the
row count, bouncing the view back to page 1.
- The open row is now tracked by a stable content key (txid, display
type, address and the amount's exact bits) so the right row stays open
across rebuilds.
- The page resets and the open row collapses only when a filter input
changes (type, sort or the full search text). A new or removed row is
data and now just clamps the current page.
- The open row is also cleared on page navigation and when the list
empties, so a wallet switch does not resurrect an old expansion.
Adds pure helpers rowKey/filterInputsChanged to
transactions_tab_helpers.h.
Tests: rowKey distinguishes each field and separates +0.0 from -0.0, and
filterInputsChanged latches each input change but not the first frame.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The History tab built a txid-to-indices map twice when its inputs
changed, once for the summary-card totals and once for the display list,
each a full pass over every transaction with string-keyed hashing.
- The rows are now grouped by txid a single time whenever the
transaction list changes, into an index-only structure (first/next
chains with no string copies), and both the summary pass and the
display pass reuse it. A sort-combo change re-sorts without regrouping.
Adds a pure, unit-testable groupByTxid helper in
transactions_tab_helpers.h.
Tests: the chains match a reference map over a random id stream, and the
empty and single-row cases.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The image picker laid out and drew every cell in the directory and image
grids, including the many cells scrolled out of view.
- A cell whose rectangle is not visible now reserves the same space with
a dummy of the cell size, so the scroll extent is unchanged, and skips
the hit-test and draw.
No visible change; only off-screen cells are skipped.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The block detail modal drew every transaction row of a block, running
the hit-test, the draw and a hash truncation for each one, even when the
row sat far outside the scroll viewport.
- A row entirely outside the scroll clip now keeps its layout slot (so
the scroll extent is unchanged) but skips the hit-test, draw and hash
truncation. The expanded row is always laid out so its inline detail
panel still appears.
- The old "showing first 100 of N" footer is dropped, since every row is
now cheap to lay out regardless of how many a block has.
No visible change; only off-screen rows are skipped.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
JSON folding in the console was bypassed in any filtered view, including
when only a channel toggle (daemon, RPC or app) was active. A collapsed
block silently expanded the moment a channel was toggled.
- Folding is now kept active unless the view is text-filtered or
errors-only, which are flat so that every match stays reachable.
Channel toggles keep folding, since JSON blocks are atomic per channel.
- The fold triangles and the collapsed " ... }" summary are drawn only
in the fold-aware view, so the glyph and the rendered block can never
disagree.
User-visible: JSON results stay foldable while channel filters are
applied.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
A long wrapped console line could extend far past the viewport, yet
every sub-row was still measured and drawn.
- Sub-rows that fall entirely outside the clip rect are now skipped. The
per-row ordinal still advances for a culled row, so scanline parity is
unchanged.
No visible change; only off-screen rows are skipped.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
A large RPC result was fully split into lines and copied before being
handed to the console, even though only the last few lines could survive
the model's line cap.
- The formatter now takes a line cap and keeps only the last N lines,
located by a backward newline scan, so a huge result is never fully
split or copied. JSON classification still keys off the first
character of the whole result.
- It works over a string view and moves line text through, and fold
spans are computed over the formatted lines directly instead of a
separate vector of copies.
- getline semantics are preserved: a trailing newline adds no empty
line and an empty result yields no lines.
Tests: getline parity for empty, trailing-newline and blank-line inputs,
the backward tail cap, and JSON classification and fold spans over a
capped result.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The console's pending, not-yet-drained queue was unbounded, so a hidden
Console page that kept receiving verbose daemon-log chunks could grow
without limit.
- Pending lines are now bounded by both a line count and a byte budget.
The oldest pending lines are dropped once either is exceeded, while
the newest line is always kept so one huge line is never lost.
- A formatted RPC result is ingested as one block under a single lock,
so lines from other producers can never interleave inside it.
The front-sequence accounting is also corrected: a pending line dropped
by the cap never entered the visible buffer, so it no longer advances
the front sequence. Only a front eviction or a clear does.
Tests: the line and byte caps keep the newest lines, a single oversized
line survives, a batch appends contiguously, and the front sequence
advances only on a front eviction or clear.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Console text filtering and match highlighting lowercased a full copy of
every line, and of every drawn segment, on each pass.
- A new alloc-free ASCII case-insensitive substring search folds both
sides on the fly, so neither the filter test nor the highlight scan
allocates a lowercased copy per line any more.
- Both call sites use it; the case-insensitive match behaviour is
unchanged.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
When the daemon disconnects, teardown stops the fast RPC worker without
draining its queued main-thread callbacks. The console busy gate was
released inside that callback, so a command whose callback was discarded
left the input disabled for the rest of the session.
- The busy gate now drops from an RAII guard at the end of the worker
job itself, so it is released however the job ends: success, error, or
a worker torn down undrained.
- The result is published to the console before the gate drops, so a
freed-up console always has the output queued.
- An unknown, non-std::exception failure is now caught and reported
instead of escaping the job.
User-visible: the Console input can no longer stay stuck disabled after
a reconnect.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The UI-schema loader walked a hardcoded list of flat TOML sections and
left [banners.*] and [layout] out, so those sections in ui.toml were
silently dropped. The node-status error strip at the top of the content
column could not be styled from the schema at all.
- The flat-section list is now one shared constant used by the file,
string and overlay loaders, and it includes banners and layout.
- The node-status banner is themeable from ui.toml like every other
section; a note in the toml records that these parse as flat sections.
No visible change unless ui.toml overrides those sections.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Market-tab portfolio groups were tied to a wallet by a hash of its
address set. That hash changed whenever an address was added, so a
group built for a wallet could silently drop out of view once the
wallet gained a new address.
- Groups are now scoped by the wallet's stable id, which is persisted
and never derived from the address set, so adding an address can no
longer orphan a group.
- An older build's address-hash scope fails open (the group stays
visible) and is migrated once per wallet per session to the stable id.
A legacy group is claimed when its old hash matches, when one of its
own addresses belongs to this wallet, or when only one wallet is
known; anything ambiguous is left legacy and stays visible.
- Scope filtering waits until the wallet's addresses are loaded, so a
group never flashes an empty 0.0000 balance and Add stays disabled
until the wallet is resolved. This works on Lite, which never stamps
an address-update time.
Behaviour: an older build opened after this change hides groups tagged
with a stable "w:" scope (they are not deleted and return on the next
upgrade) - worth a release note.
Tests: group visibility across global, stable and legacy scopes with the
wallet resolved and not; the migration rules (hash match, address
overlap, single-wallet claim, ambiguous left alone, idempotent); and a
save/load round trip where an address-set change cannot orphan the scope.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
A Settings bootstrap was only wrapped up inside the dialog's own draw
path. If the dialog finished downloading, verifying or extracting while
the user had clicked away or minimized the window, completion was never
processed: the connect and reconcile gate stayed held and the
post-bootstrap rescan was never armed.
- Completion is now a pure decision driven from a pump() that both the
per-frame update and the dialog's render call, so a finished bootstrap
is handled every tick whether or not the dialog is drawn.
- With the dialog open the behaviour is unchanged: it advances to the
Done or Failed screen and those screens still own the gate. With the
dialog closed, pump() releases the gate itself and shows a success or
failure toast; a user-cancelled run is released silently.
User-visible: a bootstrap started from Settings now completes, arms its
rescan and reports its result even if the dialog is closed or the window
is minimized when it finishes.
Tests: the completion decision for an unfinished worker, a completed run
with the dialog open and closed, a failed run, and a user-cancelled run.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The Send tab auto-selected the highest-balance address on first load.
When another tab had already pre-filled the source with "Send from this
address", that first-load pick overwrote the user's choice, and the
dropdown's selected index could drift out of step with the address that
would actually be debited when the address list changed.
- The send-from string is now the single source of truth. The one-shot
auto-pick only fills an empty source and never replaces one the user
or another tab set, and the list index is re-derived from the string
each frame so it cannot desync.
- An explicit "Send from this address" choice latches the auto-pick off,
so the first visit cannot undo it.
- A chosen source the dropdown does not list (zero balance, view-only,
or the list still loading) now previews the address that will be
debited instead of the "Select a source" placeholder.
User-visible: a send-from address chosen from another tab survives the
first load and later refresh ticks.
Tests: the source resolver keeps a pre-fill on first visit, fills an
empty source with the best spendable address, keeps a source missing
from a short list with index -1, latches nothing while disconnected or
with an empty list, and re-derives a stale index from the string.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The Validate address dialog chose its RPC from the first character of
the input, then asked the transparent-only validateaddress for every
case. That RPC is Base58-only, so a valid Sapling (z) address came back
isvalid=false and was reported invalid.
- The input is now classified with the checksum-verified recognizers,
and a shielded address is sent to z_validateaddress while a
transparent (mining) address keeps validateaddress.
- Anything that is not an address - a typo, garbage, or a pasted
viewing/spending key - is judged invalid on the spot and never sent to
the node, so a key pasted into the field is not leaked to an RPC.
- On Lite there is no node to ask, so the dialog shows the checksum
verdict alone and hides the "owned by this wallet" row.
Tests: validateRpcMethod maps shielded to z_validateaddress, transparent
to validateaddress, and a key or empty string to no RPC.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The installed-daemon lookup built the binary path from the Windows
resource key "dragonxd.exe", which never exists on Linux or macOS, so
the installed full node was never found there. The version scan also
read the whole binary, now around 200 MB, into memory on the UI thread.
- A new DAEMON_BINARY_NAME gives the daemon's file name on this platform
("dragonxd" on POSIX, "dragonxd.exe" on Windows); the old resource
constant stays only as the embedded-table key. The installed daemon is
now found on POSIX.
- The version-stamp scan streams the file in 4 MiB chunks with bounded
memory, carrying bytes across each boundary so a stamp straddling it is
neither cut short nor missed, and stops at the first match. The result
is cached by path, size and mtime, so only a changed binary is
rescanned.
- Settings stats the binary on the UI thread but runs the streamed scan
on a worker, guarded so a new scan is not started while one is still
running (the future blocks in its destructor), and the version fills
in when it lands.
Tests: the POSIX path and binary name, a version stamp in a small file,
a stamp straddling the 4 MiB chunk boundary, and a file with no stamp.
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>
Setting a PIN always unlocked the wallet with a 5 s probe and then
re-locked it, even when the wallet was already unlocked, so a PIN check
silently re-locked an unlocked wallet. And once the daemon's absolute
unlock window ran out the GUI still showed "unlocked", so the next send
failed with nothing on screen explaining it.
- The Set-PIN check now decides on the main thread: an unlocked wallet
is verified with the time it has left (capped at seven days) and is
not re-locked, so the GUI and the daemon keep agreeing; a locked
wallet still uses the 5 s probe and re-locks. Either way the daemon's
lock state is re-read afterwards, which self-heals any drift.
- When the daemon's unlock window has expired but the GUI still says
"unlocked", a bounded reconcile (once per expiry, 30 s back-off)
re-reads getwalletinfo so the lock screen and the transaction-cache
lock follow the daemon. Lite wallets, which have no daemon timer, are
left alone.
Behaviour change: with auto-lock disabled the daemon still re-locks at
its window, and the GUI now lands on the lock screen instead of
appearing unlocked until a send fails.
Tests: the Set-PIN verify plan (unlocked keeps its remaining time and
skips the re-lock, locked or near-expiry falls back to the 5 s probe and
re-lock, long windows are capped) and the unlock-expiry predicate across
encrypted, locked, no-deadline and not-yet-expired cases.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The wizard persists an "encryption requested" flag so a quit or crash
before the wallet is actually encrypted is not silent. That flag was
only reconciled on the slow-path getwalletinfo refresh, so it could be
left stale, and the clear-and-warn logic lived inline in two places.
- A single reconcileEncryptionPending now runs on every connect and on
every encryption-state refresh, keyed on the parsed getwalletinfo
reply. It clears the flag whenever the wallet is encrypted, however it
got there (deferred request, Settings, another client), and warns once
per session when encryption was requested but the wallet is not
encrypted and nothing is pending or in flight.
- A failed or warm-up (-28) RPC decides nothing, so a 15 s timeout that
forces encrypted=false can no longer mis-clear or mis-warn.
- The flag is also cleared at encryptwallet success and when the user
removes encryption, so neither path leaves a stale flag that would
warn on the next connect. No settings format change.
Tests: the pure decision helper over the flag, RPC-ok, encrypted,
deferred-pending, in-flight and already-warned combinations (clear when
encrypted, warn once when lost, nothing on a failed RPC or no flag).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The auto-lock idle clock was reset whenever any ImGui item was active,
so a text field left focused (a half-filled send form, say) counted as
continuous activity and kept the wallet unlocked indefinitely, well past
the configured idle timeout.
- The idle clock is now fed by real input events (key, text, mouse,
touch, pen, gamepad button) as they are ingested on the main thread,
plus a still-held mouse button so a drag or scrollbar grab that emits
no new event still counts. A focused but untouched widget no longer
does.
- A new header-only IdleTracker owns the clock and never moves backwards,
so an event stamped before the previous one cannot shorten the idle
time.
Behaviour change: a form left focused and idle past the timeout now
auto-locks. The form's buffers are app state and survive the lock and
unlock, so nothing typed is lost.
Tests: a new IdleTracker case covers idle time growing with no events
(enough to trip a 900s lock), activity resetting it, the clock never
moving backwards, and a query before the last activity reading zero
rather than a negative value.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
A failed backend sync was silently swallowed: the reply was never
inspected, so a sync that errored out left the wallet showing a
"syncing" state that never completed and never reported a problem.
- The sync and rescan replies are now parsed. On failure the partial
progress is saved and the sync is retried with exponential back-off
(5s doubling up to 60s, six attempts in all), then an explicit failed
state is published: done, sync not complete, and the backend's error
text carried through.
- A failed rescan is retried as a plain sync, because a rescan has
already cleared the wallet and re-running it would throw away the
partial progress just saved.
- A terminal error, such as a reorg deeper than the wallet can unwind,
stops the retries immediately instead of waiting out the attempts.
- Recovery is observed, not guessed: once a later refresh's server-bound
reads all succeed, the failed state clears itself.
Behaviour change: a sync failure now surfaces to the user as a failed
state with a message, instead of a frozen "syncing", and the client
retries on its own with back-off.
Tests: a parser case for the success, failure, and terminal replies, and
fake-backend controller cases for retrying after a failure, giving up
after the attempt cap, a terminal reorg stopping at once, a failed
rescan retrying as sync, cancel stopping the back-off, and the failed
state self-healing on a good refresh.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
When a sync pass's transparent (t-address) scan failed, the backend
mishandled it three ways: the retry computed its start height as the
block height minus the batch size, an unsigned subtraction that wrapped
to a huge value for low heights; the block range left un-scanned was
not remembered, so a transient failure left a permanent hole in the
transparent history that nothing ever revisited; and the block times
needed to date the transactions in that range were discarded.
- The owed range and the block times seen so far are now kept and folded
into the next pass, which resumes from the lower of its own first block
and any owed range.
- The scan start never subtracts, so it can no longer underflow.
- A t-address scan owed by an earlier failed pass is paid on the next
sync even when no new block is pending.
Behaviour change: a transparent scan that fails is retried in-process on
the next sync rather than being lost. The owed range lives only in
memory and is never written to the wallet file, so the file format is
unchanged and remains interchangeable with other builds. The debt is
lost if the process exits or the server is switched before a retry
completes; Re-download blocks (rescan) recovers it, as today.
Tests: library tests on the extracted t-scan seam cover a failed pass
keeping its range and block times, a later success clearing the debt,
and the scan start never exceeding the pass start across the batch-size
boundary (the old formula wrapped there).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The lite backend lists transactions oldest-first, but every consumer of
the transaction list assumes newest-first and takes a prefix of it, so
the Recent Transactions / Recent Sends / Recent Received panels and the
CSV export showed the oldest records instead of the latest.
- The UI copy is now sorted pending (0-conf) first, then newest-first by
timestamp, the same order as the History tab's default.
- The sort is stable, so several records that share a txid (a send entry
plus its per-note entries, or same-block transactions) keep a
deterministic order across refreshes. That keeps the order-sensitive
change check reporting "unchanged" and avoids bumping the transaction
revision every couple of seconds.
Tests: a new case covers the pending-first, newest-first ordering, a
stable order within one txid, and the revision staying unchanged when
the identical model is re-applied.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The bootstrap preflight's protected names could be bypassed by names
that resolve onto them, and its cancel and quit paths had two timing
gaps.
- Entry names are judged as the file system resolves them: '\' is a
separator, "." components are dropped and each component loses its
trailing dots and spaces, as Win32 does ("./wallet.dat" and
"DRAGONX.conf." name protected files). The whole archive is refused
for an absolute or drive-rooted name, a ".." component, any ':'
(drive letter or NTFS alternate data stream, "DRAGONX.conf::$DATA"),
an 8.3 short name ("DRAGON~1.CON"), a component of only dots or
spaces (other than "."), and any byte >= 0x80 or ASCII control
character: macOS folds or ignores some Unicode (U+017F, U+212A,
U+200C) onto a protected name, and the published bootstrap archive
uses plain ASCII names. A refused name is echoed
with \xNN escapes. A member is written at the path it was checked
under, and the protected-name check runs on the resolved name.
- The user's Cancel (Settings dialog and first-run wizard) goes through
requestCancel(). It is refused once the old chain data starts being
replaced, and the start of that window re-checks a cancel under the
same lock, so a click lands before the old chain is touched or not at
all.
- Quit cancels the wizard's and the Settings bootstrap when the
shutdown starts; the HEAD and checksum requests now end at once on
cancel, and a cancelled HEAD keeps the partial download. A run
cancelled before the window leaves the old chain as it was. A run
inside it stops extracting and removes its partial chain while the
shutdown screen shows "Cleaning up...", so it ends with no chain data
(wallet.dat untouched; Retry reuses the zip, or the node resyncs).
That wait is bounded: at most 2 minutes from the start of the
shutdown, then 3 s in shutdown(), after which a worker still running
is left to the process exit. Only a cleanup that overruns that cap
can leave a partial chain, which dragonxd then answers with its
reindex offer.
Tests: refusal of the Windows-resolved, non-ASCII and control-character
names with the escaped echo; the protected-name list incl. the resolved
forms and a planted -wallet file, conf and database/ that stay
byte-identical; the user-cancel window; the bounded quit wait and
safeToAbandon(); a cancel ends a HEAD request that never answers.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
ensureLogoTexture() still freed the chat emoji, header logo and coin logo
textures it replaced at once, while every other theme-driven replacement
already goes through retireTexture() and is freed at the next preFrame().
It runs at the top of render(), so nothing referenced them yet; this is
hygiene, so that the same rule covers every replaced texture.
- ensureLogoTexture(): the three direct DestroyTexture() calls become
retireTexture(). Cost: the old textures live one extra frame on a theme
or accent change.
- setGradientTexture() (unused today) retires the texture it replaces
instead of dropping it.
- No drain at shutdown: the process exits and the live logo and gradient
textures are not freed at exit either.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The "Use Tor" checkbox was saved but never read: no build routed any
traffic (node, RPC, price/update/bootstrap/pool HTTP or Lite gRPC)
through Tor, so users who ticked it believed they were anonymous.
- The checkbox is disabled and always shown unticked, on the Settings
page and in the legacy Settings window. Its tooltip says it is not
available and gives the alternatives: for the node, proxy=127.0.0.1:9050
in DRAGONX.conf with Tor running; otherwise a system-wide VPN/Tor (Lite
builds get the short version). The "Use Tor" label is kept.
- Behaviour change: a stored use_tor=true is cleared once at startup
(Settings::consumeLegacyUseTor + a synchronous save before the async
writer and any Settings page state), with a 20 s warning in the user's
language. The cleared key is the "already told" flag, so the notice
never repeats. No settings format change.
- 3 new strings (tt_tor_unavailable, tt_tor_unavailable_lite,
tor_never_applied_notice) in all 8 languages; the tooltip wraps at
tor-tooltip-wrap-width from ui.toml.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Builds that embed xmrig (Windows release) re-extracted the bundled miner
whenever its size differed from the file on disk. An "Update miner..."
install always differs, so the next start silently put the bundled build
back while settings still showed the updated version.
- The bundled miner is now placed only when it is missing, like the node
binaries (kXmrigRefreshOnSizeMismatch = false in the provision table).
Params and asmap still follow the bundled size.
- Behaviour change: a wallet upgrade no longer refreshes a miner the user
never updated; "Update miner..." is the way to get a newer build.
- The uncalled needsXmrigExtraction() is removed.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A rescan or key import holds the node's cs_main for the whole wallet
scan. On the shared clients it hit the 30 s curl timeout, reported a
failure that wasn't one, abandoned a daemon RPC slot and flipped the app
into "node busy".
- One long wallet RPC at a time (rescan, importprivkey, importaddress,
importwallet, z_importkey, z_importviewingkey, z_importwallet) runs on
its own ungated client with no client timeout (rpc/long_call_lane.h).
It can be aborted from any thread, and an abort is never lost, even
one that lands before the client opens.
- The GUI rescan, key import and sweep use it. The sweep's fund-moving
body is unchanged; only the client it calls differs. runtimeRescan()
returns bool, and a refused post-bootstrap reconcile stays armed.
- The console routes rescan and the import commands through the lane;
its input stays usable meanwhile, and a refusal is printed like any
other result. RPCClient gains callRaw(.., timeoutSec).
- Restart, wallet switch, delete-chain, bootstrap (now also from
Settings) and the other node-stopping flows are refused while it runs,
with a message saying a key import or rescan is still running; quit
asks for confirmation. The lane is aborted at quit.
- A second import or rescan is refused before the key is copied anywhere.
An import cut off after the request reached the node (a lost reply,
or a proxy's 502/504 while the node still imports) says the key may
already be in the wallet and to run Settings -> Rescan. Only the
node's own HTTP-layer refusals (401, 503, work queue full, unhandled
request) say that the key was not imported. A pure
classifyLongCallCutOff() makes that choice.
- New strings in all 8 languages; console results stay English.
Tests: the long-method list and the console's routing; one lane at a
time; an abort lands whether it comes before the client opens or during
the call; a call with no client timeout outlasts a 3 s scan, where a 1 s
gated client times out and marks the node busy; classifyLongCallCutOff()
for every failure kind with and without a sent request, and from raw
HTTP replies (502, 504, a plain 500 -> cut off; 401, 503, queue full,
unhandled -> refused).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
fake_dragonxd now answers rescan, importprivkey, z_importkey and
z_importviewingkey, so the GUI's long wallet RPCs can be reproduced
without a real node.
- Each one holds cs_main for FAKE_RESCAN_SECS (default 0). The lock-free
getrescaninfo reports the scan while it runs, and, like v1.3.0, the
scan stops early when a shutdown is requested.
- Argument checks, error codes and return values follow dragonxd
(z_importkey returns null, importprivkey the t-address, the default
"whenkeyisnew" skips a known key, z_importviewingkey needs its zaddr).
Keys are only prefix-checked and never stored or logged; an imported
spending key gets a made-up address that the address lists then show.
- FAKE_ENCRYPTED=1 / FAKE_WALLET_PASSPHRASE: an encrypted wallet that
starts locked, with walletpassphrase (-14 on a wrong passphrase),
walletpassphrasechange, walletlock and getwalletinfo unlocked_until;
the imports answer -13 while it is locked. As in dragonxd, the right
passphrase with a non-integer timeout unlocks first and only then
fails (-1): the wallet stays unlocked with no relock timer of its
own. A timer pending from an earlier unlock keeps running; otherwise
only walletlock locks it again. Without FAKE_ENCRYPTED, those three
methods answer -15 like dragonxd.
- FAKE_METHODS_ALL call events show keys and passphrases as <redacted>.
A plain run is unchanged: the same 63 GUI calls before and after, and
identical replies to every one of them.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The bootstrap removed blocks/, chainstate/ and notarizations/ first and
only then opened the archive. An unsafe entry, a full disk or a corrupt
member therefore left no chain, or a half-written one for dragonxd to
start on.
- Preflight before anything is deleted: every archive entry must be
safe (no absolute path, no "..") and readable, and the disk must hold
the extracted size plus max(256 MiB, 5%), counting what removing the
old chain frees (a symlinked chain dir counts 0). Free space counts as
unknown only when the probe errors, the capacity is 0 or the
available size is -1; a known disk with 0 bytes free is a refusal.
The old chain is kept on every preflight failure.
- Protected members are skipped, compared case-insensitively: any path
component starting with "wallet", any "database" component, a .cookie
or *.conf file, and top-level *.dat files other than fee_estimates,
peers and banlist. The number of non-critical member failures is
logged.
- The removal of the old chain is verified (a locked file fails the run
instead of mixing old and new chain files). If the extraction fails or
is aborted, the partial chain is removed again before the failure is
published; isDone() turns true only after the worker's last
filesystem change.
- Cancel is disabled while the old chain data is being replaced, and
quit aborts a running bootstrap.
- A complete zip from an earlier attempt is reused on Retry; its
checksum is re-verified. A download that cannot fit fails early, and a
disk-full write error says so.
Tests: the space rule (boundaries, unknown vs. full disk, saturation);
planArchive sizes, zip-slip, garbage and truncated zips; the
protected-name list; reclaimableBytes with symlinks; each preflight
refusal keeps the old chain and wallet.dat byte-identical; a corrupt
critical member removes the partial chain before the failure is
published.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
After an unexpected exit the monitor thread returned on its own but
stayed joinable, and start() only spawned a monitor when none was
joinable. A node restarted after a crash (no stop() in between) then ran
unwatched: a second crash was never detected, its output was never
drained, and the dead run's stdout pipe leaked into the next fork.
- start() joins a monitor that returned on its own (only when joinable,
at the top, after the "already running" early returns), closes the
dead run's stdout pipe (Windows: process and pipe handles), and always
starts a new monitor for the process it launched. crash_count_ is kept.
- New lifecycle_mutex_ serializes start(), beginStop(), stop()'s flag
writes and joins, and endStop(), so two threads can never join the same
monitor. The monitor thread and the state callback never take it; a
beginStop() racing a start() waits for it to return (its longest wait
is the ~300 ms datadir-lock poll).
- start() is refused (returns false, state unchanged) while a stop is in
progress, i.e. between beginStop() and endStop().
- The daemon tests now start the stand-in only in isolation (temp
-datadir override, port check skipped): they no longer write the real
~/.hush/DRAGONX/obsidiandragon-dragonxd.pid or tail the real debug.log.
New tests: restart after a crash is supervised, a restarted run stops
cleanly, no fd leak over crash+restart cycles (Linux), and start() is
refused while stopping.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A passphrase change left the PIN vault sealing the old passphrase. The
next PIN unlock opened the vault, walletpassphrase answered RPC -14, and
the generic catch ran the lockout curve on a correct PIN, while Settings
kept showing the PIN as active.
- Passphrase change: when the active wallet's vault exists at completion
(same vault path as at dispatch, passphrase actually changed), the
vault is marked out of date for the session and kept on disk: PIN entry
is no longer offered, a warning toast says so, and Settings shows
"PIN out of date" with a tooltip on how to fix it. The Change
Passphrase dialog notes beforehand that a PIN will go out of date.
- PIN unlock: RPC -14 after the vault opened is treated as "PIN out of
date": passphrase entry, a message, no attempt counted, no lockout, and
the vault is not deleted (-14 is only the daemon's answer; the vault may
hold the only copy of a passphrase that opens an older backup). After a
restart Settings shows the PIN as active until the first PIN attempt.
- A malformed vault (zero-filled by a removeVault() whose unlink failed,
truncated, unknown version) is treated as missing: passphrase entry,
"PIN file unreadable", no penalty. New SecureVault::isWellFormed().
- Race guards: Set/Change/Remove PIN are disabled while a passphrase
change runs, and Change Passphrase while a PIN operation runs.
- Setting or removing the PIN, or switching wallets, clears the mark;
setting a PIN offers PIN entry again. Change PIN keeps the mark (the
vault still seals the same passphrase).
- Pure decisions in data/pin_vault_policy.h, unit-tested. New strings in
all 8 languages.
No vault, wallet or settings format change.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The full UI sweep (dev builds only) ran its demo surfaces against the
real profile. Its teardown deleted wallet-{cold,trading,mining,donations,
2023,payroll}.dat from the real datadir, it created zero-filled wallet
files and picker-demo/extra-wallets folders, its demo wallet-index upsert
re-minted the active wallet's scopeId (hiding "w:"-scoped contacts), the
Contacts tab could re-attach legacy-scoped contacts to such a throwaway
id in addressbook.json, and it persisted the demo active wallet file and
Mining pool worker.
- Preflight: a full sweep is refused (toast + log line) while a real
workflow is in flight: a pending seed_migration_* setting or open
migrate dialog, a wallet switch, an encryption/import workflow, an
encrypt/PIN/lock operation, the seed-backup dialog, or a bootstrap.
The DRAGONX_FULL_SWEEP hook then exits as for an empty catalog.
- Persistence latches: AddressBook (snapshot on the first sweepBegin,
save/load latched while a demo-contacts surface is shown), WalletIndex
and Settings (active wallet file, contacts view mode, pool URL/worker)
for the whole sweep. Settings::sweepEnd() is the last statement of
clearDemoWalletData and flushes once only if a save was suppressed; a
quit mid-sweep writes nothing to the profile (the 0700 sandbox, with
zero-filled demo files only, may stay in the temp dir).
- Scope ids: during a full sweep activeWalletScopeId() returns the
persisted id or "" and never mints one, so the Contacts tab cannot
move legacy-scoped contacts to a throwaway id. The only address-book
write a sweep can still cause is the one-time legacy re-attach to the
wallet's persisted id, which opening Contacts does anyway.
- Sandbox: demo wallet files and folders go to a fresh random 0700 dir
under the temp dir (exclusive create, a pre-existing path or symlink
is refused), removed at sweep end. The teardown that deleted those six
wallet files is gone.
- Cleanup: the migrate-to-seed state, wizard phase, demo chat
conversations and a fabricated demo chat identity are reset at sweep
end; the seed-backup surface always shows the demo phrase and a real
z_exportmnemonic result is dropped while the sweep runs.
- i18n: one new key (screenshot_sweep_refused) in i18n.cpp and the 8
language files.
Tests: the address book snapshots only on the first sweepBegin, keeps
addressbook.json byte-identical while latched and restores the real
book; the wallet index keeps wallets.json and the real scope id, also
with the background writer attached; Settings restores the latched
values and flushes once only when a save was suppressed; the sandbox
helper refuses a pre-existing path or symlink and removes only a
prefixed dir, never a symlink's target.
Release note: v2.0.x UI sweeps could delete wallet-{cold,trading,mining,
donations,2023,payroll}.dat from the data directory and re-scope
contacts; this cannot be undone automatically.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The screenshot sweep and the full UI sweep are dev tools. The full sweep
injects demo data into the live wallet state and the real data and
config dirs, and the tab sweep screenshots real balances and addresses.
Both were compiled into every build: the Settings buttons were
reachable in full-node releases, and the DRAGONX_FULL_SWEEP env hook
worked in every build, Lite releases included.
A default-OFF option alone would fail open: dev and release builds
share build/linux (and build/mac), so a release configured without
--clean would inherit a cached ON from the last dev configure.
- CMake: option(DRAGONX_ENABLE_UI_SWEEP ... OFF), passed to
ObsidianDragon and ObsidianDragonTests as a compile definition.
- build.sh passes it explicitly: ON in the dev build, OFF in every
release configure (Linux, Windows, macOS osxcross and native).
- Packaging guard: while the cache says ON, dragonx_release depends on
dragonx_release_sweep_guard, which fails, so a hand-configured ON
tree cannot package a release.
- startSweepImpl is a no-op unless the option is ON (an undefined macro
counts as 0, so it fails closed). The enabled branch logs the
sentinel "[Sweep] DRAGONX_UI_SWEEP_COMPILED_IN", so a release can be
checked with `strings <bin> | grep -c DRAGONX_UI_SWEEP_COMPILED_IN`
(must be 0).
- main.cpp: the DRAGONX_FULL_SWEEP hook is compiled out (autoSweep is
false). It must be: with the sweep a no-op, the hook would quit the
app once its warm-up frames ran out.
- Settings > Node > Debug options: both sweep buttons, open-dir, the
seed-demo-chat button and the current-theme checkbox are compiled only
into dev builds. The daemon debug categories are unchanged.
No i18n change: the existing strings stay in the tables.
Verified with an isolated release (DRAGONX_BUILD_ROOT/DRAGONX_RELEASE_ROOT
in a scratch dir) whose tree was first configured by hand with ON:
build.sh --linux-release without --clean leaves the cache OFF, and the
sentinel count is 0 in the built binary, the zip and the AppImage.
With the cache set back to ON by hand, --target dragonx_release fails
at the guard before compiling anything. Dev trees configured ON keep
the sweep (sentinel present; a one-surface DRAGONX_FULL_SWEEP run
against the fake daemon captured and exited cleanly).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The Lite "Encrypt wallet" field and the Create/Restore passphrase had no
confirmation and no minimum length; only an empty passphrase was
refused. Encrypting locks the wallet at once, and a locked wallet cannot
reveal its seed, so a typo there could cost a user without a seed backup
their funds. The full-node encrypt dialog already requires 8 characters
and a confirmation.
This applies to new passphrases only. Opening and unlocking are
unchanged: an existing wallet with a shorter passphrase still opens and
unlocks.
- A pure liteEncryptPassphraseValid(pass, confirm): at least 8 bytes and
equal to the confirmation. It takes string_views, so the page's secret
buffers are never copied.
- liteLifecyclePassphraseAcceptable(op, pass, confirm) gates the Settings
create/open/restore request. Open is never checked, because its
passphrase field also unlocks an existing wallet. Create and Restore
are checked only when a passphrase was entered; an empty one still
creates an unencrypted wallet.
- Encrypt gets a Confirm field. The button stays disabled until the
passphrase is valid, and a hint says why (too short, with the count,
or not matching).
- Create/Restore show a Confirm row once a passphrase is typed, with the
same hint. Validate stays disabled while the passphrase is invalid, so
a click never wipes a typed seed over a passphrase typo. The request
builder re-checks the rule.
- The confirmation buffers are wiped with their passphrase: after a run
or an encrypt, and when the Confirm row is hidden.
- Once the wallet is closed, the Settings page wipes the Encrypt,
Confirm and Decrypt passphrase buffers whole every time, also when no
secret was revealed; before, closing the wallet wiped them only while
clearing a revealed backup or secret. They are zeroed in full because
an emptied field can still hold the old tail past its first NUL.
- New copy under Encrypt: "Anyone with the wallet file can try
passphrases offline. Back up your seed phrase first and use a long
passphrase."
- i18n: three new keys (lite_confirm_label, tt_lite_confirm_pass,
lite_encrypt_offline_warning) in i18n.cpp and the 8 language files,
added additively. The hints reuse the wizard's wiz_pass_too_short and
wiz_pass_mismatch. No new CJK glyphs, so the subset font is unchanged.
Tests: the validator rejects empty, short (7) and mismatched or
unconfirmed passphrases, and accepts 8 or more with a match. The request
gate always accepts Open, even "abc" with no confirmation, accepts
Create/Restore without a passphrase, and applies the rule to them
otherwise. An encrypted, locked wallet still opens and unlocks through
the controller with the 3-character passphrase "abc".
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
importKeyCore guessed the key type from its first character, then
retried a failed import with the other command. That retry is what sent
shielded keys to the backend's transparent-key decoder, which panicked
on them and poisoned the wallet lock. A WIF typo that was still
valid base58 also reached the backend unchecked.
- The key is classified first, and exactly one backend command runs, or
none:
- secret-extended-key-... and zxview... go to "import";
- a Base58Check WIF with version 0xBC or 0x80 and the compressed flag
goes to "timport";
- everything else is refused before any backend call.
There is never a retry with the other command. A testnet (0xEF) WIF
is not accepted, because the Lite app is mainnet-only.
- New fixed, untranslated reasons: "uncompressed transparent keys are
not supported by the Lite wallet", a full-node incoming viewing key
(zivks) is explained as such, and anything else is "unrecognized key
format". The key is never echoed.
- The backend's refusal while the wallet is encrypted now maps to "wallet
is encrypted - remove encryption to import a private key". It is
checked before "locked", because unlocking would not help.
- base58Decode reserves its buffers up front, and it and
decodeBase58Check wipe their copies of the decoded bytes with
sodium_memzero on every return path, since the input can be a private
key.
- exportSeed rejects the all-zero-entropy phrase (23 x "abandon" + "art").
A backend built before the seed-export fix returns it for a wallet
whose lock failed halfway (seed wiped, wallet still unlocked); it is
never a real backup and must not seed a chat identity.
- An open that the backend recovered from the .bak ("OK: recovered from
backup; damaged file kept as <name>") is written to the lite log.
Tests: the retry control now expects one "import" and no "timport", and
"typo-key" makes no backend call; the controller's transparent-import
test uses a real compressed WIF. A new classifier test covers both WIF
versions, the shielded and viewing keys, a single call on failure, the
uncompressed, zivks, testnet, bad-flag and bad-checksum refusals, the
encrypted reason and its lite-log line, the zero-entropy seed and the
recovery log line.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
litelib_initialize_new, litelib_initialize_new_from_phrase,
litelib_initialize_existing and litelib_check_server_online had no
catch_unwind (only litelib_execute did), so any panic below them
unwound across the C ABI into the GUI. With rustc 1.63 that is
undefined behaviour; in practice the process aborts with "fatal runtime
error: failed to initiate panic, error 5". The simplest trigger is a
malformed user-entered server URL: get_server_or_default unwraps the
http::Uri parse before anything else runs.
- ffi_guard (next to error_cstring) runs a body under catch_unwind and
turns a panic into "Error: Rust panic in <name>", which the C++ bridge
already treats as a failed call. The three init entry points keep
their extern signatures and #[no_mangle] and call ffi_guard; their old
bodies are now private *_impl functions.
- litelib_check_server_online wraps its body the same way and returns
false on a panic.
- litelib_initialize_existing opens the wallet through
read_from_disk_reporting. After a recovery from the .bak it returns
"OK: recovered from backup; damaged file kept as <name>" instead of
"OK". The C++ side treats any non-empty, non-error string as a
successful open, so this is ABI-compatible.
No ABI or symbol change: the exported symbol inventory
(lite-backend-symbols.txt) is identical. panic = "unwind" stays in the
release profile, and every guard depends on it.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
do_save flushed the temp wallet and renamed it over the wallet with no
fsync of the temp file, the .bak or the directory, so after a power cut
the rename could be on disk while the data was not, leaving a torn
wallet. The readers then panicked on malformed bytes instead of
returning an error (the gzip Decoder and WalletTx unwraps in
LightWallet::read, the version asserts in data.rs and walletzkey.rs,
from_utf8/diversifier unwraps, and vec![0; len] on a corrupt length
prefix, which can abort the process outright), so read_from_disk's .bak
fallback was never reached. And the fallback itself was destructive: it
copied the .bak over the wallet before reading it, on ANY error, so the
damaged file was lost, a transient I/O error replaced a good newer
wallet with an older .bak, and a wallet written by a newer app ("Don't
know how to read wallet version") was overwritten too, dropping imported
keys that are not in the seed.
- do_save: sync_all the temp file (removed on error), copy the .bak,
tighten and sync it through a write handle (FlushFileBuffers needs
write access on Windows), rename, sync the copy-fallback destination,
then fsync the directory on unix. No MoveFileEx write-through on
Windows.
- read_from_disk: each parse runs in read_wallet_file under
catch_unwind (panic = "unwind" is kept for release builds). The .bak
is used only when the wallet fails with InvalidData, UnexpectedEof or
InvalidInput, and it is read first, so a bad backup leaves both files
as they were. A damaged wallet whose header says encrypted is not
replaced by a plaintext .bak (only an older build leaves one): the
open fails with an explanation and nothing changes. Otherwise the
damaged wallet is moved (or, failing that, copied) to
<wallet>.dat.corrupt-<unix secs>, with a -N suffix while that name is
taken (a dangling symlink counts as taken), and is never deleted. The
recovered wallet is tightened as before.
- read_from_disk_reporting returns the same client plus "recovered from
backup; damaged file kept as <name>" after a recovery, for the FFI;
read_from_disk wraps it. Both stages are also written to stderr,
since the logger is not initialised yet when the wallet is opened.
- Readers return Err: a newer wallet version is ErrorKind::Unsupported
(was InvalidData), and so is a newer WalletTx or key version;
Decoder::new and WalletTx::read propagate with ?; a bad Utxo version,
a negative or non-UTF-8 or non-R Utxo address, non-UTF-8 metadata
addresses, an invalid diversifier and a viewing key with no default
address are InvalidData. Every length-prefixed string and address is
read through read_bytes_bounded (take(len).read_to_end, no cap, std's
own short-read error), so a corrupt length is never allocated up
front.
Flagged behaviour changes:
- fsync order: tmp, then .bak, then rename, then the dir (unix); saves
at exit, encrypt and decrypt still run on the UI thread and get
slower on slow disks;
- the readers return Err instead of panicking;
- the .bak fallback runs only for corrupt files, and the damaged file
is kept as .dat.corrupt-<ts> (every copy is kept; one from a
plaintext era keeps its seed, inside the 0700 dir);
- a wallet with a newer format errors (Unsupported) and is never
replaced;
- a damaged encrypted wallet whose backup predates encryption is not
opened.
Wallet format v9 is unchanged; valid files read and write byte for byte
as before. No ABI or symbol change.
Tests: a wallet whose parse panicked recovers from the .bak, the
garbage is kept as .dat.corrupt-<ts> and the report names it, and the
next open reports nothing; a newer-version wallet is Unsupported and
both files are unchanged (this fails when only the version error kind is
reverted); an unreadable (0000) wallet is a PermissionDenied error that
changes nothing either; a corrupt wallet with
a corrupt or no .bak changes nothing; a damaged encrypted wallet with a
pre-encryption plaintext .bak is refused with both files unchanged and
nothing moved aside; a recovery never overwrites an earlier
.dat.corrupt-<ts> or follows a dangling symlink; every truncation of a
wallet and of a WalletTx is an Err, crafted Utxo, metadata, note and key
records are the expected Err kinds, and a u64::MAX length returns at
once.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
On Linux and macOS the Lite data dir (~/.silentdragonxlite) was created
with create_dir_all (0755 under the default umask) on every
get_zcash_data_path() call, even by the startup wallet_exists() probe.
do_save wrote the wallet through File::create (0644) and renamed it into
place, and fs::copy gave the .bak the same mode, so any local account
could read the wallet file, and the plaintext seed with it when the
wallet was not encrypted. The debug log was 0644 and its roller pattern
was relative, so rolled logs landed in the process's working directory.
A crash-leftover .dat.tmp was truncated and reused: an fd another
account had opened on it read the next wallet. And after encrypt(), the
first save copied the previous, plaintext wallet to .bak, where the seed
stayed until the next save, or for good if the app quit right away.
- New helpers, each an item-level #[cfg(unix)] / #[cfg(not(unix))]
pair: create_private_dir_all (0700 components), tighten (strips only
the group/other bits of an existing dir or file, keeps the owner bits,
never follows a symlink), create_private_file_new (unlinks a stale
file, then create_new with 0600), and a once-per-process warning when
a chmod fails (drvfs, FAT, NFS).
- get_zcash_data_path creates the default data dir 0700 and tightens an
existing one (for test/regtest also its parent). A caller-supplied
data_dir is created as before and never chmodded, and the params dir
is left alone.
- do_save writes a fresh 0600 temp file and tightens the .bak after the
copy. At the plaintext -> encrypted transition (the wallet is
encrypted and the on-disk header says plaintext) the .bak is copied
from the new, encrypted file. wallet_header_encrypted reads the flag
byte for versions 4 and 6-9 only (v5 is gzip) and returns None when it
cannot tell, which keeps the old behaviour.
- read_from_disk tightens a wallet restored from .bak, and the wallet
and .bak after every successful read.
- get_log_config pre-creates the log 0600 on unix and anchors the
roller in the data dir; a data path containing "{}" keeps the old
relative pattern. log4rs re-creates the log with the umask mode after
a 5 MB roll, so the 0700 data dir is the lasting control.
Flagged behaviour changes:
- on unix, the data dir is 0700 and existing files lose their
group/other bits (owner bits kept, symlinks and a caller-supplied
data_dir untouched); a shared group or a backup user under another uid
loses read access;
- a fresh 0600 temp file is used;
- the first encrypted save backs up the encrypted file;
- the log rolls inside the data dir (old rolled logs in former working
directories are not cleaned up).
On Windows the file modes are unchanged; the encrypted first .bak and
the log-roll location apply there too. Wallet format v9 is unchanged;
no ABI or symbol change.
Tests: the wallet and .bak are 0600 after every save and after an open
from 0644, and a wallet restored from a loose .bak is 0600, while the
caller's 0755 data dir is unchanged; a stale 0644 .dat.tmp is replaced
by a new inode and an fd held on it still reads the stale bytes; tighten
keeps owner bits (0555 -> 0500), ignores the other kind and symlinks,
create_private_dir_all makes every component 0700, and
create_private_file_new never reuses or follows a stale file or link;
after encrypt the first save's .bak is encrypted, byte-identical to the
wallet and unlocks to the seed; the header probe answers only for v4 and
v6-v9; the log is 0600, the roller pattern and a real roll stay in the
data dir, and a "{}" path falls back. An opt-in test
(DRAGONX_LITE_SCRATCH_HOME=1, temp HOME) checks the default data dir,
its testnet parent, a symlinked data dir and the params dir.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The lightwalletd server controls the compact ciphertext length, the
block, prev-block and tx hash lengths, and (through the chain) the full
txs' epks. Each of these could wedge or poison a sync:
- try_sapling_compact_note_decryption asserts a 52-byte ciphertext. A
shorter (or full 580-byte) one panicked a pool job in
scan_output_internal while the original sender stayed alive, so the
collector waited forever with sync_lock, txs.write() and a wallet read
guard held.
- scan_taddress_txids kept its own sender alive too: one panicking
t-address job (scan_full_tx) left crx.iter().take(n) blocked forever.
- CompactBlock::hash()/prev_hash() assert 32 bytes when no header is
sent, and every vtx hash is copied into a [u8; 32], one of them under
txs.write(): a 31-byte tx hash poisoned the wallet for good, and a bad
hash in the block's txid list panicked after the witnesses had moved.
- scan_full_tx and scan_full_mempool_tx unwrapped as_prime_order() on
the epk. Consensus rejects only a small-order epk, so a mixed-order
one can be mined: an own tx then failed every sync, and the mempool
receiver thread died silently.
Changes:
- append_leaf(node: Node, ..) commits a leaf to the existing, block and
new witnesses and the tree; the epk-error path, the new
short-ciphertext path and the normal path all use it (a
behaviour-neutral extraction).
- A compact ciphertext under 52 bytes is skipped with its cmu still
committed; a longer one is truncated to 52 bytes, which is exact (the
compact plaintext is those bytes and the cmu re-check validates it).
- Each decryption job catches a panic and sends exactly one message;
drop(tx) after the dispatch, and the collector logs a vanished or
panicked job and treats the output as not ours.
- scan_taddress_txids runs through the private seam
scan_taddress_txids_with(job): catch_unwind per job, drop(ctx), and a
count check, so a dead job is an error, never a hang or a silent
partial success.
- scan_block_with_pool checks the block, prev-block and vtx hash lengths
right after the parse and returns Err(-1), as for an unparsable block,
before any change (Err(height) would refetch the same block forever
under sync_lock).
- scan_full_tx and scan_full_mempool_tx skip an output whose epk is not
in the prime-order subgroup (one check per output, used by every
decryption and the outgoing recovery).
- The mempool receiver thread catches a panic of scan_full_mempool_tx.
Flagged behaviour changes:
- a compact ciphertext under 52 bytes is skipped (its cmu is still
committed); one over 52 bytes is truncated;
- pool-job panics are reported (logged; the output is treated as not
ours, which a rescan repairs) instead of hanging the sync;
- bad hash lengths return Err(-1) before any change;
- outputs with a non-prime-order epk are skipped;
- the mempool thread survives a bad tx.
Wallet format v9 is unchanged; no ABI or symbol change. Honest data
takes the same path as before (an old-vs-new run over generated honest
chains is byte-identical).
Tests (each on its own thread under a 15 s watchdog):
0/51/52/53/580-byte ciphertexts in one block on the inline and deferred
witness paths (the 51-byte notes are skipped, the 53- and 580-byte notes
are found, the tree and every witness match the reference); a 31-byte tx
hash, an empty block hash, a 31-byte prev hash and a 33-byte foreign tx
hash each return Err(-1) with the txs lock usable and
last_scanned_height, trees and witnesses unchanged; a mixed-order epk in
scan_full_tx and scan_full_mempool_tx does not panic and decrypts
nothing while the real epk still decrypts; a t-address job panicking for
1 of 3 addresses is an Err. The oracle generator gains short
(0..=51-byte) and full (580-byte) ciphertext outputs without an extra
random draw, so each seed keeps its sequence of blocks, values and
keys.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
lock() zeroed the seed first and then locked the keys one by one. A key
with no encrypted copy (an import into an encrypted wallet by an older
build) failed halfway: the seed was all zeros, the earlier keys were
wiped, and the wallet still reported unlocked. Seed export then showed
the all-zero-entropy phrase ("abandon ... art") as the backup.
unlock() had the mirror problem. When the passphrase was right but a
later key failed (a zaddress/fvk mismatch, a bad sealed key), the keys
decrypted so far stayed in memory while the wallet reported locked, and
write() saves a locked wallet as is, so the next save put them in
plaintext into the encrypted file. unlock also unwrapped the nonce and
the sealed key, so a malformed or foreign file panicked under the wallet
write lock and poisoned it.
remove_encryption() on an unlocked wallet never looked at the
passphrase: any string, even "", removed the encryption for good, and
the next save wrote the seed and every key in plaintext.
- WalletTKey/WalletZKey get can_lock(), which mirrors lock()'s
precondition. lock() checks every key first and changes nothing when
one cannot be locked; the keys are locked next and the seed is zeroed
last.
- unlock() runs the t-key loop, then the z-key loop; on any error it
re-locks every key that can be locked before it returns. This drops
only plaintext copies that can be re-derived or re-decrypted.
- The nonce and enc_key unwraps in LightWallet::unlock and in
WalletTKey/WalletZKey::unlock return "Wallet encryption data is
corrupt" (InvalidData) instead.
- get_seed_phrase() returns "" when the wallet is encrypted and its seed
is all zeros.
- remove_encryption() opens the sealed seed with the given passphrase
first, locked or unlocked, before it changes anything.
Flagged behaviour changes:
- lock() checks every key before it changes anything;
- a failed unlock re-wipes;
- corrupt encryption data returns an error, not a panic;
- seed export never returns the all-zero phrase;
- remove_encryption verifies the passphrase in both lock states (a wrong
passphrase now fails with "Decryption failed. Is your password
correct?").
Wallet format v9 is unchanged; no ABI or symbol change. The KDF is
unchanged; upgrading it is left for later.
Tests: lock is atomic for an unsealed t-key and an unsealed imported
z-key (seed, unlocked flag and keys unchanged, then a clean lock and
unlock); a failed unlock leaves no t-key or z-key secret in memory or in
the written file, and with the z-key's real derivation index restored
the same passphrase unlocks every key; a key with no sealed copy, a
missing nonce or a nonce of the wrong length is an error in WalletTKey,
WalletZKey and LightWallet, and remove_encryption refuses it too;
remove_encryption with a wrong or empty passphrase fails locked and
unlocked, and the right one round-trips an unencrypted v9 file; an
encrypted wallet with a zero seed exports "".
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
import_taddr decoded a WIF with bs58::decode(..).unwrap(), a [2..66]
slice of its hex and SecretKey::from_slice(..).unwrap(), all under the
wallet write guard that do_import_tk holds. A z-key, a typo or any short
string panicked there and poisoned the wallet until restart, and the
checksum was never verified, so a mistyped WIF that was still valid
base58 imported a different key.
A private key imported into an encrypted wallet was stored without an
enc_key. The import then failed in do_save's lock(), which had already
zeroed the seed while the wallet stayed "unlocked": seed export showed
the all-zero-entropy phrase.
- decode_taddr_wif replaces the old decode: Base58Check with the
checksum verified, version 0xBC (the config prefix) or 0x80, and the
0x01 compressed flag. It decodes into a buffer it owns and wipes it
with sodiumoxide::utils::memzero on every path, including a failed
checksum. Errors are fixed strings that never contain the key.
- import_taddr and add_imported_sk refuse while the wallet is encrypted,
as add_taddr and add_zaddr already do.
- do_import_tk and do_import_sk check is_encrypted() before the
"locked" check, so a locked wallet does not send the user to unlock
and retry.
Flagged behaviour changes:
- Base58Check, version 0xBC/0x80 and the compressed flag are required;
- uncompressed and foreign-network WIFs are rejected with fixed
messages (an uncompressed key used to import under the compressed
address, where its funds are not);
- no panic and no poisoned lock;
- private-key import (t and z) is refused while the wallet is
encrypted, with a reason that says to remove encryption, import, then
encrypt again; viewing keys still import;
- the key is never echoed.
No ABI, symbol or wallet format change.
Tests: garbage, z-keys, bad checksums, a transposed or flipped
character, a zero scalar, a wrong flag, uncompressed, 0x05 and 0xEF WIFs
are rejected with their message and leave tkeys usable; the wallet's own
export, a 0x80 WIF and the published key-1 vector still import; the
encrypted refusal in the wallet and the client, locked and unlocked,
leaves lock, save and seed export intact; a viewing key still imports
into an encrypted wallet; a bad timport leaves the wallet lock unpoisoned.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
clear_state_from wiped the wallet's blocks, txs and mempool maps without
taking sync_lock. The console `clear` and `rescan` commands run
concurrently with the refresh worker's sync (litelib_execute drops the
client mutex before a command runs), so a clear could land after a sync
had already taken its nullifier snapshot. scan_block then unwrapped a
note that was no longer in the wallet and panicked under the txs write
guard, which poisons the wallet until restart.
- clear_state_from now takes sync_lock first (lock order sync_lock ->
wallet, as in do_save and do_sync). No caller holds sync_lock, so it
cannot self-deadlock; do_rescan holds no lock when it clears.
- clear_state reads the birthday into a local before calling
clear_state_from. Before, the wallet read guard was a temporary that
lived across the call, which read-locks the wallet again: with a
writer (a save, an import, a new address) queued in between, the
writer-preferring RwLock on Linux and Windows could deadlock. Holding
it while waiting for sync_lock would also have inverted the lock order
against do_save.
- scan_block resolves each spent note with and_then instead of three
unwraps. A spend of a note that is no longer in the wallet logs a
warning and is skipped.
Flagged behaviour changes:
- console clear/rescan and the GUI rescan wait for an in-flight sync or
save to finish instead of racing it;
- the recursive wallet read lock that could deadlock a concurrent save
is gone;
- a spend of a vanished note now warns and is skipped instead of
panicking; it is counted after the next rescan.
No ABI, symbol or wallet format change.
Tests: clear_state_from waits for sync_lock, clear_state holds no wallet
guard while it waits, neither leaks the lock, and a 200x clear/write
loop finishes within a 20 s watchdog (a regression guard only).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The transparent-address txid scan in the sync loop and the mempool
monitor both hard-coded no_cert = true, so they connected to the
lightwalletd through get_client's NoCertificateVerification path in
every Lite build, while the app always passes dangerous = false. Block
sync, info, broadcast and the full-tx fetcher already honour
LightClientConfig::no_cert_verification. An active man in the middle on
those two channels could hide or invent transparent balance, UTXOs and
pending transactions (signing stays local, so no funds can be taken).
Both calls now take the setting from the config:
- the sync loop passes self.config.no_cert_verification to
scan_taddress_txids;
- start_mempool_monitor reads config.no_cert_verification once and
passes it to monitor_mempool.
Flagged behaviour change: a lightwalletd with an invalid or self-signed
certificate now fails the t-address stage (do_sync retries, then returns
the error) and the mempool monitor (it logs and retries), as block sync
already does. `dangerous` is the only bypass. No ABI, symbol or wallet
format change.
A tripwire test checks that lightclient.rs never again hard-codes the
bypass for either call; its needles are joined at runtime so the test
cannot match its own source.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The qtlib wrapper's Cargo.toml, the workspace root of the backend build,
had no release profile, so libsilentdragonxlite.a was built with cargo's
defaults: no LTO and 16 codegen units, with the objects of every
dependency copied into the archive.
The release profile now sets opt-level 3, fat LTO and one codegen unit.
panic stays "unwind", written out with a comment: litelib_execute turns
a panic into an error through catch_unwind, and the full-tx fetcher and
the threadpool turn scan-job panics into errors by unwinding. There is
no strip setting: rustc does not link a staticlib, and build.sh strips
the final executable.
Measured, building the same source both ways:
- archive: Linux 126.7 -> 71.5 MB with 1580 -> 1 Rust object files;
Windows 105.9 -> 65.8 MB with 1560 -> 1;
- stripped ObsidianDragonLite: Linux 84,283,856 -> 75,366,184 bytes
(-8.9 MB, -10.6%); Windows exe 87,436,288 -> 79,379,968 bytes
(-8.1 MB, -9.2%). On Linux .text went from 20.0 to 14.8 MB and
.eh_frame from 2.67 to 1.19 MB;
- exported symbols unchanged (blake3_PW and the 8 litelib_* functions);
- a forced panic in the send command's JSON parsing still returns
"Error: Rust panic in litelib_execute", and the next call works;
- lite_smoke --restore-recent, --keys and --encrypt pass with identical
output apart from chain tip and birthday; restore CPU time is
unchanged within noise (115.8-117.2 s before, 111.7-118.8 s after)
and peak RSS dropped from 38.5 to 31.6 MB;
- backend build time: Linux 70 -> 118 s, Windows 54 -> 102 s.
This has no effect on releases until the backend artifacts are rebuilt:
build.sh links the prebuilt build/lite-backend/<platform> archives,
which are still the old non-LTO ones (the Windows one also predates the
recent backend commits). Rebuild them with
scripts/build-lite-backend-artifact.sh from a clean, committed tree.
Not verifiable here: on Windows only the link was checked, with no panic
probe, smoke run, sync or send (inlining can grow stack frames, and
mingw threads get about 2 MB of stack); the macOS artifact was not built
(CARGO_PROFILE_RELEASE_LTO=thin is the fallback if fat LTO is a problem
there); and a send, with its proving, needs a funded wallet.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
reloadThemeImages() replaced gradient_tex_ in each of its three branches
(image loaded, load failed, no background) without freeing the old
texture, so every skin activation, including the one at startup, leaked
one texture.
The logo and coin textures were freed immediately instead, although the
current frame's draw lists can still reference them: the window backdrop
records its images at the start of the frame and skin switches happen
mid-frame (Settings, Ctrl+arrow keys), and the screenshot sweep switches
skins after RenderDrawData() but before the secondary viewports are
rendered. That hands the renderer a freed texture.
Textures replaced mid-frame are now retired to a queue (retireTexture()
also zeroes the member) that the next preFrame() frees first, after the
previous frame's draw data has been rendered. The old gradient is
retired at the top of reloadThemeImages(), and the logo and coin where
they used to be freed. ensureLogoTexture() still frees directly, since
it runs at the top of render(), before this frame draws a logo.
Shutdown does not drain the queue.
Measured with a temporary live-texture counter over a screenshot sweep
of all 10 skins: before, 6, 7, ... 15 live textures (one more per skin);
now a constant 5. The first screenshot after each skin switch is
pixel-identical to before on all 10 skins, and the full sweep matches
within noise.
Not verifiable here: DX11 at runtime.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Turning blur off (blur 0), switching to low-spec mode or enabling
reduced transparency after the acrylic targets existed left the capture
FBO and both blur ping-pong targets allocated for the rest of the
session, although nothing draws with them while the effect is skipped.
They are now released at a frame boundary and rebuilt on demand:
- ImGuiAcrylic::ReleaseIdleBuffers() runs right after app.preFrame(),
before NewFrame(), when the previous frame's draw data has been
rendered, and once in the resize-watcher frame (GL and DX11). While
the effect is skipped it frees the targets; when the effect is wanted
again it reallocates them once, with no per-frame retry if that fails;
- resize() while skipped only records that a reallocation is needed;
- the release checks the GL names (and each DX11 texture) directly, so a
half-allocated ping-pong pair is freed too; DX11 also resets the
ping-pong index;
- the capture paths and applyBlur() return early without a capture
buffer, which would otherwise blit the default framebuffer onto
itself;
- each release logs "released targets (effect skipped)".
With blur on, the path and the output are unchanged (screenshot sweeps
within noise). After re-enabling, the first frame draws the tinted
fallback until a fresh capture exists.
Measured with software GL, toggling every 30 frames for 1200 frames: the
targets were released exactly one frame after each switch-off (22 of 22)
and rebuilt on each switch-on (21 of 21), with no GL or framebuffer
errors. While off at 1280x720, with a fixed malloc mmap threshold, the
process holds 4.62 MB less (the 1280x720 RGBA8 capture plus two 320x180
RGBA16F blur targets) and RssAnon is about 3.1-3.5 MB lower. With
glibc's default threshold the freed memory stays in the heap and RssAnon
does not visibly drop.
Known issue: every off/on pair now frees and reallocates those targets.
Under software GL (llvmpipe) with the default glibc allocator the freed
texture memory is not reused: toggling every 2 frames grew RssAnon from
49 to 906 MB over 240 pairs, every 8 frames from 56 to 179 MB, and every
30 frames it levels off near 120 MB. It is not a GL object leak (with a
fixed mmap threshold the mapped bytes stay flat), and on a hardware
driver (WSLg d3d12) RssAnon stayed between 67.5 and 74.9 MB under the
2-frame churn (94.3 MB without the release). Users reach fast toggling
by holding Ctrl+Shift+Down (key repeat, about 20 Hz) or by dragging the
blur slider back and forth across 0. A short delay before releasing
would avoid it if software-rendered users matter.
Not verifiable here: the DX11 release and reallocation at runtime, and
macOS.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The GL window asked for a 24-bit depth and 8-bit stencil buffer, and
every acrylic Framebuffer attached a DEPTH24_STENCIL8 renderbuffer the
size of its color texture. Nothing uses them: no code enables depth or
stencil tests (the ImGui GL3 backend disables both), every blit and
readback is color-only, and only Framebuffer::clear() also cleared the
depth buffer, which nothing read. The window and every FBO, including
the full-size capture FBO, carried that memory, and the FBO
renderbuffers were reallocated on every resize.
Now:
- the window requests depth 0 and stencil 0 explicitly (SDL3 requests a
depth buffer by default when the attributes are simply removed);
alpha 8 stays for the transparent window;
- Framebuffer has no renderbuffer, and clear() clears color only;
- a permanent startup log line,
"GL default framebuffer: depth=N stencil=N", reports what the driver
provided; "(no attachment)" marks a failed query, which is how some
drivers report a buffer that does not exist.
The log shows depth=0 stencil=0 both offscreen and on WSLg X11, where
"Window transparency enabled by compositor" is still logged, with no
framebuffer-incomplete errors. Screenshot sweeps at 1280x720 and
2560x1440, and a glass run with ui_opacity 0.6 and blur on (so panels go
through the FBO blur), match the previous build within noise. The
driver-side memory saving itself was not measured. DX11 is unaffected:
its swap chain has no depth buffer.
Not verifiable here: macOS (an NSOpenGL 3.2 core context with a
transparent window).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Skin handling did more work than it needed:
- the bundled-skin scan fully parsed each of the 9 bundled skin files to
read name, author, dark and images from their first 327-345 bytes. The
build copies the rest of ui.toml into every skin, so each file is
51-54 KB;
- setActiveSkin() reloaded ui.toml right after main() had loaded it, a
second full parse with nothing merged in between;
- in the Windows single-file exe, every getBundledSkinsDirectory() call
(skin refresh, the dragonx entry, each image-dir lookup) re-extracted
all embedded themes with a blind overwrite: more than once per launch,
and again on skin changes.
Now:
- bundled skins are parsed only up to the first table header after
[theme] other than [theme.images]. Anything unusual (no [theme],
images defined later, a parse error in the slice) falls back to the
full parse, and user skins are still parsed in full;
- UISchema tracks whether its state is exactly the base (the last change
was a base load and no hot-reload is pending), and setActiveSkin()
skips reloadBase() in that case;
- the bundled-skins directory is resolved once per process (re-checked
with exists()), and theme extraction rewrites a file only when its
bytes differ, through the new resources::writeFileIfDifferent(). It
compares bytes, not sizes, so a same-length palette edit still
updates.
Measured on the Lite build (median of 5 runs):
- bundled-skin scan: 15 -> 1 ms;
- from "Initializing ObsidianDragon" to the activated skin: 29 -> 12 ms;
- ui.toml is loaded once per launch instead of twice, and the light skin
merges its overlay exactly once.
A Python mirror of the header-slice parse agrees with a full parse on
name, author, dark and images for all 18 source and build-expanded skin
files, and full screenshot sweeps over all 10 skins match the previous
build within noise. New unit tests cover writeFileIfDifferent(): missing
file, identical bytes (mtime untouched), same-length change, and a
longer file truncated.
Behaviour changes:
- a bundled skin with a TOML error after its header is now listed
instead of skipped; the error surfaces only when it is selected;
- deleting a single file inside the extracted bundled-themes dir
mid-session is repaired only at the next launch;
- the "Extracted N embedded bundled themes" count includes files that
were already current.
Not verifiable here: Linux has no embedded themes, so the new
writeFileIfDifferent() ran only in the unit tests and the extraction
path was only compiled, in the Windows cross-build. That a second
Windows launch leaves the theme files' mtimes untouched is still to be
checked on Windows.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
main() built the font atlas before App::init() loaded ui.toml, so the
first build used the hard-coded fallback sizes (H1 24 px, Body2 12 px).
App::init() then rebuilt for a saved font scale above 1.0, still before
the schema load and so still at the fallback sizes. Only the color-emoji
rebuild requested for the first frame reached the ui.toml sizes, so
color-emoji users built the atlas twice at font scale 1.0 and three
times at 1.5.
Users with chat_emoji_color=false never got that last rebuild. They
built the atlas once or twice and ran the whole session at the fallback
sizes, e.g. Body2 at 12 px instead of 15 and H1 at 24 px instead of 20.
Now:
- App::loadBaseUiSchema(), the ui.toml load moved out of App::init()
unchanged, runs in main() before both InitImGui paths (DX11 and GL),
together with the saved font scale and emoji style from the early
settings read. It is idempotent, so the call left in App::init() is a
no-op;
- the two rebuild triggers in App::init() are gone;
- Typography records what the atlas was built from (the 15 schema sizes,
the schema's font scale, the effective emoji style and the schema
generation), and preFrame() rebuilds once when they differ. While the
schema generation and the emoji style are unchanged this costs two
compares per frame.
Measured on the Lite build (color and mono emoji, font scale 1.0 and
1.5, English and Japanese): every run builds the atlas exactly once, at
the ui.toml sizes times the scale (H1 20 px and Body2 15 px at 1.0,
30 px and 22 px at 1.5), and ui.toml is loaded before the window and GL
context are created. The packaged Lite zip and AppImage log one atlas
build instead of two. Color-emoji screenshot sweeps match the previous
build within noise.
Visible changes:
- users with chat_emoji_color=false had been rendering the whole UI at
the fallback font sizes; they now get the designed ui.toml sizes, so
every mono-emoji screen differs from before and matches the
color-emoji layout;
- a user skin whose [fonts] differ from ui.toml now rebuilds the atlas
once when it is activated or left; on a runtime switch the atlas used
to keep the old sizes until a restart. With a test skin that sets
Body2 to 18 px, startup builds twice and ends at 18 px; switching to a
bundled skin rebuilds once, switching among the 9 bundled skins
rebuilds nothing (they carry an identical copy of [fonts]), and
switching back rebuilds once;
- editing ui.toml while running still gives exactly one rebuild.
Not verifiable here: the Windows DX11 path with the schema loaded before
its first FreeType build, macOS, and the interactive font-scale slider,
Alt+scroll and emoji toggle, which were checked by reading the code.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Typography copied every font blob into a fresh heap buffer for each face
it added (IM_ALLOC + memcpy, FontDataOwnedByAtlas = true): the Ubuntu
weight, the CJK subset merge, the emoji merge, and the icon and pickaxe
fonts. The blobs are INCBIN symbols in read-only memory that live for
the whole process, so the copies (16 faces, each with its own CJK copy
and, for 8 of them, an emoji copy) only cost memory. Styles with the
same weight and size also built separate, identical ImFonts.
Now:
- every font is added straight from the embedded data with
FontDataOwnedByAtlas = false. ImGui 1.92 frees only data it owns, so
the blobs survive atlas clears and DPI or skin reloads;
- load() walks a style table and shares the ImFont between styles with
the same (weight, scaled size, emoji) key. Emoji is part of the key,
so no style gains or loses the emoji fallback. With the current
ui.toml sizes H3/H4, H6/ButtonLg and Body1/Overline share a face: 13
ImFonts instead of 16 (12 text faces plus Mono), CJK merged 13 times
instead of 16 and emoji 7 times instead of 8.
Measured on the Lite build, RssAnon 15 s after start, median of 3 runs:
- color emoji: 108.6 -> 78.8 MiB (-29.8 MiB);
- monochrome emoji: 102.8 -> 78.6 MiB (-24.2 MiB).
RssFile is also 2.4-5.1 MiB lower, because the blobs are no longer read
end to end for copying. Full screenshot sweeps (610 screens per run; color
and mono emoji at 1280x720 in English and Japanese, color at 2560x1440)
match the previous build within run-to-run noise. The only content
differences are live values such as the mining tab's RAM readout (233 MB
before, 201 MB after).
Also fixed: when AddFontFromMemoryTTF failed, ImGui had already freed
the owned copy and the caller freed it again, a latent double free;
those IM_FREE calls are gone. The emoji-merge failure log now prints the
blob size instead of passing the float font size to %u.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
CMake turned every res/lang/*.json into an xxd header that i18n.cpp
compiled in as a fallback, on every platform. Only the Windows
single-file exe needs it: the Linux zip and AppImage and the macOS .app
always ship res/lang, and loadLanguage() reads the loose file first. On
Linux the embedded copy added about 1.5 MB to the binary, and every
translation edit recompiled i18n.cpp.
A new option, DRAGONX_EMBED_LANG, defaults to ON for WIN32 and OFF
elsewhere:
- ON without xxd is now a configure error (it used to warn, then fail to
compile i18n.cpp on the missing headers);
- i18n.cpp compiles the embedded includes and the fallback only when ON;
- outside Windows, loadLanguage() also tries
<exe>/../share/<app>/res/lang (the cmake --install layout and the
full-node AppImage) and <exe>/../Resources/res/lang (the macOS .app
binary run directly), so those layouts find the loose files whatever
the working directory; before, only the embedded copy covered that.
Measured:
- full-node Linux binary: .data 1,699,696 -> 168,592 bytes; stripped
19,983,136 -> 18,456,128 bytes (-1.53 MB);
- with the ImGui demo stubs, the Linux zips and AppImages are
0.50-0.55 MB smaller, with identical file lists including all 8
language files;
- a Lite build started from an unrelated working directory loads the
ja.json next to the binary (2422 strings), and the packaged Lite zip
and AppImage load ja.json with no new not-found or failed lines;
- Windows keeps the embedded copy: the 8 language headers are generated
and the ja.json bytes are in the exe.
A new test loads "de" from the loose files and checks a translated
string; with bin/res/lang moved away exactly its two assertions fail.
Behaviour changes on Linux and macOS: a missing or corrupt res/lang now
falls back to English instead of the embedded copy, and a translation
edit needs only the copy_langs step instead of a recompile.
Not verifiable here: the macOS .app in a non-English language, and the
Windows single-file exe at runtime.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Both Windows zips shipped a loose xmrig.exe next to the wallet exe. The
exe already embeds xmrig (HAS_EMBEDDED_XMRIG), and getXmrigPath() uses,
and re-extracts when needed, the copy under %APPDATA%\<app>\dragonx; it
looks next to the exe only when that extraction fails.
build_release_win now copies it only when the generated header does not
define HAS_EMBEDDED_XMRIG, or there is no header.
Measured: each Windows zip loses exactly one entry, xmrig.exe; with the
ImGui demo stubs, the full-node zip went from 167,515,430 to 164,554,177
bytes.
The loose dragonxd, dragonx-cli, dragonx-tx, wallet-rebuild helper,
params and asmap stay in the full-node zip. The daemon copy under
%APPDATA% is written once and never refreshed, so without the loose copy
existing zip users would silently run their first-install daemon.
Dropping them needs a migration first.
Not verifiable here: mining from an unzipped Windows build without the
loose xmrig.exe.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The Windows release embeds its resources into the exe through a
generated INCBIN header. The Lite build got the same param and asmap
entries as the full node, so ObsidianDragonLite.exe carried
sapling-spend.params, sapling-output.params and asmap.dat (about 53 MB)
that it never reads: the SDXL backend embeds its own copy of the params,
and Lite already skips extracting full-node-only resources.
build_release_win now writes the param and asmap INCBINs only for the
full node (should_bundle_full_node_assets). Lite gets null/zero stubs
for those three symbols and still embeds images, themes and xmrig. Two
fixes make that safe:
- getEmbeddedResource() stopped at the first table entry with null data,
so with the params stubbed out the Lite lookup ended before xmrig:
mining would report itself available but never find the embedded
binary. It now walks to the end of the table and skips null payloads.
This also fixes a full-node build without asmap, whose lookup ended at
the null asmap entry and hid the daemon and xmrig entries.
- full and Lite builds share build/windows. When the full-node build
cannot find the params, the generated header is now deleted, so a Lite
header left behind by an earlier Lite build is not compiled into it
(the node would then never start, because the startup job would take
the embedded-extraction path and never copy the loose params).
Measured on release builds:
- ObsidianDragonLite.exe: 159,788,544 -> 106,120,192 bytes (-53.7 MB, of
which about 0.28 MB is the ImGui demo stubs);
- the Lite header has no param or asmap INCBINs and the resource object
holds only the xmrig payload; a sample of sapling-spend.params occurs
once in the exe instead of twice (the remaining copy is the
backend's);
- the full-node header is byte-identical to before.
Not verifiable here (Windows runtime): the Lite exe starting without
embedded params, and mining through the embedded xmrig. Lite installs
from older versions may still hold about 53 MB of extracted params and
asmap in %APPDATA%\ObsidianDragonLite\dragonx; nothing deletes them.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The ImGui demo window cannot be opened in the wallet (its show flag is
never set), but imgui_demo.cpp was compiled and linked in full.
It is now compiled with IMGUI_DISABLE_DEMO_WINDOWS, which reduces it to
empty stubs. ShowDemoWindow, ShowStyleEditor, ShowAboutWindow,
ShowUserGuide and ShowStyleSelector stay defined, and imgui.h declares
them unconditionally, so a per-file define is ODR-safe.
IMGUI_DISABLE_DEBUG_TOOLS is deliberately not set: it changes imgui.h
declarations and would drop ImGui's duplicate-ID highlight.
Measured:
- imgui_demo.cpp.o: 755,392 -> 1,704 bytes;
- stripped full-node Linux binary: 20,267,680 -> 19,983,136 bytes
(-284,544);
- full-node Windows release exe: 282,112 bytes smaller;
- the "Dear ImGui Demo" string is gone and the stubs are still defined.
With Makefile generators the per-file define lands in the target's
flags.make, so each existing build tree recompiles the whole app once
(153 files, about 71 s at -j8 here). No runtime or visual change.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
HushChatFixtureCheck is a dev-only CLI for the HushChat fixture files.
Nothing runs it (no ctest entry, not linked into the app or the tests),
yet every dev and test build compiled and linked it.
It is now EXCLUDE_FROM_ALL, with a usage note next to the target:
cmake --build build/linux --target HushChatFixtureCheck
A dry run of `all` no longer mentions it (4 lines before) while
ObsidianDragonTests stays in `all`, and building the target explicitly
still produces a working binary (`--help` exits 0). The fixture template
and the chat sources are unchanged.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Release builds ran a plain `cmake --build .`, which builds `all`: on top
of the app and the wallet-rebuild helper, every --*-release run also
compiled HushChatFixtureCheck, ObsidianDragonTests where BUILD_TESTING
is on (Linux, macOS) and, in Lite backend trees, lite_smoke and
lite_send_smoke. None of them ship.
A new `dragonx_release` custom target depends on ObsidianDragon plus
dragonx-wallet-rebuild, copy_langs and copy_themes where those targets
exist, and the three release paths in build.sh build only that target.
The dev build (./build.sh) still builds `all`.
BUILD_TESTING is deliberately left alone: release and dev builds share
build/<platform>, and writing BUILD_TESTING=OFF into that cache would
leave ctest running a stale test binary.
Measured:
- a dry run of dragonx_release schedules none of the test, smoke-tool or
fixture-CLI builds that `all` schedules, in a full-node, a
Lite+backend and a mingw tree; the wallet-rebuild helper is still
built;
- all four release builds (Linux and Windows, full node and Lite) log
the dragonx_release target and no ObsidianDragonTests build, and the
Linux zip and AppImage file lists are unchanged.
Consequences: a release run no longer refreshes the test binary or the
lite smoke tools in build/<platform>/bin, so run ./build.sh or
`cmake --build` before ctest, and build the Windows smoke tools with
`--target lite_smoke lite_send_smoke`. If a release step starts copying
another CMake-built file out of bin/, its target must be added to
dragonx_release.
Not verifiable here: macOS --mac-release (the .app should still contain
the app, dragonx-wallet-rebuild and Contents/Resources/res).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The release functions always built in build/<platform> and wrote to
release/<platform>. On Linux and macOS build/<platform> is also the dev
build tree, so a release run reconfigures it (--linux-release turns a
Lite+backend dev tree into a full-node one), and building a baseline to
compare a change against meant overwriting the existing release/
artifacts.
build_release_linux, build_release_win and build_release_mac now take
their build and output roots from DRAGONX_BUILD_ROOT and
DRAGONX_RELEASE_ROOT, defaulting to build/ and release/ as before:
DRAGONX_BUILD_ROOT=/tmp/rel-build DRAGONX_RELEASE_ROOT=/tmp/rel \
./build.sh --win-release
With both unset nothing changes. The dev build (./build.sh) and the lite
backend artifact dir (build/lite-backend/<platform>) do not read them.
A full release set (Linux and Windows, full node and Lite) built into
override roots left the release/ listings (file sizes and timestamps)
and the dev tree's CMakeCache.txt unchanged (same checksums before and
after).
Known gaps:
- the end-of-run summary still prints release/ as the output dir;
- configuring a Windows build still writes res/ObsidianDragon.manifest
into the source tree, so the last configured variant's version ends up
there;
- a fresh build root re-downloads the FetchContent dependencies and
appimagetool, so it needs network access;
- the macOS paths changed the same way but were not run here.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
After broadcasting, the send path looked up the spent notes' and
UTXOs' transactions with unwrap. A reorg while the transaction was
being proved could remove one of them, so the backend panicked and
poisoned the wallet lock although the transaction was already
broadcast. The lookups are now `if let` with a warning.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
When the mempool monitor saw a transaction first, its wallet entry kept
the monitor's height (often 0) after the transaction was mined, because
scanning the block reused the entry without updating its block. A later
reorg between the two heights then left a phantom spend of our own
send and a change note with an empty witness list until a rescan.
Scanning a block now sets the entry's block to the block height. This
applies to every monitor-first transaction, including payments received
from others: their reported block height, the verified/spendable timing
that depends on it, and the wallet birthday now use the real mined
height instead of the monitor's.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Notes spent by a send still in the mempool stopped getting witness
updates. After a restart (pending flags are not saved), or a reorg
that un-mined the send, those notes were spendable again but with
stale witnesses, so the next send from them used an anchor that is not
on chain and failed until a rescan. Deterministic note selection made
this sticky: the stale note was picked every time it was the best fit.
Witnesses now advance for every note that is not spent on chain,
pending or not. Witness clearing still keys on the mined spend only,
so pending notes are never cleared. The wallet file format is
unchanged; an old build reading a file written while a send was
pending can now spend from it (verified offline).
The oracle reloads and reorgs inside the pending window and compares
every unspent note's witnesses byte for byte; reverting the fix fails
9 tests.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
invalidate_block unwrapped a note's `spent` field for notes that only
had `unconfirmed_spent` set, so any reorg while one of our sends was
still in the mempool panicked the backend (and poisoned the wallet
lock). The branch could only panic or do nothing; it is gone, and a
reorg now leaves `unconfirmed_spent` as it is: the send can still be
mined on the new branch, because the chain checks the anchor's root,
not its height.
The oracle no longer steers reorgs around pending sends: it now
reorgs above, across and below the sent tip, including popping the
anchor block, and the reproducer runs by default.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
At the end of a sync the wallet fetches the full transaction of each new
wallet tx, together with every other transaction in the blocks that
contain them, as decoys. Every txid got its own pool job that built a
fresh Tokio runtime (one thread per CPU), a new rustls config and a new
TCP + TLS + HTTP/2 connection for a single GetTransaction call, so a
restore or rescan opened one connection per fetched transaction. The
first error returned from the sync while the remaining jobs kept
fetching and scanning in the background, and a stalled connection, an
unparsable transaction or a panicking scan could hang the sync forever
with its lock held.
fulltx_fetch.rs now works through the same (txid, height)-sorted,
de-duplicated list on the sync's runtime, with as many workers as the
sync pool has threads (2-8, the same fan-out as before), over one gRPC
channel that is dialed lazily and re-dialed on retry. Parsing and
scanning stay on the sync pool, and the call returns once the whole
batch has finished.
Behaviour changes:
- a failure on a decoy no longer fails the sync; it fails only when one
of the wallet's own txs could not be fetched and scanned;
- new error texts "Error reading transaction: ...", "full tx scan job
panicked" and "fetched only X of Y wallet txs", all after the existing
"Error fetching all txns for memos: " prefix;
- each tx gets up to 3 fetch attempts with a 120 s timeout each, where
the old code could wait forever; a request that legitimately takes
longer now fails;
- nothing keeps running in the background after the sync returns;
- all GetTransaction calls share one TLS/HTTP-2 connection (up to one
dial per worker at the start, plus a re-dial per retry); the request
list and its order are unchanged;
- if the network silently drops packets mid-batch, every attempt waits
out its timeout until 16 consecutive failed txs stop the batch: up to
about 12 minutes with 8 workers (48 minutes with 2) with the sync lock
held, where the old code gave up at its first failed connection.
Which txs are requested, in what order, how often they are retried and
when the batch stops do not depend on which txs are the wallet's own;
only the final result does. A server that keeps failing one tx can see
that result through the sync's retry, but it could already tell, because
an own tx that failed is requested again on the next sync without its
decoys; nothing new leaks.
tests/fulltx_fetch.rs drives the engine with a fake fetcher: the old
request list, each item fetched and scanned once, bounded concurrency, a
request sequence independent of ownership, retries, timeouts, the
breaker, panicking and unparsable scans, and a saved wallet
byte-identical to the old serial fill. The gRPC fetcher itself is not
covered by these tests.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Each scanned block deep-copied the newest witness of every note with
history to start that block's entry, although a block without shielded
outputs changes no witness, and spent or pending notes never change
theirs. A note's 100-entry list was 100 separate copies, mostly
identical.
The list now holds Arc-shared witnesses. The per-block duplicate is an
Arc::clone, and Arc::make_mut copies the newest entry only for unspent,
non-pending notes and only in blocks with at least one shielded output.
Every entry is still written in full, so the file is unchanged, and a
spend takes its own copy of the witness. A loaded wallet is not
de-duplicated, so the saving builds up over the next 100 blocks.
No output change: the saved bytes are identical; notes use less memory.
With 1000 live notes x 100 witnesses (a 70 MB file), RssAnon after 100
blocks without outputs is 18 MiB instead of 189 MiB and such a block
scans in 1.0 ms instead of 2.1-2.7 ms; when every block has an output,
the live bytes stay about the same.
The witness tests, the 1000-seed oracle and a differential run against
the unmodified backend (every witness entry and the whole saved file
after every operation; only the cleared lists and the chosen spend notes
may differ) all pass, and the measured wallet saves to the same SHA-256
as with the old build.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
send_to_address walked the wallet's notes in HashMap order and took them
until the target was covered, cloning the witness and spending key of
each note it visited, zero-value notes included. So which notes were
spent, how many and which address got the change were random: a send
could prove zero-value notes (each spend is a Groth16 proof of about
0.5-2 s plus 384 bytes) and far more notes than needed, and a send
needing 251-489 notes ground through minutes of proving to build a
transaction whose flat fee is below DragonX's default minimum relay
rate.
Selection is now a pure function (note_selection.rs) over the value and
nullifier of the eligible non-zero notes:
- the smallest single note that covers the target; otherwise the fewest
notes, largest first, with the last pick swapped for the smallest note
that still covers. Ties break on the nullifier, so map order no longer
matters;
- at most 250 spends, and fewer when the estimated size would pass
190,000 bytes (the consensus limit is 200,000);
- the notes cover the whole target whenever they can; only when they
cannot do the keyed UTXOs, which are still spent in full, reduce what
the notes must cover;
- only the chosen notes get their witness and key cloned.
Behaviour changes:
- which notes are spent, and so which address receives the change, is
deterministic: the smallest single covering note, otherwise the fewest
notes, largest first;
- zero-value notes are never spent;
- new errors before any proving: "Too many inputs: ..." (more than 250
spends needed; it states the maximum sendable amount in DRGX) and
"Transaction too large: ..." (it suggests shielding transparent funds
first). Neither contains "Insufficient", so the
GUI does not retry them. Sends that need 251-489 notes, which succeed
today after minutes of proving, are refused;
- UTXOs alone fund a send only when no eligible non-zero note exists, or
when the size budget leaves no room for a shielded input; then, as for
a wallet without notes before, the change goes to the first z-address
and is listed as a receive. Otherwise the smallest non-zero note is
still spent, so the change is recorded as change;
- the "Insufficient verified funds" text is unchanged, but its "have"
amount no longer counts UTXOs whose key the wallet lacks;
- duplicate nullifiers (corrupt state only) are de-duplicated;
- new info log: "Selected N of M spendable notes".
A note left with a stale witness by the pre-existing pending-send bug is
now picked every time it is the best fit, rather than by chance.
Pure tests check that the selection never uses more notes than the old
walk (3000 random cases, shuffled), that the count is minimal (brute
force) and independent of order, and the cap, size budget and fallbacks;
wallet tests cover best fit, a chat-fee send, the cap refused before
proving and UTXO funding. In the 1000-seed oracle every send verifies
offline at anchor offsets 0 and 4 and the largest send dropped from 7
inputs to 2; a differential run against the unmodified backend has
identical anchors and byte-identical non-selection state apart from the
cleared lists.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The block scan appended each shielded output's leaf to the witness of
every live (unspent, not pending) note as it went, one witness at a time
on the sync thread, while holding the wallet's txs write lock: live
notes x outputs appends per block, all on one core.
With 16 or more live notes, the scan now collects the block's leaves in
output order and appends them to those witnesses once, at the end of the
block, still under the same lock and before anything reads them. When
that is at least 128 appends, the witnesses are split into disjoint
chunks over min(8, cores, live notes / 8) scoped threads; otherwise the
appends run inline. Every witness receives the same leaves in the same
order, including a leaf whose epk does not parse; an output whose cmu
does not parse still adds nothing. The hashing work is the same, only
spread over cores; the speed-up has not been benchmarked yet.
No output change. Each qualifying block starts up to min(8, cores)
short-lived threads.
Tests check that the threaded helper matches the plain double loop byte
for byte for 0-64 witnesses, and that the deferred path handles bad-epk
and bad-cmu outputs. The 1000-seed oracle passes, as do the suite and
125 oracle seeds pinned to 2 cores, and a differential run against the
unmodified backend is byte-identical, apart from the lists cleared two
commits earlier, for wallets below and above 16 live notes and blocks of
0, 1 and 50 outputs.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The block scan replaced each note's witness list with the result of
split_off(), which allocates a new buffer on every call: every block,
each note with history paid an allocation and, once its list was full, a
copy of all 100 entries.
The duplicate of the newest witness is now pushed and, when the list
exceeds 100 entries, the oldest is drained in place; notes with no
history are skipped. The list keeps the same entries in the same order,
so nothing in the output changes.
The witness tests and the oracle pass, and a differential run against
the unmodified backend is byte-identical (every witness entry and the
whole saved file after every operation) apart from the lists the
previous commit clears.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Every note keeps a list of up to 100 witnesses, one per block, so that a
reorg can roll it back; a full list takes roughly 40-90 KB of the wallet
file. The block scan kept duplicating and trimming that list for every
note that had one, spent notes included, although a spent note never
reads its history again once its spend can no longer be rolled back. So
every note the wallet ever spent kept costing memory, per-block scan
time and file size.
Once the block window is full (101 blocks), a spent note whose spending
transaction's block is below the window's first block now gets an empty
witness list. This runs before each block's witness update and once when
a wallet is loaded. Such a spend can only be undone by a reorg that pops
the whole window, and that already empties every witness list, so reorgs
behave exactly as before.
Behaviour changes:
- notes whose spend lies below a full reorg window lose their witness
history in memory, at load and in the next save; existing large
wallets shrink on their first save (which, like every save, copies the
previous file to wallet.dat.bak);
- the wallet format (v9) is unchanged; old builds and SilentDragonXLite
read an empty list as an unspendable note, which a spent note already
is;
- new info log at load: "Dropped witness history of N notes whose spend
is deeper than 100 blocks".
The oracle now also accepts an empty list for a spent note that was
cleared before a later partial reorg.
New witness tests compare every block's lists with a model of the old
algorithm, including reorgs across the clear point and an old file with
full lists, which loads and re-saves to the new bytes. The 1000-seed
oracle run passes (12,207 reorgs, 993 sends verified offline), and a
differential run against the unmodified backend is byte-identical apart
from exactly the cleared lists; the old build reads the new files, and
17 sends it built from them verified offline.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Every LightClient copied the Sapling spend and output parameters, which
rust-embed already compiles into the binary, onto the heap: 51,551,256
bytes (49.2 MiB) of private memory per live client, whether or not it
ever sent anything.
LightClient::sapling_output and sapling_spend are now Cow<'static, [u8]>
and borrow the embedded image. set_sapling_params still stores an owned
copy, with its is-empty guard, hash checks and disk write unchanged. The
prover receives the same bytes.
Behaviour changes:
- about 49 MiB less anonymous memory per live LightClient; the wallet
file and JSON are unchanged;
- the first send or shield pages the 51.5 MB of embedded params in from
the binary, where client creation used to copy them (an estimated
0.3-1 s from the AppImage);
- the public fields sapling_output and sapling_spend change type from
Vec<u8> to Cow<'static, [u8]>: a Rust API change, with no C ABI or
exported-symbol change.
tests/sapling_params.rs checks that a restored, a new and a reloaded
client all borrow the embedded image (same pointer, pinned lengths and
SHA-256), that set_sapling_params keeps its guard and hash checks, and
that the borrowed params parse like a heap copy. An ignored test
measures 576 KiB of RssAnon growth for a second client, against 46.5 MiB
with the old heap copy.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
note_address() re-derived a note's payment address from its viewing key
and diversifier (a group hash, an ivk hash and a 252-bit scalar
multiplication) for every note in `notes` and `list` and for each new
note in the block scan. The z-balance sums did the same derivation in
their address filter, so `balance`, which sums four balances for each of
Z z-addresses, cost Z x (N + 2U) derivations for N notes, U of them
unspent. do_list_notes also encoded up to Z + Z^2 addresses to find the
spendable ones and cloned that set once per transaction.
Now:
- note_address() encodes the note's stored pk_d with its diversifier.
Both decryption and wallet load build that pk_d from the same key and
diversifier, so the string is byte-identical;
- the three z-balance sums share one address filter. The per-address
sums behind `balance` compare (diversifier, pk_d) with each z-address
directly; any other address string keeps the old exact string compare;
- those sums work from a copy of the z-addresses whose lock is released
before any note is read, so the txs-then-zkeys lock order that sync
uses is unchanged;
- do_list_notes builds the spendable-address set with Z encodes and
borrows it.
No output changes: balances and the notes/list/balance JSON are
byte-identical. The address filter no longer contains an unwrap (it
could not fail).
Tests compare against verbatim copies of the old functions over
randomized chains (note addresses, also after a reload; every filtered
balance for own, diversified, foreign and junk address strings; the
per-address balances; the spendable set), and a concurrency test fails
with a deadlock if the zkeys lock is held across the sums. A
differential run against the unmodified backend compared per-address
balances and the JSON after every operation, through reorgs and reloads,
and the old build re-saves the new files byte for byte.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
No existing test checks that a send's anchor, nullifiers and proofs are
valid, or that note witnesses stay right through reorgs and reloads, and
that is what the following witness and note-selection changes touch.
oracle_tests.rs drives a LightWallet through its public entry points
over seeded random fake chains that start at real mainnet checkpoints:
receives to HD, diversified, imported, view-only and foreign keys,
zero-value and dust notes, outputs with an unparseable epk, compact
spends with change, real sends that are mined later or never (mempool
expiry), reorgs of 1-100 blocks in one call or block by block (re-mining
spends on the new branch), and save/reload.
An independent reference keeps its own commitment tree, every block's
root and every note's true witness at each height. After each step it
checks the tree and block window, every note's fields and spent state,
witness-list lengths, every retained witness of a live note byte for
byte (sampled ones also by root, position and auth path), the balances
and the mempool. Sends are captured in the broadcast closure, never
sent, and verified offline: the anchor must be the reference root at tip
minus the anchor offset, every nullifier must belong to an eligible
note, and the Sapling verifier must accept every spend and output proof
and the binding signature (the first send of a run also checks that it
rejects a wrong anchor and a wrong nullifier).
The default run (20 seeds x 300 blocks) is part of the normal suite; a
1000-seed x 400-block run and a 20-seed x 5000-block soak are #[ignore].
Every run prints its seed, and a single seed can be re-run.
Two pre-existing bugs are documented as #[ignore] reproducers and kept
out of the green runs:
- a reorg while a send is pending panics the backend (invalidate_block
unwraps `spent` on a note that only has `unconfirmed_spent`);
- a pending send's notes stop receiving witness updates, so after a
restart (the send never mined) or a reorg that undoes the block that
mined it, they are spendable again with stale witnesses, and later
sends from them fail until a rescan.
On the unchanged production code the default and full runs pass (1000
seeds: 12,194 reorgs up to 100 deep, 5,893 reloads, 993 proving sends
verified offline at anchor offsets 0 and 4) and the reproducers fail as
described.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The lite backend core's in-crate tests no longer compiled (119 errors in
lightwallet/tests.rs and the lightclient and walletzkey test modules):
they still used the old LightWallet::new and send_to_address signatures,
the removed extfvks/extsks/zaddress fields and the old new_from_phrase
and do_send arities. So `cargo test` built nothing and no backend change
could be checked against them.
Port them, and the commands and checkpoints tests, to the current API,
touching only #[cfg(test)] code:
- LightWallet::new takes 4 arguments, send_to_address 7 (with an
explicit default fee, returning (txid, raw tx)), new_from_phrase 5 and
do_send 2; keys are read through zkeys and tkeys;
- every config uses the "main" chain: creating a wallet on the "test"
chain panics in base58_pubkey_address, so LightClient::unconnected
(which hard-codes "test") is no longer used;
- every LightClient lives in a temporary data dir, so a test cannot
write to the real ~/.silentdragonxlite or ~/.zcash-params;
- ten send_to_address recipient lists garbled by an upstream change in
2020 are restored from the upstream history.
Expectations that contradicted deliberate current behaviour are updated;
none is ignored:
- a pending send's mempool entry sits at the tip, not tip+1, and
zbalance no longer counts a note with a pending spend;
- keys cannot be added to an encrypted wallet, so the encrypted-receive
tests derive their second address before encrypting;
- the t-address vectors are m/44'/141'/0'/0/{0,1}, checked against an
independent BIP32 derivation; the z-address vectors record the current
output;
- test_addresses and test_checkpoints follow the current address count
and checkpoint tables.
All 43 tests pass on the unchanged production code (three runs) and
leave the real wallet and params directories untouched; that list is the
regression baseline for the following commits. They cover balances,
address derivation, bookkeeping, the mempool, encryption, serialization
and rollback, but none verifies a proof, an anchor or a witness.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
While the node-status banner shows, its detail line was fitted every
frame by popping one character at a time and re-measuring the whole
"· " + detail + "…" string: O(L^2) per frame in the detail length L,
which adds up on the ~1.5 KB Windows crash report. The pop also left a
dangling UTF-8 lead byte before the "…", so a cut in non-ASCII text drew
replacement characters in place of the ellipsis.
- util::textfit::ellipsizeTail builds the same string in O(L + m log m),
m being the length of the line the cut lands in: ImGui measures
multi-line text as its widest line, so the old loop could only stop
inside the first line that overflows on its own. Byte-identical for
ASCII; the cut now lands on a code-point boundary.
- EllipsisFitCache keeps the result until the detail, budget, font size,
font (by FontId) or rasterizer density changes, so an unchanged frame
costs one string compare.
- When ImGui would draw a line wider than the per-line split predicts
(a malformed byte joined with the following newline, as in CP-1252
output from the Windows daemon), the old whole-string loop is used for
that text.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
TruncateToWidth, the middle "front...back" ellipsis for addresses, pool
URLs, paths, labels and the status bar, cut at byte offsets: a
multi-byte character at either cut was split into invalid UTF-8 and
drawn as replacement characters (e.g. a translated Cyrillic or CJK
status). It also tried front lengths from n/2 down until one fit,
building and measuring a new string for each: up to O(n^2) per
truncated label per frame.
util::textfit::middleEllipsis keeps the same contract and is
byte-identical for ASCII, but snaps the front cut down and the back cut
up to code-point boundaries, and finds the largest fitting front length
by bisection into one reused buffer: O(log n) measures for single-line
text. Text containing a newline keeps the linear scan, because its
measured width (the widest line) does not grow steadily with the cut.
text_fit.h also gains largestFittingPrefix, a bisection over code-point
boundaries; its first caller is the node-status banner in a following
change.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Every Typography text, textWrapped and textColored call rebuilt all 15
type specs to read one uppercase flag: 28 ui.toml element lookups, 13
of them copying the element with its string map. With M such labels on
screen, that was 28M lookups and 13M copies per frame.
The specs are now built once per UI-schema generation, which every
ui.toml load, hot reload and skin or theme switch bumps. DPI, font
scale and language are not inputs, so nothing else invalidates them.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
With any console filter on, each batch of new output rescanned all N
lines to rebuild the visible set. A text filter lowercase-copied the
needle and every line to test it: O(total characters) and 2N
allocations per batch.
When the filter is unchanged and the model only appended or evicted
lines since the last pass, the visible indices drop the evicted lines
and shift down, and only the new lines are tested. The result equals
a full rescan, since line text and channel never change and the
filtered view has no folds. A filter change, Clear, or a model window
that doesn't follow on from the last one still rescans.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Each batch of new output re-wrapped all N visible lines glyph by glyph,
allocating a fresh segment vector per line; a line evicted at the
10,000-line cap shifted every index, so nothing could be kept. And on
every frame, idle or not, seeding the scanline shading summed the wrap
rows of every line above the view.
- The model numbers its lines and the view keeps one wrap per line,
aligned by that number, so an eviction drops a prefix instead of
forcing a rebuild. New output wraps only the new lines; the layout is
then a prefix sum over the N visible lines, with no measuring and no
per-line allocation.
- The wraps are kept while the wrap width, line height, font size, font
(by ImFont::FontId) and rasterizer density are exactly unchanged. A
line-spacing change only re-sums heights; filters and fold toggles
reuse the cached wraps. A resize drag still re-wraps every visible
line per frame, as before, but reuses their storage.
- The scanline seed is read from the prefix sum: O(1) per frame.
Heights, offsets and segments are bit-identical to the old rebuild. A
font reload at an unchanged size (the colour-emoji toggle) now re-wraps
the console at once; before, it waited for the next line of output.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The Overview address list built all N rows on every frame (icons,
labels, balances, buttons, an InvisibleButton and a context-menu
check per address) and allocated a vector of N row positions, however
few rows fit on screen; the edge mask then walked all their vertices.
An ImGuiListClipper now submits only the rows that intersect the
list's clip rect, plus:
- one guard row on each side, so dividers and anti-aliased edges at
the clip boundary are drawn as before;
- the row being dragged and the row whose context menu is open, so
drag-to-reorder and the menu keep working when they scroll away;
- the rows keyboard navigation targets (kept by the clipper itself).
Before each row the cursor is put back exactly where the unclipped
loop left it, so the density-scaled spacing, which depends on the
cursor position, and every row's geometry are unchanged. Row tops are
computed from the index, so the per-frame vector is gone, and the
scroll extent is the same. The row model (sort, filter) is still
rebuilt over all addresses each frame.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
ApplyScrollEdgeMask, which fades a scroll area's content at its top
and bottom edges, walked every vertex the area emitted on every frame
and tested both edges per vertex, even when the content fit without
scrolling and no vertex could ever be written. It now returns before
the walk when neither edge fades.
The fade maths moved unchanged into scroll_edge_fade.h and is tested
against the old inline expression, so rendering is identical. All six
callers benefit without an edit: the Overview address list, Mining's
recent blocks, Console, History, Explorer and Peers.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The Mining tab's memory bar read used system RAM and the wallet's own
memory on the UI thread every frame: synchronous OS queries, on Linux
an open and parse of /proc/meminfo and /proc/self/status per frame.
Both are now sampled at most once a second; the first frame after
more than a second idle resamples at once. Total RAM was already
cached and daemon RAM already comes from a background sampler.
The used-RAM and wallet-memory readings in the bar and its tooltip now
refresh at most once a second; what they show is unchanged.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The Mining tab's earnings card walked all N wallet transactions on
every frame to total Today / Yesterday / All time and pick the 4 most
recent rows: four string compares per transaction plus a memo search
on receives. It also ran localtime and mktime every frame to find
local midnight.
The pass is now a pure function behind a one-slot cache keyed on
WalletState::tx_revision, the transaction count, the filter pill, the
Solo/Pool mode, solo support and local midnight, with a 2 s maximum
age as a safety net. It reruns only when one of those changes or the
2 s age runs out, instead of every frame. The totals are bit-identical
(same loop, same summation order), and crossing local midnight still
moves Today into Yesterday at once.
Local midnight is recomputed only when the clock second changes,
through the shared util::localTime; a failed localtime now yields a
zeroed struct instead of an uninitialised one. The "x ago" labels and
Est. Daily are still computed per frame.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The copy button of a text selection in a chat bubble copied
body.substr(start, len) with byte offsets kept from earlier frames.
Those offsets can outlive a thread change (an older message inserted
above shifts the message indices, so the selection can land on a
shorter body), and a start past the end made substr throw
std::out_of_range out of the chat render; the main loop catches it,
but the rest of that frame's UI is dropped.
The copy now runs only when the start is not past the end of the body.
substr already clamps the length, so the copied text is the same in
every case that did not throw before.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Every frame the open chat thread handled all N messages: grouping flags
from two day keys each, a measuring layout of every body, then the
draw, which laid every body out a second time and ran the bubble, hover
and popup code, however few rows were on screen.
The thread now keeps a layout cache: grouping flags, each body's
wrapped size (keyed by message, so only new or re-wrapped bodies are
measured) and each row's height, with prefix offsets. A frame binary-
searches the visible range and submits those rows plus any failed send
(Retry stays reachable with Tab), the row being drag-selected and the
row whose context menu is open; one spacer stands in for each skipped
run, and scrolling to the newest message works as before.
- Rows are rebuilt when the chat store changes or when the wrap width,
fonts, font size, DPI scale, rasterizer density or compact mode
change. Fonts are keyed by FontId, so a font reload at the same size
(the colour-emoji toggle) re-measures. A rebuild is O(N), about
0.5-1 ms at 3000 messages.
- Grouping flags are recomputed on a data change and once a minute,
together with util::refreshTimezone(), so a system timezone change
reaches chat timestamps within a minute.
Rows above the visible range are now one spacer of their summed
heights, so the visible rows no longer move by a pixel each time a row
above them crosses the top of the app window. Debug builds log once if
a cached wrap width or row height ever disagrees with the live layout.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The open chat thread called std::localtime at least twice per message
per frame (two day keys for grouping), and more for day separators and
time labels. On glibc every call re-checks the timezone with a stat of
/etc/localtime. The chat time helpers now use util::localTime() from
the new util/local_time.h: localtime_r (localtime_s on Windows), with
no per-call timezone check and no shared static result buffer.
glibc's localtime_r reads the timezone only once, so local_time.h also
adds util::refreshTimezone() (tzset) for caches to call when they
rebuild. Nothing calls it yet; its first caller is the chat thread's
grouping-flags cache in the next commit.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Every frame the History tab:
- hashed the txid, amount, confirmations, time, type and address of
all N transactions (about 0.6-5 ms at 5k-20k) to decide whether its
totals and its merged, sorted row list were stale;
- copied every chat txid into a fresh set;
- ran the type and search filter over all N rows, with up to three
case-insensitive substring searches each.
The totals and the row list now key on tx_revision (the row list also
on the sort mode), the Message badge and the Chat filter use the chat
store's txid index, and the filtered rows are rebuilt only when the row
list, the type filter, the search text or, under the Chat filter, the
chat store changes. A frame is now a few compares plus one txid lookup
per row of the current page. An unchanged full-node refresh no longer
rebuilds the totals and the row list either: the old fingerprint folded
in the refresh time.
On lite, a change to only a memo or a sender address now shows at once.
The old fingerprint left those fields out and relied on a refresh time
that lite never sets, so such a row stayed stale until something else
in the list changed, such as the next block.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The History tab copies every chat txid into a fresh set each frame to
badge and filter chat transactions: M string allocations for M chat
messages. ChatStore gains hasChatTxid(), an O(1) lookup in a txid index
that is rebuilt only after the store changed (append, delivery update,
erase, clear) and never built for an empty store. clear() also drops
the index, so no chat txids linger after a lock. The History tab moves
to it in the next commit.
The chat note-buffer pump, which runs every frame while chat is idle,
built the whole conversation-id list just to see whether the store was
empty; it now asks store().empty(), O(1) instead of O(M).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
WalletState had no change signal for its transaction list, so a UI
cache had to fingerprint all N transactions every frame to notice an
update. It now carries tx_revision, a counter that only increases;
clear() on disconnect bumps it instead of resetting it, so a cache
built before a reconnect or wallet switch can never match again.
- Every writer of the list bumps it: the full-node refresh, loading the
cached history, adding, completing and removing a pending send, a
mining-address relabel, the lite refresh and the screenshot demo
rows.
- The full-node refresh and the lite refresh (every ~2 s) swap in the
new list only when it differs field by field, the amount compared
bit-exactly because -0.0 and +0.0 print differently. An unchanged
refresh keeps the list and the revision; the compare costs O(N) per
refresh, not per frame.
Nothing reads the counter yet.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The image picker decoded up to 6 full-size images per frame on the UI
thread to make its thumbnails (1.5-2.7 s per frame with 12 MP photos,
until the visible grid filled) and read every .gif/.webp a second time
just to see whether it animates. Thumbnails were freed only on navigate
or close, so a large folder and every hovered animation (up to ~20 MB
each) stayed resident.
Thumbnails now decode on util::StartImageDecode workers (at most 2 at
once, so the top of the visible grid fills first) and the UI thread
uploads up to 8 small textures per frame. The animation badge comes from
the same decode, with no second read. Hover previews get their own
2-slot lane, and the picker asks for a frame every 100 ms while anything
is pending; under idle pacing a deferred thumbnail used to wait for input
or the 2 s tick.
Behaviour changes:
- thumbnails and hover frames are LRU-capped at 64 MiB per folder;
evicted ones re-decode when they are needed again;
- leaving an animated thumbnail before its preview finishes decoding
cancels it (re-hover restarts it); a finished preview is kept;
- animated WebP files get a real thumbnail instead of the placeholder;
- images above the decoder's limits show the placeholder.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The Contacts tab read, decoded and downscaled custom avatars inside the
draw call, two per frame, rows scrolled out of view included: a 12 MP
photo froze the window for 0.25-0.45 s, a long GIF for up to 1.5 s, and
the avatar cache was never evicted. "Add image" decoded the picked file
and copied it inside the picker's confirm callback, freezing it again.
Avatars now decode on util::StartImageDecode workers (at most 2 at once).
The UI thread only uploads finished frames, at most about 4 MB per UI
frame, and asks for a frame every 100 ms while anything is pending;
under idle pacing a deferred avatar used to wait for input or the 2 s
tick.
Behaviour changes:
- an avatar scrolled out of view is not decoded until it scrolls in
(the badge shows for 0.1-0.3 s the first time);
- avatar textures are LRU-capped at 128 MiB; the least recently drawn
go first, never one drawn on-screen in the previous Contacts frame,
and they re-decode on scroll-back;
- an animated avatar starts playing once all its frames are uploaded;
- an import is validated and copied on a worker: the avatar appears
0.1-0.5 s after confirming and Save/Add stays disabled until it does;
a result only lands in the dialog session that started it;
- the copy goes to a temporary file renamed over the target (retried
briefly), so a crash or a failed copy never leaves a truncated avatar
or removes the existing library image;
- imports get the decoder's limits, and an animated WebP can now be
imported (it always failed with "couldn't be loaded").
Applying an import also no longer destroys an avatar texture the same
frame has already drawn (a use-after-free on DX11).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Image decoding lived in the GL texture file and had no limits: a file of
any size was read whole, stb and libwebp allocated whatever the header
claimed (a WebP alone up to 1 GB), and a GIF was decoded at full size,
every frame, before the 300-frame cap. A 720p 150-frame GIF avatar cost
0.2-1.5 s and 140-370 MB on the UI thread. stb's GIF loader also read
before the start of its buffer on disposal-3 frames, and when a long GIF
ran out of memory it freed the frame delays that the caller then freed
again (an abort).
Decoding, and the stb implementation, now live in the GL-free
util/image_decode_core, safe on any thread:
- the header is probed first; stills above 256 MP, any GIF or animated
WebP canvas above 32 Mi px, and files above 256 MiB are rejected
before anything is allocated (real photos, 200 MP phone shots and
wide panoramas still load);
- a GIF decodes one composited frame at a time, downscaled as it goes,
and stops at the frame cap: peak memory is stb's working buffers, two
canvases and the small frames; a truncated GIF keeps the frames
decoded so far;
- a header stb cannot read means "unsupported", never a blind decode;
- decodes above 16 MP run one at a time; a decode can be cancelled
between frames.
util::StartImageDecode runs one decode on a detached worker that owns its
job and its lane's in-flight counter, so nothing waits at exit; the next
commits move avatars and the image picker onto it. The limits apply now
to avatars and the picker's hover previews; thumbnails and avatar imports
get them when they move to workers.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
On a first run or after an upgrade, Windows release builds wrote the
bundled Sapling params, asmap, node and miner (about 88 MB) in
App::init, before the first frame: 0.5-3 s of frozen blank window, 5-30 s
on a slow disk or under antivirus. Zip, AppImage and .app builds copied
the bundled files into the daemon dir and hashed the 51.5 MB of params on
the UI thread at the first node start, a 0.3-1.5 s freeze in the wizard
or the connect loop.
A stat-only check at startup decides whether anything is missing, stale
or unverified; a normal launch starts no thread and shows nothing.
Otherwise one background job extracts (Windows) or copies (Linux, macOS)
the files, then verifies the params once:
- The first frame paints at once. If the job takes longer than 0.4 s, a
chip at the bottom shows "Preparing wallet files... N%", then
"Verifying wallet files...".
- A node start on the UI thread while the job runs is deferred and
replayed when it ends (never into a bootstrap download, a restart or a
live connection); worker threads wait, and give up on quit. After an
upgrade that ships a new miner, the node starts after the miner is
written instead of before. Pool-miner starts and the miner version
check wait too, so the old miner is never run or reported while it is
being replaced.
- Each file goes to "<name>.partial", is flushed and renamed into place
under one lock shared with the daemon start, the bundled-node
reinstall and the miner extraction, and the job stops between 4 MB
chunks at shutdown. An interrupted write used to leave a truncated
binary that was trusted forever; a leftover .partial is now removed.
Other behaviour changes:
- Linux and macOS full-node builds copy the bundled node files (about
77 MB) into the daemon dir at first launch, even if the wallet ends up
on an external node; before, this happened at the first embedded start.
- Lite no longer writes the 53 MB of params and asmap it never reads.
- A disk-full or permission failure that leaves the node files unusable
shows a warning; a miner blocked by antivirus alone does not.
- A corrupt or truncated param in a zip, AppImage or .app install is
replaced from the bundle and verified again instead of trusted forever.
- Reinstalling the bundled node holds off the connect loop and a deferred
start for its whole run (either could launch the old binary while the
new one was written), and is refused while another restart is running.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
On Windows every full-node launch read the whole installed dragonxd.exe
(about 16.7 MB) on the UI thread and byte-scanned it for a version
string, to decide whether to offer the bundled node, which only needs
whether the file exists and its size. getInstalledDaemonInfo() takes a
scanVersion flag, and the update offer now only stats the file.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Every open of the Wallets dialog, Refresh, folder add or remove, and Open
on a stale row walked each user-added folder recursively on the UI thread
before the dialog drew: up to 40,000 entries per folder, roughly
50-150 ms warm on Linux and seconds on a cold disk, a network share or
Windows.
The datadir listing (a few top-level files) stays synchronous, so a
wallet just created shows at once. The recursive walk runs on a detached
worker with the same depth, hit and entry limits; its rows are merged at
the start of a later frame and only the new rows are probed.
- The dialog opens at once with the datadir rows. The external rows
arrive when the walk ends, with "Scanning folders..." beside the
scanned-folders caption, and the card grows once.
- Reopening within 60 s with the same folders reuses the last walk
without touching the disk, so its rows and sizes can be up to a minute
stale: a wallet copied into a folder in that minute needs Refresh.
Refresh and Open on a stale row always walk again.
- While a walk or a wallet probe runs (up to 30 s) the dialog keeps
frames coming, so rows and badges appear without moving the mouse.
- The 200-row cap now holds across all folders; each extra folder could
add one row past it.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Open on a wallet row whose file was moved or deleted after the list was
built rebuilt the list from inside the dialog's row loop. The loop still
held a reference to that row and walked a sort order sized for the old
list, so the click read freed memory and could index past the end. Open
now flags a rescan for the next frame; the vanished row drops out one
frame later, with no crash.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The backend's error for a key it doesn't recognize repeats the key
("'<key>' was not recognized as either a spending key or a viewing
key"). Import passed that text on as its error, so a mistyped private
key was written to the lite log (shown in the Console tab) and to the
Settings status line. A failed import now carries a fixed reason
(unrecognized format, wallet locked, key already exists, could not be
decoded, or rejected), which is all the log records. Settings shows
"Couldn't import the key — check it, unlock the wallet (if encrypted)
and try again." instead, translated in all eight languages.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Clicking the Network-tab card of the server the wallet was already
pinned to and connected to tore the controller down and reopened the
wallet against the same server. That click is now a no-op. A wallet that
failed over to another server still reconnects, which retries the pinned
one.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
New address, Import key, Encrypt and Remove encryption called the
backend on the UI thread. Each ends in the backend's save, which waits on
the lock a scan holds for its whole duration and every refresh read holds
briefly, so during a scan the window froze until the scan ended. A create
or restore with a passphrase encrypted and saved just after its first
scan had started: for a restore, a freeze for the whole initial scan. A
server switch destroyed the old controller on the UI thread and joined
its refresh worker, which can sit for minutes in a read against a dead
server (each balance, notes or list read retries for ~62 s).
- The four ops run one at a time on a background lane and their results
are applied on the main thread; Settings shows "Working…" meanwhile.
Key and passphrase copies are wiped after the call, or when a queued
op is dropped.
- A create or restore encrypts and saves (an open unlocks) before the
first scan starts, so the save takes milliseconds and the scan runs on
the encrypted file.
- Import, Encrypt, Remove encryption and Receive's New address are
disabled while a scan runs ("Wait for blockchain to sync"); Redownload
blocks is disabled while an op is queued.
- A switch retires the old controller without blocking (queued ops
dropped, pending ones reported failed, its client shut down) and
destroys it on a background thread. It no longer saves on the way out:
the new client re-scans the blocks since the last save, as after a
restart.
- A switch waits for an op in flight, at most 15 s, so the op can't
finish and save on the old client while the new one owns the wallet
file. The Network tab already shows the new selection meanwhile.
- The refresh worker stops between backend commands, skips the remaining
server reads once one fails, and then retries after 4, 8, then 15 s
instead of every 2 s.
- Quit waits up to 2.5 s for an op in flight, e.g. Encrypt's save.
Encrypt and Remove encryption now report a failed save: the backend
returns it as a JSON error that the old check never saw, so the failure
was reported as success. The trimmed copy of a key typed into Import is
now wiped too, and stopping the refresh worker can no longer miss its
wakeup.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A FIFO of blocking jobs, run one at a time on a background thread that a
post starts when none is running and that exits once the queue is empty.
Each job returns a completion, and completions run only in pump(), on
the owner's thread. Unlike RPCWorker and AsyncTaskManager nothing ever
joins: abandon() (also run by the destructor) drops queued jobs and
undelivered completions and refuses later posts, and a job stuck in a
blocking call finishes on its own thread. It is meant for backend calls
that can wait out a long scan and may have to be given up. post()
reports a dropped job (abandoned lane, or no thread could be started),
and waitIdle() is bounded.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The daemon's RSS was read on the UI thread: a scan of every /proc entry
on Linux (1-7 ms), a forked ps on macOS (up to ~30 ms). The mining poll
probed every 1-5 s on every tab, and the Mining tab's memory bar probed
again every 1.5 s.
util::BackgroundSampler now runs the probe on one lazily started thread
and caches the result. getDaemonMemoryUsageMB() returns the last sample
and asks for a new one at most every 3 s, and only while the Mining tab
memory bar or the Peers tab Memory cell is drawn: nothing on other tabs
or while minimized. EmbeddedDaemon::memoryProbe() hands the sampler a
pid snapshot (on Windows the value read from the owned process handle),
so nothing off-thread touches daemon state. The mining poll does no I/O
any more; it carries the last sample. Both shutdown paths stop the
sampler.
Fixed along the way: an unreadable /proc made the directory iterator
throw on the UI thread; the scan now just stops.
User-visible:
- the Peers tab Memory cell keeps updating during sync and shows an
external daemon's memory (it showed the mining poll's value, which
pauses during sync and covers only the embedded daemon);
- the Mining tab memory bar refreshes every ~3 s (was 1.5 s).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
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>
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>
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>
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>
The thread grid is inert during a benchmark, but the -/+ buttons and the
count field still took input: a click changed the shown and saved count
while the benchmark owned the miner's (before the previous commit it
also restarted xmrig at that count and spoiled the measurement). The
buttons and the field are now disabled until the benchmark ends.
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>
The requested thread count is written by generateConfig() on the
mining-control thread and read on the UI thread (the thread-grid sync
under idle thread scaling, the pool auto-balance restart) without any
synchronisation. The write also stored the unclamped count first and
clamped it afterwards, so a reader could see more threads than cores.
It is now an atomic, set by one store of the clamped value.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
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>
Six sliders (noise opacity, UI opacity, window opacity, chat font size,
chat poll rate, contacts list scale) rewrote settings.json, with its two
fsyncs, on every frame the value changed: 60-300 durable writes per
drag, at 1-4 ms each on NVMe, 3-20 ms on a Windows SSD, 20-60 ms on a
HDD and seconds while dragonxd is busy on the disk.
The value still applies live and is kept in Settings for the per-frame
readers; settings.json is written once, when the slider is released, as
the blur and font-scale sliders already did. A drag cut off before its
release (a dialog closed mid-drag) is persisted by the next save or at
exit.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
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>
On Linux and macOS the lock file was opened without O_CLOEXEC, so
xdg-open and the browser it starts, dragonxd and xmrig all inherited it.
A clean exit unlocks explicitly, but after a crash or kill the lock
stayed held for as long as any of those children ran (indefinitely with
"keep daemon running"), and the wallet refused to relaunch. The fd is
now opened O_CLOEXEC; nothing else changes.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
On Windows writeFileAtomically wrote the temp file through std::ofstream,
whose CRT handle is inheritable. xmrig and the hidden helper commands
(where, nvidia-smi, xmrig --version) are started with handle inheritance
on, so one started during a save kept "<file>.tmp" open without delete
sharing, and every later replace of that file failed for as long as the
child ran: hours while mining, with settings saves quietly not landing.
The flush then reopened the file with share mode 0, so it was silently
skipped whenever AV or the search indexer had the file open, and a
replace refused by them just failed.
- Windows: one CreateFileA handle, not inheritable, writes and flushes
the temp file; the replace is retried up to 4 times (20+40+80+160 ms)
on access-denied, sharing or lock violations, where it used to fail.
- Linux/macOS: the temp file and directory fds are opened O_CLOEXEC, so
a dragonxd or xmrig fork+exec doesn't inherit them.
A later commit moves settings writes to a background thread, which makes
every one of them concurrent with those spawns. The vault, the exports
and DRAGONX.conf use the same function and get the same fix.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
hasPendingRPCResults() becomes hasPendingBackgroundResults(): the one
check the idle loop makes, every <= 200 ms, for work that finished on a
background thread and still has to be applied by update(). Lanes added
later put their term there. It still covers only the two RPC worker
lanes; no behaviour change.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
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>
"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>
Lite opens an encrypted wallet locked, but the model saying so arrives
a moment later. The first seed export therefore failed with "locked"
and disabled chat for good, so encrypted Lite wallets never got chat.
- While the wallet is locked, a failure recorded before the lock was
seen is cleared, so provisioning runs again after the unlock (like
lockLiteWallet()). Nothing is retried while locked: that branch
returns before any RPC or backend call.
- Lite waits for the first model's lock state before exporting the
seed.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- 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>
checkIdleMining ran every frame: an X11 idle query and, with GPU-aware
idle detection, an nvidia-smi launch on the UI thread per frame.
- The check runs once a second, since its decisions work on a seconds
scale (the idle delay is tens of seconds or more). A miner it started
or scaled up still yields on the first frame that sees the user back:
that path skips the gate, because frames can be seconds apart while
the app idles.
- The GPU is probed only when input is idle and the result can change
a decision, at most every 5 s, with hysteresis (busy at 10%+, clear
below 5%) so a GPU hovering near 10% can't keep restarting the miner.
- Thread scaling returns early when no miner is running or a toggle is
in flight.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
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>
- 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>
Restoring or un-maximizing the window called refreshNow(), which wipes
the history-scan progress and re-scans every z-address (a
z_listreceivedbyaddress per address, each holding cs_main). The Peers
tab's refresh button did the same, and holding F5 auto-repeated it.
- Window restore no longer forces a refresh; the regular timers catch
up (skipping the daemon-output backlog stays).
- The Peers refresh button refreshes peers only.
- F5 ignores key repeat. An explicit F5 or History refresh still does
the full re-scan.
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>
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>
Low-spec mode, theme effects, blur, UI and noise opacity and the
console scanline reached the live effects state only once the Settings
page or the first-run wizard was opened. "Effects off" and low-spec
therefore reverted at every launch, and the animated defaults kept the
main loop busy (91% of a core on an idle Lite wallet whose effects were
saved off).
- App::applyVisualSettings(): one low-spec-aware path from the saved
prefs to the effects state, used at startup (after the skin, since
setActiveSkin reloads the effect config), by the low-spec hotkey, the
wizard and the Settings page.
- Low-spec stays a runtime-only override. The Settings page shows the
overrides but persists the real prefs from its snapshot, and opening
it under low-spec no longer turns theme effects and blur back on.
Unticking a low-spec in the wizard that was loaded rather than ticked
there now restores the saved prefs.
- Theme effects on/off always pairs with reduced transparency (the
rainbow, shimmer and glare layers), as at startup and in the hotkeys.
- kAcrylicMaxBlur moves to imgui_acrylic.h, so startup clamps blur
values saved in the old 0-4 range the way the slider does.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Logic timers advanced by io.DeltaTime, which update() clamps to 0.25 s
so animations don't jump after a pause. That matched real time only
while the main loop rendered continuously: with frames seconds apart, a
timer fed the clamped delta runs up to 8x slow. Affected: the refresh
scheduler, the seed-migration poll, the chat fast scan, the encryption
check, the loading and shutdown timers, the lock-screen countdowns, the
Settings debug gate and the mining benchmark.
- App::logicDeltaTime(): steady-clock seconds since the previous
update(), capped at 6 s so a sleep/resume fires each timer once
instead of replaying the gap. Visual interpolation keeps the clamped
io.DeltaTime.
- The refresh timers tick before the RPC results are drained, so a
hold that a callback arms (the connect-retry backoff) starts fresh
instead of being charged the gap before it existed.
- The shutdown timer is measured from shutdown_start_time_:
beginShutdown runs from SDL event handling, so an accumulated delta
would count the idle gap before the close request.
- The lock-screen error and lockout countdowns subtract only the time
since they were armed: a failed unlock arms them from an RPC result,
which can land on the frame that ends an idle gap.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This branch's only commit (424954f) is already on dev as an identical patch
(fae8b20, merged in 1151141; git cherry reports it as equivalent). A normal
merge conflicts only where dev has since moved past it: the NotoSansCJK
subset was rebuilt again as a superset that still contains the FAQ glyphs,
and app.cpp and node_status_banner.h changed later. This merge records the
branch as integrated and keeps dev's content unchanged.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Every fix on this branch already reached dev through the port in bcee4bf
("apply audit remediations to dev"), adapted to dev's code and in several
places hardened further (0600-at-open xmrig config, strict 127.0.0.0/8
loopback check, metadata cap centralised in httpGetString). F14-1 and F8-2
were fixed independently on dev. All 10 conflicting files therefore resolve
to dev's side, which drops nothing.
The one thing dev lacked is the F14-1 regression test: a quoted empty
console argument ("") must be kept rather than shifting later positional
RPC params. This merge brings that test in.
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>
Follow-up to 5246898. A transparent address only ever receives mining
rewards on DragonX (private chain), so:
- One explainer ("Receives solo-mining rewards only - the network rejects
payments to it. Rewards are shielded automatically once they mature.")
on the Overview row hover, the Receive picker hover, the Receive card
and the Validate Address result (now typed "Mining address
(transparent)").
- Receive: a selected mining address shows the explainer and a "Use a
shielded address" button (creates one if none exists) instead of the
payment request, and a "Not for payments" panel instead of a QR code.
The default selection prefers a shielded address; the "All" filter
hides empty mining addresses unless solo mining or marked as a mining
address; the filter reads All / Shielded / Mining.
- Request Payment: no transparent section, never prefilled with one.
- Overview: no "Mining - Mining" tag on a marked transparent row.
- FAQ: address answers rewritten; the send and memo answers no longer
imply you can pay a transparent address. All 8 languages.
i18n: 4 new keys, 6 FAQ answers updated in place, 2 dead keys removed,
CJK subset rebuilt. UI sweep: receive-mining-address and
modal-validate-mining targets.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
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>
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>
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>
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>
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>
The one-time "back up your seed phrase" nudge only stopped probing on a
success or the exact "not derived from a mnemonic" error. Any other
answer re-armed it for the next frame, so a daemon without the RPC
("Method not found", e.g. the older bundled binary) was sent
z_exportmnemonic at frame rate: ~2,300 requests/s against a test node.
Chat identity setup re-posted its fetch on the next frame after a
transient failure in the same way.
An inconclusive nudge probe now waits 5 minutes before the next; a
transient identity-fetch failure waits 10 seconds.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- getrescaninfo takes no lock in dragonxd (it only reads wallet fields),
so the stub no longer holds it behind cs_main. Holding it there made
the GUI's rescan-progress poll look like an abandoned request every
30 s during a simulated rebuild.
- a `methods` event lists the 10 most-requested JSON-RPC methods with
counts (alongside `stats`, and at exit): what the GUI is polling.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
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>
Holds cs_main like a witness rebuild but logs no rebuild markers: a node
that is busy while its output says nothing (an external node, or a
rebuild path that prints no progress), for exercising the GUI's
"busy but alive" handling.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
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>
Scripted runs nobody needs to watch can run the GUI without a window:
OpenGL still loads, and connecting and shutting down behave as usual.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
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>
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>
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>
A Lite-configured tree builds ObsidianDragonLite and has no embedded
daemon, so any bin/ObsidianDragon left in it is a stale full-node binary
that the harness would otherwise run silently. Point --build-dir at a
full-node tree instead.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
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>
A scriptable fake dragonxd that reproduces, in minutes, the incident where
the GUI hard-killed a node busy with an hour-long witness rebuild:
- dragonxd's HTTP-RPC queue (8 workers + 16 queued, -rpcthreads /
-rpcworkqueue honoured): a full queue answers an immediate plain-text
500 "Work queue depth exceeded", and a request whose client gave up
keeps its slot until a worker reaches it
- a global cs_main held by a "witness rebuild" that logs the real
markers; every method except stop/help takes it
- warmup -28 with the daemon's allow-list, an optional startup replay
- dragonxd's shutdown order: stop (or SIGTERM on POSIX) unbinds RPC,
503s open connections, waits for the rebuild, then logs
"Shutdown: In progress..." / "Shutdown: done" and exits 0
Everything is appended to <datadir>/fake_dragonxd.events; a missing
"clean-exit" line means the process was killed. One portable C++17 file
(POSIX + Winsock), OFF by default (-DDRAGONX_BUILD_FAKE_DAEMON=ON), built
into <build>/fake_dragonxd/ so it can't shadow the real bin/dragonxd.
run-gui-with-fake.sh runs the GUI against it in a throwaway HOME.
Against master's GUI on Linux it reproduces the bug: closing during a
rebuild queues stop behind cs_main-blocked workers, then the GUI sends
SIGTERM and SIGKILL mid-rebuild, with no clean exit.
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
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
Ship https://lite6.dragonx.is and https://lite7.dragonx.is as default
lightwalletd servers in both the Settings defaults and the connection-service
fallback list. Existing installs (whose saved server list predates these)
pick them up via a load-time merge that appends any missing default server —
safe because servers are hidden, never deleted, so a removed server isn't
resurrected. Consolidates the Settings default list into a single
Settings::defaultLiteServers() source of truth.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Complete the de/es/fr/ja/ko/pt/ru/zh translations so every English source
key in i18n.cpp is now covered (2341 keys per language, 0 missing). Covers
the chat delete/block flow, the block-database reindex + wallet-recovery
prompts, node auto-shield status, the in-app FAQ (full-node + lite), the
daemon updater/version picker, the debug-options gate, and more.
Additive-only (no existing key changed). Printf format signatures preserved
and validated against i18n.cpp formatSignature; newlines, the "→" arrow, and
brand/technical identifiers kept verbatim. Terminology grounded on each
language's existing translations for consistency.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Derive the macOS BDB depends triple from the build arch + pass the BDB paths
to CMake, so the full-node mac release builds the wallet-rebuild helper on
Intel (x86_64) as well as Apple Silicon.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The macOS release path hardcoded aarch64-apple-darwin for the wallet-rebuild
helper's static libdb and — unlike the Linux/Windows paths — never passed
-DBDB_INCLUDE_DIR/-DBDB_LIBRARY to CMake. So on an Intel Mac the helper either
couldn't find libdb, or picked up macOS's SDK stub /usr/include/db.h (a DB 1.85
shim with no db_create) and failed to compile.
- mac_bdb_dir(): map the target arch to the depends triple (x86_64 ->
x86_64-apple-darwin, arm64 -> aarch64-apple-darwin; universal / unknown falls
back to the host arch).
- Pass -DBDB_INCLUDE_DIR / -DBDB_LIBRARY (when the vendored libdb is present) in
BOTH the native and osxcross configures, so CMake uses our BDB 6.2 header
instead of the SDK stub. Full-node only (lite has no BDB wallet.dat).
- Use the derived triple in require_wallet_rebuild_helper's fix hint.
Verified end-to-end by building both ObsidianDragon 2.0.1 and ObsidianDragonLite
1.1.0 .dmg/.app on an Intel Mac (x86_64; built single-arch via DRAGONX_MAC_ARCHS
since the vendored deps are x86_64-only).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Brings the full 2.0.x line to master: security-audit remediations, diagnostics/
logging, mining overhaul, HiDPI/UI audit, wallet recovery + migrate-to-seed,
daemon-startup hardening, seed-phrase backup, in-app FAQ, i18n + 8-language
translations, chat delete/block, per-frame render perf, and the Lite variant
(1.1.0) with its variant-aware FAQ. Windows app exe now stripped.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Bump DRAGONX_LITE_VERSION 1.0.0 -> 1.1.0: since lite-1.0.0 the Lite variant
gained the whole 2.0.x shared UI/UX + diagnostics + i18n + perf work and this
session's chat delete/block — a minor bump (new features, no breaking change).
Make the FAQ variant-aware (walletFaq(fullNode)) so the Lite build reads
correctly: "What is ObsidianDragonLite?", encryption in the Wallet tab (not
Node & Security), migrate-to-seed hidden, node-specific answers reworded
neutrally ("the wallet syncs"), a new "Lite Wallet" subcategory (server model +
privacy tradeoff) standing in for the hidden Daemon group, and the redundant
single "Wallet" group tab dropped when there's no Daemon group to switch to.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The Linux and macOS release paths already strip the main binary (and the
Windows path even stripped the dragonx-wallet-rebuild helper), but the
Windows app exe was shipping unstripped — ~5 MB of symbols on the full
node, ~19 MB on the params-heavy lite build. Strip it right after the
Windows build, best-effort with a warning fallback.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- Auto-shield status: the node's z_autoshieldstatus disabled_reason was echoed
verbatim ("HD seed origin is not known-recoverable; back the seed up and pass
-autoshield=1") and drawn INSIDE the Wallet OPTIONS checkbox grid, wedging a
full-width line between the checkboxes. Move it to a full-width note BELOW the
grid, and replace the raw daemon text with friendly, actionable wording keyed
on the seed_recoverable flag (back up your seed to enable it); the raw daemon
reason is kept on hover. Adds App::daemonAutoShieldSeedRecoverable().
- FAQ: add a Chat & Contacts entry ("How do I hide, delete, or block a
conversation?") covering the hide / delete-revive / delete-&-block actions and
the local-only caveat — data + i18n only, no UI code change.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
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>
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>
The Chat/History nav buttons centered their icon+label in a region that
shrank when a badge (unread count / mining dot) was present, and the
"has badge" test read the LIVE count — so the text jumped sideways the
moment a count toggled (e.g. a new chat message arrived). Reserve badge
clearance by whether the page CAN show a badge (constant per item), center
in the full button width regardless, and cap the label with symmetric
clearance so a long label still can't run under the corner badge.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Bundles the session's UI work (the touched files carry several of these
changes together, so they are committed as one coherent UI batch):
- FAQ: new RenderFaqDialog + data-driven faq_content, opened from a
status-bar "?" (and the Windows title bar), styled like the Wallets
modal with search, Wallet/Daemon tabs, and smooth scroll.
- DPI/font-scale audit: multiply hand-drawn absolute geometry by
Layout::dpiScale() across ~30 files so nothing renders native-size at
HiDPI / font_scale 1.5 (verified with a full sweep at 1.5x).
- Mining: chart now fills the horizontal space; thread stepper +/- buttons
match the input-box height; move the stratum-host toggle into
Node & Security (v1.3.0+).
- Settings: fix the auto-shield status text overlapping the grid.
- Sidebar: drop the peer-count badge on the Network button.
- i18n: wrap 193 hardcoded literals with TR() (keys/translations added in
the preceding i18n commit), so the security/PIN/lock flow, seed-backup
wizard, and witness-rebuild dialog localize.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Adds the i18n source for 193 previously-hardcoded UI strings (the
security / PIN / lock flow, first-run seed-backup wizard, witness-rebuild
shutdown dialog, and balance / mining / explorer labels) so they can be
localized. The TR() call-site wrapping rides in the accompanying UI commit.
- 193 keys added to loadBuiltinEnglish() (the English source of truth),
including one pre-existing missing key (copied_to_clipboard).
- Additive res/lang/{de,es,fr,ja,ko,pt,ru,zh}.json (+193 each); every
translation's printf format-signature was validated against English
(0 mismatches) so the runtime validator accepts them.
- Rebuilt res/fonts/NotoSansCJK-Subset.ttf to cover ~30 new Han/Hangul
glyphs introduced by the zh/ja/ko translations (tofu-free).
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>
The shutdown guard fired its "Node is rebuilding its witness cache" prompt almost always on an
active wallet: the daemon does a per-tx VerifyAndSetInitialWitness as each newly received wallet
tx lands during normal sync, and those markers (sparse — minutes apart — with no matching
"rebuilt N note witness cache(s) … in Xms") kept the heuristic latched "active".
- daemonWitnessRebuildActive() now requires the last progress marker to be part of the CURRENT
log activity (within ~15s of the newest log line, via a same-log timestamp delta), so routine
minutes-old per-tx witness sets no longer count as an ongoing rebuild.
- shouldConfirmDaemonStop() suppresses the prompt entirely on v1.3.0+ daemons (version >= 1030000):
they checkpoint witness-rescan progress, so stopping mid-rebuild resumes on the next start rather
than redoing it — the warning's "restarts it (several minutes)" premise no longer holds.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The daemon-output parser treats autonomous background witness rebuilds as a rescan (they set
state_.sync.rescanning via the foundWitness branch), so one completing fired "Blockchain rescan
complete" even though the user never started a rescan — most visibly after a minimize, where a
whole rebuild's start+finish arrives in one batch.
Add user_initiated_rescan_ (atomic — some triggers run on worker threads), set it at the wallet's
real rescan triggers (the Rescan button, a -rescan/salvage/zap/reindex restart, key import, seed
migration), and gate the three "rescan complete" toasts on it, clearing it when shown. The
rescan/witness progress state machine is untouched — only the toast is gated — so background
rebuilds no longer announce a completed rescan while genuine user rescans still do (and can't
double-toast).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Previously the main loop just did SDL_Delay+continue when minimized, skipping app.update() —
so the wallet stopped draining RPC results, ticking the refresh scheduler, and reconnecting
until it was restored (and a large daemon-output backlog piled up, which is what produced the
spurious "rescan complete" toast on restore).
Now app.update() runs while minimized (it only reads GetIO/GetTime/IsAnyItemActive, all valid
outside a NewFrame) with a real-clock DeltaTime, skipping only the ImGui frame + GPU present,
throttled to ~5 Hz so CPU stays near-idle. Also clamp io.DeltaTime at the top of App::update()
so a long minimize (or machine sleep) can't report a huge delta and fire every refresh/animation
timer at once on the next update. No backlog now builds, so skipDaemonOutputBacklog becomes a
harmless no-op.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
While minimized the main loop skips the frame body (SDL_Delay+continue), so App::update() —
which drains the daemon output via outputSince(daemon_output_offset_) — never runs and the
offset isn't advanced. On restore the whole accumulated backlog is parsed in one batch: a
background witness rebuild's progress lines (parsed as a rescan → state_.sync.rescanning=true)
AND its "rebuilt … in Xms" completion (parsed as finished) arrive together and fire
"Blockchain rescan complete" for a scan the user never initiated.
On WINDOW_RESTORED, discard the daemon-output backlog (advance the offset to the current end,
via App::skipDaemonOutputBacklog) before the resumed update parses it — so only new output is
parsed. Genuine user-initiated rescans still surface completion via the getrescaninfo monitor.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
It added clutter to the loading screen (the yellow title + two lines of explanatory text).
The live daemon-output panel below it is the real progress signal. connect_stall_since_ stays
maintained in app_network.cpp for connection bookkeeping; it just no longer drives any on-screen
text (loading_stall_* i18n strings + util::connectHasStalled are now unused).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Add an opt-in "Host a mining pool (stratum)" toggle so a v1.3.0+ node can run its native
RandomX stratum server for other miners to point at. Settings toggle + optional allow-IP/CIDR;
passes -stratum (+ -stratumallowip) to the daemon launch args, mirroring the -maxconnections
plumbing (EmbeddedDaemon::setStratumHosting <- DaemonController::syncSettings <- Settings).
Backwards compatible / safe by default:
- UI gated on daemon_version >= 1030000, so it's never offered where it would do nothing.
- The launch flag is harmless on older daemons (they ignore unknown args), and the toggle can
only be enabled while connected to a v1.3.0+ node anyway.
- Blank allow-IP => the daemon serves loopback only (its safe default); entering a subnet opens
it to that LAN, with an explicit exposure warning in the UI.
- Takes effect on the next daemon start/restart.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Rec3 — v1.3.0+ opens a wallet that lost its hdchain in DEGRADED mode (existing funds spendable,
but no new HD-key derivation) instead of aborting. Add a daemon-log classifier
(walletOpenedDegraded) + detectWalletDegraded(), warned once per session. Pre-1.3.0 daemons
never emit that line, so it's a no-op there.
O1 — probe z_autoshieldstatus once per connection (now decoupled from our own toggle/balance) and,
in Settings, show whether the node handles auto-shield itself (+ its destination, or the daemon's
disabled_reason). The checkbox now governs only the wallet's fallback shielder, which defers to the
node. Nothing renders on pre-1.3.0 daemons (no such RPC), so behaviour is unchanged.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Replace the wallet's stale hardcoded -addnode list (node. + node1-4.dragonx.is — a drifted
subset with a bogus bare 'node.') with the daemon's authoritative vSeeds: seed.dragonx.is (a
round-robin A record over the live seed set, so it self-updates without a wallet release) plus
node1/node5 as static fallbacks. Applied in both seeding sites: the launch args
(embedded_daemon.cpp) and the generated DRAGONX.conf (connection.cpp).
Kept (not deleted) — pre-1.3.0 daemons had broken peer discovery and rely on these -addnode
entries to find peers at all; plain hostname resolution works on every daemon version.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
v1.3.0 daemons auto-shield coinbase by default (when the HD seed is recoverable — which
every ObsidianDragon-created wallet is, via -usemnemonic=1). The wallet also ran its own
client-side auto-shield every refresh tick, so both raced for the same coinbase UTXOs and
split funds across different z-addresses (the wallet picks the first z_listaddresses entry;
the daemon uses a seed-hardened derivation).
Probe z_autoshieldstatus once per connection (while synced, so the daemon is past warmup)
and skip the wallet's client-side shield when the daemon reports it active. Fail-closed: a
pre-1.3.0 daemon has no such RPC, so the probe returns active=false and the wallet keeps
shielding — no regression on the currently-bundled v1.0.3. Re-probes on reconnect (handles
a live daemon upgrade/swap).
Follow-up (not done): drive the Settings "Auto-shield" toggle + disabled_reason from
z_autoshieldstatus (O1) — needs runtime verification of the RPC fields on a live v1.3.0 node.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
_popen/_popen-style shell-outs flash a cmd.exe console window on Windows. Add
Platform::runHiddenCapture() — CreateProcess + CREATE_NO_WINDOW capturing stdout on
Windows, popen on POSIX — and route the remaining shell-outs through it:
- GPU-aware idle detection (getGpuUtilization: "where nvidia-smi" / "nvidia-smi --query-gpu")
- xmrig discovery + version (findXmrigBinary "where xmrig.exe"; "<bin> --version", stderr merged)
- wallet-rebuild helper (app_network) — keeps its exit-code check via the new exitCode out-param
None of these are on the launch path (that was the daemon spawn, fixed in a2f84be); each
would flash a console only when it ran (idle-GPU mining, mining tab, wallet recovery).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The embedded daemon was launched with CREATE_NEW_CONSOLE + SW_HIDE. CREATE_NEW_CONSOLE
allocates a console window that flashes on screen before SW_HIDE hides it — visible as a
console-window flash every time the wallet starts dragonxd (i.e. on launch). Switch to
CREATE_NO_WINDOW (the console child gets no window at all, matching the xmrig launcher);
dragonxd logs to debug.log, not a console, so nothing is lost.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Stopping dragonxd while it's rebuilding the Sapling witness cache discards the
in-progress work — BuildWitnessCache aborts on shutdown without persisting — so
the next launch redoes a multi-minute rebuild (the "Activating best chain…" hang).
This bites especially with stop_external_daemon enabled, where wallet exit sends
the node a stop.
beginShutdown() now defers when it would StopDaemon while a rebuild is active and
shows a confirm modal: "Keep node running & quit" (DisconnectOnly — leaves it up
to finish), "Stop anyway & quit", or "Cancel". Rebuild detection reads the
debug.log tail markers (Cleared witness data / Setting Initial Sapling Witness /
Reading blocks for witness rebuild, vs. the "rebuilt … in …ms" / abort lines).
The gate lives entirely in beginShutdown()/render() — no SDL event-loop changes.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- Loading "taking longer than expected" notice: shorten the body + hint so the
startup screen reads less wordy (same info, ~half the text).
- Shutdown screen: when the wallet attached to an EXTERNAL daemon (no captured
stdout — debug_log_path_ is only set when we spawn it), the "dragonxd output"
panel was always empty, leaving just a spinner. Fall back to tailing the
daemon's debug.log so the user can watch the node flush the block index and
exit. Adds App::tailDaemonDebugLog() (best-effort, reads only the file tail).
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>
Make Merge to Address actually serve wallet-bloat consolidation and be far less
opaque. New ShieldDialog::showConsolidate() preset (used by the large-wallet
Settings banner + alert action) frames it as "Consolidate funds" and targets
shielded notes — the bloat the nudge warns about.
- Source selector: consolidate shielded notes (ANY_SAPLING), transparent
(ANY_TADDR), or both (*) — previously hardcoded to ANY_TADDR, which never
reduced the shielded-witness bloat. Batch limit now applies to the right side.
- Scope: on open, count spendable UTXOs + notes (listunspent / z_listunspent)
and show "N transparent + M shielded · ~X DRGX"; warn "repeat to finish" when
the set exceeds one batch.
- Destination auto-selects the best spendable z-address (button enabled by
default); empty wallets get an inline "Create shielded address" (z_getnewaddress).
- Advanced disclosure hides Fee + "Max inputs per batch" (renamed from the "UTXO
Limit" jargon) with sane defaults.
- Inline confirm step before the fund-moving call (amount + input count + dest).
- Live progress: self-polls z_getoperationstatus to show Consolidating… →
Done/Failed, replacing the raw opid + manual "Check status" button.
All three merge entry points now use the typed showMerge()/showConsolidate()
(no stale-static leaks from direct show(MergeToAddress)). Shield-coinbase mode
keeps working. New i18n keys fall back to English.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Extend the wallet-bloat warning beyond the Settings banner: when wallet.dat
first crosses 500 MB (full-node, synced), fire a one-time warning toast plus a
clickable "Consolidate notes…" entry in the bell/alerts panel that opens Merge
to Address. The persisted large_wallet_warned flag keeps it once-only and
re-arms if the file later shrinks back under the threshold.
- AlertRecord gains an optional onClick + actionHint; Notifications::action()
pushes a toast and a clickable history entry. renderAlertHistoryPanel() now
renders the accent action link (under the message) and measures true content
height so wrapped messages + the link aren't clipped.
- App::maybeWarnLargeWallet() (mirrors maybeRemindSeedBackup) runs once per
launch from update(); reuses the existing wallet_size_warn/consolidate strings.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The BDB wallet.dat bloats with shielded-note witness data and never shrinks
in place, so a mining/shielded wallet can grow past 500 MB. Below the Wallet
Size row, show a one-line amber hint once wallet.dat crosses 500 MB with a
"Consolidate notes…" shortcut that opens the Merge to Address (z_mergetoaddress)
dialog. Full-node only (lite has no wallet.dat here); threshold is a single
named constant. i18n keys fall back to English for non-English locales.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Add "The DragonX Developers" to the About-tab credits (after The Hush
Developers), acknowledging the DragonX chain/daemon this wallet drives.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
ensureLogoTexture() rasterized the embedded DragonX SVG into logo_tex_ and
returned early (added in 1752500 "themed DragonX logo"), so the app/product
branding — the top-left header (app.cpp AddImage) and the About tab
(getLogoTexture) — showed the DragonX coin mark instead of the ObsidianDragon
logo. Drop that step so logo_tex_ resolves via the intended path: active-skin
override → ui.toml header-icon → bundled ObsidianDragon dark/light PNG (disk,
then embedded RESOURCE_LOGO). The DragonX SVG stays for coin_logo_tex_ (balance
card) and drgx_emoji_tex_ (chat emoji), which are the currency mark and correct.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Settings tabs brought closer to the approved mockup:
- ActionButton/renderCardButton retune (settings-scoped): 7px radius, 9px
padX, Primary → accent-outline chip, Secondary/card buttons more defined.
- Daemon-binary card: compact status right-aligned on the DAEMON BINARY
heading (Up to date / Version differs / Not installed), filled/rounded
status box, neutral danger divider (was alarming red), roomier spacing.
- RPC Connection: two-row column-aligned layout (Host | Port, then
Username | Password) so the password no longer clips off the card edge.
- Chat settings tab: live conversation preview below the Appearance /
Messaging cards; "Focus input on open" checkbox reflowed onto the console
color-toggle row.
- Debug Options: "Current theme only" toggle restricts either screenshot
sweep to the active theme instead of cycling every skin.
- Tabs fill the full content width (content-max-width cap disabled) and the
sidebar nav panel centers within the true visible area.
- i18n: new keys for the above (untranslated keys fall back to English).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Send and Receive are now consistent in layout, and the Send recipient row's buttons
render correctly.
Card envelope (Send ⇄ Receive consistency):
- Add Layout::mainComposeCardBox(availW) — a single shared source for the compose card's
width + centering (fill the available column up to content-max-width, then center). Both
tabs derive their card from it, so they can't drift again. Previously Send capped at
760dp and Receive at 860dp, so the Send card rendered ~150px narrower on any window wider
than ~860dp; now they fill available width identically.
Receive:
- Justify the footer buttons edge-to-edge (equal shares over the live count) instead of
left-clustering with dead space, matching Send's full-width footer rhythm.
- Build the address-dropdown preview to the combo's real pixel width so the trailing
balance ("— 12.00000000 DRGX") no longer hard-clips at 150% (was char-count truncation).
Send recipient row (input | Paste | contacts-icon):
- Pin the contacts icon button to the frame height so the larger iconMed font doesn't
auto-size it taller than Paste/the input.
- Reserve the real ItemSpacing.x gaps (not the smaller spacingSm token) so the row no
longer overshoots the card and clips the icon's right border.
draw_helpers (root cause, app-wide):
- TactileButton's icon path measured/drew the label INCLUDING the "##id" suffix (which
CalcTextSizeA/AddText don't strip the way ImGui's text render does), shoving the glyph
off-center-left. Strip at "##" before measuring/drawing. Corrects any icon button that
passes an explicit size and a "##id" label; no-op for labels without "##".
Verified via headless sweeps at 1.0x and 1.5x, plus a real 3800px-wide render (both cards
byte-identical at L=1174/R=2773). ctest 1/1.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Implements the layout-improvement suggestions from the layout audit (visual arrangement
only; no functionality added or removed):
- Send/Receive: the recent-activity list now grows to fill the space below the fixed
compose/receive card (more history visible), with a centered empty-state when there is
none — instead of leaving dead canvas.
- Shield/Merge: pair the Fee and UTXO-Limit fields on one row to tighten vertical rhythm.
- Market: extend + frame the portfolio group-list as one contained panel (with a bottom
edge) and center its empty-state, closing the previously un-anchored gap.
- Overlay dialogs: raise the card glass fill/border alpha (35/50 -> 60/90 of 255) so the
dialog card reads as a distinct surface over busy backdrops (global, all overlays).
- Wallets: size the list height to the actual wallet count instead of always reserving 7
rows, removing the large gap before the scan/create prompts (still scrolls when many).
- Contacts: width-aware address truncation shows more of the address on wide rows.
- Transfer Funds: give the "sends the full balance" warning a warning icon + color so the
stakes stand out from the neutral result-preview lines.
- First-run wizard: collapse a completed Step 1 (Appearance) to the compact pill like
Step 2, so a finished step is no longer taller than the active one.
- Explorer: distribute the Chain card's two stats to match the density of the sibling
metrics grid.
- Validate Address: a "Results will appear here" caption fills the pre-interaction blank.
- Change Passphrase: add the warning banner its sibling security dialogs have.
- Migration ShowSeed: box the 24-word mnemonic grid (a GlassSectionScope behind the
existing RenderSeedWordGrid) so the critical secret reads as a distinct artifact —
purely visual, no seed/logic/state change.
Verified at 1280 across full-node + Lite + Windows (ctest green) and an adversarial diff
review (clean). New i18n key backfilled into all 8 languages.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Addresses the cross-screen inconsistencies from the layout audit by routing screens onto
the design system's own (previously under-used) shared helpers:
- Dialog footers: migrate ~9 overlay dialogs off hand-rolled placement onto the shared
helpers — DialogActionFooter (primary+Close), DialogConfirmFooter, or
BeginOverlayDialogFooter for custom/multi-button rows — so footers share one centered
treatment. All footer/action buttons now use TactileButton (glass press) instead of the
bare StyledButton some dialogs used.
- Empty states: add a shared material::DrawEmptyState(icon, title, hint) (centered icon +
title + wrapped hint) and adopt it in Peers, Transactions, and Market-portfolio, which
previously showed a bare left-aligned caption.
- Security dialogs: add the missing Cancel to Change Passphrase and Set PIN so the whole
security family shares a two-button footer (Cancel dismisses without applying).
- Transactions pager: shared TactileButton helpers (matching Explorer).
- Frosted-pane rounding: Contacts/Chat use Layout::glassRounding() instead of hardcoded
12/10/8px literals, matching Peers.
- "Set Label..." title loses its stray trailing ellipsis.
Preserves every button's label and action; the transfer footer's order becomes
[Confirm][Cancel] to match the shared helper's primary-first convention. Verified at 1280
across full-node + Lite + Windows (ctest green) and an adversarial diff review (clean).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Fixes the cut-off/clipping bugs from the layout audit (all visible at the default
1280/1024 window sizes):
- Receive "Recent Received" rows: the amount collided with the relative-time
("+15.7500 DRGX14 days ago") and the type label touched the address at narrow
widths. Use the shared short time format (formatTimeAgoShort, matching Overview),
chain the amount's right edge off the measured time width, and start the address
after the measured type-label width — so neither pair can collide.
- Daemon & xmrig updater verify-note: drawn unwrapped and clipped at the card's right
edge; wrap it (PushTextWrapPos) within the already-reserved height.
- Request Payment: the three footer buttons shared one fixed width (clipping "Copy
Full Address"); size each to its own label. The Payment URI overflowed a plain
field; render it in a bordered read-only box (bounded, un-chunked).
- Console: the filter input shrank below its own placeholder (gone entirely at 1024);
give it a min width >= the placeholder and drop the "N lines" count when the row
can't fit both.
- Address-label "Choose Icon" grid: had NoScrollbar hiding most of the catalog with
no cue; give it a real scrollbar.
- Overview "Recent Transactions": drop the 4th row at 1024 (it clipped off-screen) by
capping to rows that fully fit the reserved height.
- Sidebar: reserve the unread-badge width in the nav-label centering so History/Chat
labels no longer collide with their badge.
Verified at 1024 and 1280 across full-node + Lite + Windows (ctest green) and an
adversarial diff review (clean). Skipped the legacy settings_window overlay footer
(dead code / removal candidate).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Wide/ultrawide (1440-3440px) responsiveness was unhealthy: no page/content-level
max-width cap existed, and every card/form/table derived width from raw
GetContentRegionAvail().x with floor-only clamps, so surfaces stretched edge-to-edge
(2000-3000px inputs, ballooning cards, giant grid cells, 2000px+ dead row-voids).
Root cause: adopt the (previously dead-code) clamp helpers.
- New Layout::kContentMaxWidth() (~1600dp, tunable via ui.toml [layout]
content-max-width; <=0 disables). Cap ##ContentArea to it and center the column in
wider windows — every tab derives from this child, so one change tames the app at
wide widths. No-op below the cap (fills as before), so 1080p/1440p are unaffected.
Per-surface upper-clamps (std::min(cap*dp, expr), floors preserved) where a single
element is still too wide even within the capped column:
- Settings: Theme/Layout/Language combos, the font-scale slider (~3000px -> 360dp),
the effect sliders, Explorer URL and RPC credential fields.
- Send / Receive: cap the compose / receive cards to a readable form width and center
them (Indent(pad+offset) so the auto-layout fields align with the hand-drawn card);
the recent-tx lists below keep the full column width.
- Chat message bubbles + composer, mining pool URL/payout inputs + stats left/right
split, contacts search, and the lite-network add-server row / server cards / status
panel (capped + centered).
- Wizard: vertically center the cards when they fit (was top-anchored, leaving a void
on tall monitors), compensating the content-height measurement so it can't oscillate.
The 1600 cap also subsumes the fixed-4-column balance grids (~400px cards) and the
right-anchored row dead-gaps (voids shrink from ~2700px to ~800px), so those are left
to the cap rather than blind column/row redesigns.
Verified at 1024/1280 (and via a temporary 900dp cap to exercise the cap+center path,
since the test display clamps to 1280) across full-node + Lite + Windows (ctest green)
and an adversarial diff review (clean). The true wide/ultrawide look and the 1600dp cap
value still want eyes on a real wide monitor.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The tail of the DPI/font-scale/responsiveness audit — ~26 remaining findings.
Container / recent-list (Theme-1 leftovers):
- Send: drop NoScrollbar|NoScrollWithMouse on ##SendFormScroll so Recent Sends is
reachable at font_scale 1.5 (parity with receive).
- Receive: cap the QR/form card via std::min(mainCardTargetH, availH - recentReserve)
so RECENT RECEIVED stays on-screen (identity at 1.0x).
- Wallets dialog: size the capped-mode list to whole rows so it no longer clips a
partial row / crowds "Create a new wallet".
Narrow-width (1024px) reflow:
- Console toolbar reserves space for ALL trailing controls (both icon toggles + zoom
buttons) so the +/- zoom no longer runs off-window.
- History sort combo sized to its measured widest localized label ("Newest first").
- Settings Theme/Layout/Language row: scale the wide→stacked breakpoint by dpiScale so
it drops to full-width stacked combos at 1.5x (Consolidated Card no longer clips).
- Recent-tx type label: derive the address column X from the measured label width so it
can't collide at narrow widths.
- Mining Recent Pool Payouts: floor the panel height to fit the empty-state caption.
Cosmetic ×dpiScale() on absolute geometry (no-ops at 1.0x): mining SOLO|POOL toggle &
idle combos, market pair-chips, password/PIN strength bars, receive/send currency
toggles, explorer search bar/rows/rounding, About-card logo, chat empty-state wrap,
recent-list address/time offsets, address-toolbar & two-row action buttons, console
line-gap/status-dot/pane rounding.
Verified at font_scale 1.5 and at 1024px across full-node + Lite + Windows (ctest
green) and an adversarial diff review (one over-reserve regression fixed).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
At font_scale 1.5 ~13 sites read absolute geometry (schema .size/.width) straight
into ImGui without ×dpiScale(), so they stayed native-size while the font grew and
overlapped/clipped real text or money:
- Shield/Merge fee + UTXO inputs, Request Payment amount, Block Info height input:
×dpiScale() so the value no longer clips (e.g. 0.00010000 -> 0.00010).
- Send: the confirm-popup Amount Details divider (floored the row step at the scaled
caption height so it no longer strikes the Fee row), the tx-progress error and
sending/success cards, and the zero-balance CTA button — all ×dp.
- Mining pool row: ellipsis-truncate the hostname so it can't collide with the
right-aligned hashrate.
- Chat conversation list: scale the pane-width clamp AND clip the peer name to the
column left of the timestamp (measure-then-clip) so name and time never overlap.
- Explorer block-detail label column, Peers row offsets, and DialogConfirmFooter
button height — ×dp.
transaction_details keeps its negative fill-sentinel widths unscaled (a content
margin, not raw px). Verified at font_scale 1.5 across full-node + Lite + Windows
(ctest green) and an adversarial diff review (three wrong-scale regressions fixed).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
At font_scale 1.5 (dpiScale 1.5) three fixed, non-scrolling containers clipped
content off the bottom with no scroll escape:
- First-run wizard: the hand-drawn cards grow ~1.5x past the fixed window,
pushing Continue / Encrypt & Continue / Skip off-screen (a setup blocker).
Inject a wheel-driven scroll offset into the layout seed + a scroll indicator;
gate the wheel on !IsPopupOpen + NoPopupHierarchy so an open combo popup does
not scroll the wizard behind it. No-op at 1.0x.
- Overlay dialogs (BeginOverlayDialog): auto-height cards taller than the
viewport (About, Request Payment) ran their footer off the bottom. Add a
sticky per-open overflow flag that clamps the card to the viewport and makes
the content child scrollable; short dialogs still center unchanged. Give the
nested settings clear-history confirm its own idSuffix so it can't inherit the
parent dialog's overflow state or collide on the child window id.
- Balance Recent Transactions: the dp-scaled address card evicted the recent-tx
list off the non-scrolling tab host. Cap the card inside RenderSharedAddressList
against the space that actually remains (minus a caller-provided reserve) so
the section below stays on-screen — covers all 10 balance layouts.
Verified at font_scale 1.5 across full-node + Lite + Windows (ctest green) and an
adversarial diff review (two low-severity regressions found + fixed).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Mining-tab UI improvements:
- Thread selector: a centered [-] N [+] stepper (top-aligned in the header) to pick an exact
thread count — number centered, -/+ step by one (clamped to [1, cores]), still typeable
(commits on Enter so it doesn't restart the miner mid-typing).
- Thread tiles now render at an adaptive step (1/2/4/8 by core count) plus 1 and the max, so a
high-core CPU (e.g. a 192-thread EPYC) shows ~25 tiles instead of one-per-thread and no longer
overflows the card. Unchanged for <=24-core machines.
- Fix: clicking the X (or a row) in an open saved-pools / payout-address dropdown no longer bleeds
through to the thread tiles / Mine button — the custom drawlist hit-tests now gate on
IsPopupOpen(AnyPopup).
- xmrig update button: shows "xmrig releases" when installed >= latest (numeric version compare),
else "Update <latest>"; the "Current: <ver>" text and the button are subtle green when up to date
and subtle orange when an update is available (neutral when either version is unknown).
New i18n keys back-filled across all 8 languages; CJK subset font rebuilt for the new glyphs.
Verified across full-node, lite, and Windows builds; the stepper layout confirmed via the UI sweep.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Fixes all 22 confirmed findings from the mining-tab audit (10 Medium, 12 Low; 0 Critical/High),
adversarially reviewed (6 follow-ups found + fixed, incl. the review-caught idle-auto-start bypass
and a wrong benchmark-restore condition).
Crash-safety & lifecycle:
- M-04: join a stale/finished monitor thread in XmrigManager::start() and ~XmrigManager so an xmrig
crash-then-restart (or quit) no longer std::terminate()s the wallet.
- L-03/L-10: surface an unexpected miner exit once and clear the stale running flag.
UI never blocks (M-03/L-06/L-08/L-09/L-13): pool start/stop now run on a dedicated serialized FIFO
mining-control thread (joined before teardown), so the ~13 call sites don't block the render thread on
stop()'s SIGTERM->SIGKILL->join; the spawn result marshals back to the UI.
Miner-process / pool trust boundary:
- M-01: validate the payout address (util::isValidRecipientAddress) at EVERY start path — the UI gate
AND App::startPoolMining() (idle auto-start / thread scaling) — so a stale/wrong-chain address can't
silently lose rewards.
- M-09: SSRF guard skips the background pool-stats GET for loopback/private/link-local/single-label hosts.
- M-02/L-02: cap the pool-stats + xmrig-API HTTP response bodies.
- L-01: write the xmrig config 0600 at creation (POSIX open with mode) — no world/group-readable window.
- M-10: reject shell-metacharacter binary paths before the version popen (excluding '()' so Program Files
(x86) still works).
Solo mining: M-06/M-08 clamp thread count to [1, cores] at the setgenerate/xmrig boundary; M-07 notify +
don't lie on stop failure.
Correctness: L-05 block-time constant 75->150s (chainparams); M-05 discloses pool-mode "Est. Daily" as a
rough solo-equivalent; L-04/L-11/L-12 benchmark lifecycle (cancel on nav-away / mode-switch with restore,
skip rebalance mid-benchmark); L-07 honor cancel mid-extract in both the xmrig and daemon updaters.
Two new i18n keys back-filled across all 8 languages; CJK subset font rebuilt for the new glyphs.
Verified across full-node, lite, and Windows builds; tests pass.
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>
A shielded send with a memo failed: "Invalid parameter, expected memo data in
hexadecimal format or to use 'utf8:' prefix." The Send-tab path (and its fee-gap
retry) put the user's plain-text memo straight into the z_sendmany recipient,
which the daemon now rejects — it wants the memo hex-encoded or with a "utf8:"
prefix. The chat path already prefixes with "utf8:"; do the same for user memos.
Only the RPC recipient["memo"] is prefixed; the raw memo is still what's stored
for the transaction-history display, and the daemon returns the decoded memoStr
to receivers, so it round-trips as plain text.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
"Stop external daemon" silently left an external dragonxd running. beginShutdown()
calls rpc_->requestAbort() (a sticky abort flag, cleared only by connect()) to
unblock in-flight requests; the shutdown thread's stopEmbeddedDaemon() then sent
the graceful "stop" over that same connection, so curl self-aborted it
(CURLE_ABORTED_BY_CALLBACK). doRPC swallowed the error but stop_sent was set true
anyway, skipping the temp-connection fallback that would have worked — so the
daemon never received "stop" and only died via the 20s by-name force-kill (which
collides with the 8s "Force Quit / may corrupt chain data" prompt, so it read as
"doesn't work").
Clear the abort before the shutdown stop and send it synchronously via
sendStopCommandSafely so real delivery success is surfaced (and the fallback can
still run on failure). The graceful stop now reaches the daemon, it exits in a
second or two, and the 20s stall / Force-Quit prompt no longer appears.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Turns the "Daemon Error + raw log dump" moment into one calm, honest recovery
dialog plus a recovery-aware rescan screen. Presentation + orchestration only —
the file-safety logic in rebuildWalletDatabase()/restoreOriginalWallet() (source
selection, verify-before-swap, copy/rename-never-delete, .bak) is unchanged.
- One authoritative dialog with a phase machine Offer -> Working -> Done/Failed.
The duplicate in-overlay recovery card, the untranslated red "Daemon Error"
heading, and the raw daemon-log dump are gone for the recovery case (they stay
for genuine, unrelated crashes).
- Offer is a choice-cards layout: "Repair automatically" (recommended, accent-
tinted) vs "Restore original", side by side; the rare actions ("Show me the
files", "Decide later") and a plain-language "What happens to my files?" sit in
a quiet footer. When the rebuild helper is missing, it collapses to a single
Restore card — never a dead end.
- Post-repair rescan shows a calm "Finishing your wallet repair" screen with
elapsed time + the growing wallet size, instead of "RPC timeout / taking longer
than expected / restart daemon"; the daemon-crash toast is suppressed and the
detection toast is downgraded from red to info.
- Fixes a confirmed dead-end: if a repair succeeds but the restarted daemon then
crashes for a *different* reason (block index, disk, OOM), the recovery flags
now clear (in tryConnect + onConnected) so it surfaces as a normal daemon
failure instead of freezing forever on a reassuring "don't restart" screen.
- Clickable "Wallet repair available" status-bar chip for re-entry.
The same app.cpp changes HiDPI-harden the surfaces the recovery flow lives on:
the status-bar and loading-overlay hand-drawn geometry are multiplied by dpiScale
(they rendered native-size and clipped at HiDPI / font_scale>1), the loading-
overlay status text wraps instead of running off both edges, and the node-status
banner floors its height to its DPI-baked font so the title can't clip off the top.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Findings from a UI-cutoff audit — each is a spot where an in-tree helper
(truncateMiddle / TruncateToWidth / measured button width / the *dpiScale/*hs
factors) was bypassed:
- notifications: the toast-pill height/padding/icon-gap were raw logical px while
the icon/text drawn inside are DPI-baked, so they clipped the pill at HiDPI.
Scale the geometry by dpiScale (not the already-scaled glyph metrics).
- settings: in the two-column NODE & SECURITY layout the data-directory path could
overrun into the Daemon-binary column (shared draw list, no clip rect between
them). Middle-ellipsize it to the column width; the full path stays in the
tooltip + click-to-open + copy.
- send: the "Confirm & Send" button width came straight from the schema and was
never measured against the label, clipping the Russian translation on the
pre-broadcast dialog. Size to max(schema width, measured label + padding).
- balance: the recent-tx address-column offset missed the `* hs` DPI factor its
sibling (amount-right-margin) uses, overlapping the type label at HiDPI.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The dragonx-wallet-rebuild helper is the only thing that repairs a genuinely
BDB-inconsistent wallet.dat — plain "Restore" just re-triggers the daemon's
salvage cascade — yet it was silently dropped from every packaged build:
- Linux zip/AppImage copied a hand-picked file list that omitted it.
- Windows bundled it only behind a soft `[[ -f ]]` guard (silent skip).
- macOS never wired Berkeley DB, never built it, never bundled it.
build.sh now HARD-REQUIRES the helper for full-node releases (fails the build if
the vendored Berkeley DB depends are missing, rather than shipping recovery-less),
and ships it in the Linux zip + AppImage, the Windows zip, and the macOS .app.
It also compiles the helper standalone for Windows and INCBINs it, and
embedded_resources gains ensureWalletRebuildHelperExtracted() to extract it on
demand — so a self-contained ObsidianDragon.exe carries recovery exactly like the
embedded daemon, even on a machine where first-run param extraction already ran.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Reported with a screenshot: on a salvage-then-abort, the status correctly read
"Wallet needs recovery — see the prompt" but no prompt appeared — the separate
BeginOverlayDialog is occluded by the full-frame loading/daemon-error overlay
that's drawn every frame while the node is down.
Render the recovery actions directly IN the daemon-error overlay when a salvage is
detected: a concise message + prominent one-click "Rebuild wallet database" /
"Restore original" / "Open data folder" buttons (same handlers as the dialog),
placed right after the title and skipping the verbose daemon-output dump so they
stay on-screen. The verbose diagnostics + crash-count hint still show for
non-recovery errors. Build clean, suite green.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
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>
Wires the wallet-rebuild recovery helper into the release pipeline so a shipped
build actually carries it (the app locates it next to dragonxd).
- CMakeLists: link the vendored STATIC Berkeley DB for the helper — add
Threads::Threads + dl (Linux) / ws2_32 (Windows) that the static libdb-6.2 needs
(the system shared lib pulled those in transitively; the static one doesn't).
- build.sh (Linux + Windows): pass BDB_INCLUDE_DIR/BDB_LIBRARY explicitly at
configure, pointing at external/dragonx/depends/<triple>/{include,lib/libdb-6.2.a}
— the same libdb the daemon links, so the helper's output is a v6.2 btree the
bundled dragonxd reads. Explicit paths bypass find_library (and the mingw
toolchain's sysroot-only find restriction). Guarded: no depends → helper simply
not built/bundled. Strip + copy the helper next to dragonxd(.exe) in both bundles.
Verified: Linux links the vendored libdb-6.2.a statically (no dynamic libdb) and
rebuilds the real broken wallet correctly; the helper cross-compiles cleanly with
mingw against the vendored Windows libdb-6.2.a to a PE32+ x64 exe. Full app +
helper build, suite green (1/1), build.sh syntax OK.
Remaining: macOS Berkeley DB (no in-tree depends artifact) + resource-embedding as
an alternative to side-by-side bundling.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
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>
A wallet.dat placed in the repo root for recovery was untracked but NOT ignored,
so a stray 'git add .' could commit private keys. *.bak already covered the
salvage backups; add wallet.dat / wallet-*.dat / wallet.dat.* explicitly.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
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>
Adds the restore action to the wallet-auto-recovery warning: undo the daemon's
salvage by swapping the untouched original (wallet.<ts>.bak) back over the
salvaged copy and clearing the stale BDB env that triggered the false recovery,
then restarting. Modeled on beginAdoptSeedWallet (stop daemon → file ops →
restart on a worker; result pumped to the main thread for notifications).
Safety (fund-adjacent file ops on a real wallet — copy/rename only, never delete
user data):
- picks the newest wallet.<unixtime>.bak via the pure, unit-tested
newestWalletSalvageBak(); aborts if none.
- verifies the .bak is a real Berkeley DB (probeWalletFile) before touching
anything — won't overwrite a working wallet with a bad backup.
- stops the daemon first (stopDaemonForWalletSwitch) so wallet.dat is released.
- moves the salvaged copy aside to wallet.dat.salvaged-<ts>.dat (kept), COPIES
the .bak into place (the .bak stays), moves database/ aside to
database.pre-restore-<ts>.bak (kept), and drops only the transient __db.*
BDB region files. Rolls back the move if the copy fails.
- relaunches the node even on failure so it's never left down.
The warning dialog now offers Restore original wallet / Open data folder /
Keep salvaged copy. Full-node only; lite-safe. Build clean, suite green (1/1).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
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>
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>
The wallets list badged the active/linked row from the runtime seed status
(activeWalletSeedBadge → wallet_seed_status_, reset only on disconnect) in
preference to the offline on-disk probe. Two issues let a genuinely legacy
wallet render as "seed phrase":
- The offline probe's budget-fallback branch dropped the fMnemonicSeed flag:
res.mnemonic was set only in the (parsed && complete) branch. The probe shares
a 768 MB budget across all wallet files, so a large wallet (e.g. a 194 MB one)
probed after the budget is spent falls into the fallback, loses its seed/legacy
classification (mnemonic → 0), and the row defers to the runtime badge.
- With mnemonic == 0, the code used the runtime badge, which can still carry a
HasMnemonic from a previously-active mnemonic wallet — mislabelling the legacy
wallet.
Fix:
- Carry the definitive positives (fMnemonicSeed/hdSeed/mkey) from a cap-truncated
btree walk — a found marker is authoritative even when the scan didn't finish.
- Make the on-disk fMnemonicSeed read take precedence: it's the SAME flag the
daemon's IsMnemonicSeed()/z_exportmnemonic consult, so a definitive read wins;
the runtime badge is used only when the probe genuinely couldn't decide, and
never overrides a definitive on-disk classification.
Verified the wallet in question is truly legacy (fMnemonicSeed=false on disk,
matching the daemon's CHDChain serialization + IsMnemonicSeed). The flag reader
(hdChainMnemonicFlag) is already unit-tested; suite green.
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 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 (6ff1fda) but the
first-run wizard gate (df14533) was never updated — same restore path, two
verdicts, crash only via the more-common first-run path.
Add shared util/seed_phrase.{h,cpp} as the single source of truth:
- normalizeSeedPhrase: fold NBSP/en/em/ideographic/narrow spaces to ASCII,
strip zero-width marks, collapse+trim (word bytes untouched)
- seedPhraseWordCount
- isCompleteRecoveryPhrase(int) == 24 (the sole SDXL contract)
Both restore gates now count via the normalizer and gate via
isCompleteRecoveryPhrase, and both submit the normalized phrase. This closes
the crash, reconciles the two gates so they can't drift again, and — because
tiny-bip39 splits on literal ASCII space with no NFKD — makes an NBSP-pasted
24-word seed (common from PDFs/note apps) restore correctly instead of being
undercounted and rejected.
Adds testSeedPhraseHelpers. Suite green (1/1).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Audit of the lite import path found no false-rejection defect (no client-side
gate; the two-command fallback in importKey makes the U/5/K/L prefix guess
non-binding; lite send reuses the now-P2SH-fixed send_tab helpers). But the
"zxview" viewing-key comment — which was WRONG in the full node (fixed earlier)
— is genuinely CORRECT here: SDXL's import takes an extended full viewing key
(zxviews…, hrp_sapling_viewing_key), whereas the full node's z_importviewingkey
takes an incoming viewing key (zivks…). The two are not interchangeable.
Add a note so nobody "harmonizes" the two gates and reintroduces the full-node
bug. Comment-only; no behavior change.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
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>
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>
The alert-history popup content sat flush against the popup's top and bottom edges. Add a
padY spacer above the header and below the content (on both the empty and populated paths).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Full-node ObsidianDragon 2.0.0 -> 2.0.1 (single source of truth: the project() VERSION in
CMakeLists.txt). Verified the generated header renders "2.0.1 (ObsidianDragon)". The Lite
variant is versioned independently (DRAGONX_LITE_VERSION, unchanged at 1.0.0).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
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>
Follow-up to the node-banner / staleness-badge / alert-history features — a 5-dimension
finder->verify review surfaced 4 real issues (the ImGui-stack-balance finder found none):
- Alert popup grew off the right edge: pivot (0,1) pinned the panel's LEFT edge at the
bell, which sits near the window's right edge, so a 320px panel overflowed rightward
(an explicit SetNextWindowPos pivot skips ImGui's on-screen clamp). Anchor the
bottom-RIGHT corner at the bell instead (pivot (1,1) at bellMax.x) so it grows left.
- Staleness badge could flash red on reconnect: WalletState::clear() reset everything
except the four last_*_update stamps, so the pre-outage timestamp survived and the
badge briefly showed "Updated Nm ago" the same frame the node banner cleared. Zero the
stamps in clear() (all readers treat 0 as "never"; app_network.cpp:1473 guards != 0).
- Banner min-height floor wasn't DPI-scaled: std::max(minH, baseH*vScale()) now uses
minH * dpiScale() so both operands are in scaled px.
- New i18n keys weren't in res/lang/: back-filled all 16 diagnostics/QoL keys into the 8
language files, additively (128 insertions, 0 deletions). zh/ja/ko reworded around 2
glyphs missing from the CJK subset and hard-asserted tofu-free against the subset font.
Build-clean both variants; ctest 1/1.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Toasts fade in 1-4s, so anything that scrolled past was gone. Notifications now retains
every pushed alert in a capped (100) ring buffer with a wall-clock epoch (AlertRecord) —
separate from the 5-item live-toast deque — plus a monotonic total_pushed_ counter.
A bell in the status-bar right cluster opens an upward popup listing recent alerts
newest-first: severity icon + colour (reusing the toast palette), the message, and a
relative age (formatTimeAgoShort), with a Clear-all action. An unread dot on the bell,
coloured by the most-severe unseen alert, marks alerts that arrived since the panel was
last opened — driven by totalPushed() deltas so it survives capping/clearing.
Thread note: every push is on the UI thread (RPC results run as main-thread MainCb
callbacks), matching this class's existing lock-free model; documented as a
no-raw-worker-thread invariant.
New i18n keys (alerts_*). Build-clean; ctest 1/1 (adds testNotificationHistory: retention,
order, cap, monotonic counter, clear). Closes the QoL bundle and the Foundation tier.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
When the wallet is connected but the balance has quietly stopped refreshing — a busy
daemon can fail z_gettotalbalance without dropping the whole connection (only *both*
core RPCs failing 3x triggers a disconnect) — the old number sits on screen while the
node-status banner stays hidden. The Total Balance card now shows a small pill on its
status line ("Updated 2m ago", amber, escalating to red past 3 min) so the stale value
isn't silently trusted; hovering explains it and points at the node connection.
No refresh-path changes: WalletState::last_balance_update is already stamped only on a
successful fetch (network_refresh_service.cpp), so the badge reads it and computes age
against the same std::time clock via util::formatTimeAgoShort. The decision is a pure,
unit-tested helper (ui/staleness_badge.h::evaluateStalenessBadge, 45s/180s thresholds)
gated on connected so it never contradicts the banner.
Closes P2 (5/5). Build-clean; ctest 1/1 (adds testStalenessBadge).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
A persistent horizontal strip now appears at the top of the content column whenever the
wallet can't reach its node — unlike the transient toasts it stays up for as long as the
fault persists, so an offline wallet is never silently mistaken for a working one.
The show/severity/action decision is a pure, unit-tested function
(ui/node_status_banner.h::evaluateNodeStatusBanner) fed a state snapshot by the new
App::renderNodeStatusBanner(). Three cases:
- full-node offline -> amber, "Reconnect" (App::tryConnect)
- embedded daemon crashed
& auto-restart gave up -> red, "Restart node" (App::restartDaemon)
- lite wallet open failed -> red, message-only
Suppressed during the wizard / wallet-switch / daemon-restart / screenshot-sweep / shutdown,
and while an expected startup phase (warmup / init / connect-in-progress) already owns the
screen. Banner height lives in res/themes/ui.toml (banners.node-status); colours come from the
material semantic palette; the detail text is ellipsis-clipped so it can't push the action
button off-screen. Drawn before the content edge-fade vertex capture so it stays fully opaque.
New i18n keys (node_banner_*). Build-clean both variants; ctest 1/1 (adds testNodeStatusBanner).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Settings (logging section) gains two support-friendly actions, now that the logging
foundation actually produces logs (W7-2):
- Open log folder: opens the config dir (Platform::openFolder) so users can find
dragonx-debug.log / dragonx-crash.log.
- Copy diagnostics: copies a plaintext support snapshot to the clipboard via the new
App::buildDiagnosticsReport() — version, build variant, platform, connection status,
active wallet path + existence + size, encryption/lock state, sync heights, and (full-
node) daemon status/running/crash-count/lastError, plus the log paths. No secrets.
Build-clean; ctest 1/1. Remaining QoL: persistent alert history, a daemon/RPC error
banner, and the W6-2 refresh-staleness badge. See docs/wallet-hardening.md.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The Foundation tier — answers the original "make it easier to diagnose problems" ask.
- W7-2 (keystone): the app-level Logger file sink was never initialized, so LOG/LOGF/
VERBOSE_LOGF went nowhere and dragonx-debug.log didn't exist on Linux/macOS at all.
main() now calls Logger::init(<config>/dragonx-debug.log) on every platform. Fixed a
latent deadlock this exposed: init() wrote its banner via write(), which re-locks the
non-recursive mutex_ it already holds — now written directly. On Windows the raw
stdout/stderr freopen moved to a separate dragonx-stdout.log so the two writers don't
contend on one file. Added testLoggerFileSink (also a deadlock guard — it would hang if
the fix regressed).
- W7-3: no crash handler existed on Linux/macOS. Added an async-signal-safe sigaction
handler (SIGSEGV/ABRT/BUS/FPE/ILL) that writes the signal id + a backtrace_symbols_fd
backtrace to dragonx-crash.log, then re-raises the default disposition for a core dump —
the POSIX counterpart of the Windows SEH filter.
- W7-4: Logger::init now rotates the log to a single .1 backup past 10 MB, so a long or
verbose session can't grow it unbounded.
Build-clean; ctest 1/1. Remaining Foundation: the QoL bundle (mostly UI). See
docs/wallet-hardening.md.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
P2 robustness batch (localized):
- W5-1 (Med, lite): persistAfterBroadcast returned false on a persistent post-send/shield
save failure, but both callers discarded it and it never logged — completely silent. It
now liteLogs the failure (the spent note re-derives on the next sync, so it's a
robustness gap, not fund loss).
- W5-2 (Med, lite): the post-sync and post-rescan save results (in the detached scan
threads) were ignored; both now liteLog on failure. LiteDiagnostics::log is mutex-guarded,
so it's safe from those threads.
- W6-1 (Med): WalletState::clear() didn't reset mining/pool_mining, so a wallet switch could
briefly show the previous wallet's hashrate/blocks. Now reset in clear() (the daemon
restarts on switch, so mining genuinely stops).
- W6-3 (Low): AddressBook::load() cleared entries_ then threw on the first non-object array
element — discarding EVERY contact. It now guards is_object() + per-entry try/catch,
skipping and counting malformed entries.
Build-clean; ctest 1/1. Remaining P2: W6-2 (refresh-staleness badge — needs UI, overlaps
the diagnostics Foundation bundle). See docs/wallet-hardening.md.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
- W1-3 (Med): updateWalletIndexForActiveWallet stamped syncedHere in the markOpened block
at bare connect (idHash still empty), letting a freshly-restored wallet skip its needed
rescan. syncedHere is now stamped only once the wallet's identity is verified (idHash
non-empty), so it takes effect at the post-address-refresh index update; lastOpenedEpoch
still records at open.
- Startup guard (the W1-1 launch counterpart): App::init now exists()-checks the recorded
active wallet before the daemon is configured. A non-default active wallet moved/deleted
between sessions falls back to the default wallet.dat with a warning, instead of the
daemon silently auto-creating an empty wallet under the missing name. Runs before the PIN
vault init so the vault is scoped to the wallet actually opened.
Completes P1-B. Remaining P1: W3-3 (sweep opid persistence) deferred for careful
adversarially-reviewed work — re-tracking a stale opid could hang the migration if the op
poller doesn't time out; the existing balance/mined gates already prevent fund loss. See
docs/wallet-hardening.md.
Build-clean; ctest 1/1.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
- W1-1 (High): switchToWallet never verified the target wallet file exists before
switching. dragonxd auto-creates a fresh empty wallet for a missing -wallet=<name>, so a
moved/deleted wallet file silently "opened" as a brand-new empty wallet with a zero
balance — looking exactly like fund loss. It now std::filesystem::exists-checks
datadir/<walletFile> before switching (ahead of the daemon-stop prompt) and blocks with a
"not found (moved or deleted?)" warning. Because the check runs regardless of how
switchToWallet is invoked, it also closes W1-4 (the stale switcher-row TOCTOU).
- W1-2 (Med): walletOutputLooksCorrupt matched the generic "Error loading wallet" string,
which dragonxd also prints for DB_TOO_NEW (a newer-version wallet) — so a version mismatch
was offered a -salvagewallet repair that cannot fix it. The generic match is now excluded
when the output also contains "newer version".
Build-clean; ctest 1/1. Remaining P1-B: W1-3 (syncedHere timing) + the startup-path
existence check. See docs/wallet-hardening.md.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Migrate-to-seed (legacy -> mnemonic wallet) moves real funds; three correctness fixes:
- W3-1 (High): beginAdoptSeedWallet swapped a hardcoded datadir/wallet.dat instead of the
ACTIVE wallet file. With a non-default active wallet (e.g. wallet-2.dat) it installed the
swept seed wallet into an unloaded wallet.dat and left the daemon reloading the emptied
legacy wallet — swept funds only recoverable via the seed phrase. Now swaps
datadir + "/" + getActiveWalletFile(), captured on the main thread (switching is blocked
during migration, so no race).
- W3-2 (High): SeedWalletCreator::create() ran remove_all(<config>/seed-migrate)
unconditionally at the start, so a prior migration that swept funds into the temp wallet
but was abandoned/crashed before adopting would have that fund-bearing wallet destroyed.
It now refuses (with a clear message) when DRAGONX/wallet.dat already exists — a completed
migration removes the dir on adopt, so a leftover means an unfinished one.
- W3-4 (Med): switchToWallet blocked switching only while the migration dialog was open;
closing it via "Later" mid-migration dropped the guard. Now also blocks while
getSeedMigrationPending().
Build-clean; ctest 1/1. Remaining P1-A: W3-3 (persist the sweep opid). See
docs/wallet-hardening.md.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
P0-B encryption-integrity cluster.
W2-2: the first-run wizard's "encrypt" stored the passphrase only in memory and let the
user into the app immediately, so a quit/crash or a failed daemon connect before the
deferred encryption applied left the wallet unencrypted with NO record encryption was
ever requested — the user believing it was encrypted. A persisted encryption_pending
settings flag is now set the moment encryption is requested (never the passphrase, only
the fact). refreshWalletEncryptionState() reconciles it on every connect: wallet observed
encrypted -> clear the flag; wallet NOT encrypted while the flag is set and no deferred
encryption is pending/in-flight -> a once-per-session "your wallet is NOT encrypted — open
Settings to finish" warning (the flag stays set, so it recurs each launch until resolved).
The passphrase is deliberately never persisted to auto-complete — surfacing it is the
secure choice.
W2-4: lockWallet()'s continuation only handled success — a failed walletlock RPC silently
left the wallet UNLOCKED (an unfulfilled auto-lock). It now logs and warns once (reset on
the next successful lock) so a failing auto-lock is visible instead of leaving the wallet
exposed.
Touches settings.{h,cpp}, app_wizard.cpp, app_security.cpp, app.h. Not unit-testable at
this layer (RPC/connect-driven state). Build-clean; ctest 1/1. See docs/wallet-hardening.md.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The lite create/open/restore requests carried a passphrase field that the UI collected
(a labeled, masked "passphrase" input) but the backend initialize* calls never used —
so a user could believe their lite wallet was passphrase-protected at creation when it
did nothing. It now has a real meaning, wired in LiteWalletController:
- create / restore -> encryptWallet(passphrase): the backend encrypts + locks + saves
the brand-new wallet.
- open -> unlockWallet(passphrase), but only when encryptionStatus() reports the existing
wallet is actually encrypted + locked (no spurious unlock on an unencrypted wallet).
encryptWallet/unlockWallet take their own copy of the passphrase and wipe it; the
request copy is still wiped as before. A post-create encrypt failure is liteLog'd (the
wallet still exists, so the create is not failed).
Six existing lite-controller tests carried an incidental "hunter2" create passphrase from
when the field was dead; removed (they exercise non-encryption flows and want an
unencrypted wallet), and added testLiteWalletControllerCreateEncryptsWithPassphrase to
prove the new behavior. Completes the wallet-hardening P0-A cluster (7/7). ctest 1/1.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The seed-phrase "Save" already wrote the file 0600 and zeroed the in-memory buffer, but
the success message was a bare "Saved to <path>" — no hint that it's a permanent
UNENCRYPTED copy of the seed at a predictable location. The message now reads
"Saved an UNENCRYPTED seed file — move it to secure offline storage and delete this
copy: <path>". English source updated; the res/lang back-fill of this changed key is
deferred to the batch i18n pass.
Also documents W5-3 (lite create-time passphrase) as a product decision rather than a
speculative change: the field is already wiped on every path (minimal security risk),
but the labeled masked "passphrase" input at lite create/open/restore is never consumed
by the backend — so either remove the dead UI or wire it into the lite encrypt flow.
Finishes the actionable part of the wallet-hardening P0-A cluster (docs/wallet-hardening.md).
Build-clean; ctest 1/1.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The wallet-hardening memzero cluster. Uses the file's established sodium_memzero
pattern (matching the existing lambda-capture scrub at app_network.cpp:2885 and the JSON
scrub at :4025) rather than a new type, since importPrivateKey/sweepPrivateKey are
fund-moving code.
- W4-1 importPrivateKey / sweepPrivateKey: the spending/viewing key was copied ≥3×
(calling frame -> worker-lambda capture -> JSON params) and never scrubbed. Now zeroed
on all paths: the calling-frame copy after the worker post, the lambda's captured copy
(lambda made mutable, zeroed once the request is sent), and the request params copy.
- W4-3 exportAllKeys / backupWallet: the concatenated all-keys buffer is now zeroed after
the consumer uses it, and the backup is written via
Platform::writeFileAtomically(..., restrictPermissions=true) — atomic and owner-only
(0600) — instead of a umask-default std::ofstream that left it world-readable.
- W2-3 decrypt-wallet passphrase: std::move-captured into the worker lambda (no plaintext
copy left in the calling frame) and sodium_memzero'd right after unlockWallet, its only
use.
Not unit-testable (no observable RPC effect — the key value sent to the daemon is
unchanged; only post-use memory zeroing is added). Build-clean; ctest 1/1. See
docs/wallet-hardening.md.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Removing wallet encryption z_exportwallet'd every private key in cleartext to
<datadir>/obsidiandecryptexport<ts>, re-imported it, and never deleted it — leaving a
full plaintext dump of every key on disk permanently. The decrypt flow now scrubs
(best-effort in-place zero-overwrite) and removes that file as soon as the
z_importwallet attempt resolves, on both the success and failure paths. Recovery, if
ever needed, remains the encrypted backup (wallet.dat.encrypted.bak), never this file.
Second fix in the wallet-hardening P0-A cluster (docs/wallet-hardening.md). Not
unit-testable (fs I/O in a deep worker lambda); build-clean, ctest 1/1.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The RPC console echoed and stored typed commands verbatim, so `walletpassphrase
<secret>`, `z_importkey <key>`, `encryptwallet <pass>` etc. left the secret in the
visible log AND the 100-entry recall history (copyable). Adds a pure, unit-testable
RedactConsoleCommand()/ConsoleCommandCarriesSecret() (allowlist of 13 secret-bearing
first-tokens) in console_tab_helpers; submitConsoleCommand() now echoes and stores
`> walletpassphrase ****` while still executing the real command unredacted. Bare
secret commands and non-secret commands pass through unchanged.
Output-secret commands (dumpprivkey / z_exportkey / z_exportmnemonic) — whose secret is
in the RESULT — are a separate redaction concern, tracked as a follow-up.
First fix in the wallet-hardening P0-A cluster (see docs/wallet-hardening.md). New
testConsoleSecretRedaction (11 assertions); ctest 1/1.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Links the real EmbeddedDaemon into the ObsidianDragonTests target (its deps were
already present) and adds two POSIX integration tests that exercise the actual
fork/exec/waitpid fixes headlessly:
- testExecFailureReported (F2): start() against a non-executable file must fail with a
precise "not executable or wrong architecture" reason.
- testDaemonCrashDetected (F1): a short-lived child that exits abnormally is still
detected (crash_count_ increments) while isRunning() is hammered from the test
thread — a regression test for the reap race.
Writing the F2 test surfaced a real bug: start()'s failure branch called
setState(State::Error, "Failed to start dragonxd process"), and setState stores the
Error message into last_error_ — clobbering the precise message startProcess() had
just set, so getLastError()/the UI only ever saw the generic string. Fixed to pass the
preserved detail to setState, so the precise reason survives and now also reaches the
state callback (crash panel / status).
ctest 1/1, green including the two new integration tests.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Introduces CHANGELOG.md (Keep a Changelog style, Unreleased section) covering this
batch, with F8's breaking change — remote plaintext RPC now refused by default —
called out front and center along with the rpctls=1 / rpcallowplaintext=1 recovery
steps. Also records the Security / Fixed / Added entries for F1-F7. Updates the
tracking doc's status to reflect the completed release-notes + i18n back-fill and the
remaining pre-release items (F1/F2 manual repros, CJK subset-font rebuild).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Additively adds translations for the 6 new keys from this batch
(sb_daemon_extract_failed, sb_daemon_files_failed, loading_stall_{title,body,hint},
sb_plaintext_remote_blocked) across res/lang/*.json. es/de/fr/pt/ru get all 6; for
zh/ja/ko a string is only added when every glyph is already in the current
NotoSansCJK-Subset.ttf, since the subset can't be rebuilt here — 6 zh/ja/ko entries
whose glyphs aren't yet subsetted are left on the English fallback rather than render
as tofu. Written sorted, indent=4, ensure_ascii=False (matching add_missing_translations.py);
purely additive (42 insertions, 0 removals). The remaining 6 need a
scripts/build_cjk_subset.py font rebuild.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
F8 (security). Two related fixes to how the wallet decides whether an RPC target is
safe to send Basic-auth credentials to:
- isLocalHost() was matching any host that merely *starts* "127." via
rfind("127.",0)==0, so "127.evil.com" (and "127.0.0.1.attacker", "127.300.0.1",
"1270.0.0.1") were misclassified as loopback and treated as local. It now uses a
strict isExactIPv4Loopback() parser: exactly four 0-255 dot-separated octets with
the first == 127. localhost / ::1 / [::1] handling is unchanged.
- A remote rpchost over plain HTTP (no rpctls=1) previously only produced a
dismissible warning and then sent rpcuser:rpcpassword in cleartext, where a
local-network MITM could capture them. tryConnect() now REFUSES that connection
(clear status line + one-time notification, no creds sent) unless the user opts in
explicitly with rpcallowplaintext=1 in DRAGONX.conf (new
ConnectionConfig::allow_plaintext_remote, parsed in parseConfFile; policy in the
new allowsPlaintextRemote()). Local/embedded daemons and rpctls=1 remotes are
unaffected.
BREAKING: a wallet configured for remote plaintext RPC will stop connecting until
rpcallowplaintext=1 (or rpctls=1) is added to DRAGONX.conf. Must be called out in the
release notes. The Settings-toggle UI is deferred (the conf-key opt-in is the recovery
path; see docs/daemon-startup-hardening.md).
Adds testIsLocalHost and testAllowsPlaintextRemote to test_phase4.cpp; one i18n key
(English) added to i18n.cpp.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
F3: the daemon connect loop retried forever with only an animated spinner when the
daemon was reachable-but-never-ready (stuck in RPC warmup / -28, or an external daemon
that never finishes init) -- no error, no guidance, no escape. It now stamps
connect_stall_since_ the moment the daemon first goes "reachable but not ready" (the
warmup branch + applyDaemonInitStatus) and clears it on connect / disconnect /
warmup-complete. A pure, unit-testable util::connectHasStalled() helper (new
util/connect_stall.h, 45s default from ui.toml [screens.loading].stall-timeout-sec)
drives a "Taking longer than expected" notice in renderLoadingOverlay(): a title, a
reassuring body with elapsed seconds, and a full-node hint to Settings > Restart Daemon
or the Console. The background retry keeps running underneath, so the notice self-clears
the instant it connects. Guarded off while the daemon is in State::Error (that case is
owned by the existing crash-count hint).
The overlay is a pure draw-list layer with no interactive widgets, so this follows the
existing crash-hint idiom (guidance text, not injected buttons); the stalled state is
computed locally in the overlay, so the only new App member is connect_stall_since_.
Adds testConnectHasStalled to test_phase4.cpp and three i18n keys to i18n.cpp (English
source of truth; the res/lang/*.json back-fill is deferred to a single
add_missing_translations.py run at the end of the batch).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Three verified daemon-startup edge-case fixes centered on the config/params
filesystem path:
- F7: new non-throwing Platform::ensureDirectory(dir, outError) with one
consistent "Cannot create <dir>: <reason>. Check permissions / free space."
message. Replaces the unchecked/throwing create_directories sites at main.cpp
(pre-init: log + Windows MessageBox + return 1), connection.cpp's
autoDetectConfig (was the *throwing* overload -- could raise an uncaught
filesystem_error through its callers; now sets the new
ConnectionConfig::dir_error), and both app.cpp daemon-dir sites (surface via
daemon_status_ + return false). The primary connect path (app_network.cpp)
checks dir_error and shows it instead of mislabelling it "waiting for config".
embedded_resources.cpp already checked its error_code, so it is left as-is.
- F6: verifySaplingParams() now hash-verifies each param against its pinned
canonical SHA-256 (source of truth: scripts/build-lite-backend-artifact.sh)
instead of only checking existence, so a truncated / corrupt-but-present param
is rejected up front rather than failing later on a shielded operation. A
<params_dir>/.sapling_verified marker keyed on size:mtime avoids re-hashing
~48MB on every startup. Logic extracted to the injectable, unit-testable
verifySaplingParamsIn(dir, digests); reuses util::sha256Hex (no new hash impl).
- F5: startEmbeddedDaemon() now checks extractEmbeddedResources()'s return and
the previously-dropped copy_file error_code in the daemon-binary fallback loop,
aborting with a clear status (sb_daemon_extract_failed / sb_daemon_files_failed)
instead of failing opaquely at spawn. An absent source file stays non-fatal.
Adds testPlatformEnsureDirectory and testVerifySaplingParams to test_phase4.cpp.
i18n keys added to i18n.cpp (English source of truth); the res/lang/*.json
back-fill via add_missing_translations.py is deferred to a single run at the end
of the batch. Progress tracked in docs/daemon-startup-hardening.md.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Three verified daemon-startup edge-case fixes in the embedded-daemon process
lifecycle (all in embedded_daemon.{cpp,h}):
- F1: EmbeddedDaemon::isRunning() (POSIX) now reads the atomic state_ instead of
calling waitpid(WNOHANG) from the UI thread, which raced monitorProcess()'s own
reap. waitpid is one-shot: whichever thread won consumed the exit status; if
isRunning() won, the monitor never saw the crash, so crash_count_/State::Error
and the 3-strike restart cap were silently lost. monitorProcess() is now the sole
reaper (predicate Running || Stopping keeps stop()'s wait loops correct). Mirrors
the existing XmrigManager::isRunning() fix.
- F2: startProcess() (POSIX) adds a close-on-exec self-pipe exec handshake. On a
non-executable / wrong-arch / corrupt binary, execv fails in the child and the
parent now learns synchronously (reads errno vs EOF), reaps the zombie, sets a
precise last_error_ ("not executable or wrong architecture"), and returns false
-- instead of reporting State::Running for a daemon that never started. Uses
pipe()+FD_CLOEXEC (not pipe2) so the branch stays shared with macOS. Parent-side
setpgid is now best-effort + logged.
- F4: start() gates on a lingering datadir lock after the port check. A graceful
shutdown releases the RPC port ~90s before the datadir .lock, so a rapid
stop->start spawned a daemon that died on the lock and, three times in ~12s,
tripped the 3-strike crash cap before the lock cleared. start() now polls
isDaemonProcessRunning() with a bounded ~300ms wait and bails with a distinct
non-crash Error (no crash_count_ bump) that the connect loop retries once the
lock clears. Isolated migrate-to-seed starts (skip_port_check_ / -datadir
override) are exempt.
Adds the testDatadirLockGate unit test (pure evaluateDatadirLockGate matrix) to
test_phase4.cpp. Plan and progress tracked in docs/daemon-startup-hardening.md.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Several related mining-tab pool improvements:
- Report the default pool fee correctly: pool.dragonx.is is 1%, not 0%.
The registry constant was hardcoded to 0. It now also fetches the live
poolFee from the pool's /api/stats alongside hashrate (no extra
request), so the displayed fee self-corrects and falls back to the
compile-time value only when the fetch hasn't landed.
- Show fractional fees: new FormatFeePercent trims trailing zeros so
whole fees read "1%" and fractional ones keep their decimals ("1.5%").
- Surface saved + custom pools in the pool list card: the list is now
the union of the official pools, the user's saved favorites, and the
currently-mined pool (effectivePools), each a selectable, endpoint-
deduped row. Previously the card only showed the hardcoded knownPools().
- Fix the xmrig "user" field: the "Payout Address" field now drives the
pool login rewards are credited to (resolveMiningUserAddress), instead
of being written only to "pass" while "user" was auto-derived from the
wallet's own first z-address -- which silently ignored a configured
payout address and could route rewards to the wrong address.
Unit tests cover parsePoolFee, FormatFeePercent, effectivePools, and
resolveMiningUserAddress; full app + ObsidianDragonTests build clean.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Release notes for ObsidianDragon v2.0.0 (full-node) and ObsidianDragonLite
v1.0.0, with verified SHA-256s for the Linux/Windows artifacts and the
osxcross-built macOS artifacts. Both tags point at this commit's parent
(fffee9f); tables cover AppImage/zip/exe/dmg/app.zip per variant.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
build.sh runs under `set -e`, and its final line was
`[[ -d release/mac ]] && echo …`. On a build that did not produce a mac
artifact (e.g. --win-release), the `[[ -d ]]` test is false; set -e exempts the
left side of an && so the script does not abort, but that non-zero status
becomes the script's exit code — making a fully successful build report failure
(and breaking CI status). Add an explicit `exit 0` at the end; real failures
already exit 1 at their point of failure.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The lite-backend "signature verification" was attestation theater:
build-lite-backend-artifact.sh recorded a self-attested
signature_verification.verification_status="verified" with no cryptographic
check (the "verified" SHA was simply the artifact's own SHA), and CMake's
DRAGONX_LITE_BACKEND_REQUIRE_SIGNATURE gate trusted it — so a hand-edited
manifest could link an arbitrary prebuilt backend into ObsidianDragonLite.
Per the chosen remediation (build-from-source is the trust root):
- build-lite-backend-artifact.sh: reject --artifact/--no-build (prebuilt) and
all --signature-* flags; the backend is always built from the vendored
in-tree source (third_party/silentdragonxlite). Record built_from_source:true
in the manifest provenance. Usage/header updated.
- CMakeLists.txt: remove the DRAGONX_LITE_BACKEND_REQUIRE_SIGNATURE option and
its manifest signature-verification gate (kept the real required-symbol
inventory check); update the config summary line.
Full-node CMake reconfigure + build pass; script bash -n clean and the removed
flags now hard-error. (The lite-backend link path needs cargo + backend and was
not runtime-tested in this environment.)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Release-pipeline supply-chain hardening from the audit (F15-3/4, F15-6):
- appimagetool: stop fetching the moving "continuous" AppImageKit tag and
running it unverified on the release builder. Pin appimagetool 1.9.0 and
verify its SHA-256 (46fdd785…83d1) before exec, refusing to package on
mismatch; a system-installed appimagetool (maintainer's own) is still used
as-is. Applied in both build.sh and scripts/create-appimage.sh.
- setup.sh --sapling: the z.cash param download is plain HTTPS with no
signature; verify both params against their fixed canonical SHA-256 (the
same values already pinned in build-lite-backend-artifact.sh) and delete
the file on mismatch.
All three scripts pass bash -n.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
- export: neutralize CSV/spreadsheet formula injection in transaction export —
escapeCSV() now prefixes fields beginning with = + - @ / tab / CR with a
single quote so a hostile memo/address cannot execute on open (F13-1).
- console: preserve explicitly-quoted empty arguments in ParseConsoleCommandArgs
so a "" no longer collapses and shifts later positional RPC params; adds a
regression assertion to testConsoleInputModel (F14-1).
- send: copy pending payment-URI address/memo with snprintf("%s") instead of
strncpy(…, sizeof-1), which does not NUL-terminate on truncation (F8-2).
All build clean (full-node + tests) and the suite passes.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Static audit of the wallet surfaced supply-chain, TLS, and secret-lifecycle
issues. This fixes the six High-priority items plus four high-value Medium
items; all build clean (full-node + tests) and the suite passes.
Supply chain / integrity:
- bootstrap: reject zip-slip / path-traversal archive members before writing
(isSafeArchivePath); the sibling updaters flatten via baseName(), bootstrap
keeps sub-paths so it validates instead (F2-1).
- bootstrap: require a SHA-256 checksum — fail closed instead of extracting
unverified chain data when none is published (F2-2).
- xmrig updater: fail closed when a signature is required but no key is pinned,
matching the daemon updater (was: silent downgrade to checksum-only) (F1-1).
- xmrig/daemon updaters: cap metadata responses (MAXFILESIZE + 16 MiB hard cap
in the write callback, which also catches chunked/no-Content-Length) (F1-2).
Network / TLS:
- rpc_client: set SSL_VERIFYPEER/VERIFYHOST explicitly instead of relying on
libcurl defaults (F4-1).
- rpc_client: bound response accumulation at 256 MiB to prevent OOM from a
compromised daemon (F4-3).
- lite_connection_service: reject remote http:// lite servers (loopback still
allowed) to prevent a silent TLS downgrade of lightwalletd traffic (F8-1).
Secret lifecycle / fund safety:
- lite controller: propagate the save() result from encrypt/decryptWallet so a
failed persist is not reported as success (F7-1).
- xmrig_manager: chmod(0600) the pool config before writing the API token /
wallet address, closing the world-readable window (F5-2).
- app: clear a copied secret from the OS clipboard on shutdown so it does not
outlive the process when the 45s auto-clear never fires (F3b-1).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-22 01:16:24 -05:00
290 changed files with 65196 additions and 9475 deletions
message(WARNING"xxd not found; runtime language JSON files will be copied, but embedded build/generated/embedded/lang_*.h files will not be regenerated")
if(DRAGONX_EMBED_LANG)
find_program(XXD_EXECUTABLENAMESxxd)
endif()
if(DRAGONX_EMBED_LANGANDNOTXXD_EXECUTABLE)
message(FATAL_ERROR"DRAGONX_EMBED_LANG is ON but xxd was not found; it generates build/generated/embedded/lang_*.h. Install xxd or configure with -DDRAGONX_EMBED_LANG=OFF")
- Daemon: `dragonx` @ `e9247f5c6` (`perf/history-rpc-copies`), paths prefixed `dx:`. `test/v1.3.1-candidate` @ `ff28e34c8` is the same plus a crash fix; on that branch, `dx:wallet.cpp` lines past ~1263 shift by +10.
Anything not marked *(inference)* or *(estimate)* was verified in the code or measured.
---
## 1. Problem
**The wallet measured:** an isolated copy of the owner's mainnet wallet, run in a network namespace with no peers.
- 16,206 Sapling notes, all unspent.
- 5 z-addresses and 11,889 wallet txs.
- Unencrypted; `wallet.dat` is about 2.1 GB.
| Call | v1.3.0 | After daemon PR-A+B+C |
|---|---|---|
| Balance RPCs | ~14 s | ~50 ms |
| Windowed `z_listsentbyaddress` (filter type 2) | 19.5 s | 128 ms |
| **Full `z_listsentbyaddress` (no watermark)** | **424 s**; RSS 8 → 19.4 GB, never released | **113 s** |
- **The watermark lives only in memory.** `history_sent_scan_height_` starts at `-1` (`src/app.h:1764`).
- **Every disconnect resets it to `-1`.** That covers daemon restarts, reconnects and wallet switches. The path is `onDisconnected` → `resetTransactionHistoryCacheSession` → `invalidateShieldedHistoryScanProgress(false)` (`src/app_network.cpp:936`, `:1847`, `:1889-1895`).
- **Other resets:**
- a locked-wallet connect;
- imports (`:4013`);
- rescans (`:7279`, `:7302`; `src/app.cpp:6298`) and zap (`app.cpp:6352`);
- a new, never-scanned z-address (`app_network.cpp:2593-2597`);
- the end of a warmup during which a rescan was observed (`:2186-2195`).
- **No watermark means a full sweep.** `sentSweepIsFull` is then true (`src/services/history_scan_policy.cpp:35-38`). The GUI sends `[z,0]` via `callLong(..., kFullSentSweepTimeoutSec = 120)` (`src/services/network_refresh_service.cpp:1324-1325`, `history_scan_policy.h:31`).
- **The daemon ignores the address argument.** It walks every wallet tx under `LOCK2(cs_main, cs_wallet)` and calls `zsWalletTxJSON(wtx, ret, "*", false, 0, …)` for each one (`dx:src/wallet/rpcwallet.cpp:3719-3746`; the call is at `:3743`).
- **The full sweep barely fits, or never fits, the timeout.**
- On the fixed daemon the sweep finishes only **7 s inside** the 120 s timeout.
- On v1.3.0 it never finishes. Failures back off 10 min → 1 h → 6 h (`fullSentSweepRetryDelaySec`). That backoff is also kept only in memory (`app.h:1765-1766`).
- It reads only `txid`, `confirmations`, `blocktime`/`time` and `sends.transparentSends`.
- It never reads `received` (20.8 MB of the 26.2 MB) or `spends`.
- **Shielded sends come from enrichment.** Every txid goes into `knownTxids` for `z_viewtransaction` enrichment, capped at 25 per cycle (`app.h:1753`). The rest wait in the in-memory `history_enrich_backlog_`.
- **One exception:** `locateSweepTxid` (seed-migration resume) does read `sends.saplingSends` (`app_network.cpp:6394`).
---
## 2. Part 1: GUI fix (persist the watermark)
**Goal:** a relaunch, reconnect or daemon restart runs only the windowed sweep, `[z,0,2,(tip-W)+margin]`. A full sweep then happens only in three cases:
- there is no persisted state;
- the persisted state fails verification;
- the periodic safety-net re-sweep is due.
### 2.1 Where to store it
**Use the existing encrypted history snapshot.** Store an optional `sent_sweep` object in its JSON payload, not in `settings.json` or `wallets.json`. Why:
- The watermark is only valid together with the rows it produced. Both are written in one `INSERT OR REPLACE` in WAL mode (`src/data/transaction_history_cache.cpp:438-457`), so a crash leaves the previous pair intact.
- The snapshot already provides what the watermark needs:
- per-wallet keying (BLAKE2b of the sorted z+t address set, `:157-186`);
- encryption (Argon2id from the passphrase, or salted BLAKE2b when the wallet is unencrypted);
- **Keep `kSchemaVersion = 1`.** `load()` rejects any other version (`:351`), so a bump would throw away every user's cache.
- Unknown keys are ignored, so older builds read new payloads safely.
- A missing `sent_sweep` means `W = -1`, which is today's behaviour.
| Field | Type | Meaning |
|---|---|---|
| `height` | int | `W`: the tip used for the sweep's `maxDepth`. Read in the same worker job, right before the call |
| `anchor_height` | int | `A = W - margin`: the bottom edge of the window the sweep covered |
| `anchor_hash` | hex | `H_A = getblockhash(A)`, fetched by the worker after the sweep succeeds |
| `backlog` | [txid] | `history_enrich_backlog_`. **Required**, see 2.4 |
| `full_failures`, `full_retry_delay_s` | int | Full-sweep backoff. The delay is relative to save time, not absolute |
| `daemon_id` | string | Daemon version + start time, from `getinfo` / uptime. If it changes, the restored backoff resets (2.5) |
| `last_full_height`, `last_full_time` | int | Last successful full sweep; drives the safety net (2.6) |
| `txcount`, `k` | int | Optional tripwire (2.3). Both captured **in the sweep's worker job** |
Do **not** reuse the snapshot's `tip_height`/`tip_hash` columns. They record the tip at store time, not at sweep time (`app_network.cpp:2069-2070`).
### 2.2 Code changes
| File | Change |
|---|---|
| `src/data/transaction_history_cache.h:20-28`, `:33-48`, `:79-84` | Add a `SentSweepState` struct to `LoadResult`, `SnapshotJob` and `replace()` |
| `src/data/transaction_history_cache.cpp:412-426` (`writeSnapshot`) | Emit `sent_sweep` only if `0 < A <= W <= job.tipHeight` |
| `src/data/transaction_history_cache.cpp:351-370` (`load`) | Parse with defaults (`W=-1`, empty backlog). Drop the object if it has the wrong type or `W` is above the stored `tip_height` |
| `src/data/transaction_history_store_policy.h:35-41` / `.cpp:62-113` | Add `W`/`A`/`H_A` to the `slow` signature. They move every block, so this means a write every 600 s and always on disconnect, lock and exit. Add the backlog to `content` (written within about 20 s). Otherwise an invalidation that only changes the watermark is skipped as "unchanged". Update both callers (`app_network.cpp:2012-2014`, `:2041-2045`). The epoch counter is never persisted, so it doesn't belong in a signature |
| `src/app_network.cpp:2066-2079` (`storeTransactionHistoryCacheIfAvailable`) | Fill `job->sentSweep` from the `history_sent_*` and backlog members |
| `src/app_network.cpp:1998-2017` (`loadTransactionHistoryCacheIfAvailable`) | Restore the members. `++history_sent_epoch_`. Set `history_sent_verify_pending_ = true`. Apply any **pending rescan action** (2.3) |
| `src/app_network.cpp:2028` | The store returns early when there are no rows. On a `persistCache=true` invalidation with no rows, call `clearWallet()` so a stale W can't survive on disk |
| `src/services/network_refresh_service.h:256-266`, `:299` | New snapshot/result fields: `anchorHeight`/`anchorHash` in, the verify result and `H_A` out, and the band cursor |
| `src/services/network_refresh_service.cpp:1314-1345` | Read the tip immediately before the call. After success, fetch `getblockhash(A)`. Add the verify step (2.3) and the large-window path (2.5) |
| `src/app_network.cpp:2784-2792` | Gate both the end-of-pass sweep and the **mid-pass** windowed sweep (`allowMidPassSentSweep`) on the pending verify. Extend the retry gate at `:2792`, which today applies only when `W < 0`, to large windows |
| `src/app_network.cpp:2843-2854` | Count large-window failures toward the backoff; today only `sentSweepFull` failures count (`:2849`). Store the band cursor in `App` and reset it with `history_sent_epoch_`, like the watermark |
| `src/app.cpp:restoreOriginalWallet` | Call `invalidateShieldedHistoryScanProgress(true)` before `onDisconnected`, as `rescanBlockchain` and zap already do (`app.cpp:6298`, `:6352`) |
**Verify at the bottom edge of the window, not at its tip.**
- **The check:** before the first windowed sweep after a load, call `getblockhash(A)` and compare it with `H_A`.
- **Match:** any fork point lies above `A`, so it is inside the window. Run the windowed sweep.
- **Mismatch:** a reorg went deeper than the margin, or this is a different chain or datadir. Drop W and run the banded full sweep (2.5).
- **Why not the tip:** checking `getblockhash(W)` would trigger a full sweep on an ordinary one-block reorg of the tip, even though the window already covers that reorg.
| Event | Action | Mechanism |
|---|---|---|
| First windowed sweep after a load | Anchor verify (above) | `getblockhash`: O(1), under `cs_main` |
| Stored `tip_height` > current tip at load (replay after an unclean stop, older datadir) | **Today:**`load()` deletes the whole snapshot (`cache.cpp:338-344`, `clearWalletByHash` at `:341`). Rows and W are lost, and a full sweep runs | Keep as the default. **Optional improvement:** have `load()` keep the rows and `sent_sweep`, flag "ahead", and defer the sent sweep until `tip >= A`, then verify `H_A`. That requires changes in `sentSweepIsFull` (`history_scan_policy.cpp:37`: `W > tip` means full today), the gate at `app_network.cpp:2789-2792`, and the test at `tests/test_phase4.cpp:15831` |
| Wallet switch, or an address added or imported while the GUI was closed | Nothing new | Different address set → different snapshot key → full sweep |
| GUI-initiated rescan, zap, import, runtime rescan | Already `invalidate(true)`; with 2.2 it now reaches disk | `app.cpp:6298`, `:6352`; `app_network.cpp:4013`, `:7279`, `:7302` |
| New never-scanned z-address during a session | Already resets W (`:2593-2597`); the reset must be persisted | Signature change in 2.2 |
| Disconnect, daemon restart, same-wallet reconnect | Keep today's in-memory reset | The disk copy is written first (`:857`) and reloaded after connect |
| **Rescan observed in the daemon log** (the daemon's catch-up "Rescanning last N blocks (from block H)", or an external `-rescan`) | **Sticky pending action** (see below) | The warmup-end invalidation at `:2186-2195` runs *before* the snapshot loads, so today it can't reach a persisted W |
| Wallet changed outside the GUI with the same address set (Console `z_importviewingkey`, external `-rescan`, same-seed restore) | Optional tripwire: after the first windowed sweep returns R rows, run the banded full sweep if `txcount − txcount_saved > \|R\| − k + slack`. `k` is the rows of the saved sweep within the same `100 + E` depth band the first window uses | `getwalletinfo.txcount` = `mapWallet.size()` (`dx:rpcwallet.cpp:2791`). The GUI already fetches it on every connect (`network_refresh_service.cpp:251-252`, `:295-298`; `app_network.cpp:2140-2143`). Measured cost: ~8-20 ms warm, ~180 ms on the first call after a start |
| Daemon version change | Keep W. Reset the persisted backoff | Same filter semantics since 2020; v1.3.0 and the candidate both report 1.3.0.50 |
**The sticky rescan signal.** Why it's needed:
- During warmup no address list is fetched (`scan_policy.h:42-44`, `:64-68`; `app_network.cpp:2492`), and `state_.clear()` has emptied the rows (`wallet_state.h:406-409`).
- So when `invalidate(true)` runs at `app_network.cpp:2195`, it finds an empty identity and returns (`:2028`, `:2032`).
- The cache then loads afterwards (`:2608`) and would restore the old W unchanged.
The fix:
1. Whenever `rescan_confirmed_active_` is set (`app.cpp:1193`, `:1324`, `:1397`), record a pending action that survives `resetTransactionHistoryCacheSession`:
- **clamp** to `H`, parsed from "Rescanning last N blocks (from block H)" or "Rescanning from height H";
- **full reset** if `H` is unknown or near genesis.
2.`loadTransactionHistoryCacheIfAvailable` applies it to the loaded W (`W = min(W, H)`, and `A` follows) and to the receive-scan heights, then clears it.
With daemon item D4 (wallet locator on clean shutdown, 3.2) shipped, the daemon's catch-up rescan line disappears from normal starts.
**Reorg overlap.**
- **Window extent.** Filter type 2 drops only txs deeper than N (`dx:rpcwallet.cpp:3740`), and `maxDepth = tip − W + margin`. So the window covers heights ≥ `W − margin + 1`, plus the mempool (depth 0 always passes).
- **Margin.**
-`MAX_REORG_LENGTH` defaults to 99 (`dx:src/main.h:79`), and a longer reorg shuts the node down (`dx:main.cpp:4314-4333`).
- A margin of 100 therefore leaves 1 block of slack, which tip drift between reading the tip and making the call can use up.
- Use **margin ≈ 120** in steady state *(recommendation)*, and read the tip in the same worker job as the call.
- **First sweep after a load.** Use margin `120 + E` with E ≈ 1000 blocks (~10 h).
- This absorbs wallet-notifier lag at save time: `ThreadNotifyWallets` applies blocks asynchronously (`dx:src/validationinterface.cpp:89-262`), and no RPC exposes the wallet's processed tip.
- E is an inferred bound, not a measurement.
- **Large `-maxreorg`.** A `-maxreorg` set above the margin is caught by the anchor check.
- **Conflicted txs.** Conflicted txs (depth −1) are dropped by both the full and the windowed sweep (`dx:rpcwallet.cpp:3725`), so nothing changes there.
**Locked-wallet rule.**
- **The rule:** never advance or persist W from a sweep dispatched or completed while the wallet is locked, and never load or store the snapshot while it is locked.
- **What already protects this today:**
- History scans don't run while locked (`src/services/scan_policy.h:48-51`).
- An encrypted wallet's snapshot opens only after unlock (`app_network.cpp:797-798`, `:1944-1962`).
- Locking keeps W and stores immediately (`:1851-1859`).
- **What breaks if it's ever violated:** a locked daemon wallet returns empty `saplingReceived` and `saplingSends`, because `GetSaplingHistoryKeys` needs spending keys (`dx:rpchushwallet.cpp:19-39`).
- The fields the main consumer reads (txid, `transparentSends`) don't depend on the lock.
-`locateSweepTxid` reads `saplingSends`, so it must never run on a locked wallet.
### 2.4 Backlog rule (required)
- **The risk.** Enrichment is capped at 25 per cycle, and the txids that miss the cap go to `history_enrich_backlog_` (`app_network.cpp:2843`).
- A pure z→z send leaves only a change note, which produces no receive row (`network_refresh_service.cpp:972`).
- So its txid exists **only** in a sweep result.
- If W is persisted while those txids sit in the backlog, the sends are lost for good once the window moves past them.
- **The rule:** persist the backlog together with W.
- **Rejected fallback:** "persist `W = -1` while the backlog is non-empty" isn't enough on its own. Enrichment retries are unbounded (`network_refresh_service.cpp:1521`), so one txid that keeps failing would stop W from ever being persisted. If that fallback is wanted as well, cap the retries per txid.
### 2.5 Timeouts, large windows, banded full sweep
**Large windows.**
- The windowed sweep uses `rpc.call`, which has curl's 30 s timeout (`src/rpc/rpc_client.cpp:223`). Only full-sweep failures count toward the backoff (`app_network.cpp:2846-2853`).
- So a watermark saved days ago produces a huge "windowed" call that times out on every block, while the daemon keeps working on each abandoned call.
- Rule: if `tip − W > 2400` (about 1 day of 36 s blocks), use `callLong` and count failures the same way as for a full sweep.
**Banded full sweep.**
- **The call:** replace the single 113 s (fixed daemon) or 424 s (v1.3.0) call with depth bands, `z_listsentbyaddress(z, minconf=a, 2, maxDepth=b)`. `minconf` (`dx:rpcwallet.cpp:3732`) and filter type 2 (`:3740`) are independent filters, so each call returns exactly the depth band `[a, b]`.
- **Order:**
- Go newest band first; the first band uses `minconf=0` so it includes the mempool.
- Overlap bands by at least 10 blocks, plus the blocks that arrive between calls, and dedupe by txid.
- Size bands adaptively, aiming for about 5 s each.
- **Scheduling:** run **one band per history cycle**, using the epoch-guarded band cursor from 2.2. All bands in one job would tie up the shared RPC worker for about 113 s, exactly as today.
- **Watermark:** take `W` from the tip at the first band, and set it only after every band has succeeded.
- **Don't page with `nCount`.** The iteration order is `nOrderPos`, not height (`:3709`, `:3746`).
- **Limits:**
- Banding shortens each `LOCK2` hold but not the total CPU.
- On v1.3.0 every call has a ~19.5 s floor, because every `CWalletTx` is deep-copied *(inference from the windowed measurement)*. There the bands need `callLong`.
**Backoff persistence.**
- Store the failure count and the relative delay (2.1), so a relaunch doesn't immediately re-run a sweep that keeps failing.
- Reset both when `daemon_id` changes. Otherwise a user who installs the fixed daemon could wait up to 6 h for a full sweep that would now succeed.
### 2.6 Safety net
- **When:** about every 7 days, run one banded full re-sweep.
- **Conditions:** only when synced, unlocked, idle (not on the Mining tab) and admitted by the governor.
- **On success**, update `last_full_*`. On failure, back off.
- **Why:** it catches what the guards can't see, such as a Console `z_importviewingkey`. That case is also missed within a session today.
### 2.7 Edge cases
| Case | Handling |
|---|---|
| Stored tip < current tip: `load()` doesn't check the hash (`cache.cpp:338-344`) | The anchor verify covers the watermark. Optionally extend `load()` too |
| Transparent send rows from `appendSentTransparentOutputs` aren't reproduced by windowed sweeps | Probably dropped on the next clean cycle (`network_refresh_service.cpp:1356-1368`, `:1528-1539`) *(inference)*. Negligible on DragonX, where consensus bans non-notary/burn t-outputs (`dx:main.cpp:1606`, `:1633`, `:1657`). Optional fix: carry over `send` rows that `listtransactions` didn't produce |
| `locateSweepTxid` runs a full sweep under the 30 s timeout (`app_network.cpp:6388`) | That times out on this wallet. Switch it to a windowed or banded call |
| Watch-only addresses aren't part of the snapshot key | Covered by the tripwire and the safety net |
| Switching between two wallets | `pruneOtherWallets` keeps one snapshot, so a switch still costs a full sweep (open question 4) |
| GUI crash mid-sweep | W advances only on `sentSweepOk`, and the write is atomic |
| Regtest or testnet | Different z-address prefixes, so a different snapshot key |
### 2.8 Test plan
**Unit tests** (`tests/test_phase4.cpp`):
- In `testTransactionHistoryCache` (`:9057`):
- every `sent_sweep` field round-trips;
- a missing key reads as −1;
- a wrong type, `W > tip_height` or `A > W` reads as −1;
- unknown keys are ignored.
- Signatures (~`:9244`): a change to W, A or `H_A` changes `slow`; a change to the backlog changes `content`.
- Policy (~`:15829`, `:16098-16213`):
- the anchor is `W − margin`;
- the first window after a load uses `120 + E`;
-`tip − W > 2400` uses `callLong` and counts failures;
- band planning: boundaries, overlap, dedupe, and cursor reset on an epoch change;
- an anchor mismatch means a full sweep;
- the pending rescan action clamps W;
-`daemon_id` changes reset the backoff.
**Integration tests** (regtest or `-testnode`):
1. Full sweep, quit, relaunch: the log shows only `[z,0,2,N]` calls.
2.`invalidateblock` one block above A while the GUI is closed: the windowed sweep runs and returns the right rows.
3.`invalidateblock` below A: the verify fails and a banded full sweep runs.
4. More than 25 z→z sends, then quit before enrichment finishes: after relaunch every send appears.
5. A daemon started with `-rescan` while the GUI was closed: the sticky action clamps or resets W.
6.`restoreOriginalWallet`: W is invalidated.
7. Locked encrypted wallet: W doesn't advance and the snapshot isn't loaded.
**Mainnet copy:** relaunch against the isolated owner copy. Expect no `[z,0]` call and a sent sweep well under 1 s.
### 2.9 Expected effect
| Scenario | Today | With Part 1 |
|---|---|---|
| Relaunch or reconnect, fixed daemon | 113 s full sweep under `LOCK2`, 7 s from the timeout | `getblockhash` plus one windowed sweep over `120 + E` + the closure gap. Steady-state windows measure ~128 ms; the first window after a load is larger *(unmeasured)* |
| Relaunch, v1.3.0 daemon | 424 s; never fits in 120 s, so it loops in backoff with RSS at 19.4 GB | ~19.5 s+ windowed once a W exists. Seeding the first W needs the banded sweep |
| Safety net | — | One banded full sweep a week, each `LOCK2` hold about 5 s |
---
## 3. Part 2: daemon follow-up
### Where the 113 s goes (owner wallet, full sweep)
| Component | Cost | Basis |
|---|---|---|
| Trial decryption of outputs that aren't wallet notes: ~172,800 outputs × 5 ivks ≈ 864k failed tries × ~0.13 ms | **~112 s (~99%)** | Output count derived from tx sizes; per-try cost fitted to the full (112.7 s) and windowed (126 ms / 740 tries) measurements *(estimate)*. Code: `OutputDecryptions::Get` (`dx:rpchushwallet.cpp:87-100`), loop at `:303-346` |
| `DecryptSent` esk-path decrypts | **0 on this wallet** | No tx spends a wallet note, so the sends helper is gated off (`:586`) |
| UniValue build + copy under `LOCK2` | ~0.28 s | Synthetic benchmark on the repo's UniValue |
| Reply serialization (after the lock is released) | ~0.31-0.34 s | Same benchmark |
### Where the foreign outputs come from
**Sietch padding.**`z_sendmany` pads its z-recipients up to `-sietch-min-zouts` (default 7, clamped to 3-50; `dx:rpcwallet.cpp:5424-5434`), with change on top.
- Recipient and dummy outputs take the sender's ovk: the from-address's `fvk.ovk`, or the seed ovk for t-address sources (`dx:asyncrpcoperation_sendmany.cpp:352-368`, applied at `:437`).
- Consolidation (`saplingconsolidation.cpp:244`), sweep (`sweep.cpp:299`), shieldcoinbase (`:239-254`) and autoshield (`:435`) also add dummies with the sender's ovk.
- So a **sending** wallet's `DecryptSent` opens every one of its own dummies. That is the ~0.36 ms esk-path decrypt each, which is D2.
- Every other wallet sees them as foreign outputs and tries them under every ivk, which is D1.
**On this wallet** most foreign outputs are **not** sietch dummies. About 158.6k of the ~174k sit in the 9,597 txs with 10 or more outputs, which are mostly multi-recipient payouts *(inference from tx sizes)*. That doesn't change D1.
| D1 | Per-output negative ivk cache, plus parallel pre-fill of the txs a call selects | Byte-identical | Warm 113 s → 0.4-1 s. First full sweep after a restart ~20-25 s (pre-fill at the measured 4.75× warm-up scaling). A windowed call stays ~100 ms *(estimate)* | Low-med | None |
| D2 | `DecryptSent` cache per (output, ovk) | Byte-identical | 0 here; ~0.36 ms per send output on sending wallets | Low | None |
| D3 | `getalldata` balance loop | Byte-identical | ~20-35 s → 10-30 ms *(estimate)* | Low | None (the GUI never calls it) |
| D4 | Write the wallet locator on clean shutdown | n/a | Start: −49-62 s (post-rescan locator write, *inferred from the log gap*) and −5-10 s of block rescan; the ~25 s witness-root pass moves to the first live block. Shutdown: +3-60 s *(inferred)* | Medium | Check the stop policy |
- A `mutable std::vector<uint64_t>` on `CWalletTx`, with one mask per `vShieldedOutput` index.
- It is sized once, never serialized, and cleared in `Init()`.
- Precedents: `cachedPlaintext` (`dx:src/wallet/wallet.h:340-348`) and the mutable caches at `:472-490`.
- Bit k means "history key k does not decrypt this output".
- An append-only ivk → index registry on `CWallet`, under `cs_wallet`. It is resolved once per RPC into `SaplingHistoryKeys::SpendableAddress` (`dx:rpchushwallet.cpp:19-39`).
**Logic in `OutputDecryptions::Get`**
- If the bit is set, return null without decrypting.
- After a failed `Decrypt`, set the bit.
- Never cache a success: wallet notes already carry `cachedPlaintext`.
- Keys with index ≥ 64 aren't cached.
**Invariant: the result is a pure function of (txid, n, ivk).** A v4 txid commits to every output byte, so:
- nothing ever goes stale;
- a newly added key has a clear bit and is still tried;
- locking, or losing keys, only means fewer keys are consulted;
- reorgs, deletions and reloads are harmless;
- output stays byte-identical.
**Thread safety**
- The mask is mutable behind a `const CWalletTx&`, so the compiler won't enforce locking. Every mask read and write therefore happens on the RPC thread under `cs_wallet`, with an `AssertLockHeld` in the accessor.
- Pre-fill workers follow `FillSaplingNoteCache`'s rule that workers must not lock, or even assert a lock (`dx:wallet.cpp:5745-5746`). They call only `decrypt_with_note` on pre-resolved (output, ivk) pairs and write private masks, which are merged serially after the join.
- Workers must not call `GetSaplingHistoryKeys` or `IsNullifierOfAddress`, because both assert locks.
**Fill**
- Lazily, as the helpers run.
- In addition, pre-fill in parallel, but **only the txs the call has already selected**:
1. In `z_listsentbyaddress`, first collect the `wtx` pointers that pass the filters at `dx:rpcwallet.cpp:3722-3741`, honouring `nCount`.
2. Pre-fill their outputs, using the 128-item threshold and `-witnessbuildthreads` from `dx:wallet.cpp:5739-5797`.
3. Then emit.
- Pre-fill time is spent under `LOCK2`. For a full sweep that's ~20-25 s once; a windowed call stays small.
- Optionally, also record the misses `FindMySaplingNotes` already computes (`:2282-2322`).
- A startup precompute is opt-in only: ~112 CPU-s, about 24 s of wall time per start *(estimate)*.
**Also benefits:**`getalldata`'s tx list (`dx:rpcwallet.cpp:3254`) and `z_listreceivedaddress` (`:3555`).
**Tests**
- Extend the verbatim oracle `dx:src/gtest/test_wallet_history.cpp`: cold, warm and pre-filled runs must match byte for byte.
- A key added after the mask was filled, which opens a foreign output, must appear in the output.
- More than 64 keys; locked and encrypted wallets; `-witnessbuildthreads=1` vs 24; a `DEBUG_LOCKORDER` build.
- The mainnet A/B harness: `cmp` cold and warm replies against the captured 26,233,144-byte reply (`/home/d/dragonx-ab/mainnet-ab/out/B/cap/023_z_listsentbyaddress_addr0_full.json`).
**D2.** Cache `DecryptSent` results per (n, ovk index): the plaintext and address on success, or "no". That's about 650 B per entry. Key the seed ovk by value, so lock state still decides whether the cache is consulted. Use the same oracle tests as D1.
### 3.2 Smaller items
**D3: the `getalldata` balance loop** (`dx:rpcwallet.cpp:3108-3151`, under `LOCK2` at `:2998`).
- Today it copies each `SaplingNoteData` by value, witnesses included (`:3110`). For every unspent note and every address it re-derives the spending key and trial-decrypts (`:3117-3121`).
- Fix:
- build `SaplingHistoryKeys` once, and reuse it at `:3233`;
- take note data by `const&`;
- use `OutputDecryptions(wtx, op.n).Get(a.ivk)` over `keys.spendable`, moving `OutputDecryptions` out of its anonymous namespace (`rpchushwallet.cpp:56-120`) into `rpchushwallet.h`.
- Keep two quirks for byte-identity, and fix them later as a separate change with a release note:
- The no-op `txType == 3;` at `:3124` means locked notes are never counted as locked. When fixed, use a per-note local, `noteType = (txType == 0 && IsLockedNote(op)) ? 3 : txType`. `txType` is per tx (`:3043`), so assigning to it would mark every later note of the tx as locked.
- Every address whose ivk opens the note is credited, because the `continue` doesn't exit the address loop.
- Don't switch to `GetFilteredNotes`: that changes the output.
- Test: an oracle gtest with a verbatim copy of the old loop (shared-ivk, locked-note, watch-only and encrypted cases).
**D4: write the wallet locator on clean shutdown.**
- **Evidence:** the copy, taken from a clean Windows shutdown, was 12 blocks behind its chainstate. So every start rescans those blocks.
- **Cause:**
-`FlushStateToDisk` writes the locator only if `DATABASE_WRITE_INTERVAL` (3600 s, `dx:main.h:145`) has passed, even for `FLUSH_STATE_ALWAYS` (`dx:main.cpp:3838-3841`).
-`Shutdown()` calls it at `dx:init.cpp:259`.
-`pwalletMain->Flush(true)` at `:280` only flushes BDB.
- **Hook:** in `Shutdown()`, between `:259` and `:280`:
```cpp
LOCK2(cs_main, pwalletMain->cs_wallet);
const CBlockIndex* tip = pwalletMain->GetWalletTip(); // new accessor: pindexWalletTip is private (wallet.h:988-994)
- **Don't route it through `CWallet::SetBestChain`.** That swaps in `pindexWalletTip` when set but falls back to the passed locator when it's null (`dx:wallet.cpp:829-834`). The null case is exactly "stamp the tip before the init rescan ran", which would skip the unscanned blocks for good.
- **The failure path needs the lock too.** AppInit2's failure path calls `Shutdown()` without `join_all` (`dx:bitcoind.cpp:232-243`), so the notifier can still be running. Reading the tip under `LOCK2` keeps that safe.
- **Don't drop the interval check globally.** `gettxoutsetinfo` and init also flush with ALWAYS, and each would then rewrite ~2 GB under `cs_main`.
- **Trade-off:** one locator write rewrites every note-bearing `CWalletTx`, ~2 GB here (`SetBestChainINTERNAL`, `dx:wallet.h:939-986`, WriteTx loop `:947-962`). The cost moves to shutdown rather than disappearing; see the D4 row in the table above.
- **Ship it together with a parallel witness-root check at init.**
- **Risk:** the GUI's stop policy (`src/daemon/daemon_controller.h:360`, `evaluateStopWait`) must allow about 16 s plus 3-60 s. Context: the 2026-09-30 Windows kill loop.
- **Tests:**
- Stop within the hour and restart: no "Rescanning last" line, and identical balances.
- Stop during the startup rescan: the locator does not advance.
**D5: JSON trims.**
- Use `static_cast<const CTransaction&>` at `dx:rpchushwallet.cpp:553`. Today it copies 190.6 MB of tx data per full sweep.
- Drop the `getValues` / reverse / `push_backV` copy (`dx:rpcwallet.cpp:3749-3755`). Collect the selected `wtx` pointers in the existing reverse loop, which keeps `nCount` picking the newest txs, then build `ret` oldest to newest. The same pattern is at `getalldata` `:3259-3265`.
**Also found; each needs its own design.**
- The hourly `SetBestChain` rewrites ~2 GB while `cs_main` is held (3-47 s gaps in the live log, *inferred from log gaps*).
- `FindMySaplingNotes` tries foreign outputs once per viewing key and again per address (`dx:wallet.cpp:2282-2322`). De-duplicating ivks would probably halve the per-block tries *(inference)*.
### 3.3 Options that change RPC output (need a GUI opt-in)
**D6: sends-only mode.**
- **The parameter:** add an optional 6th parameter `returnType` to `z_listsentbyaddress`, default 0 (unchanged output).
- The code's meanings are 1 = spends, 2 = received, 3 = sends. The comment at `dx:rpchushwallet.cpp:561-566` swaps 1 and 3; fix it in the same change.
- **With `3` and no address filter:**
- the spends helper still runs (`:569`), but its output isn't emitted;
- `received`, where all the trial decryption happens, is skipped (`:576-581`);
- `sends` is emitted (`:584-592`), and every tx is still pushed (`:594-598`).
- The GUI gets the same txids and the same `sends.transparentSends`.
- **Daemon changes:**
- Raise the check at `dx:rpcwallet.cpp:3606` from `> 5` to `> 6`, keeping the `== 3` rejection.
- Change `:3693` to `params.size() >= 5`. Today it reads `nCount` only when `size() == 5`, so every 6-parameter call would silently drop it.
- Read `params[5]`, reject anything outside 0-3, and document it in the help text. `dx:src/rpc/client.cpp:165` already converts index 5.
- **GUI side:** send 6 parameters. An old daemon throws its help text for more than 5, so fall back to 5 parameters and remember that per connection.
- Alternative: a new RPC name, probed through "Method not found" the same way as `z_autoshieldstatus` (`app.h:1744-1747`).
- **Owner decision:** a variant that returns only txs with wallet spends would return 0 rows here, but it changes the `knownTxids` set.
**D7: honour the address argument.**
- The GUI passes `shieldedAddresses.front()` (`network_refresh_service.cpp:1319`) and relies on the daemon using `"*"` (`dx:rpcwallet.cpp:3743`). Honouring the argument by default would silently shrink GUI sweeps to one address.
- It isn't faster either: with a filter set, `fBool=true` runs `zsTxSendsToJSON` for every tx (`:586`).
- Recommendation: document `"*"` as a compatibility contract. If honouring the argument is ever wanted, put it behind a flag.
**Additive and safe for the GUI: a wallet-tip / history fingerprint.**
- Expose `pindexWalletTip`, for example in `getwalletinfo` or a new `z_getwallethistorystate(maxheight)` returning `{count, txid-hash, wallet_tip}`.
- The GUI ignores unknown JSON fields.
- This would make Part 1's invalidation exact, replacing the E-margin guess and the `txcount` tripwire.
---
## 4. Rollout and compatibility
| Step | Ships in | Works with | Notes |
|---|---|---|---|
| Part 1: watermark + anchor verify + backlog + sticky rescan + large-window path + banding + safety net | Next GUI release | Every daemon version; it uses only existing `z_listsentbyaddress` parameters, `getblockhash`, `getwalletinfo` and `getinfo` | Old GUI with a new snapshot: the key is ignored. New GUI with an old snapshot: one full sweep, which is today's behaviour. On v1.3.0, seeding the first W needs the banded sweep |
| D1, D2, D3, D5 | Daemon v1.3.1 / v1.3.2 | Invisible to the GUI | Gate each on the byte-identical oracle and the mainnet `cmp` |
| D6 + GUI probe | Daemon first, then GUI | The GUI falls back on old daemons | Makes the safety net and any full sweep cheap |
| D4 | Together with a parallel witness-root check | Invisible apart from shutdown time | Confirm the GUI stop policy first |
---
## 5. Open questions
1. How big is the backlog after the first full sweep on the owner's wallet? Unmeasured; it sizes the persisted `backlog` field.
2. What is the full-sweep time on the owner's 24-core Windows node? The 113 s was measured on Linux/WSL, 7 s inside the 120 s timeout.
3. How long does the wallet notifier lag after a catch-up on this wallet? This sets E.
4. Should the snapshot store keep more than one wallet, so that switching wallets also benefits?
5. D6 shape: a 6th parameter or a new RPC? Should a "wallet-spends-only" variant exist?
6. Does any client besides ObsidianDragon depend on `returnType 0` output? This sets D1's priority once D6 exists.
7. Are the 5 ivks distinct? Assumed. The per-address note counts sum to exactly 16,206, which fits but doesn't prove it.
8. Does the owner want the additive wallet-tip / fingerprint RPC, which makes Part 1's invalidation exact?
ObsidianDragonLite is the lightweight, node-free companion to ObsidianDragon — it connects to a DragonX lite-wallet server for near-instant startup, with the same native ImGui interface and shielded-first workflow. **v1.1.1 is a maintenance release** centered on an important wallet-encryption fix.
---
## ⚠️ Security fix — please read
**Creating or restoring a Lite wallet with a passphrase now actually encrypts it.**
In prior versions, the create / restore / open flow used by the Settings UI **silently discarded the passphrase you entered**:
- A **newly created or restored** wallet was saved to disk **unencrypted** — the seed and spending keys were stored in the clear even though you set a passphrase.
- **Opening** an already-encrypted wallet did not unlock it with the supplied passphrase (it opened locked and re-prompted).
v1.1.1 fixes this: create/restore now **encrypt** the wallet with your passphrase (the backend encrypts and locks it), and open now **unlocks** it.
**What you should do:** if you created or restored a Lite wallet **with a passphrase** on an earlier version, treat that wallet's on-disk data as unencrypted. Open it in v1.1.1 and re-apply encryption from **Settings → encrypt** (and, if you're concerned the plaintext wallet file may have been exposed, move the funds to a freshly created encrypted wallet). Wallets you never set a passphrase on are unaffected.
---
## Also fixed
- **Chinese, Japanese, and Korean FAQ text renders correctly.** Several Help/FAQ titles showed `??` instead of characters (e.g. 什么, 门) after the previous translation back-fill; the bundled CJK font subset now includes those glyphs.
---
## Downloads
macOS builds are x86-64 (Intel; run on Apple Silicon via Rosetta 2).
For the full feature set (node-free operation, wallet lifecycle, shielded + transparent send/shield/receive, HushChat, server browser with failover, 8-language UI), see the **lite-v1.0.0** release notes — v1.1.1 changes only the items listed above.
ObsidianDragon is a native ImGui-based full-node wallet for the DragonX network, with an embedded `dragonxd` daemon and the integrated DRG-XMRig CPU miner. **v2.0.2 is a maintenance release** — a focused set of transaction-history, sync-safety, and correctness fixes on top of v2.0.x. No configuration changes are required; just replace the binary.
---
## What's Fixed in v2.0.2
### Transaction history
- **Outgoing shielded sends now appear in History.** Sends *from* a shielded (z-)address — both **z→z** (shielded-to-shielded) and **z→t** (deshield to a transparent address) — were missing from the History tab, because the daemon's `listtransactions` doesn't report them and `z_viewtransaction` doesn't expose the transparent leg. The wallet now enumerates them via `z_listsentbyaddress`, so **your History matches your balance.** (Your funds were never affected — only the display.)
- **More accurate send rows** — a single transaction with two equal-value outputs to different recipients is no longer collapsed into one row, and a transparent self-send / change output is no longer double-counted as both sent and received.
### Sync & fund safety
- **A node that loses all its peers is no longer reported as "synced."** Previously, a wallet whose node dropped to **0 peers** could keep showing a stale balance as if it were current — and allow a Send against it. The wallet now treats a node with no peer-derived chain tip as *not synced*, and a new **"Connected, but no network peers — the node can't sync"** banner explains the stall instead of silently looking healthy.
- **Migrate-to-seed hardening.** If a migrate-to-seed was interrupted (app or daemon restart) *after* the sweep transaction had broadcast but *before* it confirmed, the wallet could mistake the temporarily-zero balance for an empty wallet and adopt the new seed wallet — swapping `wallet.dat` — before the sweep was mined. It now records that a sweep is in flight and **will not swap your wallet until the sweep confirms**, so funds can't be stranded in the timestamped backup.
- **Fixed a rare crash** caused by a data race on the daemon-status text (the daemon-monitor thread writing it while the UI thread read it).
### Display / internationalization
- **Chinese, Japanese, and Korean FAQ text renders correctly.** Several Help/FAQ titles showed `??` instead of characters (e.g. 什么, 门) after the previous translation back-fill; the bundled CJK font subset now includes those glyphs.
---
## Downloads
macOS builds are x86-64 (Intel; run on Apple Silicon via Rosetta 2).
For the full feature set (HushChat messaging, seed-phrase wallets & migrate-to-seed, in-app node/miner updaters, built-in mining, 8-language UI), see the **v2.0.0** release notes — v2.0.2 changes only the fixes listed above.
"de":"Die Notes jeder Adresse werden in genau diese Adresse zurückgeführt, sodass Mittel, die Sie unter verschiedenen Bezeichnungen getrennt halten, getrennt bleiben. Nur abgeschirmte Adressen – transparente Mittel können nur abgeschirmt werden (In eine Adresse).",
"es":"Las notas de cada dirección se fusionan de nuevo en esa misma dirección, así que los fondos que mantienes separados con distintas etiquetas siguen separados. Solo direcciones blindadas: los fondos transparentes solo pueden blindarse (A una sola dirección).",
"fr":"Les notes de chaque adresse sont refusionnées dans cette même adresse : les fonds que vous gardez séparés sous des libellés différents restent séparés. Adresses blindées uniquement — les fonds transparents ne peuvent qu'être blindés (Vers une seule adresse).",
"pt":"As notas de cada endereço são mescladas de volta nesse mesmo endereço, então os fundos que você mantém separados com rótulos diferentes continuam separados. Só endereços blindados — fundos transparentes só podem ser blindados (Para um só endereço).",
"ru":"Заметки каждого адреса объединяются обратно в тот же адрес, поэтому средства, которые вы держите раздельно под разными метками, остаются раздельными. Только экранированные адреса — прозрачные средства можно только экранировать («На один адрес»).",
"ko":"각 주소의 노트는 같은 주소로 다시 병합되므로, 다른 라벨로 나눠 둔 자금은 계속 나뉜 상태로 유지됩니다. 차폐 주소만 해당되며, 투명 자금은 차폐만 할 수 있습니다(한 주소로).",
},
"merge_keep_each_none":{
"de":"Jede abgeschirmte Adresse hat bereits nur eine Note – nichts zu konsolidieren.",
"es":"Cada dirección blindada ya tiene una sola nota: no hay nada que consolidar.",
"fr":"Chaque adresse blindée ne contient déjà qu'une seule note — rien à consolider.",
"pt":"Cada endereço blindado já tem uma única nota — nada a consolidar.",
"ru":"На каждом экранированном адресе уже одна заметка — объединять нечего.",
"zh":"每个屏蔽地址都只有一张票据——无需整合。",
"ja":"すべてのシールドアドレスのノートはすでに 1 つです。統合するものはありません。",
"ko":"모든 차폐 주소에 노트가 이미 하나뿐입니다. 통합할 것이 없습니다.",
},
"merge_keep_each_row_fmt":{
"de":"%s · %d Notes · %s DRGX",
"es":"%s · %d notas · %s DRGX",
"fr":"%s · %d notes · %s DRGX",
"pt":"%s · %d notas · %s DRGX",
"ru":"%s · заметок: %d · %s DRGX",
"zh":"%s · %d 张票据 · %s DRGX",
"ja":"%s · ノート %d 件 · %s DRGX",
"ko":"%s · 노트 %d개 · %s DRGX",
},
"merge_keep_each_too_small":{
"de":"Zu klein zum Konsolidieren: Die Gebühr würde diese Mittel aufbrauchen.",
"es":"Demasiado poco para consolidar: la comisión se llevaría estos fondos.",
"fr":"Trop faible pour être consolidé : les frais absorberaient ces fonds.",
"pt":"Pequeno demais para consolidar: a taxa consumiria esses fundos.",
"ru":"Слишком мало для объединения: комиссия съест эти средства.",
"zh":"金额太小,无法整合:手续费会耗尽这些资金。",
"ja":"少なすぎて統合できません。手数料でこの資金がなくなります。",
"ko":"너무 적어서 통합할 수 없습니다. 수수료가 이 자금을 모두 소모합니다.",
},
"merge_keep_each_batches_fmt":{
"de":"Adressen mit mehr als %d Notes werden in mehreren Durchgängen zusammengeführt; führen Sie es nach deren Bestätigung erneut aus, um den Rest zusammenzuführen.",
"es":"Las direcciones con más de %d notas se fusionan en varios lotes; vuelve a ejecutarlo cuando se confirmen para fusionar el resto.",
"fr":"Les adresses de plus de %d notes sont fusionnées en plusieurs lots ; relancez l'opération une fois ceux-ci confirmés pour fusionner le reste.",
"pt":"Endereços com mais de %d notas são mesclados em vários lotes; execute de novo depois que forem confirmados para mesclar o restante.",
"ru":"Адреса с более чем %d заметками объединяются в несколько партий; запустите снова после их подтверждения, чтобы объединить остальное.",
"de":"%s–%d Notes werden zusammengeführt (%d von %d Durchgängen fertig)…",
"es":"%s: fusionando %d notas (%d de %d lotes listos)…",
"fr":"%s — fusion de %d notes (%d lots sur %d terminés)…",
"pt":"%s — mesclando %d notas (%d de %d lotes concluídos)…",
"ru":"%s — объединение %d заметок (готово партий: %d из %d)…",
"zh":"%s——正在合并 %d 张票据(已完成 %d/%d 批)…",
"ja":"%s — %d 件のノートを統合中(%d / %d 回完了)…",
"ko":"%s — 노트 %d개 병합 중(%d/%d 배치 완료)…",
},
"merge_keep_each_run_again":{
"de":"Adressen, die in mehreren Durchgängen zusammengeführt wurden, haben jetzt eine Note pro Durchgang. Führen Sie dies nach deren Bestätigung erneut aus, um sie zu einer zusammenzuführen.",
"es":"Las direcciones fusionadas en varios lotes tienen ahora una nota por lote. Vuelve a ejecutarlo cuando se confirmen para fusionarlas en una.",
"fr":"Les adresses fusionnées en plusieurs lots contiennent maintenant une note par lot. Relancez l'opération une fois ceux-ci confirmés pour les fusionner en une seule.",
"pt":"Endereços mesclados em vários lotes agora têm uma nota por lote. Execute de novo depois que forem confirmados para mesclá-las em uma.",
"ru":"На адресах, объединённых в несколько партий, теперь по одной заметке на партию. Запустите снова после их подтверждения, чтобы объединить их в одну.",
"ko":"여러 번에 나눠 병합된 주소에는 배치마다 노트가 하나씩 남아 있습니다. 확인된 후 다시 실행하면 하나로 병합됩니다.",
},
"mining_addr_explainer":{
"de":"Empfängt nur Solo-Mining-Belohnungen – das Netzwerk lehnt Zahlungen an diese Adresse ab. Belohnungen werden automatisch abgeschirmt, sobald sie gereift sind.",
"es":"Solo recibe recompensas de minería en solitario; la red rechaza los pagos a esta dirección. Las recompensas se blindan automáticamente cuando maduran.",
"fr":"Ne reçoit que des récompenses de minage en solo — le réseau refuse les paiements vers cette adresse. Les récompenses sont blindées automatiquement une fois arrivées à maturité.",
"pt":"Recebe apenas recompensas de mineração solo — a rede rejeita pagamentos para este endereço. As recompensas são blindadas automaticamente quando amadurecem.",
"ru":"Принимает только награды за соло-майнинг — сеть отклоняет платежи на этот адрес. Награды экранируются автоматически, когда созревают.",
"ko":"솔로 채굴 보상만 받습니다. 네트워크가 이 주소로의 결제를 거부합니다. 보상은 성숙하면 자동으로 차폐됩니다.",
},
"receive_use_shielded":{
"de":"Abgeschirmte Adresse verwenden",
"es":"Usar una dirección blindada",
"fr":"Utiliser une adresse blindée",
"pt":"Usar um endereço blindado",
"ru":"Использовать экранированный адрес",
"zh":"使用屏蔽地址",
"ja":"シールドアドレスを使う",
"ko":"차폐 주소 사용",
},
"receive_not_for_payments":{
"de":"Nicht für Zahlungen",
"es":"No para pagos",
"fr":"Pas pour les paiements",
"pt":"Não é para pagamentos",
"ru":"Не для платежей",
"zh":"不用于付款",
"ja":"支払いには使えません",
"ko":"결제용이 아님",
},
"validate_mining_type":{
"de":"Mining-Adresse (transparent)",
"es":"Dirección de minería (transparente)",
"fr":"Adresse de minage (transparente)",
"pt":"Endereço de mineração (transparente)",
"ru":"Адрес для майнинга (прозрачный)",
"zh":"挖矿地址(透明)",
"ja":"マイニングアドレス(透明)",
"ko":"채굴 주소(투명)",
},
"send_mining_addr_short":{
"de":"Mining-Adresse – kann keine Zahlungen empfangen",
"es":"Dirección de minería: no puede recibir pagos",
"fr":"Adresse de minage — ne peut pas recevoir de paiements",
"pt":"Endereço de mineração — não pode receber pagamentos",
"ru":"Адрес для майнинга — не принимает платежи",
"zh":"挖矿地址——无法接收付款",
"ja":"マイニングアドレス — 支払いを受け取れません",
"ko":"채굴 주소 — 결제를 받을 수 없음",
},
"send_mining_addr_blocked":{
"de":"Dies ist eine Mining-Adresse (transparent). Bei DragonX können solche Adressen nur Mining-Belohnungen empfangen – das Netzwerk lehnt Zahlungen an sie ab. Bitten Sie den Empfänger um eine abgeschirmte Adresse (sie beginnt mit zs).",
"es":"Esta es una dirección de minería (transparente). En DragonX solo pueden recibir recompensas de minería; la red rechaza los pagos a ellas. Pide al destinatario una dirección blindada (empieza por zs).",
"fr":"Ceci est une adresse de minage (transparente). Sur DragonX, elles ne peuvent recevoir que des récompenses de minage — le réseau refuse les paiements vers elles. Demandez au destinataire une adresse blindée (elle commence par zs).",
"pt":"Este é um endereço de mineração (transparente). No DragonX eles só podem receber recompensas de mineração — a rede rejeita pagamentos para eles. Peça ao destinatário um endereço blindado (começa com zs).",
"ru":"Это адрес для майнинга (прозрачный). В DragonX такие адреса могут получать только награды за майнинг — сеть отклоняет платежи на них. Попросите у получателя экранированный адрес (начинается с zs).",
"ko":"채굴(투명) 주소입니다. DragonX에서 이런 주소는 채굴 보상만 받을 수 있으며, 네트워크가 이 주소로의 결제를 거부합니다. 받는 사람에게 차폐 주소(zs로 시작)를 요청하세요.",
},
"mining_payout_needs_shielded":{
"de":"Pools zahlen mit gewöhnlichen Transaktionen aus, die bei DragonX keine Mining-Adresse (transparent, R…) erreichen können. Verwenden Sie eine abgeschirmte Adresse (sie beginnt mit zs) als Auszahlungsadresse.",
"es":"Los pools pagan con transacciones normales, que en DragonX no pueden llegar a una dirección de minería (transparente, R…). Usa una dirección blindada (empieza por zs) como dirección de pago.",
"fr":"Les pools paient avec des transactions ordinaires, qui ne peuvent pas atteindre une adresse de minage (transparente, R…) sur DragonX. Utilisez une adresse blindée (elle commence par zs) comme adresse de paiement.",
"pt":"Os pools pagam com transações comuns, que no DragonX não conseguem chegar a um endereço de mineração (transparente, R…). Use um endereço blindado (começa com zs) como endereço de pagamento.",
"ru":"Пулы выплачивают обычными транзакциями, которые в DragonX не могут дойти до адреса для майнинга (прозрачного, R…). Укажите экранированный адрес (начинается с zs) как адрес для выплат.",
"ko":"풀은 일반 트랜잭션으로 지급하는데, DragonX에서는 이것이 채굴(투명, R…) 주소에 도달할 수 없습니다. 지급 주소로 차폐 주소(zs로 시작)를 사용하세요.",
},
"new_mining_address":{
"de":"Neue Mining-Adresse",
"es":"Nueva dirección de minería",
"fr":"Nouvelle adresse de minage",
"pt":"Novo endereço de mineração",
"ru":"Новый адрес для майнинга",
"zh":"新建挖矿地址",
"ja":"新しいマイニングアドレス",
"ko":"새 채굴 주소",
},
"tt_new_mining_address":{
"de":"Erstellt eine transparente Adresse (R…) für Solo-Mining-Belohnungen. Sie kann keine Zahlungen empfangen – teilen Sie dafür eine abgeschirmte Adresse.",
"es":"Crea una dirección transparente (R…) para recompensas de minería en solitario. No puede recibir pagos; para eso comparte una dirección blindada.",
"fr":"Crée une adresse transparente (R…) pour les récompenses de minage en solo. Elle ne peut pas recevoir de paiements — partagez une adresse blindée pour cela.",
"pt":"Cria um endereço transparente (R…) para recompensas de mineração solo. Ele não pode receber pagamentos — compartilhe um endereço blindado para isso.",
"ru":"Создаёт прозрачный адрес (R…) для наград за соло-майнинг. Он не принимает платежи — для них сообщайте экранированный адрес.",
"ko":"솔로 채굴 보상용 투명(R…) 주소를 만듭니다. 결제는 받을 수 없으니, 결제용으로는 차폐 주소를 공유하세요.",
},
"mining_address":{
"de":"Mining-Adresse",
"es":"Dirección de minería",
"fr":"Adresse de minage",
"pt":"Endereço de mineração",
"ru":"Адрес для майнинга",
"zh":"挖矿地址",
"ja":"マイニングアドレス",
"ko":"채굴 주소",
},
"addr_type_mining":{
"de":"Mining",
"es":"Minería",
"fr":"Minage",
"pt":"Mineração",
"ru":"Майнинг",
"zh":"挖矿",
"ja":"マイニング",
"ko":"채굴",
},
"mining_rewards":{
"de":"Mining-Belohnungen",
"es":"Recompensas de minería",
"fr":"Récompenses de minage",
"pt":"Recompensas de mineração",
"ru":"Награды за майнинг",
"zh":"挖矿奖励",
"ja":"マイニング報酬",
"ko":"채굴 보상",
},
"mining_rewards_unshielded":{
"de":"Noch nicht abgeschirmt",
"es":"Aún sin blindar",
"fr":"Pas encore blindées",
"pt":"Ainda não blindadas",
"ru":"Ещё не экранированы",
"zh":"尚未屏蔽",
"ja":"未シールド",
"ko":"아직 차폐되지 않음",
},
"baltab_mining_addresses_count":{
"de":"%d Mining-Adressen",
"es":"%d direcciones de minería",
"fr":"%d adresses de minage",
"pt":"%d endereços de mineração",
"ru":"Адресов для майнинга: %d",
"zh":"%d 个挖矿地址",
"ja":"マイニングアドレス %d 件",
"ko":"채굴 주소 %d개",
},
"baltab_mining_amount":{
"de":"Mining-Belohnungen %.8f",
"es":"Recompensas de minería %.8f",
"fr":"Récompenses de minage %.8f",
"pt":"Recompensas de mineração %.8f",
"ru":"Награды за майнинг %.8f",
"zh":"挖矿奖励 %.8f",
"ja":"マイニング報酬 %.8f",
"ko":"채굴 보상 %.8f",
},
"balance_mining_fmt":{
"de":"Mining-Belohnungen: %.8f",
"es":"Recompensas de minería: %.8f",
"fr":"Récompenses de minage : %.8f",
"pt":"Recompensas de mineração: %.8f",
"ru":"Награды за майнинг: %.8f",
"zh":"挖矿奖励:%.8f",
"ja":"マイニング報酬:%.8f",
"ko":"채굴 보상: %.8f",
},
"mining_card_tooltip":{
"de":"Solo-Mining-Belohnungen gehen an Ihre Mining-Adressen (transparent, R…). Diese Adressen können keine Zahlungen empfangen. Belohnungen werden automatisch abgeschirmt, sobald sie gereift sind.",
"es":"Las recompensas de minería en solitario llegan a tus direcciones de minería (transparentes, R…). Esas direcciones no pueden recibir pagos. Las recompensas se blindan automáticamente cuando maduran.",
"fr":"Les récompenses de minage en solo arrivent sur vos adresses de minage (transparentes, R…). Ces adresses ne peuvent pas recevoir de paiements. Les récompenses sont blindées automatiquement une fois arrivées à maturité.",
"pt":"As recompensas de mineração solo chegam aos seus endereços de mineração (transparentes, R…). Esses endereços não podem receber pagamentos. As recompensas são blindadas automaticamente quando amadurecem.",
"ru":"Награды за соло-майнинг поступают на ваши адреса для майнинга (прозрачные, R…). Эти адреса не принимают платежи. Награды экранируются автоматически, когда созревают.",
"ko":"솔로 채굴 보상은 채굴(투명, R…) 주소로 들어옵니다. 이 주소는 결제를 받을 수 없습니다. 보상은 성숙하면 자동으로 차폐됩니다.",
},
"mining_card_click_to_shield":{
"de":"Klicken, um gereifte Belohnungen jetzt abzuschirmen.",
"es":"Haz clic para blindar ahora las recompensas maduras.",
"fr":"Cliquez pour blinder dès maintenant les récompenses arrivées à maturité.",
"pt":"Clique para blindar agora as recompensas amadurecidas.",
"ru":"Нажмите, чтобы экранировать созревшие награды сейчас.",
"zh":"点击立即屏蔽已成熟的奖励。",
"ja":"クリックすると成熟した報酬を今すぐシールドします。",
"ko":"클릭하면 성숙한 보상을 지금 차폐합니다.",
},
"contact_chip_mining":{
"de":"Nur Mining",
"es":"Solo minería",
"fr":"Minage seul",
"pt":"Só mineração",
"ru":"Только майнинг",
"zh":"仅限挖矿",
"ja":"マイニング専用",
"ko":"채굴 전용",
},
"failed_create_mining_address":{
"de":"Mining-Adresse konnte nicht erstellt werden",
"es":"No se pudo crear la dirección de minería",
"fr":"Impossible de créer l'adresse de minage",
"pt":"Falha ao criar o endereço de mineração",
"ru":"Не удалось создать адрес для майнинга",
"zh":"创建挖矿地址失败",
"ja":"マイニングアドレスを作成できませんでした",
"ko":"채굴 주소를 만들지 못했습니다",
},
"new_mining_address_created":{
"de":"Neue Mining-Adresse erstellt",
"es":"Nueva dirección de minería creada",
"fr":"Nouvelle adresse de minage créée",
"pt":"Novo endereço de mineração criado",
"ru":"Новый адрес для майнинга создан",
"zh":"已创建新的挖矿地址",
"ja":"新しいマイニングアドレスを作成しました",
"ko":"새 채굴 주소를 만들었습니다",
},
"appx_node_busy_try_later":{
"de":"Der Node ist mit einer langen Aufgabe beschäftigt (etwa dem Neuaufbau der Note-Witnesses). Versuchen Sie es erneut, wenn sie abgeschlossen ist; ein Stoppen jetzt könnte diese Arbeit von vorn beginnen lassen.",
"es":"El nodo está ocupado terminando una tarea larga (como reconstruir los testigos de notas). Vuelve a intentarlo cuando termine; detenerlo ahora podría hacer que empiece ese trabajo de nuevo.",
"fr":"Le nœud termine une longue tâche (comme la reconstruction des témoins de notes). Réessayez une fois qu'elle est finie ; l'arrêter maintenant pourrait lui faire recommencer ce travail.",
"pt":"O nó está ocupado concluindo uma tarefa longa (como reconstruir as testemunhas de notas). Tente novamente quando terminar; pará-lo agora pode fazê-lo recomeçar esse trabalho.",
"ru":"Узел занят долгой задачей (например, перестройкой свидетелей заметок). Повторите попытку, когда она завершится; если остановить его сейчас, эту работу, возможно, придётся начать заново.",
"de":"Beendet den Node sofort. Alles, was er noch nicht gespeichert hat, geht verloren, und beim nächsten Start spielt er die Blöcke seit dem letzten Speichern erneut ein.",
"es":"Detiene el nodo de inmediato. Se pierde todo lo que no haya guardado, y en el próximo inicio volverá a procesar los bloques desde el último guardado.",
"fr":"Arrête le nœud immédiatement. Tout ce qu'il n'a pas enregistré est perdu, et au prochain démarrage il rejouera les blocs depuis son dernier enregistrement.",
"pt":"Encerra o nó imediatamente. Tudo o que não foi salvo se perde, e no próximo início ele reprocessa os blocos desde o último salvamento.",
"ru":"Немедленно завершает узел. Всё несохранённое будет потеряно, а при следующем запуске он заново обработает блоки с момента последнего сохранения.",
"ko":"노드를 즉시 종료합니다. 저장되지 않은 내용은 모두 사라지며, 다음 시작 시 마지막 저장 이후의 블록을 다시 처리합니다."
},
"shutdown_kill_confirm_title":{
"de":"Node erzwungen beenden?","es":"¿Forzar el cierre del nodo?","fr":"Tuer le nœud ?",
"pt":"Forçar o encerramento do nó?","ru":"Принудительно завершить узел?","zh":"强制结束节点?",
"ja":"ノードを強制終了しますか?","ko":"노드를 강제 종료할까요?"
},
"shutdown_kill_confirm_msg":{
"de":"Der Node zeigt seit Langem keine CPU- oder Festplattenaktivität und hängt möglicherweise.\nWenn Sie ihn erzwungen beenden, geht alles Ungespeicherte verloren; sein nächster Start kann deutlich länger dauern.",
"es":"El nodo lleva mucho tiempo sin actividad de CPU ni de disco y puede estar bloqueado.\nSi fuerzas su cierre se perderá todo lo que no haya guardado; su próximo inicio puede tardar mucho más.",
"fr":"Le nœud ne montre plus d'activité processeur ni disque depuis longtemps et est peut-être bloqué.\nLe tuer fait perdre tout ce qu'il n'a pas enregistré ; son prochain démarrage peut être beaucoup plus long.",
"pt":"O nó não mostra atividade de CPU nem de disco há muito tempo e pode estar travado.\nForçar o encerramento perde tudo o que não foi salvo; o próximo início pode demorar muito mais.",
"ru":"Узел давно не проявляет активности процессора и диска и, возможно, завис.\nПри принудительном завершении всё несохранённое будет потеряно; следующий запуск может занять гораздо больше времени.",
"zh":"节点已长时间没有 CPU 或磁盘活动,可能已卡住。\n强制结束会丢失所有未保存的数据;下次启动可能需要更长时间。",
"ja":"ノードは長時間 CPU やディスクの活動がなく、停止している可能性があります。\n強制終了すると保存されていない内容はすべて失われ、次回の起動に大幅に時間がかかることがあります。",
"ko":"노드가 오랫동안 CPU나 디스크 활동이 없어 멈췄을 수 있습니다.\n강제 종료하면 저장되지 않은 내용이 모두 사라지고, 다음 시작이 훨씬 오래 걸릴 수 있습니다."
},
"shutdown_kill_confirm_yes":{
"de":"Erzwungen beenden","es":"Forzar cierre","fr":"Tuer le nœud","pt":"Forçar encerramento",
"de":"Läuft seit %s. Bei großen Wallets kann das etwa eine Stunde dauern, und der Node kann der Wallet erst danach antworten.",
"es":"En curso desde hace %s. En carteras grandes puede tardar alrededor de una hora, y el nodo no puede responder a la cartera hasta que termine.",
"fr":"En cours depuis %s. Pour un gros portefeuille, cela peut prendre environ une heure, et le nœud ne peut pas répondre au portefeuille avant la fin.",
"pt":"Em andamento há %s. Em carteiras grandes pode levar cerca de uma hora, e o nó não consegue responder à carteira até terminar.",
"ru":"Выполняется %s. Для больших кошельков это может занять около часа, и до окончания узел не может отвечать кошельку.",
"ja":"ノード処理中:ブロックを追いかけています","ko":"노드 작업 중: 블록 따라잡는 중"
},
"node_busy_replay_desc":{
"de":"Läuft seit %s. Der Node wendet Blöcke erneut an, die er vor dem letzten Beenden empfangen hat.",
"es":"En curso desde hace %s. El nodo está volviendo a aplicar bloques que recibió antes de detenerse por última vez.",
"fr":"En cours depuis %s. Le nœud réapplique les blocs reçus avant son dernier arrêt.",
"pt":"Em andamento há %s. O nó está reaplicando blocos que recebeu antes da última parada.",
"ru":"Выполняется %s. Узел повторно применяет блоки, полученные до последней остановки.",
"zh":"已运行 %s。节点正在重新应用上次停止前收到的区块。",
"ja":"経過時間 %s。ノードは前回停止する前に受信したブロックを再適用しています。",
"ko":"%s 동안 진행 중입니다. 노드가 마지막으로 멈추기 전에 받은 블록을 다시 적용하고 있습니다."
},
"node_busy_rpc_title":{
"de":"Node beschäftigt: antwortet noch nicht","es":"Nodo ocupado: aún no responde",
"fr":"Nœud occupé : ne répond pas encore","pt":"Nó ocupado: ainda não responde",
"ru":"Узел занят: пока не отвечает","zh":"节点繁忙:暂未响应",
"ja":"ノード処理中:まだ応答していません","ko":"노드 작업 중: 아직 응답하지 않음"
},
"node_busy_rpc_desc":{
"de":"Der Node läuft, ist aber seit %s zu beschäftigt, um der Wallet zu antworten – meist wegen eines langen Vorgangs wie dem Neuaufbau der Witnesses.",
"es":"El nodo está en marcha, pero lleva %s demasiado ocupado para responder a la cartera, normalmente por una operación larga como la reconstrucción de testigos.",
"fr":"Le nœud fonctionne mais est trop occupé pour répondre au portefeuille depuis %s, généralement à cause d'une longue opération comme la reconstruction des témoins.",
"pt":"O nó está em execução, mas está ocupado demais para responder à carteira há %s, geralmente por causa de uma operação longa como a reconstrução de testemunhas.",
"ru":"Узел работает, но уже %s слишком занят, чтобы отвечать кошельку, обычно из-за долгой операции, например перестройки свидетелей.",
"ko":"노드는 실행 중이지만 %s 동안 지갑에 응답할 수 없을 만큼 바쁩니다. 보통 위트니스 재구성 같은 긴 작업 때문입니다."
},
"node_starting_title":{
"de":"DragonX-Node wird gestartet…","es":"Iniciando el nodo DragonX…","fr":"Démarrage du nœud DragonX…",
"pt":"Iniciando o nó DragonX…","ru":"Запуск узла DragonX…","zh":"正在启动 DragonX 节点…",
"ja":"DragonX ノードを起動しています…","ko":"DragonX 노드 시작 중…"
},
"node_starting_busy_desc":{
"de":"Der Node ist erreichbar, aber beschäftigt und antwortet noch nicht. Das ist nach einem Update oder beim ersten Start normal und kann einige Minuten dauern.",
"es":"El nodo está accesible pero ocupado y aún no responde. Es normal tras una actualización o en el primer inicio; puede tardar unos minutos.",
"fr":"Le nœud est joignable mais occupé et ne répond pas encore. C'est normal après une mise à jour ou au premier lancement ; cela peut prendre quelques minutes.",
"pt":"O nó está acessível, mas ocupado, e ainda não responde. Isso é normal após uma atualização ou na primeira execução; pode levar alguns minutos.",
"ru":"Узел доступен, но занят и пока не отвечает. Это нормально после обновления или при первом запуске; может занять несколько минут.",
"de":"Er läuft seit %s, und bei großen Wallets kann das etwa eine Stunde dauern. Wenn Sie den Node jetzt stoppen, wird entweder bis zum Ende des Neuaufbaus gewartet, oder der nächste Start muss den Neuaufbau samt Nachholen wiederholen.\n\nEmpfohlen: Wallet schließen und den Node weiterlaufen lassen. Er beendet die Arbeit selbstständig.",
"es":"Lleva %s en curso, y en carteras grandes puede tardar alrededor de una hora. Detener el nodo ahora o bien espera a que termine la reconstrucción, o bien hace que el próximo inicio la repita junto con cualquier puesta al día.\n\nRecomendado: cierra la cartera y deja el nodo en marcha. Terminará por su cuenta.",
"fr":"Elle est en cours depuis %s, et pour un gros portefeuille elle peut prendre environ une heure. Arrêter le nœud maintenant attend la fin de la reconstruction, ou oblige son prochain démarrage à la refaire, rattrapage compris.\n\nRecommandé : fermez le portefeuille et laissez le nœud actif. Il terminera seul.",
"pt":"Está em andamento há %s, e em carteiras grandes pode levar cerca de uma hora. Parar o nó agora ou espera a reconstrução terminar, ou faz o próximo início refazê-la junto com qualquer recuperação.\n\nRecomendado: feche a carteira e deixe o nó em execução. Ele termina sozinho.",
"ru":"Она выполняется уже %s, а для больших кошельков может занять около часа. Если остановить узел сейчас, он либо дождётся окончания перестройки, либо при следующем запуске выполнит её заново вместе с догонянием блоков.\n\nРекомендуется: закройте кошелёк и оставьте узел работать. Он завершит всё сам.",
"ko":"%s 동안 진행 중이며, 큰 지갑은 약 1시간이 걸릴 수 있습니다. 지금 노드를 중지하면 재구성이 끝날 때까지 기다리거나, 다음 시작 시 재구성과 블록 따라잡기를 다시 해야 합니다.\n\n권장: 지갑을 닫고 노드는 계속 실행하세요. 노드가 스스로 마칩니다."
},
"stopconfirm_busy_title":{
"de":"Der Node ist beschäftigt","es":"El nodo está ocupado","fr":"Le nœud est occupé",
"pt":"O nó está ocupado","ru":"Узел занят","zh":"节点正忙","ja":"ノードは処理中です",
"ko":"노드가 작업 중입니다"
},
"stopconfirm_busy_body":{
"de":"Er ist seit %s zu beschäftigt, um der Wallet zu antworten – vermutlich wegen eines langen Vorgangs wie dem Neuaufbau der Witnesses. Wenn Sie ihn jetzt stoppen, muss der nächste Start diese Arbeit eventuell wiederholen.\n\nEmpfohlen: Wallet schließen und den Node weiterlaufen lassen. Er beendet die Arbeit selbstständig.",
"es":"Lleva %s demasiado ocupado para responder a la cartera, probablemente por una operación larga como la reconstrucción de testigos. Detenerlo ahora puede hacer que el próximo inicio repita ese trabajo.\n\nRecomendado: cierra la cartera y deja el nodo en marcha. Terminará por su cuenta.",
"fr":"Il est trop occupé pour répondre au portefeuille depuis %s, probablement à cause d'une longue opération comme la reconstruction des témoins. L'arrêter maintenant peut obliger son prochain démarrage à refaire ce travail.\n\nRecommandé : fermez le portefeuille et laissez le nœud actif. Il terminera seul.",
"pt":"Está ocupado demais para responder à carteira há %s, provavelmente por causa de uma operação longa como a reconstrução de testemunhas. Pará-lo agora pode fazer o próximo início refazer esse trabalho.\n\nRecomendado: feche a carteira e deixe o nó em execução. Ele termina sozinho.",
"ru":"Уже %s он слишком занят, чтобы отвечать кошельку, вероятно, из-за долгой операции, например перестройки свидетелей. Если остановить его сейчас, при следующем запуске эту работу, возможно, придётся выполнить заново.\n\nРекомендуется: закройте кошелёк и оставьте узел работать. Он завершит всё сам.",
needsRealloc_=false;// one attempt; no per-frame retry on alloc failure
intw=viewportWidth_,h=viewportHeight_;
viewportWidth_=viewportHeight_=0;// force resize() past its early-out
resize(w,h);
hasValidCapture_=false;
markBackgroundDirty();
}
}
voidAcrylicMaterial::captureBackground()
{
// Unused on DX11 — capture is done via captureBackgroundDirect()
Some files were not shown because too many files have changed in this diff
Show More
Reference in New Issue
Block a user
Blocking a user prevents them from interacting with repositories, such as opening or commenting on pull requests or issues. Learn more about blocking a user.