Outgoing echoes were recorded optimistically regardless of whether the broadcast
was actually submitted. Now broadcastChatMemos / broadcastChatMemosLite return
whether the send was submitted (false on immediate failures: not connected, no
spendable z-address, or a lite send already in progress), and the echo records a
ChatDelivery status (Sent / Failed). The Chat tab shows a "not sent" marker in
the error color on failed messages. The status persists in the DB (backward-
compatible: old rows deserialize as Sent).
(A later async failure — an opid that fails after submission — still surfaces via
the existing send-progress notification; only the submit gate is reflected here.)
Also removes the now-unused chat_readonly_note string (the composer replaced the
read-only footer). Test: delivery status round-trips through the DB. Verified:
Linux + Windows build with chat ON, ctest 100%, hygiene clean.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Flip DRAGONX_ENABLE_CHAT to default ON (a fresh configure now builds chat in),
and make full-node chat work for every wallet + any daemon.
Legacy compat: chat identity is derived from the wallet's mnemonic
(z_exportmnemonic) for a portable, SDXLite-compatible identity — but a legacy
random-seed wallet has no mnemonic, and released daemons don't have that RPC at
all. Provisioning now falls back to a stable z-address's spending key
(z_exportkey) in those cases: a functional, wallet-local identity that works on
any daemon. Since a chat identity is local (peers learn your public key from
your message headers, not your derivation), interop is unaffected; only
cross-client portability needs the mnemonic. The spending key is an in-memory
KDF input over a key the wallet already holds, wiped after use — no new exposure.
Stability: the chosen chat z-address (the reply-to in headers AND the legacy
identity source) is now persisted in settings (chat_reply_zaddr), so the
identity + reply address don't shift when new addresses are generated.
chatReplyZaddr() picks the smallest spendable z-addr once and reuses it.
CLAUDE.md updated to reflect the default flip. Verified: Linux + Windows build
with chat ON, fresh-configure default confirmed ON, ctest 100%, hygiene clean.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Security-review finding (low): on the lite variant, "Lock now" ran the backend
`lock` but never updated state_.locked — that only refreshes on the next ~2s
poll. Since chat teardown is driven by state_.isLocked() in maybeProvision
ChatIdentity, the decrypted in-memory store, the chat identity secret key, and
the seed-derived AEAD DB key all lingered in RAM for up to ~2s past an explicit
lock (the full-node path closes this immediately, since App::lockWallet sets
state_.locked synchronously).
New App::lockLiteWallet() mirrors the full-node behavior: on a successful lite
lock it sets state_.locked and tears down the chat session now (clearIdentity +
store().clear() + chat_db_.lock() + re-arm). The lite "Lock now" button routes
through it. (Note: the lite variant has no auto-lock at all — checkAutoLock ->
App::lockWallet early-returns for lite — which is a separate, general lite gap.)
Completes the end-to-end feature security review (1 confirmed finding of 3 raw,
now fixed). Verified: Linux + Windows build with chat ON, ctest 100%, hygiene clean.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Add App::seedChatDemoData() + a "Seed demo chat" button in the DEBUG OPTIONS
card (shown only in chat-enabled builds, next to the screenshot sweep). It gives
the Chat tab a demo identity and injects a few sample conversations into the
in-memory store (NOT persisted — no DB attached, gone on restart) so the
screenshot sweep captures the tab's real UI (conversation list, thread bubbles,
composer, and the "waiting for reply" contact-request state) instead of the
empty "unlock" hint. No-op when the chat feature is off.
Verified: Linux + Windows build with chat ON, ctest 100%, hygiene clean.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The full-node receive harvest works off z_viewtransaction; the lite wallet's
transactions come from the backend instead, so lite chat receive was unwired.
App::ingestLiteChatMemos runs after each lite refresh: the backend lists one
entry per received (non-change) note — same txid, distinct position + decoded
UTF-8 memo — so a chat tx yields two entries (header + payload). Group the
Receive-kind notes by txid, feed them through the SAME extractHushChatTransaction
Metadata parser (now order-tolerant, so the daemon's output shuffle is a non-issue),
and thread the result into ChatService. The store dedups (txid+position), so
re-listing every refresh is harmless.
This closes the lite variant's chat loop end-to-end: identity (exportSeed), DB
unlock/load, receive (here), UI, and send (broadcastChatMemosLite). Gated by
DRAGONX_ENABLE_CHAT (default OFF). Verified: Linux + Windows build with chat ON,
ctest 100%, hygiene clean; caches restored to OFF.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Make composed messages actually reach the wire: broadcastChatMemos sends the
header + payload as two 0-value memo outputs to the peer's z-address (header
first, the lower memo position), on both variants.
- chat_outgoing: chatSendOutputs(memos, utf8Prefix) — the pure, testable memo
encoder. Full-node memos get a "utf8:" prefix (dragonxd rejects raw JSON and
then UTF-8-encodes on-chain, byte-identical to SilentDragonXLite's
Memo::from_str); the payload is NOT double-hex. Lite memos are raw UTF-8 (the
backend does Memo::from_str directly). Header is always output 0.
- App::broadcastChatMemos: full-node builds a two-recipient z_sendmany array
(amount 0, from the spendable reply z-address, default fee) via submitZSendMany
with markFeeGapRetry=true — deliberately, to suppress the fee-gap auto-retry,
which rebuilds a single-recipient tx and would drop the payload output. Lite
routes to broadcastChatMemosLite (two 0-value LiteSendRecipients, raw memos;
the backend accepts duplicate addresses + 0-value for exactly this pattern).
- Encoding + design established by a four-codebase mapping (wallet, daemon, SDXL,
lite backend) and an adversarial review of the wiring (0 confirmed findings).
- Tests: chatSendOutputs (utf8:-prefix + header-first for full-node, raw for
lite) + on-chain round-trip (strip "utf8:" -> the harvest parser re-pairs and
decrypts).
Remaining as a LIVE test (cannot be proven from source): that dragonxd returns
the memo under memoStr verbatim, that recipient-array order maps to note
position, that a 0-value memo-only tx relays, and full SDXL<->DragonX interop.
Gated by DRAGONX_ENABLE_CHAT (default OFF). Verified: Linux + Windows(mingw)
build with chat ON, ctest 100%, hygiene clean; caches restored to the OFF default.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Add the outgoing side of HushChat: compose messages and start conversations.
The wire construction is the byte-exact inverse of the receive parser and is
proven by a self-consistent round-trip (build → parse → decrypt).
- src/chat/chat_outgoing.{h,cpp} (pure): buildOutgoingMessage encrypts via
encryptOutgoing and buildOutgoingContactRequest carries plaintext; both emit
the header memo JSON ({h,v,z,cid,t,e,p}, which nlohmann serializes
alphabetically to match SilentDragonXLite) + the payload memo, validating the
512-byte memo limit, the 64-hex peer key, and the "no leading '{'" rule for
request text. My public key goes in header "p"; the peer's key is the
encryption recipient.
- ChatService: composeMessage/composeContactRequest (encrypt with the held
identity) + recordOutgoing (echo an Outgoing ChatMessage into the store + DB —
we never harvest our own sends, so this is the only local record).
- App: sendChatMessage(cid,text) sources the peer z-addr + public key from the
conversation, composes, and records a random-id echo; startChatConversation
(zaddr,text) mints a random cid + composes a plaintext contact request;
chatReplyZaddr() picks a spendable z-addr. broadcastChatMemos() is the Phase-5
transport seam (network delivery + real-SDXL interop verification land there).
- Chat tab: a message composer (shown once the peer's key is known, else a
"waiting for reply" hint) + a "New conversation" modal that sends a contact
request to a z-address.
- Tests: outgoing round-trip through the receive parser + decrypt, contact-request
passthrough, validation guards, and ChatService compose + recordOutgoing echo.
Adversarially reviewed (crypto/interop, secret hygiene, logic, UI) — 0 findings.
Gated by DRAGONX_ENABLE_CHAT (default OFF). Verified: Linux + Windows(mingw)
build with chat ON, ctest 100%, hygiene clean; caches restored to the OFF default.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Add the first user-visible HushChat surface: a Chat tab in the sidebar with a
two-pane, read-only conversation view — a conversation list on the left and the
selected thread on the right — reading from the App-owned ChatService store.
- New src/ui/windows/chat_tab.{h,cpp} (RenderChatTab(App*), mirroring the
Contacts tab). Conversations are sorted by most-recent activity; peer
z-addresses resolve to contact names via the address book when known; each
message shows sender + timestamp + wrapped body, with a contact-request tag
and a read-only footer (composing arrives in a later phase). Empty states
cover "wallet locked / identity not ready" and "no conversations yet".
- Sidebar wiring: NavPage::Chat + registry entry (static_assert stays balanced),
NavPageSurface + GetNavIconMD (ICON_MD_CHAT), dispatch in app.cpp, trace/sweep
page names, and App::chatService() accessor. The tab is gated on the new
WalletUiSurface::Chat, which returns DRAGONX_ENABLE_CHAT != 0 — so it only
appears in chat-enabled builds and is hidden (and unreachable) by default.
- i18n: English defaults for the chat nav label + hint strings.
Verified: Linux + Windows(mingw) build with chat ON, ctest 100%, hygiene clean;
caches restored to the OFF default. The screenshot sweep now includes the Chat
tab (sweepPageName "chat") for per-theme visual review.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Swap the in-memory-only chat store for durable sqlite persistence with real
per-transaction timestamps, encrypted at rest under a key derived from the
wallet's own seed (no passphrase, works on encrypted and unencrypted wallets).
- ChatDatabase (src/chat/chat_database.{h,cpp}): sqlite store mirroring
data::TransactionHistoryCache. unlockWithSecret(seed) derives a 32-byte AEAD
storage key and a wallet-partition tag via domain-separated keyed BLAKE2b
(generichash) contexts. Every record — bodies, peer z-addrs, threading,
timestamps — is crypto_aead_xchacha20poly1305_ietf-encrypted with a random
nonce and the wallet tag as associated data; even the dedup key is a keyed
hash of txid+position, so nothing about your conversations is in cleartext on
disk. Rows are partitioned per-wallet; a different seed sees nothing. Messages
are decrypted once at ingest then re-encrypted under the storage key, so
load() needs only the storage key, not the chat identity.
- ChatService: ingest() now stamps each message with its own transaction time
(txid->time map + fallback) and writes new (store-deduped) messages through to
the database; loadFromDatabase() rehydrates the in-memory read model on unlock.
- App: unlock the chat DB with the same seed in provisionChatIdentityFromSecret
and load prior history; lock the DB + clear the decrypted in-memory store on
relock and on lite-controller rebuild.
Adversarial review (4 confirmed findings, all fixed): don't provision if the
wallet locks mid-fetch (re-check isLocked at completion); wipe the serialized
plaintext temporary in append(); trim the seed into a separate fully-wiped
buffer (no residue past a shrunk size()); scrub the mnemonic copy in the RPC
json result.
Tests: ChatDatabase round-trip (persist/reload, field + order fidelity), dedup,
per-wallet isolation, lock inertness, and ChatService write-through + reload
without an identity. Gated by DRAGONX_ENABLE_CHAT (default OFF). Verified:
Linux + Windows(mingw) build with chat ON, ctest 100%, hygiene clean; caches
restored to the OFF default.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Complete the Phase-1 App integration for HushChat, now that the dragonx
`hd-transparent-keys` daemon exposes a BIP39 mnemonic (z_exportmnemonic)
that is byte-compatible with SilentDragonXLite. Both variants derive the
chat identity from the wallet's OWN seed phrase, so it is portable across
full-node and lite (same words -> same KDF input -> same identity) and
recoverable from the single wallet backup.
- App owns a dragonx::chat::ChatService; maybeProvisionChatIdentity() runs
each update() tick and, once the wallet seed is reachable and unlocked,
derives the identity via deriveChatIdentityFromSecret and sets it on the
service. Full-node fetches z_exportmnemonic off the UI thread via the RPC
worker; lite reads it synchronously through LiteWalletController::exportSeed.
Secrets are wiped on every path.
- Wire the previously-dead TransactionRefreshResult.hushChatMetadata sink:
both the full and recent transaction-refresh completions now ChatService
::ingest the harvested memos (before the result is moved) so incoming
messages are decrypted and threaded. The store dedups across both paths.
- Robust provisioning gates: skip + re-arm on relock (isLocked, mirrored for
both variants), don't fetch before the encryption state is known, and treat
a non-mnemonic wallet (RpcError) as identity-unavailable rather than
retrying every tick. rebuildLiteWallet re-arms so a server switch / re-open
re-derives from the newly opened wallet.
Gated by DRAGONX_ENABLE_CHAT (default OFF) via the constexpr
hushChatFeatureEnabledAtBuild() gate, so the wiring folds away in shipping
builds. Verified: Linux + Windows(mingw) build with chat ON, ctest 100%,
hygiene clean; caches restored to the OFF default.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Phase 0b. The address book was buried in Settings behind a modal dialog.
Make it a first-class "Contacts" tab (which will also become the chat
roster), rendered inline in the main content area.
- New NavPage::Contacts (after History; ICON_MD_CONTACTS) +
WalletUiSurface::Contacts. isUiSurfaceAvailable's `default: return true`
shows it in BOTH variants; uiSurfaceNeedsWalletData's default keeps it
usable before wallet data loads. All the touchpoints wired: NavPageSurface,
GetNavIconMD, the app.cpp dispatch case, the app_network.cpp tracePageName
case, and the `contacts` i18n label.
- New src/ui/windows/contacts_tab.{h,cpp}: RenderContactsTab lifts the
toolbar + table + add/edit modal out of address_book_dialog, rendered in a
BeginChild scroll region (peers_tab pattern) instead of an overlay; the
add/edit form stays a modal layered over the tab. Reuses the existing
address_book_* i18n keys and the dialogs.address-book schema.
- Delete address_book_dialog.{h,cpp}; remove its app.cpp render pump and the
dead App::show_address_book_. The Settings "Address Book…" button now
navigates to the tab (setCurrentPage) instead of opening the modal, so the
Tools & Actions grid layout is untouched.
- CMake: swap the dialog sources for contacts_tab.
Behavior-preserving move; search/sort/keyboard/contrast land in 0d. Visual
check pending the per-theme screenshot sweep.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Phase 0a of the Contacts/Chat plan. The address book was a file-scoped
singleton (`s_address_book` + `getAddressBook()`) trapped inside
address_book_dialog.cpp, reachable only from that TU. Promote it to an
App-owned member so the upcoming Contacts tab, the Send contact picker,
and a future Chat roster all read one source of truth.
- app.h: add `data::AddressBook address_book_;` + `App::addressBook()`
accessors, next to the other owned data models.
- app.cpp: load it once in App::init() (idempotent; missing file is fine;
purely local, no daemon dependency).
- address_book_dialog.cpp: delete the singleton + getAddressBook(); read
through the App* the dialog already carries (dropping the dead
`(void)app;`). show() is static so it can't reach the App — drop its
per-open reload (the book is authoritative for the app lifetime and
self-saves on mutation). Drop the now-unused <memory> include.
No behavior change; groundwork for the Contacts tab.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The last four (more involved) robustness items from the audit:
- deleteBlockchainData: post the deleted-item count to the main thread (via an
atomic, since Notifications isn't thread-safe) and show a completion toast
("Blockchain data deleted (N items). Daemon restarting…") — previously the
result was only logged.
- Pool mining: pool start has a connect delay with no feedback; announce
"Starting pool miner — connecting…" on a successful start and "Pool miner
connected and hashing." once the poll confirms it (contained pool_starting_
flag; the shared mining-toggle state machine is untouched).
- Force Quit (shutdown screen): gate it on the status text having STALLED (a
genuine hang) rather than a bare 10s timer — with a 20s hard-ceiling backstop —
and show a state-aware caution naming the stuck step (force-quitting mid daemon
flush risks the chainstate).
- Benchmark: require a confirming second click that first builds candidates to
estimate the duration ("Benchmark takes ~Ns and interrupts mining"), instead
of interrupting mining immediately.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
From the app-wide robustness audit (calibrated to the import-key dialog). These
are the safety-critical fixes; several could cost users funds:
- Backup/export false success (FUND LOSS): exportAllKeys pre-seeded a header, so
a keyless result (the usual case when the wallet is encrypted+locked) still
looked non-empty and backupWallet wrote a private-key-less file and reported
"Backup saved". Now exportAllKeys returns an exported count; backupWallet and
the export-all dialog refuse to write / report success on 0 keys, disclose the
count, flag partial results as INCOMPLETE, and the backup dialog confirms
before overwriting an existing file (+ trims the path).
- Bootstrap: fail CLOSED — refuse to install an unverified multi-GB archive when
no checksum is published (was `return true`), mirroring the xmrig/daemon updaters.
- Restore-from-seed: require a valid BIP39 word count (12/15/18/21/24) with a live
"should be 24 words — you have N" hint instead of accepting any non-empty text.
- Encryption passphrase: detect leading/trailing whitespace and BLOCK with a
warning (not a silent trim, which would change the passphrase and lock the user
out).
- Receive payment QR: emit the canonical `drgx:` scheme with a URL-encoded memo
via a new shared util::buildPaymentUri (the request-payment dialog now routes
through it too, so they can't diverge); the old "dragonx:" + raw memo was
unparseable by the wallet's own scanner.
Also clamps the receive "Recent Received" list to the 4 most recent (companion to
the recent-lists commit).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- Organize sweep output into per-tab subfolders: <config>/screenshots/<tab>/<skin>.png
(was one timestamped folder with idx_skin_tab.png names).
- Fixed folder (no timestamp): subsequent sweeps overwrite the existing PNGs in
place instead of creating a new directory each run.
- Add an "Open location" TactileButton next to "Run screenshot sweep" that opens
the screenshots folder via Platform::openFolder(app->screenshotDir()).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Add a debug tool that cycles every skin across every build-enabled tab and saves a
PNG of each to a timestamped folder under the config dir (screenshots/sweep_<ts>/)
— for gathering visual context on theme-specific UI work.
- App state machine (app_network.cpp): startScreenshotSweep() builds the
skin+enabled-page lists, saves the current skin/page, and steps through them;
updateScreenshotSweep() (top of App::render) pins the page + settles ~4 frames
after each skin/page change (skin switches reload TOML + reset the acrylic
capture, so they need to settle); wantsScreenshotThisFrame()/screenshotSweepPath()
/onScreenshotCaptured() coordinate with main.cpp. Restores the original skin/page
when done; nothing persisted.
- Framebuffer capture (main.cpp): read the finished frame and encode PNG via the
bundled miniz (tdefl_write_image_to_png_file_in_memory_ex). GL reads FBO 0 (RGBA,
bottom-up -> flip); DX11 copies the backbuffer to a staging texture + maps it
(BGRA, top-down -> channel-swap). Alpha forced opaque.
- Settings UI: move the Verbose logging checkbox off the wallet row into a new
"Debug options" button that opens a dialog housing the verbose toggle + a "Run
screenshot sweep" button. New i18n keys.
Both platforms build; no-crash smoke verified.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Grep-verified dead, zero live callers (the live paths all go through
BeginOverlayDialog and already got the blur backdrop):
- App::renderAboutDialog (app.cpp/app.h) — dispatch uses ui::RenderAboutDialog.
- src/ui/windows/import_key_dialog.{h,cpp} (ImportKeyDialog class) — live path is
App::renderImportKeyDialog.
- src/ui/windows/backup_wallet_dialog.{h,cpp} (BackupWalletDialog class) — live
path is App::renderBackupDialog.
Drop the two dead files from CMakeLists. No behavior change.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Portfolio-group sparklines previously resampled the ~24-minute in-session price
buffer, so the hour/day/week/month intervals could never fill. Fetch real
historical USD series from CoinGecko /market_chart and back each interval with
appropriately-grained data.
- MarketInfo gains two timestamped series: price_chart_intraday (days=1, 5-min)
and price_chart_daily (days=365, daily), plus a fetch timestamp.
- App::refreshMarketChart() fetches both on the RPC worker via the TLS-verifying
util::httpGetString helper, self-throttled to ~30 min (historical data moves
slowly). Gated by getFetchPrices(); triggered on connect and each price tick.
- Pure NetworkRefreshService::parseCoinGeckoMarketChart() parses {"prices":
[[ms,price],...]} into (unix-seconds, price); malformed rows skipped. Unit-tested.
- market_tab pfSparklineSeries() maps interval -> series+bucket: minute = live
buffer; hour = intraday bucketed to 1h; day/week/month = daily bucketed to
1d/7d/30d. Falls back to the in-session buffer until the fetch populates.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Extract App::restartDaemonAfterEncryption(taskName, announceRestartStatus)
from the two verbatim copies in encryptWalletWithPassphrase and
processDeferredEncryption. The two differed only in the async task name and
whether they set connection_status_ = "restarting_after_encryption" first
(the immediate encrypt path does; the deferred path does not) — both
parameterized. The 20x100ms settle loop, cancel/shutdown guards, and
stop/startEmbeddedDaemon sequence are preserved exactly.
Deliberately NOT done: the audit's "decrypt 5-deep callback pyramid" finding.
On inspection the nesting is load-bearing, not gratuitous — WalletSecurityWorkflow
is unsynchronized main-thread-only state, and each step's worker->MainCb bounce
is what keeps its mutations on the main thread (the UI reads snapshot()/importActive()
every frame); collapsing the pipeline onto one worker/async task would race the
UI, and the final restart+import stage requires an AsyncTaskManager::Token that
worker_->post can't supply. Left byte-for-byte intact.
Full-node + Lite build clean; ctest 1/1 (incl. WalletSecurity controller tests);
hygiene clean.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Three behavior-preserving consolidations in src/app_network.cpp:
1. Fold the three near-identical pending-send balance-delta blocks
(upsertPendingSendTransaction debit, removePendingSendTransactions
restore, applyPendingSendBalanceDeltas debit) into one
App::applyPendingSendDelta(from, signedAmount, includeAggregates). The
restore path's unclamped `+amt` is provably identical to the clamped
signed form (balance/amount are >=0 invariants), so a single clamped
helper reproduces all three exactly.
2. Collapse createNewZAddress/createNewTAddress into one
App::createNewAddress(bool shielded, cb) with thin public forwarders,
preserving the lite early-return, the dual push into the type list AND
the combined state_.addresses view, and the dirty-flag/refresh bookkeeping.
3. Extract App::submitZSendMany() shared by sendTransaction and
resendWithFeeGapWorkaround — the only differences (the TraceScope label
and the retry-only send_feegap_retried_opids_.insert) become parameters.
The in-flight counter, opid tracking, upsertPendingSendTransaction, and
pending_send_callbacks_ bookkeeping are byte-equivalent.
Full-node + Lite build clean; ctest 1/1; hygiene clean.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Three P1 structural refactors from the audit; no behavior change.
1. Extract the ~180-line daemon-stdout rescan/witness parser out of the
~790-line App::update() into a pure, static, unit-testable
NetworkRefreshService::parseDaemonRescanOutput() returning a
DaemonRescanScan result struct (sits next to the existing static parse*
siblings). The scanning half moved verbatim; App::update() keeps the
state-application half, now reading scan.* fields. Adds
testParseDaemonRescanOutput() covering all six daemon signals + edge
cases against fixture log snippets — previously untestable without a
live daemon.
2. Extract the ~95-line global keyboard-shortcut block (Ctrl+, / theme
cycle / F5 / low-spec / effects / gradient / wizard) out of
App::update() into App::handleGlobalShortcuts().
3. Collapse the ImGuiAcrylic frontend fork: the GLAD and DX11 namespaces
were ~375 lines of semantically-identical code (the frontend makes zero
direct GL/DX calls — all backend work delegates to AcrylicMaterial, which
has a backend per API). Extended the single frontend's guard to
#if defined(DRAGONX_HAS_GLAD) || defined(DRAGONX_USE_DX11) and deleted the
DX11 duplicate, leaving the no-backend stub. Kills the Windows/DX11 drift
hazard (acrylic frontend changes now made once).
Verified: full-node + Lite build clean; ctest 1/1 (incl. the new parser
tests); source-hygiene clean; and the collapsed acrylic path was
compile-verified under DX11 via the mingw-w64 Windows cross-build
(ObsidianDragon.exe links).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Four correctness fixes surfaced by the codebase audit:
1. Lite address-book path bug: AddressBook::getDefaultPath() hardcoded
"ObsidianDragon" while Settings::getDefaultPath() uses DRAGONX_APP_NAME,
so the Lite build wrote addressbook.json into the full-node app's config
dir instead of ObsidianDragonLite/. Now uses DRAGONX_APP_NAME on all
platforms (no macOS path change — the getConfigDir consolidation, which
would move the macOS dir, is deferred).
2. PIN-unlock lockout bypass: the PIN path duplicated unlockWallet's
success/lockout logic and its RPC-error branch bumped the attempt counter
but skipped the escalating-lockout math entirely — a PIN user hitting an
RPC error escaped the lockout curve. Extracted App::applyUnlockSuccess()
and App::applyUnlockFailure() and routed all unlock paths (passphrase,
PIN vault-fail, PIN RPC-fail) through them, so the lockout curve now
applies uniformly. (noVault stays a mode-switch, not a failed attempt.)
3. Witness/rescan reset drift: the ~11-line rescan+witness completion reset
was copy-pasted at four sites (app.cpp x2, app_network.cpp x2); adding a
witness field and missing a copy would leave stale progress. Folded into
App::resetWitnessRescanProgress(). Left the distinct phase-transition
reset in App::update() untouched (it sets witness_phase, not 0).
4. Truncation underflow: send_tab/receive_tab's size_t TruncateAddress
copies computed (maxLen - 3) without guarding maxLen <= 3, which wraps
and throws std::out_of_range on short inputs. Added the guard.
Full-node + Lite build clean; ctest 1/1; source-hygiene clean.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The two consoles were separate implementations (the rich full-node ConsoleTab and a
minimal RenderLiteConsoleTab). Introduce ConsoleCommandExecutor to abstract the only
real differences — command execution + log source — so both variants share the one rich
terminal (the lite console now gains selection, zoom, copy, and JSON-colored output).
- console_command_executor.{h,cpp}: interface + FullNodeConsoleExecutor (dragonxd log
ingestion + RPC command execution, result queue) and LiteConsoleExecutor (lite
diagnostics ring + backend runConsoleCommand/takeConsoleResult, connection/sync status).
- ConsoleTab::render() now takes a ConsoleCommandExecutor&; log ingestion, command
submission, and command results flow through it. The RPC command-reference popup and
the daemon/errors/rpc-trace/app filter toggles are gated on the executor's capabilities;
the toolbar status dot + a status header come from the executor.
- Safety preserved: `clear`/`cls` is intercepted as a view-only clear in the shared UI so
it is NEVER forwarded (the lite backend's `clear` wipes tx history); command output is
not persisted to diagnostics.
- App owns the executor (created per variant) and routes both NavPages to console_tab_;
lite_console_tab.{h,cpp} deleted.
Needs runtime verification of BOTH consoles (full-node RPC + lite: clear, seed/export
secrecy, sync status, command results).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- Fetch /coins/dragonx-2/tickers once per session (App::refreshExchanges, reusing the
price-fetch worker path) and build the exchange registry at runtime into
state.market.exchanges; parseCoinGeckoTickers groups tickers by venue. The Market tab
sources from this live list and falls back to the compiled-in registry (now including
OurBit + Nonkyc.io) when offline. Fixes the missing OurBit pair and auto-tracks future
listings.
- Fix stale selection defaults (TradeOgre / DRGX-BTC -> Nonkyc.io / DRGX/USDT) that never
matched the registry, and make the attribution generic ("Price data from CoinGecko").
- Reframe the single-asset portfolio card as a DRGX holdings dashboard: relabel to
"MY DRGX" and show the 24h change on the holdings value (colored by direction).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- Address list: a mining-flagged address stays visible even at 0 balance when
"hide zero balances" is on (payout addresses shouldn't vanish).
- Drag-reorder: persist dense sort orders (0..N-1) for the whole visible list on
drop via App::reorderAddresses, instead of a pairwise swap that no-ops when both
rows are still at the default un-ordered state. First drag now always takes
effect, and explicit order keeps overriding the starred/type/balance sort.
- Tests: 0-balance mining row survives hide-zero; ordered non-favorite outranks a
favorite.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- app: periodic weighted-random auto-balance of the pool while pool-mining
(~30-min cadence; restarts the miner only when the pick actually changes),
plus snapshot + refresh accessors for the UI.
- app_network: resolve the xmrig algo per pool at start (pool.dragonx.cc =
rx/dragonx, pool.dragonx.is = rx/hush; custom hosts keep the setting).
- xmrig_manager: schema-aware pool-side hashrate readout (fixes a silent 0 for
Miningcore pools); expose the running miner's version from its HTTP API and a
cached `xmrig --version` detection so the UI can show it before mining.
- wallet_state: carry the running miner's version.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Verified by running the app against a live node and watching a real rescan. Three
issues that only surfaced at runtime:
- Wrong RPC name: this daemon (hush/komodo) exposes the runtime rescan as
"rescan <height>", not bitcoin's "rescanblockchain". runtimeRescan() and
RPCClient::rescanBlockchain() used the bitcoin name and failed with "Method not
found" on every node. Corrected to "rescan".
- Witness/rescan progress never surfaced during a rescan: the daemon-output parser
that drives it was gated behind rpcConnected, but a heavy rescan holds cs_main so
getinfo times out and the RPC reads disconnected — silencing the parser exactly
when it's needed. The parser reads the daemon's stdout pipe (no RPC), so it now
runs whenever the daemon process is alive. It also now parses INLINE on the main
thread instead of via fast_worker_, so it can't be starved when the worker is
blocked on a getrescaninfo call (which waits on cs_main during a witness rebuild).
- Witness rebuild has TWO sub-phases with different scales — the initial-witness
pass ("Setting Initial Sapling Witness for tx <hash>, <i> of <N>") and the cache
walk ("Building Witnesses for block <h> <frac> complete, <n> remaining"). Tracking
them with one monotonic value pinned the bar at the initial pass's ~100% through
the whole cache walk. They're now tracked as distinct phases (witness_phase) with
their own monotonic progress and labels ("Setting witnesses" vs "Rebuilding
witnesses"), so neither resets/bounces and the long phase shows real movement.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The witness-rebuild bar reset repeatedly because the daemon's "Building Witnesses
for block <h> <frac> complete" line reports per-call progress: BuildWitnessCache is
re-invoked for each connected block and each call walks from its own start height to
the tip, so the fraction restarts every time. The earlier "Setting Initial Sapling
Witness for tx <i> of <m>" counter resets per call too, so neither is a usable
overall metric.
Derive a stable, monotonic percentage from the "<n> remaining" count instead: track
the largest "remaining" seen during the phase as the full span and show how far
remaining has fallen below it. The longest pass defines 0→100%; the short per-block
follow-up passes only nudge the bar near the end rather than resetting it. The
"Setting Initial" line now only marks the phase active. Per-phase tracking resets at
phase start and every rescan-completion site.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
A wallet bootstrapped from a snapshot keeps its wallet.dat but never rescans, so
its spent-state is stale and the first send tries to spend already-spent notes and
is rejected. The startup -rescan flag can't fix it either: the snapshot lacks the
pre-snapshot block history -rescan needs, so it errors. The working fix is a runtime
rescanblockchain RPC from a height the snapshot actually has.
- Add App::runtimeRescan(startHeight): runs rescanblockchain via the worker, drives
the rescanning UI state, and owns completion via the RPC callback (getrescaninfo
is unavailable on this daemon). Suppresses the per-second mining/rescan pollers
and the Core/balance/tx refreshes while the daemon holds cs_main for the scan.
- Add App::detectLowestAvailableBlockHeight(): async binary search via getblock for
the lowest height whose block data is on disk → the snapshot base, and whether the
node still has full history.
- Auto-reconcile after bootstrap: both completion sites (wizard + Settings download
dialog) mark a pending rescan; once the daemon is back up and the tip is known,
detect the base and runtimeRescan() from it (or -rescan restart on a full node).
- Settings "Rescan Blockchain" now probes first: full-history nodes get the existing
-rescan restart; bootstrapped/pruned nodes get a prompt pre-filled with the
detected base height that runs the runtime rescan.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Previously the wallet re-extracted the bundled dragonxd on startup whenever the
installed binary's size differed from the bundle ("stale" overwrite), which could
replace a node a user had deliberately placed in dragonx/.
Now dragonx binaries (dragonxd/cli/tx) are auto-placed ONLY when missing — never
auto-overwritten on a size mismatch (needsParamsExtraction + extractEmbeddedResources).
Params/asmap keep their size-based refresh; a daemon dropped next to the wallet exe
still takes priority and is never touched.
Replacing the daemon is now an explicit action: Settings → "Daemon binary" reports the
installed binary's version (scanned from the file), size and modified date, compares it
to the version bundled in this build, and offers an "Install bundled daemon" button.
That stops the node, overwrites dragonxd/cli/tx with the bundled copies (waiting for the
process to release the file lock), and restarts — wallet/keys/chain data untouched.
Adds resources::{getInstalledDaemonInfo,getBundledDaemonInfo,reextractBundledDaemon}
(+ a version-string scanner) and App::reinstallBundledDaemon().
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
dragonxd's z_sendmany picks notes to cover the recipient total (nTotalOut) but not
the miner fee, then rejects the build unless the selected notes cover amount+fee
(rpcwallet.cpp:5312 vs asyncrpcoperation_sendmany.cpp:278). So a shielded send whose
largest notes sum exactly to the amount fails with "Insufficient shielded funds,
have H, need H+fee" despite ample balance — e.g. sending exactly 2.0 from an address
whose biggest note is 2.0.
Since the failure is async (reported via the opid poll), detect it there: when a
shielded send fails with that message and the selected total H >= the requested
amount (selection covered the amount but stopped one note short of the fee — vs a
genuine shortfall where H < amount), re-issue the send once with a tiny self-output
(= fee) back to the from-address. That lifts the daemon's selection target past the
boundary so it grabs another note and can cover the fee; the recipient still receives
the exact amount. Retries are tracked so a second failure surfaces normally (no loop).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
When a note's stored record is corrupt or its tx isn't in the canonical chain,
z_sendmany fails to build a valid sapling spend proof even after a full -rescan,
because a plain rescan replays witnesses but keeps the existing tx/note records.
The zcashd repair for this is -zapwallettxes=2, which deletes all wallet tx/note
records and rebuilds them from the chain (keys/addresses preserved).
Adds a RepairWallet lifecycle operation that mirrors the existing -rescan plumbing
(one-shot zapOnNextStart flag on the embedded daemon; -zapwallettxes=2 implies and
supersedes -rescan), an App::repairWallet() that reuses the rescan status UI (so the
status bar + warmup-end completion detection apply), and a confirmed "Repair Wallet"
button + dialog in Settings → node maintenance (embedded daemon only).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
History streams in over many refresh cycles (the incremental shielded scan
walks every z-address), so the first batch appears long before the list is
complete — with no indication more is still coming. The existing loading banner
deliberately goes quiet once any rows are on screen.
Track whether the first full shielded scan has finished
(initial_history_scan_complete_) and, until it has, surface a progress percentage
(fraction of z-addresses scanned) in transactionRefreshProgressText() — which the
History tab already renders as its pulsing loading indicator. Goes quiet once the
first scan completes; routine per-block re-scans don't re-trigger it. Reset on a
full history invalidation (rescan / session reset) so it shows again on reload.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Clicking Settings → Rescan restarted the daemon with -rescan correctly, but the
progress poll fired "Blockchain rescan complete" the instant it was clicked,
then showed nothing for the entire (multi-hour) rescan — so it looked broken.
Cause: the very first getrescaninfo poll runs before the daemon has restarted
and hits the still-running pre-restart daemon, which answers rescanning=false.
The completion branch took that as "done", cleared the rescanning flag, and the
real rescan then ran invisibly. (Confirmed from a Windows debug-log capture: an
instant OK{"rescanning":false}, then ~6400 warmup errors over ~5h, all swallowed.)
Fixes:
- Gate completion on a new rescan_confirmed_active_ flag that's only set once we
actually observe the rescan running, so a pre-restart rescanning=false can't be
misread as completion.
- While the daemon is in -rescan RPC warmup it rejects every call with the live
phase as the message ("Loading block index..." -> "Rescanning..."). Treat that
as proof-of-progress: surface it as rescan_status and mark confirmed-active,
instead of silently swallowing it. The status bar keeps its animated
"Rescanning..." for the whole run, then reports complete when warmup ends.
- Read rescan_progress whether the daemon returns it as a string or a number
(the get<std::string>() would have thrown on a numeric field).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The full-node wallet polled the daemon at the per-tab cadence regardless of sync state.
On the Peers/Network tab that meant getpeerinfo every 5s + core every 5s + a full
transaction scan on every new block — and blocks arrive fast during sync. Each of those
calls takes the daemon's cs_main lock, the same lock block connection needs, so the node
synced noticeably slower than on the lightweight Console tab (core 10s, no peer polling).
Make the refresh cadence sync-aware:
- RefreshScheduler::kSyncProfile {core 10s, transactions/addresses/peers disabled} is applied
to ALL tabs while state_.sync.syncing, and reverts to the per-tab profile when sync ends.
applyRefreshPolicy() picks the profile; update() re-applies it on the syncing<->synced
transition. This suppresses getpeerinfo and the per-block tx scan during sync (that data is
incomplete mid-sync anyway) — every tab now syncs as fast as Console.
- collectCoreRefreshResult(rpc, includeBalance): skip z_gettotalbalance (wallet lock + cs_main)
while syncing; only getblockchaininfo runs, which is also what drives sync-progress detection.
applyCoreRefreshResult already leaves the balance untouched when balanceOk is false.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
- rpc_client: wipe the plaintext "user:password" temporary with sodium_memzero after
base64-encoding it into the auth header (std::string doesn't zero its buffer on
destruction).
- connection: the auto-generated DRAGONX.conf holds rpcuser/rpcpassword in plaintext but
was written with the default umask (often world-readable 0644). Restrict it to owner
read/write after creation so another local user can't read the credentials.
- app: copying a seed phrase / private key to the clipboard now arms an auto-clear —
App::copySecretToClipboard() copies the secret and, after 45s, wipes the clipboard IF it
still holds that secret (compared via a stored hash, never the plaintext). Wired into the
lite first-run wizard's seed Copy and the Settings export-secret Copy, with a
"clipboard auto-clears in 45s" notice. pumpSecretClipboardClear() runs each frame.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The daemon can spawn successfully (CreateProcess OK) and then exit immediately —
a missing runtime DLL, wrong architecture, corrupt binary, datadir lock, etc.
EmbeddedDaemon's crash monitor already builds a detailed reason for this
(translated Windows exit code, e.g. "STATUS_DLL_NOT_FOUND — required DLL not
found", plus the launch command and a debug.log tail) and stores it in
lastError(), but it runs on a background thread and was never shown. The result
was the exact symptom users reported: the wallet unpacks dragonxd.exe, looks
"stuck connecting", and the node silently dies-and-respawns until it gives up —
with no visible reason (manually starting dragonxd works, so the wallet then
connects to it).
tryConnect now watches the daemon's crash count (on the main thread, where it
already logs daemon state) and surfaces each NEW crash's lastError() once, as a
sticky error notification, with a concise "Couldn't start dragonxd" status. The
counter resets on a successful connect (alongside the daemon's own crash-count
reset), so a later crash re-notifies.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Two failure modes left the wallet stuck on a silent "connecting / Starting
dragonxd…" spinner with no path forward:
1. Stale external-daemon latch. EmbeddedDaemon::start() sets
external_daemon_detected_ whenever the RPC port was busy at a prior attempt
and never re-checks it, so tryConnect's no-config branch trusted that latch
and waited forever for a config the phantom would never write — even after a
stale/half-dead process freed the port. Now the port is re-evaluated LIVE
(EmbeddedDaemon::isRpcPortInUse()) each attempt: if it's genuinely busy we
keep waiting (and, after a bounded ~20s with no config, warn that whatever
owns the port isn't a usable DragonX node and how to fix it); if it's free we
fall through and start our own daemon.
2. Silent start failure. When startEmbeddedDaemon() failed (binary not found,
Sapling params missing, spawn failure) the status stayed on "Starting
dragonxd…" with the real reason only in a VERBOSE log. Now the reason
(daemon_controller_->lastError()) is surfaced once as a sticky error
notification, with a short "Couldn't start dragonxd" status.
Both counters reset on a successful connect so the messages re-arm for the next
disconnect. Lite is unaffected (tryConnect returns early for lite builds).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
When the full-node connect probe (getinfo) times out, the daemon is reachable
at the TCP level but busy initializing (loading the block index, verifying,
activating best chain, …) and won't answer RPC. The wallet only recognized the
JSON-RPC -28 warmup reply, so a raw socket timeout fell through to a bare,
alarming "Connection failed" retry with no indication of what the user was
waiting on.
Add a daemon-initializing UI state that drives the existing loading overlay:
- WalletState::daemon_initializing — daemon up/launching but not serving yet
(distinct from warming_up, which needs a -28 reply).
- App::applyDaemonInitStatus() infers the current phase from the daemon's own
console output (scanning recent lines for Loading/Verifying/Activating/
Rescanning/Rewinding/Pruning) and the latest block height, producing a
friendly title + description, e.g. "Processing blocks… (Block 123456)".
- The connect loop calls it from the daemon-starting and external-detected
branches: a timeout -> "reachable but initializing", a connect refusal ->
"launching, waiting to come online". Cleared on a real connect.
- The loading overlay now shows the description for daemon_initializing too,
and the status-bar amber indicator covers it (so Peers/Console tabs without
the overlay still explain the wait).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Opening an existing lite wallet ran synchronously on the UI thread and used a
single server, so a dead/unreachable lightwalletd server froze startup for the
connect timeout and then stranded the wallet ("disconnected" spinner) — and the
DragonX lite servers are flaky (often several down at once).
Add LiteWalletController::beginOpenExisting() / pumpAsyncOpen(): the open runs on
a background thread (mirroring the sync/broadcast shared-lifetime pattern — it
captures only shared_ptrs + value copies, never `this`), trying the preferred
server first and then every other usable default until one succeeds. The main
thread finalizes the result (flips walletOpen, starts sync) or records the reason.
The rollout gate is still checked up-front on the main thread.
App: auto-open now calls beginOpenExisting() and pumps it each tick, retrying on
a 20s interval so a transient outage self-heals once a server returns; a failed
open surfaces its reason (notification + Network tab) instead of a silent spinner.
Tested: a fake bridge that fails specific servers exercises both
preferred-dead -> fallback-opens and all-dead -> fails-with-reason. Built clean
for full-node, lite, and Windows cross-compile.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The startup auto-open of an existing lite wallet discarded openWallet()'s result,
so when initialize_existing failed (e.g. the lightwalletd server is unreachable)
the UI just showed a "disconnected" spinner with no reason — and DEBUG_LOGF is
compiled out of release builds, so there was no way to see why. Capture the
failure: store the reason, show it in the Network tab status line (in place of
"no wallet open"), and raise a notification. Cleared once a wallet opens.
This doesn't change open behaviour — it makes a stuck open diagnosable.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The connection state machine never tore down on a lost connection: refresh-loop
RPC errors were swallowed, rpc_->isConnected() stayed true after a daemon
crash/restart/socket drop, and the UI showed stale balances with no reconnect.
Several operations also ran synchronous curl straight from ImGui handlers.
- Add handleLostConnection(): after N consecutive cycles where BOTH core RPCs
fail (warmup excluded, so no reconnect loop), disconnect so update()'s
reconnect branch re-enters tryConnect().
- Move banPeer/unbanPeer/clearBans and key export/import onto the worker thread
(import requests a rescan that could freeze the UI for the curl timeout).
- Run the block-info dialog's two chained RPCs on the worker thread (+ guard the
getblockhash result type).
- Detect daemon warmup via the JSON-RPC -28 code (new RpcError carrying the code;
message text preserved so 401/warmup string-matching is unaffected), and widen
CONNECTTIMEOUT to 10s for remote/TLS hosts (2s localhost).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
z_sendmany returns an opid immediately; the tx is built/signed/broadcast
asynchronously afterward. The send path showed "Transaction sent successfully!"
and cleared the form on opid receipt, so a later async failure contradicted it.
Shield/merge stored the opid only in a dialog-local static (never polled), and
auto-shield ran a blocking z_shieldcoinbase on the UI thread and discarded its
opid — async failures of all three were silently lost.
- Add App::trackOperation(opid) so shield/merge/auto-shield register with the
shared opid poller (failures surface, balances refresh on completion).
- Defer the full-node send's success/failure to the poller via per-opid callbacks
(parseOperationStatusPoll now exposes failureByOpid); the "Sending..." spinner
covers the finalizing window, and the form is kept until terminal status.
- Dispatch auto-shield through the worker thread and use the configured fee.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
A lite-wallet-only "Network" tab (full-node keeps the Peers tab; exactly one shows per variant)
to manage lightwalletd servers, replacing the basic selector that was in Settings.
- Card list of servers with per-server latency + status dot, DNS host + resolved IP, and an
Official/Custom pill. Official DragonX servers get a glowing outline.
- Pick a server (Sticky) by clicking its card, or toggle "use a random server" (Random mode);
selection applies immediately (App::rebuildLiteWallet(force=true) tears down + rebuilds the
controller against the new server and resyncs — its dtor detaches the uninterruptible sync
thread, so this doesn't block).
- Add custom servers; hide/unhide servers (persisted set, revealed by a "Show hidden" toggle).
- Latency/IP come from a new background probe (util/LiteServerProbe): libcurl CONNECT_ONLY does
the TCP+TLS handshake (works for gRPC lightwalletd, no HTTP response needed), recording
APPCONNECT_TIME as latency and CURLINFO_PRIMARY_IP. Auto-runs on tab open + a Refresh button.
Wiring: WalletUiSurface::LiteNetwork (gated !fullNodePagesAvailable) + NavPage::LiteNetwork in
the sidebar + app.cpp dispatch; settings gains a hidden-servers set; isOfficialLiteServer() added
to lite_connection_service. The Settings page lite-server selector + its plumbing are removed
(single source of truth = the tab).
Reuses the existing server model (LiteServerPreference, Sticky/Random, selectLiteServer) and UI
primitives (DrawGlassPanel, ThemeEffects glow, peers-tab ping-dot idiom). Unit-tested
(liteServerHost, isOfficialLiteServer) + an env-gated live probe (verified vs lite.dragonx.is:
online, latency, IP). Both variants + lite-backend build; suite passes; hygiene clean; GUI
smoke-launched without crash.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Addresses confirmed findings from the multi-lens review of the updater:
- Cancelable + live progress (was: download uncancelable, progress stuck at 0%, closing
the dialog mid-download blocked the UI thread on the worker join). Wire a libcurl
CURLOPT_XFERINFOFUNCTION that publishes byte counts and returns abort when cancel() is
requested; add a Cancel button. The dialog's destructor now aborts the transfer promptly,
so closing mid-download no longer freezes the UI.
- Graceful "unavailable" instead of a red error on platforms with no published build
(macOS / ARM): new terminal State::Unavailable rendered neutrally, not as a failure.
- Install-time running guard (TOCTOU): App::isPoolMinerRunning() re-checked in the dialog
before each install, so a dialog opened before mining started can't replace a live binary.
- Size caps: CURLOPT_MAXFILESIZE on the download and a per-archive-member ceiling before
decomphressing into memory, to bound an attacker-controlled archive.
- Distinguish a local read failure of the downloaded archive from a checksum mismatch
(was reported misleadingly as "possible tampering").
- Reword the dialog's verification note to "checked against the release's published SHA-256
checksum" (integrity, not authenticity — see the signing note below).
Not fixed here (needs your input): WinRing0x64.sys has no per-file hash published, but it is
covered by the verified archive checksum (it is inside the verified zip); and the release is
not cryptographically signed — checksums and binary share one trust root. Adding a pinned-key
ed25519/minisign signature is the real supply-chain hardening and needs an offline signing key
+ a release-process change.
Both variants build; suite passes; live worker re-verified end-to-end on linux-x64.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Startup lock screen (soft): once the first refresh reveals the auto-opened wallet
is encrypted+locked, show the unlock modal on launch (reusing renderLiteUnlockPrompt,
one-shot per session). Soft by design — balances stay viewable via viewing keys
while locked, so the user may dismiss and browse read-only; only spending needs
the passphrase.
Real-backend verification: add `lite_smoke --encrypt` (create -> encryptionstatus
-> encrypt -> lock -> unlock, checking flags; passphrase never printed). Running it
against the real SDXL backend showed encrypt LOCKS immediately
(after encrypt: encrypted=1, locked=1) — the backend removes spending keys right
after encrypting. The controller already relays encryptionstatus faithfully (UI is
state-driven, so unaffected), but the fake modeled encrypt->unlocked; corrected the
fake (encrypt -> encrypted+locked) and the test sequence (encrypt -> unlock -> lock
-> decrypt) to match real behavior.
Builds clean, tests pass, hygiene clean.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
When the user confirms a send on a locked encrypted lite wallet, show an unlock
modal (passphrase -> unlockWallet) instead of letting the backend reject it with
"Wallet is locked". After unlocking, the user re-confirms the send (the form is
preserved). Balances remain viewable while locked; only spending needs unlock.
- send_tab: the Confirm-and-send button routes to App::requestLiteUnlock() when
getWalletState().isLocked(), else sends as before.
- App::renderLiteUnlockPrompt(): centered modal, passphrase (Enter submits),
Unlock/Cancel; the passphrase buffer is sodium-zeroed after every path.
Full-node unaffected (gated on liteWallet()/isLocked()). Builds clean, launches
clean, tests pass.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Replace the bare "land on main UI with a No-wallet overlay" first-run with a
lite welcome modal, shown when no wallet file exists yet (lite_wallet_ present,
not open, walletExists() false):
- "Create new wallet" — one-click createWallet({}); on success, notifies the user
to back up their recovery phrase and navigates to Settings (Backup & keys),
where the seed can be revealed/copied via the existing backup UI.
- "Restore from seed" — navigates to Settings (Lite wallet request → Restore).
- "Later" — dismiss for the session.
Routes to the already-built + verified create/restore/backup flows rather than
re-implementing seed display in the modal (no new secret-handling surface).
Dismissed once an action is chosen; never shown again once a wallet exists.
Full-node is unaffected (renderLiteFirstRunPrompt() returns early when
lite_wallet_ is null). English i18n built-ins added.
Verified: fresh-HOME lite launch shows the prompt, clean run + shutdown, no
crash/RPC noise; tests pass.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Auto-open: on the first update() tick (kept off init() so a slow
initialize_existing network call can't freeze startup before the window), if a
wallet file exists, open it. initialize_existing needs no passphrase — it loads
the file; a previously-synced + saved wallet resumes from its height (fast)
instead of rescanning from the checkpoint. Adds LiteWalletController::walletExists()
(bridge.walletExists on the connection's chain) + a chainName_ member.
RPC-refresh gating: the earlier connected=walletOpen() fix (so the wallet UI is
enabled in lite) had a side effect — the full-node periodic + per-page RPC
refreshes (mining/balance/peers/txs, and setCurrentPage's immediate refresh)
gate on state_.connected, so they began firing in lite and failing
("X error: Not connected"). Re-gate those on ACTUAL RPC connectivity
(rpc_ && rpc_->isConnected()) instead of the lite proxy. Full-node is unchanged
(state_.connected ⟺ rpc connected there); lite no longer issues any RPC.
Runtime-verified in WSLg with a pre-seeded wallet: app auto-opens (Starting
Mempool + sync begins), and "Not connected" / getMiningInfo / RPC-connect noise
all drop to 0 — a fully clean lite run. tests pass.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Route the existing receive/balance/send UI to the lite controller in lite builds,
with no per-tab UI changes — the existing buttons just work:
- App::createNewZAddress / createNewTAddress: lite branch calls
lite_wallet_->newAddress() (synchronous local key derivation), injects the new
address into WalletState so the UI selects it next frame, and invokes the
receive-tab callback. Placed before the full-node !connected guard.
- App::sendTransaction: lite branch builds a LiteSendRequest (DRGX -> zatoshis,
memo; `from`/`fee` ignored since the backend selects inputs and adds the fee),
fires the controller's async broadcast, and stashes the send_tab callback.
- App::update: drains takeBroadcastResult() and delivers txid/error to the stored
callback, so the send_tab's existing "sending.../sent" flow works unchanged.
All branches guard on lite_wallet_ (null in full-node). Verified: lite app +
test suite + full-node variant all build/link clean; hygiene clean.
Backup/import UI (export seed/keys, import) is deferred — it needs new
secret-display UI rather than an existing button.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>