From 6ee81a5abeb1f88b0aa0f446677bfb3b890cec46 Mon Sep 17 00:00:00 2001 From: DanS Date: Tue, 18 Aug 2026 13:18:49 -0500 Subject: [PATCH] fix: security-audit remediation (15 findings), empty-wallet warning, and send/chat/console/shutdown UX 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..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) --- CMakeLists.txt | 6 +- res/fonts/NotoSansCJK-Subset.ttf | Bin 667124 -> 669212 bytes res/lang/de.json | 11 ++ res/lang/es.json | 11 ++ res/lang/fr.json | 11 ++ res/lang/ja.json | 11 ++ res/lang/ko.json | 11 ++ res/lang/pt.json | 11 ++ res/lang/ru.json | 11 ++ res/lang/zh.json | 11 ++ src/app.cpp | 188 ++++++++++++++++++- src/app.h | 37 +++- src/app_network.cpp | 117 ++++++++++++ src/app_security.cpp | 74 +++++--- src/app_wizard.cpp | 10 +- src/config/settings.cpp | 10 + src/config/settings.h | 13 ++ src/daemon/daemon_controller.cpp | 6 +- src/daemon/daemon_controller.h | 2 +- src/daemon/embedded_daemon.cpp | 29 ++- src/daemon/embedded_daemon.h | 5 +- src/daemon/xmrig_manager.cpp | 27 +-- src/daemon/xmrig_manager.h | 9 +- src/data/wallet_state.h | 3 +- src/main.cpp | 1 + src/rpc/rpc_client.cpp | 9 +- src/services/network_refresh_service.cpp | 2 + src/services/network_refresh_service.h | 4 + src/ui/pages/settings_page.cpp | 8 + src/ui/windows/chat_tab.cpp | 43 ++++- src/ui/windows/console_command_reference.cpp | 4 +- src/ui/windows/console_tab.cpp | 7 +- src/ui/windows/console_tab.h | 5 + src/ui/windows/send_tab.cpp | 42 ++++- src/ui/windows/wallets_dialog.h | 5 +- src/util/daemon_updater.cpp | 7 +- src/util/i18n.cpp | 17 ++ src/util/wallet_file_probe.h | 43 +++++ src/util/xmrig_updater.cpp | 7 +- src/wallet/lite_wallet_controller.cpp | 7 + 40 files changed, 745 insertions(+), 90 deletions(-) diff --git a/CMakeLists.txt b/CMakeLists.txt index 90c7188..ba26f5d 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -213,7 +213,7 @@ include(FetchContent) FetchContent_Declare( json GIT_REPOSITORY https://github.com/nlohmann/json.git - GIT_TAG v3.11.3 + GIT_TAG 9cca280a4d0ccf0c08f47a99aa71d1b0e52f8d03 # v3.11.3 — pinned to immutable commit (L-08); tags are mutable GIT_SHALLOW TRUE ) FetchContent_MakeAvailable(json) @@ -222,7 +222,7 @@ FetchContent_MakeAvailable(json) FetchContent_Declare( tomlplusplus GIT_REPOSITORY https://github.com/marzer/tomlplusplus.git - GIT_TAG v3.4.0 + GIT_TAG 30172438cee64926dc41fdd9c11fb3ba5b2ba9de # v3.4.0 — pinned to immutable commit (L-08); tags are mutable GIT_SHALLOW TRUE ) FetchContent_MakeAvailable(tomlplusplus) @@ -289,7 +289,7 @@ message(STATUS "Fetching libwebp (decode-only, static)...") FetchContent_Declare( libwebp GIT_REPOSITORY https://github.com/webmproject/libwebp.git - GIT_TAG v1.4.0 + GIT_TAG 845d5476a866141ba35ac133f856fa62f0b7445f # v1.4.0 — pinned to immutable commit (L-08); tags are mutable GIT_SHALLOW TRUE # libwebp's cpu.cmake applies -mno-sse2/-mno-sse4.1 to its scalar reference DSP # files when it can't probe SSE support. Under a macOS universal build diff --git a/res/fonts/NotoSansCJK-Subset.ttf b/res/fonts/NotoSansCJK-Subset.ttf index 6f3f92d43a17c05406c9e5516a1d042d3b612ba0..9d09bd06c7205c6e54a3061cbf2b36dda2b41f21 100644 GIT binary patch delta 20807 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 diff --git a/res/lang/de.json b/res/lang/de.json index 9a9df80..dee943f 100644 --- a/res/lang/de.json +++ b/res/lang/de.json @@ -485,6 +485,17 @@ "download_bootstrap": "Bootstrap herunterladen", "dragonx_green": "DragonX (Grün)", "edit": "Bearbeiten", + "empty_wallet_keys_suffix": "Schlüssel", + "empty_wallet_open_manager": "Wallet-Verwaltung öffnen", + "empty_wallet_restore": "Mein Wallet wiederherstellen", + "empty_wallet_salvage_body": "Dieses Wallet ist leer, weil eine frühere automatische Reparatur Ihr ursprüngliches Wallet als Sicherung beiseitegelegt hat. Ihre Coins befinden sich fast sicher in dieser Sicherung und sind nicht verloren. Stellen Sie sie wieder her, um Ihr Guthaben erneut zu laden — nichts wird gelöscht; die aktuelle Datei wird zuerst beiseitegelegt.", + "empty_wallet_salvage_headline": "Ihre Coins sind sicher in einer Sicherungsdatei.", + "empty_wallet_salvage_title": "Ihr Wallet wurde möglicherweise repariert", + "empty_wallet_warning_body": "Dieses Wallet hat keine Adressen und kein Guthaben, aber eine andere Wallet-Datei in Ihrem DragonX-Ordner enthält Schlüssel. Ihre Coins befinden sich höchstwahrscheinlich dort und sind nicht verloren. Öffnen Sie die Wallet-Verwaltung, um zu dem Wallet mit Ihrem Guthaben zu wechseln.", + "empty_wallet_warning_dismiss": "Für dieses Wallet nicht mehr warnen", + "empty_wallet_warning_dismiss_tip": "Beendet diese Warnung nur für die aktuelle Wallet-Datei. Wenn Sie später zu einem anderen leeren Wallet wechseln, kann die Warnung erneut erscheinen.", + "empty_wallet_warning_headline": "Möglicherweise haben Sie das falsche Wallet geöffnet.", + "empty_wallet_warning_title": "Dieses Wallet ist leer", "enc_confirm": "Bestätigen:", "enc_desc": "Die Verschlüsselung Ihrer Wallet schützt Ihre privaten Schlüssel mit einer Passphrase. Nach der Verschlüsselung wird der Daemon neu gestartet.", "enc_encrypting": "Wallet wird verschlüsselt...", diff --git a/res/lang/es.json b/res/lang/es.json index 5535247..78009cd 100644 --- a/res/lang/es.json +++ b/res/lang/es.json @@ -485,6 +485,17 @@ "download_bootstrap": "Descargar Bootstrap", "dragonx_green": "DragonX (Verde)", "edit": "Editar", + "empty_wallet_keys_suffix": "claves", + "empty_wallet_open_manager": "Abrir administrador de carteras", + "empty_wallet_restore": "Restaurar mi cartera", + "empty_wallet_salvage_body": "Esta cartera está vacía porque una reparación automática anterior apartó tu cartera original como copia de seguridad. Tus monedas casi con certeza están en esa copia, no perdidas. Restáurala para volver a cargar tus fondos: no se elimina nada; primero se aparta el archivo actual.", + "empty_wallet_salvage_headline": "Tus monedas están a salvo en un archivo de copia de seguridad.", + "empty_wallet_salvage_title": "Es posible que tu cartera haya sido reparada", + "empty_wallet_warning_body": "Esta cartera no tiene direcciones ni fondos, pero otro archivo de cartera en tu carpeta de DragonX contiene claves. Lo más probable es que tus monedas estén ahí, no perdidas. Abre el administrador de carteras para cambiar a la cartera que tiene tus fondos.", + "empty_wallet_warning_dismiss": "No volver a avisar para esta cartera", + "empty_wallet_warning_dismiss_tip": "Detiene este aviso solo para el archivo de cartera actual. Si más tarde cambias a otra cartera vacía, podría avisarte de nuevo.", + "empty_wallet_warning_headline": "Es posible que haya abierto la cartera equivocada.", + "empty_wallet_warning_title": "Esta cartera está vacía", "enc_confirm": "Confirmar:", "enc_desc": "Cifrar tu monedero protege tus claves privadas con una frase de contraseña. Tras el cifrado, el daemon se reiniciará.", "enc_encrypting": "Cifrando el monedero...", diff --git a/res/lang/fr.json b/res/lang/fr.json index e3dd5cf..b5ae70a 100644 --- a/res/lang/fr.json +++ b/res/lang/fr.json @@ -485,6 +485,17 @@ "download_bootstrap": "Télécharger Bootstrap", "dragonx_green": "DragonX (Vert)", "edit": "Modifier", + "empty_wallet_keys_suffix": "clés", + "empty_wallet_open_manager": "Ouvrir le gestionnaire de portefeuilles", + "empty_wallet_restore": "Restaurer mon portefeuille", + "empty_wallet_salvage_body": "Ce portefeuille est vide car une réparation automatique antérieure a mis votre portefeuille d'origine de côté comme sauvegarde. Vos pièces se trouvent presque certainement dans cette sauvegarde, elles ne sont pas perdues. Restaurez-la pour recharger vos fonds — rien n'est supprimé ; le fichier actuel est d'abord mis de côté.", + "empty_wallet_salvage_headline": "Vos pièces sont en sécurité dans un fichier de sauvegarde.", + "empty_wallet_salvage_title": "Votre portefeuille a peut-être été réparé", + "empty_wallet_warning_body": "Ce portefeuille n'a aucune adresse ni fonds, mais un autre fichier de portefeuille dans votre dossier DragonX contient des clés. Vos pièces s'y trouvent très probablement, elles ne sont pas perdues. Ouvrez le gestionnaire de portefeuilles pour passer au portefeuille qui contient vos fonds.", + "empty_wallet_warning_dismiss": "Ne plus avertir pour ce portefeuille", + "empty_wallet_warning_dismiss_tip": "Arrête cet avertissement uniquement pour le fichier de portefeuille actuel. Si vous passez plus tard à un autre portefeuille vide, il pourra avertir à nouveau.", + "empty_wallet_warning_headline": "Vous avez peut-être ouvert le mauvais portefeuille.", + "empty_wallet_warning_title": "Ce portefeuille est vide", "enc_confirm": "Confirmer :", "enc_desc": "Chiffrer votre portefeuille protège vos clés privées avec une phrase secrète. Après le chiffrement, le daemon redémarrera.", "enc_encrypting": "Chiffrement du portefeuille...", diff --git a/res/lang/ja.json b/res/lang/ja.json index 7cd4a10..931fb5d 100644 --- a/res/lang/ja.json +++ b/res/lang/ja.json @@ -485,6 +485,17 @@ "download_bootstrap": "ブートストラップをダウンロード", "dragonx_green": "DragonX(グリーン)", "edit": "編集", + "empty_wallet_keys_suffix": "個の鍵", + "empty_wallet_open_manager": "ウォレットマネージャーを開く", + "empty_wallet_restore": "ウォレットを復元", + "empty_wallet_salvage_body": "このウォレットが空なのは、以前の自動修復によって元のウォレットがバックアップとして脇に保存されたためです。コインはほぼ確実にそのバックアップの中にあり、失われていません。復元すれば資金を再び読み込めます。何も削除されません。現在のファイルは先に脇へ保存されます。", + "empty_wallet_salvage_headline": "コインはバックアップファイルに安全に保管されています。", + "empty_wallet_salvage_title": "ウォレットが修復された可能性があります", + "empty_wallet_warning_body": "このウォレットにはアドレスも資金もありませんが、DragonX フォルダー内の別のウォレットファイルに鍵が含まれています。コインはおそらくそちらにあり、失われていません。ウォレットマネージャーを開いて、資金のあるウォレットに切り替えてください。", + "empty_wallet_warning_dismiss": "このウォレットでは今後警告しない", + "empty_wallet_warning_dismiss_tip": "現在のウォレットファイルに対してのみこの警告を停止します。後で別の空のウォレットに切り替えると、再び警告される場合があります。", + "empty_wallet_warning_headline": "間違ったウォレットを開いた可能性があります。", + "empty_wallet_warning_title": "このウォレットは空です", "enc_confirm": "確認:", "enc_desc": "ウォレットを暗号化すると、パスフレーズで秘密鍵が保護されます。暗号化後、デーモンが再起動します。", "enc_encrypting": "ウォレットを暗号化しています...", diff --git a/res/lang/ko.json b/res/lang/ko.json index 7c56e41..0a3d210 100644 --- a/res/lang/ko.json +++ b/res/lang/ko.json @@ -485,6 +485,17 @@ "download_bootstrap": "부트스트랩 다운로드", "dragonx_green": "DragonX(그린)", "edit": "편집", + "empty_wallet_keys_suffix": "개 키", + "empty_wallet_open_manager": "지갑 관리자 열기", + "empty_wallet_restore": "내 지갑 복원", + "empty_wallet_salvage_body": "이 지갑이 비어 있는 것은 이전의 자동 복구가 원본 지갑을 백업으로 따로 보관했기 때문입니다. 코인은 거의 확실히 그 백업에 있으며 사라지지 않았습니다. 복원하면 자금을 다시 불러올 수 있습니다. 아무것도 삭제되지 않으며, 현재 파일은 먼저 따로 보관됩니다.", + "empty_wallet_salvage_headline": "코인은 백업 파일에 안전하게 보관되어 있습니다.", + "empty_wallet_salvage_title": "지갑이 복구되었을 수 있습니다", + "empty_wallet_warning_body": "이 지갑에는 주소도 자금도 없지만, DragonX 폴더의 다른 지갑 파일에 키가 들어 있습니다. 코인은 대부분 그 안에 있으며 사라진 것이 아닙니다. 지갑 관리자를 열어 자금이 있는 지갑으로 전환하세요.", + "empty_wallet_warning_dismiss": "이 지갑에 대해 다시 경고하지 않기", + "empty_wallet_warning_dismiss_tip": "현재 지갑 파일에 대해서만 이 경고를 중지합니다. 나중에 다른 빈 지갑으로 전환하면 다시 경고할 수 있습니다.", + "empty_wallet_warning_headline": "잘못된 지갑을 열었을 수 있습니다.", + "empty_wallet_warning_title": "이 지갑은 비어 있습니다", "enc_confirm": "확인:", "enc_desc": "지갑을 암호화하면 암호로 개인 키를 보호합니다. 암호화 후 데몬이 다시 시작됩니다.", "enc_encrypting": "지갑을 암호화하는 중...", diff --git a/res/lang/pt.json b/res/lang/pt.json index a5e0eda..6d0c0bf 100644 --- a/res/lang/pt.json +++ b/res/lang/pt.json @@ -485,6 +485,17 @@ "download_bootstrap": "Baixar Bootstrap", "dragonx_green": "DragonX (Verde)", "edit": "Editar", + "empty_wallet_keys_suffix": "chaves", + "empty_wallet_open_manager": "Abrir gerenciador de carteiras", + "empty_wallet_restore": "Restaurar minha carteira", + "empty_wallet_salvage_body": "Esta carteira está vazia porque um reparo automático anterior colocou sua carteira original de lado como backup. Suas moedas quase certamente estão nesse backup, não perdidas. Restaure-o para carregar seus fundos novamente — nada é excluído; o arquivo atual é guardado primeiro.", + "empty_wallet_salvage_headline": "Suas moedas estão seguras em um arquivo de backup.", + "empty_wallet_salvage_title": "Sua carteira pode ter sido reparada", + "empty_wallet_warning_body": "Esta carteira não tem endereços nem fundos, mas outro arquivo de carteira na sua pasta do DragonX contém chaves. Suas moedas provavelmente estão nele, não perdidas. Abra o gerenciador de carteiras para mudar para a carteira que contém seus fundos.", + "empty_wallet_warning_dismiss": "Não avisar novamente para esta carteira", + "empty_wallet_warning_dismiss_tip": "Interrompe este aviso apenas para o arquivo de carteira atual. Se você mudar para outra carteira vazia mais tarde, poderá avisar novamente.", + "empty_wallet_warning_headline": "Você pode ter aberto a carteira errada.", + "empty_wallet_warning_title": "Esta carteira está vazia", "enc_confirm": "Confirmar:", "enc_desc": "Criptografar sua carteira protege suas chaves privadas com uma senha. Após a criptografia, o daemon será reiniciado.", "enc_encrypting": "Criptografando a carteira...", diff --git a/res/lang/ru.json b/res/lang/ru.json index d59c39a..e476059 100644 --- a/res/lang/ru.json +++ b/res/lang/ru.json @@ -485,6 +485,17 @@ "download_bootstrap": "Скачать бутстрап", "dragonx_green": "DragonX (Зелёная)", "edit": "Редактировать", + "empty_wallet_keys_suffix": "ключей", + "empty_wallet_open_manager": "Открыть менеджер кошельков", + "empty_wallet_restore": "Восстановить мой кошелёк", + "empty_wallet_salvage_body": "Этот кошелёк пуст, потому что предыдущее автоматическое восстановление отложило ваш исходный кошелёк в качестве резервной копии. Ваши монеты почти наверняка находятся в этой копии и не потеряны. Восстановите её, чтобы снова загрузить средства — ничего не удаляется; текущий файл сначала откладывается в сторону.", + "empty_wallet_salvage_headline": "Ваши монеты в безопасности в файле резервной копии.", + "empty_wallet_salvage_title": "Возможно, ваш кошелёк был восстановлен", + "empty_wallet_warning_body": "В этом кошельке нет адресов и средств, но другой файл кошелька в вашей папке DragonX содержит ключи. Ваши монеты, скорее всего, находятся в нём и не потеряны. Откройте менеджер кошельков, чтобы переключиться на кошелёк с вашими средствами.", + "empty_wallet_warning_dismiss": "Больше не предупреждать для этого кошелька", + "empty_wallet_warning_dismiss_tip": "Останавливает это предупреждение только для текущего файла кошелька. Если позже вы переключитесь на другой пустой кошелёк, предупреждение может появиться снова.", + "empty_wallet_warning_headline": "Возможно, вы открыли не тот кошелёк.", + "empty_wallet_warning_title": "Этот кошелёк пуст", "enc_confirm": "Подтвердите:", "enc_desc": "Шифрование кошелька защищает ваши приватные ключи паролем. После шифрования демон перезапустится.", "enc_encrypting": "Шифрование кошелька...", diff --git a/res/lang/zh.json b/res/lang/zh.json index 6b0dee2..fa7c265 100644 --- a/res/lang/zh.json +++ b/res/lang/zh.json @@ -485,6 +485,17 @@ "download_bootstrap": "下载引导程序", "dragonx_green": "DragonX(绿色)", "edit": "编辑", + "empty_wallet_keys_suffix": "个密钥", + "empty_wallet_open_manager": "打开钱包管理器", + "empty_wallet_restore": "恢复我的钱包", + "empty_wallet_salvage_body": "此钱包为空,因为先前的一次自动修复已将您的原始钱包作为备份保存到一旁。您的币几乎肯定在该备份中,并未丢失。恢复它即可重新加载您的资金——不会删除任何内容;当前文件会先被保存到一旁。", + "empty_wallet_salvage_headline": "您的币安全地存放在备份文件中。", + "empty_wallet_salvage_title": "您的钱包可能已被修复", + "empty_wallet_warning_body": "此钱包没有地址也没有资金,但您的 DragonX 文件夹中的另一个钱包文件包含密钥。您的币很可能在其中,并未丢失。打开钱包管理器以切换到持有您资金的钱包。", + "empty_wallet_warning_dismiss": "不再为此钱包提示", + "empty_wallet_warning_dismiss_tip": "仅对当前钱包文件停止此提示。如果您以后切换到另一个空钱包,可能会再次提示。", + "empty_wallet_warning_headline": "您可能打开了错误的钱包。", + "empty_wallet_warning_title": "此钱包为空", "enc_confirm": "确认:", "enc_desc": "加密钱包会用密码短语保护您的私钥。加密后,守护进程将重新启动。", "enc_encrypting": "正在加密钱包...", diff --git a/src/app.cpp b/src/app.cpp index 85607e5..2991453 100644 --- a/src/app.cpp +++ b/src/app.cpp @@ -127,14 +127,24 @@ App::App() // Seed the auto-balance RNG once per run so weighted-random pool selection isn't // deterministic across launches. balance_rng_.seed(std::random_device{}()); + // Purge any plaintext key export left behind by a crashed/interrupted decrypt flow. (H-02) + sweepStaleDecryptExports(); } App::~App() { - // Scrub any seed/phrase secret still resident (e.g. app quit with a backup/migration modal open). + wipeSecrets(); +} + +// Scrub every resident secret buffer. Idempotent + safe to call from the forced-exit path (main.cpp +// _Exit bypasses destructors), so key/seed material isn't left in freed heap on the real quit path. (L-05) +void App::wipeSecrets() +{ if (!seed_migration_seed_.empty()) sodium_memzero(&seed_migration_seed_[0], seed_migration_seed_.size()); if (!seed_backup_phrase_.empty()) sodium_memzero(&seed_backup_phrase_[0], seed_backup_phrase_.size()); + sodium_memzero(export_result_, sizeof(export_result_)); // exported WIF/z-key (SECRET) + sodium_memzero(import_key_input_, sizeof(import_key_input_)); // pasted private key (SECRET) } namespace { @@ -824,6 +834,10 @@ void App::update() // One-time reminder to back up the wallet's seed phrase (mnemonic wallets only). maybeRemindSeedBackup(); + // One-time warning if the active wallet loaded empty while a sibling wallet file holds funds + // (a prior/unwitnessed salvage likely moved the coins into a wallet..bak). + maybeWarnEmptyWalletWithFundedSiblings(); + // Classify the wallet's mnemonic status (once per connect) so the Migrate-to-seed button can // glow for a legacy, pre-seed-phrase wallet. probeWalletSeedStatus(); @@ -957,7 +971,7 @@ void App::update() if (xmrig_poll_tick && xmrig_manager_ && xmrig_manager_->isRunning()) { xmrig_manager_->pollStats(); auto& ps = state_.pool_mining; - auto& xs = xmrig_manager_->getStats(); + const auto xs = xmrig_manager_->getStats(); // getStats() now returns a locked copy (M-03) ps.xmrig_running = true; ps.hashrate_10s = xs.hashrate_10s; ps.hashrate_60s = xs.hashrate_60s; @@ -1777,6 +1791,12 @@ void App::render() // Page transition: detect change, ramp alpha if (current_page_ != prev_page_) { page_alpha_ = (ui::effects::isLowSpecMode() || (settings_ && settings_->getReduceMotion())) ? 1.0f : 0.0f; + // Switching INTO the console → put the cursor in the command box (toggleable). Done here at the + // transition (not in the console render) because prev_page_ is updated below; the console renders + // later this same frame and consumes the one-shot request. + if ((current_page_ == ui::NavPage::Console || current_page_ == ui::NavPage::LiteConsole) + && settings_ && settings_->getConsoleAutoFocus()) + console_tab_.requestInputFocus(); prev_page_ = current_page_; } if (page_alpha_ < 1.0f) { @@ -2148,6 +2168,7 @@ void App::render() renderSwitchStopDaemonDialog(); renderBlockDbReindexDialog(); renderWalletRecoveredDialog(); + renderEmptyWalletWarningDialog(); // Render notifications (toast messages) ui::Notifications::instance().render(); @@ -4657,6 +4678,129 @@ void App::renderWalletRecoveredDialog() ui::material::EndOverlayDialog(); } +// Auto-shown when the active wallet loaded EMPTY but a sibling wallet file in the datadir still holds keys +// (see maybeWarnEmptyWalletWithFundedSiblings). Funds are not lost — they're in another file, most likely a +// wallet..bak left by an earlier BDB salvage. This routes the user to the wallet manager to switch, and +// remembers a per-file dismissal so it never nags again for this wallet. +void App::renderEmptyWalletWarningDialog() +{ + if (!show_empty_wallet_warning_) return; + + const bool salvage = empty_wallet_has_salvage_bak_; // salvage .bak → offer Restore; else → switch wallet + + ui::material::OverlayDialogSpec ov; + ov.title = TR(salvage ? "empty_wallet_salvage_title" : "empty_wallet_warning_title"); + ov.p_open = &show_empty_wallet_warning_; + ov.style = ui::material::OverlayStyle::BlurFloat; + ov.cardWidth = 560.0f; + ov.idSuffix = "emptywalletwarn"; + if (!ui::material::BeginOverlayDialog(ov)) return; + const float dp = ui::Layout::dpiScale(); + + // Header: wallet icon in a warning tint + a calm "your coins are likely in another file" framing. + { + ImFont* icoF = ui::material::Type().iconLarge(); + const float rowTop = ImGui::GetCursorPosY(); + ImGui::PushFont(icoF); + ImGui::PushStyleColor(ImGuiCol_Text, ui::material::Warning()); + ImGui::TextUnformatted(ICON_MD_ACCOUNT_BALANCE_WALLET); + ImGui::PopStyleColor(); + ImGui::PopFont(); + ImGui::SameLine(); + ImFont* txtF = ui::material::Type().subtitle1(); + const float iconH = icoF->LegacySize; + const float textH = txtF ? txtF->LegacySize : ImGui::GetFontSize(); + if (iconH > textH) ImGui::SetCursorPosY(rowTop + (iconH - textH) * 0.5f); + ImGui::PushFont(txtF); + ImGui::TextWrapped("%s", TR(salvage ? "empty_wallet_salvage_headline" : "empty_wallet_warning_headline")); + ImGui::PopFont(); + } + ImGui::Dummy(ImVec2(0, ui::Layout::spacingSm())); + + ImGui::PushTextWrapPos(0.0f); + ImGui::TextWrapped("%s", TR(salvage ? "empty_wallet_salvage_body" : "empty_wallet_warning_body")); + ImGui::PopTextWrapPos(); + + // For the "wrong wallet" case, name the other wallet file(s) that hold keys, with a compact key count — + // concrete evidence the coins are recoverable from them. The count is built with std::to_string so no + // printf format lives in a translatable string (translations are additive and could otherwise drop a %d). + // (The salvage case has no sibling list — restoreOriginalWallet() finds the backup itself.) + if (!empty_wallet_funded_siblings_.empty()) { + ImGui::Dummy(ImVec2(0, ui::Layout::spacingSm())); + for (const auto& s : empty_wallet_funded_siblings_) { + ImGui::Bullet(); + ImGui::SameLine(); + ImGui::TextUnformatted(s.fileName.c_str()); + ImGui::PushFont(ui::material::Type().caption()); + ImGui::PushStyleColor(ImGuiCol_Text, ui::material::OnSurfaceMedium()); + const std::string keys = " " + std::to_string(s.transparentKeys + s.shieldedKeys) + + " " + TR("empty_wallet_keys_suffix"); + ImGui::SameLine(); + ImGui::TextUnformatted(keys.c_str()); + ImGui::PopStyleColor(); + ImGui::PopFont(); + } + } + + // Primary action: route the user to the wallet manager to switch files (accent-tinted so it dominates). + ImGui::Dummy(ImVec2(0, ui::Layout::spacingMd())); + ImFont* rbf = ui::material::Type().button(); + auto fitBtnW = [&](const char* label) { + return std::max(120.0f * dp, + rbf->CalcTextSizeA(rbf->LegacySize, FLT_MAX, 0, label).x + + ImGui::GetStyle().FramePadding.x * 2.0f + 28.0f * dp); + }; + const char* primaryLabel = salvage ? TR("empty_wallet_restore") : TR("empty_wallet_open_manager"); + ImGui::PushStyleColor(ImGuiCol_Button, ui::material::WithAlpha(ui::material::Primary(), 65)); + ImGui::PushStyleColor(ImGuiCol_ButtonHovered, ui::material::WithAlpha(ui::material::Primary(), 100)); + ImGui::PushStyleColor(ImGuiCol_ButtonActive, ui::material::WithAlpha(ui::material::Primary(), 125)); + if (ui::material::TactileButton(primaryLabel, ImVec2(fitBtnW(primaryLabel), 0))) { + show_empty_wallet_warning_ = false; + if (salvage) + restoreOriginalWallet(); // self-contained: swaps the .bak back + drives the recovery dialog's progress + else + ui::WalletsDialog::show(this); + } + ImGui::PopStyleColor(3); + + // Quiet footer: open the data folder, or dismiss permanently for THIS wallet file. + auto linkText = [&](const char* label) -> bool { + ImGui::PushStyleColor(ImGuiCol_Text, ui::material::OnSurfaceMedium()); + ImGui::TextUnformatted(label); + ImGui::PopStyleColor(); + const bool clicked = ImGui::IsItemClicked(); + if (ImGui::IsItemHovered()) { + ImGui::SetMouseCursor(ImGuiMouseCursor_Hand); + const ImVec2 mn = ImGui::GetItemRectMin(), mx = ImGui::GetItemRectMax(); + ImGui::GetWindowDrawList()->AddLine(ImVec2(mn.x, mx.y), ImVec2(mx.x, mx.y), + ui::material::OnSurface()); + } + return clicked; + }; + ImGui::Dummy(ImVec2(0, ui::Layout::spacingMd())); + if (linkText(TR("wallet_recovered_open_folder"))) + util::Platform::openFolder(util::Platform::getDragonXDataDir()); + ImGui::SameLine(0, ui::Layout::spacingSm()); + ImGui::TextDisabled("\xC2\xB7"); // middle dot separator + ImGui::SameLine(0, ui::Layout::spacingSm()); + if (linkText(TR("empty_wallet_warning_dismiss"))) { + if (settings_) { + settings_->ackEmptyWalletWarn(settings_->getActiveWalletFile()); + settings_->save(); + } + show_empty_wallet_warning_ = false; + } + if (ImGui::IsItemHovered()) { + ImGui::BeginTooltip(); + ImGui::PushTextWrapPos(ImGui::GetFontSize() * 22.0f); + ImGui::TextUnformatted(TR("empty_wallet_warning_dismiss_tip")); + ImGui::PopTextWrapPos(); + ImGui::EndTooltip(); + } + + ui::material::EndOverlayDialog(); +} + // Auto-shown when the embedded node aborts on an unreadable block database — offers the one-click // -reindex rebuild instead of leaving the wallet stuck on a silent zero balance. void App::renderBlockDbReindexDialog() @@ -5043,6 +5187,12 @@ void App::stopEmbeddedDaemon() return; } + // Do we hold a live process handle for this node? Capture it BEFORE stop() reaps the pid. If owned, + // daemon_controller_->stop() below BLOCKS until the process actually exits. If NOT owned (external / + // adopted / direct-connected), stop() returns at once — we can only ask it over RPC and then watch + // for it to disappear (handled after the stop() call). + const bool owned = daemon_controller_->isRunning(); + // Send RPC "stop" command — this is the graceful path that lets the // daemon flush state, save block indexes, close sockets, etc. bool stop_sent = false; @@ -5099,6 +5249,27 @@ void App::stopEmbeddedDaemon() // 20s grace period for the RPC "stop" to complete (LevelDB flush). // Only after that does stop() escalate to SIGTERM, then SIGKILL. daemon_controller_->stop(20000); + + // EXTERNAL / adopted node during app shutdown: we hold no process handle, so the stop() above returned + // immediately (it can only wait on a node WE spawned). But the user turned on "Stop external daemon", + // so keep the window on the shutdown screen and poll until the node is actually gone — surfacing a live + // status so they can SEE it stop — rather than closing while it's still flushing. Bounded ~120s (a + // graceful full-node shutdown can flush LevelDB for 60-90s). Scoped to real shutdown; the shutdown + // screen's Force Quit stays available and flips shutdown_complete_, which breaks us out at once. + if (stop_sent && !owned && shutting_down_) { + auto stillUp = []() { + return daemon::EmbeddedDaemon::isRpcPortInUse() || daemon::EmbeddedDaemon::isDaemonProcessRunning(); + }; + // Set the phase text ONCE, then just poll — the shutdown screen already renders a live "N seconds" + // elapsed counter on the UI thread, so the user still sees time passing. (shutdown_status_ is now a + // GuardedStatus, so per-iteration writes would be race-safe; the single write is just a UX choice.) + shutdown_status_ = "Waiting for the external node to stop..."; + for (int i = 0; i < 1200 && stillUp() && !shutdown_complete_; ++i) + std::this_thread::sleep_for(std::chrono::milliseconds(100)); + shutdown_status_ = stillUp() ? "External node still stopping — closing anyway..." + : "External node stopped"; + DEBUG_LOGF("stopEmbeddedDaemon: external node %s\n", stillUp() ? "still up (timed out)" : "confirmed stopped"); + } } bool App::isEmbeddedDaemonRunning() const @@ -5497,7 +5668,8 @@ void App::renderShutdownScreen() // it's never impossible to escape. static std::string s_lastShutStatus; static float s_shutStallTimer = 0.0f; - if (shutdown_status_ != s_lastShutStatus) { s_lastShutStatus = shutdown_status_; s_shutStallTimer = 0.0f; } + const std::string curShut = shutdown_status_.get(); // one consistent snapshot per frame (M-05) + if (curShut != s_lastShutStatus) { s_lastShutStatus = curShut; s_shutStallTimer = 0.0f; } else s_shutStallTimer += ImGui::GetIO().DeltaTime; const bool shutdownStalled = s_shutStallTimer >= 8.0f; const bool allowForceQuit = shutdownStalled || shutdown_timer_ >= 20.0f; @@ -5593,11 +5765,11 @@ void App::renderShutdownScreen() // ------------------------------------------------------------------- // 3. Phase status (what the shutdown thread is doing) // ------------------------------------------------------------------- - if (!shutdown_status_.empty()) { - ImVec2 ts = ImGui::CalcTextSize(shutdown_status_.c_str()); + if (!curShut.empty()) { + ImVec2 ts = ImGui::CalcTextSize(curShut.c_str()); ImGui::SetCursorPosX(cx - ts.x * 0.5f); ImGui::PushStyleColor(ImGuiCol_Text, ImVec4(0.75f, 0.75f, 0.75f, 1.0f)); - ImGui::TextUnformatted(shutdown_status_.c_str()); + ImGui::TextUnformatted(curShut.c_str()); ImGui::PopStyleColor(); } @@ -5631,8 +5803,8 @@ void App::renderShutdownScreen() ImGui::Spacing(); // State-aware caution: while the status is a daemon flush/exit step, force-quitting risks the // chainstate; say so instead of a bare button. - if (shutdownStalled && !shutdown_status_.empty()) { - std::string stalledMsg = "Still \"" + shutdown_status_ + "\" — force quitting now may corrupt chain data."; + if (shutdownStalled && !curShut.empty()) { + std::string stalledMsg = "Still \"" + curShut + "\" — force quitting now may corrupt chain data."; ImVec2 ms = ImGui::CalcTextSize(stalledMsg.c_str()); ImGui::SetCursorPosX(cx - ms.x * 0.5f); ImGui::PushStyleColor(ImGuiCol_Text, ImGui::ColorConvertU32ToFloat4(Warning())); diff --git a/src/app.h b/src/app.h index dbba9a3..48c9cac 100644 --- a/src/app.h +++ b/src/app.h @@ -71,14 +71,28 @@ enum class EncryptDialogPhase { Done // Finished — close dialog }; +// A status string written by a background/worker thread and read every frame by the UI thread. Its +// operator= locks, so all the plain `x = "..."` assignment sites stay unchanged; readers call get() +// for a consistent per-frame snapshot instead of racing a non-atomic std::string. (M-05, L-06) +class GuardedStatus { +public: + GuardedStatus() = default; + GuardedStatus& operator=(std::string v) { std::lock_guard lk(m_); v_ = std::move(v); return *this; } + std::string get() const { std::lock_guard lk(m_); return v_; } +private: + mutable std::mutex m_; + std::string v_; +}; + /** * @brief Main application class - * + * * Manages application state, RPC connection, and coordinates UI rendering. */ class App { public: App(); + void wipeSecrets(); // scrub all resident secret buffers; called from ~App() AND the forced-exit path (L-05) ~App(); // Non-copyable @@ -798,6 +812,10 @@ private: // One-time nudge: on a full-node wallet that has a mnemonic, remind the user (once per // install) to back up their seed phrase. Cheap early-outs keep it idle until it can act. void maybeRemindSeedBackup(); + void maybeWarnEmptyWalletWithFundedSiblings(); // full-node: empty active wallet + a funded sibling → warn once + void scanFundedSiblingsAsync(); // off-UI-thread probe of sibling wallet files + static void scrubAndRemoveExport(const std::string& path); // zero + delete a plaintext key export (H-02) + void sweepStaleDecryptExports(); // startup net: purge stale obsidiandecryptexport* files (H-02) // Seed-wallet migration (Phase 1: create a new mnemonic wallet in isolation, no funds moved). void beginCreateSeedWallet(); // starts the isolated create on a background thread @@ -869,7 +887,7 @@ private: std::atomic shutting_down_{false}; std::atomic shutdown_complete_{false}; bool address_list_dirty_ = false; // P8: dedup rebuildAddressList - std::string shutdown_status_; + GuardedStatus shutdown_status_; // thread-safe: written by the shutdown thread, read by the UI (M-05) std::thread shutdown_thread_; float shutdown_timer_ = 0.0f; bool force_quit_confirm_ = false; @@ -906,6 +924,16 @@ private: bool wallet_auto_recovered_ = false; // a salvage happened this session bool wallet_auto_recovered_warned_ = false; // guard: only surface it once per session bool show_wallet_recovered_dialog_ = false; // auto-shown warning dialog + // Complementary on-disk safety net for a salvage we DIDN'T witness this launch (happened on a prior + // run, or under an external daemon whose startup output we never captured): if the active wallet loads + // empty while a sibling wallet file in the datadir still holds keys, warn once so the user's funds + // (likely in a wallet..bak) aren't mistaken for loss. See maybeWarnEmptyWalletWithFundedSiblings(). + struct FundedSibling { std::string fileName; int transparentKeys = 0; int shieldedKeys = 0; }; + bool show_empty_wallet_warning_ = false; // auto-shown warning modal + bool empty_wallet_warn_checked_ = false; // evaluated this wallet-open already (reset in onConnected) + bool empty_wallet_scan_in_flight_ = false; // a sibling scan is running (main-thread only) + bool empty_wallet_has_salvage_bak_ = false; // modal variant: a funded salvage .bak → offer Restore + std::vector empty_wallet_funded_siblings_; // scan result (main-thread only) // The recovery dialog is the ONE authoritative surface: it stays open through the async rebuild/ // restore, driven Offer → Working → Done/Failed (pumpWalletRestore sets the outcome). Presentation // only — the fund-safety file ops in rebuildWalletDatabase()/restoreOriginalWallet() are unchanged. @@ -1264,8 +1292,8 @@ private: services::WalletSecurityWorkflow wallet_security_workflow_; // Wizard: stopping an external daemon before bootstrap - bool wizard_stopping_external_ = false; - std::string wizard_stop_status_; + std::atomic wizard_stopping_external_{false}; // written by the stop worker, read by the UI (L-06) + GuardedStatus wizard_stop_status_; // thread-safe: written by the stop worker, read by the UI (L-06) // PIN vault std::unique_ptr vault_; @@ -1358,6 +1386,7 @@ private: void renderBlockDbReindexDialog(); // offer to rebuild an unreadable block database (-reindex) void reindexBlockDatabase(); // restart the daemon with -reindex to rebuild the block DB void renderWalletRecoveredDialog(); // warn that the node auto-recovered/salvaged wallet.dat + void renderEmptyWalletWarningDialog();// warn that the active wallet is empty while a sibling holds funds void detectWalletAutoRecovery(); // scan daemon output for a salvage; fire the warning once/session void restoreOriginalWallet(); // swap the wallet..bak back over the salvaged copy + restart void pumpWalletRestore(); // main-thread: surface the restore/rebuild op's result diff --git a/src/app_network.cpp b/src/app_network.cpp index e85d17b..e586e9e 100644 --- a/src/app_network.cpp +++ b/src/app_network.cpp @@ -620,6 +620,13 @@ void App::onConnected() detectWalletAutoRecovery(); // also runs every tryConnect tick — catches a salvage even if we never connect + // Re-arm the empty-wallet-with-funded-sibling check for this (possibly switched) wallet: re-evaluate the + // on-disk state once it finishes loading + syncing. Deliberately DON'T touch empty_wallet_scan_in_flight_ + // here — a scan from a prior connect self-clears it when it posts back, and resetting it while that scan + // is still running would let the next submit() block the UI thread on join() (async_tasks_ is only ever + // cancelled at shutdown, so the flag can't wedge during normal runtime). + empty_wallet_warn_checked_ = false; + // Stamp the active wallet as opened in the index (last-opened + size + synced-here). Balance + // address count fill in on the first address refresh (addresses aren't loaded yet here). updateWalletIndexForActiveWallet(/*markOpened=*/true); @@ -1761,7 +1768,12 @@ void App::refreshAddressData() auto result = NetworkRefreshService::collectAddressRefreshResult(refreshRpc, addressSnapshot); return [this, previousAddressCount, previousWalletIdentity, result = std::move(result)]() mutable { + const bool addrListOk = result.addressListOk; // capture before the move NetworkRefreshService::applyAddressRefreshResult(state_, std::move(result)); + // Mark the address list as loaded ONLY if enumeration actually succeeded — a swallowed + // z_listaddresses/getaddressesbyaccount failure returns a falsely-short list, and stamping it + // would let the empty-wallet warning trust a spurious 0 count (see maybeWarnEmptyWallet…). + if (addrListOk) state_.last_address_update = std::time(nullptr); applyPendingSendBalanceDeltas(false); address_validation_cache_dirty_ = false; address_list_dirty_ = true; @@ -4190,6 +4202,111 @@ void App::maybeRemindSeedBackup() }); } +// Complementary on-disk safety net (full-node) for a wallet salvage we did NOT witness this launch — it +// happened on a prior run, or under an external daemon whose startup output we never captured, so +// detectWalletAutoRecovery() never fired. If the active wallet loads EMPTY while a sibling wallet file in +// the datadir still holds keys, warn once: the user's funds were likely moved into a wallet..bak by a +// prior BDB salvage and are not lost, just in another file. Fires at most once per wallet-open and once per +// unacknowledged wallet filename; the probe runs off the UI thread (scanFundedSiblingsAsync). +void App::maybeWarnEmptyWalletWithFundedSiblings() +{ + if (capture_mode_) return; // no live ops during a UI sweep + if (lite_wallet_ || !supportsFullNodeLifecycleActions()) return; // full-node only (lite = single-file dir) + if (!settings_) return; + if (empty_wallet_warn_checked_ || empty_wallet_scan_in_flight_) return; // at most once per wallet-open + if (show_empty_wallet_warning_) return; // already surfaced + // The console-driven recovery flow owns the salvage-this-launch case — don't double-warn. + if (wallet_auto_recovered_ || show_wallet_recovered_dialog_) return; + // Only meaningful once the wallet is truly loaded AND fully synced: a mid-sync wallet reads empty. + if (!state_.connected || !state_.encryption_state_known) return; + if (state_.warming_up || state_.daemon_initializing || !state_.sync.isSynced()) return; + // Wait for the first Core refresh to land. The ConnectionInit prefetch sets sync.blocks but NOT headers, + // so isSynced() (blocks >= headers-2) is spuriously true in the window before the Core refresh — during + // which balance/addresses also read 0. last_balance_update flips non-zero only when the Core refresh + // applies (network_refresh_service.cpp), by which point balance & headers are real. + if (state_.last_balance_update == 0) return; + // And wait for the ADDRESS list to have loaded at least once — otherwise getAddressCount()==0 is + // ambiguous ("no keys" vs "not fetched yet"), which would false-fire on a spent-down wallet (0 balance + // but has addresses) whose address refresh lands a beat after the balance refresh. + if (state_.last_address_update == 0) return; + // "Empty" = no addresses and no funds. A salvage-created fresh wallet has no keys; a legitimately + // spent-down wallet keeps its addresses, so requiring zero addresses avoids nagging the latter. + if (state_.getAddressCount() != 0) return; + if (state_.totalBalance > 0.0 || state_.spendableTotalBalance > 0.0) return; + if (settings_->isEmptyWalletWarnAcked(settings_->getActiveWalletFile())) return; // dismissed for this file + + empty_wallet_warn_checked_ = true; // evaluate the on-disk state once for this wallet-open + scanFundedSiblingsAsync(); +} + +// Off-UI-thread: enumerate the datadir's OTHER wallet files (incl. salvage wallet..bak backups) and +// offline-probe each for key material. Read-only; never opens a file in the daemon. The probe reads up to a +// bounded prefix per file, so it runs on its own task thread (not the RPC worker) and the result is +// marshaled back to the main thread before touching any UI state. Routing, applied on the main thread: +// • a funded salvage .bak exists → the coins were moved aside by an unwitnessed salvage; hand off to the +// existing recovery dialog, whose Restore action swaps the .bak back (the correct, tested fix). +// • otherwise a funded sibling .dat exists → the user simply opened the wrong (empty) wallet; show the +// lightweight warning modal that routes to the wallet manager to switch. +void App::scanFundedSiblingsAsync() +{ + if (empty_wallet_scan_in_flight_) return; + empty_wallet_scan_in_flight_ = true; + const std::string datadir = util::Platform::getDragonXDataDir(); + const std::string activeFile = settings_ ? settings_->getActiveWalletFile() : std::string("wallet.dat"); + async_tasks_.submit("Empty-wallet sibling scan", + [this, datadir, activeFile](const util::AsyncTaskManager::Token& tok) { + std::vector funded; // funded plain-.dat wallets → the "switch wallet" modal + bool hasSalvageBak = false; // a funded wallet..bak → route to the recovery/restore dialog + for (const auto& path : util::enumerateDatadirWalletFiles(datadir, activeFile, /*includeSalvageBaks=*/true)) { + if (tok.cancelled()) return; + const auto bt = util::parseWalletBtree(path); + if (!(bt.parsed && bt.addresses() > 0)) continue; // ignore junk / empty siblings + const std::string name = std::filesystem::path(path).filename().string(); + const bool isBak = name.size() > 4 && name.compare(name.size() - 4, 4, ".bak") == 0; + if (isBak) { + // Only a salvage-pattern wallet..bak has a defined restore path; other .bak files are + // ignored (the wallet manager lists only .dat, so routing them there would be a dead end). + if (daemon::parseWalletSalvageBakTs(name) >= 0) hasSalvageBak = true; + continue; + } + FundedSibling s; + s.fileName = name; + s.transparentKeys = bt.transparentKeys; + s.shieldedKeys = bt.shieldedKeys; + funded.push_back(std::move(s)); + } + // On teardown/cancel, skip posting (shutdown only; the in-flight flag is irrelevant then). + if (tok.cancelled() || !worker_) return; + // Apply UI state on the main thread only (the render loop reads these members). + worker_->post([this, funded, hasSalvageBak]() -> rpc::RPCWorker::MainCb { + return [this, funded, hasSalvageBak]() { + empty_wallet_scan_in_flight_ = false; + if (wallet_auto_recovered_ || show_wallet_recovered_dialog_) return; // recovery already owns it + // Re-validate emptiness on the main thread: balance/address refreshes may have landed while + // the scan ran (it takes long enough to read+parse sibling files), so a warm-reconnect or a + // spent-down wallet that momentarily read empty is now correctly excluded. + if (!state_.connected || state_.getAddressCount() != 0 || + state_.totalBalance > 0.0 || state_.spendableTotalBalance > 0.0) return; + // Both cases surface OUR modal (renderEmptyWalletWarningDialog), keyed by has_salvage_bak. + // We deliberately DON'T set the wallet_auto_recovered_ latch or auto-open the recovery dialog: + // the daemon is healthy, and that latch gates the crash-restart loop (app_network.cpp:556) + + // crash-toast suppression, so it would wedge the wallet offline on any later unrelated crash. + // For the salvage case the modal's "Restore" button calls restoreOriginalWallet() directly + // (self-contained: it drives the recovery dialog into its Working phase itself). + if (hasSalvageBak) { + empty_wallet_has_salvage_bak_ = true; + empty_wallet_funded_siblings_.clear(); + show_empty_wallet_warning_ = true; + } else if (!funded.empty()) { + empty_wallet_has_salvage_bak_ = false; + empty_wallet_funded_siblings_ = funded; + show_empty_wallet_warning_ = true; + } + }; + }); + }); +} + // One-shot (per connect) probe of the current wallet's mnemonic status, so the Settings // Migrate-to-seed button can glow for a legacy wallet without opening the migration dialog. Same // classification as the migration Intro pre-flight, but proactive and cached. Reads no secret past diff --git a/src/app_security.cpp b/src/app_security.cpp index 15c8217..192f986 100644 --- a/src/app_security.cpp +++ b/src/app_security.cpp @@ -238,6 +238,41 @@ private: // daemon off the main thread (to avoid stalling the UI), or ask the user to // restart an external daemon. Shared by encryptWalletWithPassphrase() and // processDeferredEncryption(); must be called on the main thread. +// Zero (overwrite) then delete a plaintext key export so a full cleartext dump of every private key is +// never left readable on disk. Idempotent + error-tolerant (safe on a missing/locked file). (H-02) +void App::scrubAndRemoveExport(const std::string& path) +{ + if (path.empty()) return; + std::error_code ec; + const auto sz = std::filesystem::file_size(path, ec); + if (!ec && sz > 0) { + std::fstream scrub(path, std::ios::binary | std::ios::in | std::ios::out); + if (scrub) { + const std::vector zeros(static_cast(sz), 0); + scrub.write(zeros.data(), static_cast(sz)); + scrub.flush(); + } + } + std::filesystem::remove(path, ec); +} + +// Startup net for H-02: a crash/kill/early-return between exporting the cleartext keys and scrubbing them +// could leave an obsidiandecryptexport* file behind. Purge any found in the data dir on launch. +void App::sweepStaleDecryptExports() +{ + std::error_code ec; + const std::string dir = util::Platform::getDragonXDataDir(); + std::filesystem::directory_iterator it(dir, ec), end; + for (; it != end; it.increment(ec)) { + if (ec) break; + const std::string name = it->path().filename().string(); + if (name.rfind("obsidiandecryptexport", 0) == 0) { + scrubAndRemoveExport(it->path().string()); + DEBUG_LOGF("[decrypt] swept stale plaintext key export: %s\n", name.c_str()); + } + } +} + void App::restartDaemonAfterEncryption(const char* taskName, bool announceRestartStatus) { if (isUsingEmbeddedDaemon()) { if (announceRestartStatus) { @@ -1344,9 +1379,13 @@ void App::renderEncryptWalletDialog() { enc_dlg_pin_status_.clear(); std::string savedPass = enc_dlg_saved_passphrase_; if (worker_ && vault_) { - worker_->post([this, pinStr, savedPass]() -> rpc::RPCWorker::MainCb { + worker_->post([this, pinStr, savedPass]() mutable -> rpc::RPCWorker::MainCb { // Argon2id runs here (worker thread) bool ok = vault_->store(pinStr, savedPass); + // Scrub the captured PIN + passphrase copies (they live in the worker's task + // queue until this runs); the source member is scrubbed in the MainCb. (L-03) + if (!savedPass.empty()) util::SecureVault::secureZero(&savedPass[0], savedPass.size()); + if (!pinStr.empty()) util::SecureVault::secureZero(&pinStr[0], pinStr.size()); return [this, ok]() { if (ok) { settings_->setPinEnabled(true); @@ -1588,6 +1627,11 @@ void App::renderDecryptWalletDialog() { std::chrono::steady_clock::now()); auto restartAndImport = [this, exportPath](const util::AsyncTaskManager::Token& token) { + // Scrub + delete the plaintext key export (obsidiandecryptexport…) on EVERY exit path — + // success, a restart-failure early return, or an exception. A full cleartext dump of all + // private keys must never outlive this step. The startup sweep is a further net for a + // crash/kill mid-flight. (H-02) + struct ExportScrub { std::string p; ~ExportScrub() { App::scrubAndRemoveExport(p); } } exportScrub{exportPath}; WalletSecurityDaemonAdapter daemonAdapter(*this, token); WalletSecurityDecryptRpcAdapter decryptRpc(rpc_.get(), [this](rpc::RPCClient& client, const char* context) { @@ -1641,26 +1685,7 @@ void App::renderDecryptWalletDialog() { WalletSecurityImportRpcAdapter importAdapter(rpc_.get(), saved_config_); auto importResult = services::WalletSecurityWorkflowExecutor::importWallet( importAdapter, exportPath); - - // The plaintext key export (obsidiandecryptexport…) has served its purpose now - // that the import attempt has resolved — scrub and remove it so a full cleartext - // dump of every private key isn't left on disk forever. Recovery, if ever needed, - // is the encrypted backup (wallet.dat.encrypted.bak), never this file. - { - std::error_code delEc; - const auto sz = std::filesystem::file_size(exportPath, delEc); - if (!delEc && sz > 0) { - std::fstream scrub(exportPath, - std::ios::binary | std::ios::in | std::ios::out); - if (scrub) { - const std::vector zeros(static_cast(sz), 0); - scrub.write(zeros.data(), static_cast(sz)); - scrub.flush(); - } - } - std::filesystem::remove(exportPath, delEc); - DEBUG_LOGF("[decrypt] removed plaintext key export after import\n"); - } + // (exportScrub scrubs + deletes the plaintext key export on scope exit — H-02) if (!importResult.ok) { std::string err = importResult.error; @@ -1930,12 +1955,14 @@ void App::renderPinDialogs() { memset(pin_confirm_buf_, 0, sizeof(pin_confirm_buf_)); if (rpc_ && rpc_->isConnected() && worker_) { - worker_->post([this, passphrase, pin]() -> rpc::RPCWorker::MainCb { + worker_->post([this, passphrase, pin]() mutable -> rpc::RPCWorker::MainCb { // Verify passphrase via RPC (worker thread) try { rpc::RPCClient::TraceScope trace("Security / PIN setup"); rpc_->call("walletpassphrase", {passphrase, 5}); } catch (const std::exception& e) { + if (!passphrase.empty()) util::SecureVault::secureZero(&passphrase[0], passphrase.size()); + if (!pin.empty()) util::SecureVault::secureZero(&pin[0], pin.size()); return [this]() { pin_status_ = "Incorrect passphrase"; pin_in_progress_ = false; @@ -1944,6 +1971,9 @@ void App::renderPinDialogs() { // Passphrase correct — store in vault (Argon2id, worker thread) bool storeOk = vault_ && vault_->store(pin, passphrase); + // Captured passphrase + PIN are no longer needed — scrub the worker-queue copies. (M-01) + if (!passphrase.empty()) util::SecureVault::secureZero(&passphrase[0], passphrase.size()); + if (!pin.empty()) util::SecureVault::secureZero(&pin[0], pin.size()); // Lock wallet back try { diff --git a/src/app_wizard.cpp b/src/app_wizard.cpp index 84f8be1..a878c4f 100644 --- a/src/app_wizard.cpp +++ b/src/app_wizard.cpp @@ -889,8 +889,9 @@ void App::renderFirstRunWizard() { } if (wizard_stopping_external_) { + const std::string ws = wizard_stop_status_.get(); dl->AddText(captionFont, captionFont->LegacySize, ImVec2(cx, cy), dimCol, - wizard_stop_status_.c_str()); + ws.c_str()); cy += captionFont->LegacySize + 8.0f * dp; } else { float stopW = 150.0f * dp; @@ -1376,6 +1377,13 @@ void App::renderFirstRunWizard() { encrypt_status_ = TR("wiz_skip_confirm"); } else { s_skipEncConfirm = false; + // Skipping leaves the wallet UNENCRYPTED — wipe the passphrase/PIN the user may have + // typed so it doesn't linger in these process-lifetime buffers (only the Encrypt + // path cleared them before). (L-07) + memset(encrypt_pass_buf_, 0, sizeof(encrypt_pass_buf_)); + memset(encrypt_confirm_buf_, 0, sizeof(encrypt_confirm_buf_)); + memset(wizard_pin_buf_, 0, sizeof(wizard_pin_buf_)); + memset(wizard_pin_confirm_buf_, 0, sizeof(wizard_pin_confirm_buf_)); wizard_phase_ = WizardPhase::Done; settings_->setWizardCompleted(true); settings_->save(); diff --git a/src/config/settings.cpp b/src/config/settings.cpp index 0e95e87..7374515 100644 --- a/src/config/settings.cpp +++ b/src/config/settings.cpp @@ -204,6 +204,7 @@ bool Settings::load(const std::string& path) loadScalar(j, "console_text_color", console_text_color_); loadScalar(j, "console_zoom", console_zoom_); if (!(console_zoom_ >= 0.25f && console_zoom_ <= 4.0f)) console_zoom_ = 1.0f; // guard bad/NaN + loadScalar(j, "console_auto_focus", console_auto_focus_); if (j.contains("hidden_addresses") && j["hidden_addresses"].is_array()) { hidden_addresses_.clear(); for (const auto& a : j["hidden_addresses"]) @@ -231,6 +232,11 @@ bool Settings::load(const std::string& path) } loadScalar(j, "wizard_completed", wizard_completed_); loadScalar(j, "seed_backup_reminded", seed_backup_reminded_); + if (j.contains("empty_wallet_warning_acked") && j["empty_wallet_warning_acked"].is_array()) { + empty_wallet_warning_acked_.clear(); + for (const auto& w : j["empty_wallet_warning_acked"]) + if (w.is_string()) empty_wallet_warning_acked_.insert(w.get()); + } loadScalar(j, "encryption_pending", encryption_pending_); loadScalar(j, "daemon_update_prompted_size", daemon_update_prompted_size_); loadScalar(j, "active_wallet_file", active_wallet_file_); @@ -478,6 +484,7 @@ bool Settings::save(const std::string& path) j["console_line_accents"] = console_line_accents_; j["console_text_color"] = console_text_color_; j["console_zoom"] = console_zoom_; + j["console_auto_focus"] = console_auto_focus_; j["hidden_addresses"] = json::array(); for (const auto& addr : hidden_addresses_) j["hidden_addresses"].push_back(addr); @@ -499,6 +506,9 @@ bool Settings::save(const std::string& path) } j["wizard_completed"] = wizard_completed_; j["seed_backup_reminded"] = seed_backup_reminded_; + j["empty_wallet_warning_acked"] = json::array(); + for (const auto& w : empty_wallet_warning_acked_) + j["empty_wallet_warning_acked"].push_back(w); j["encryption_pending"] = encryption_pending_; j["daemon_update_prompted_size"] = daemon_update_prompted_size_; j["active_wallet_file"] = active_wallet_file_; diff --git a/src/config/settings.h b/src/config/settings.h index bc0f748..2fb7129 100644 --- a/src/config/settings.h +++ b/src/config/settings.h @@ -256,6 +256,9 @@ public: void setConsoleTextColor(bool v) { console_text_color_ = v; } float getConsoleZoom() const { return console_zoom_; } void setConsoleZoom(float v) { console_zoom_ = v; } + // Auto-place the text cursor in the command box when the Console tab is opened. + bool getConsoleAutoFocus() const { return console_auto_focus_; } + void setConsoleAutoFocus(bool v) { console_auto_focus_ = v; } // Hidden addresses (addresses hidden from the UI by the user) const std::set& getHiddenAddresses() const { return hidden_addresses_; } @@ -327,6 +330,14 @@ public: bool getSeedBackupReminded() const { return seed_backup_reminded_; } void setSeedBackupReminded(bool v) { seed_backup_reminded_ = v; } + // Wallet filenames for which the one-time "this wallet is empty but a sibling holds funds" + // warning has been dismissed. Keyed per active wallet file so switching to a different empty + // wallet can warn again (see App::maybeWarnEmptyWalletWithFundedSiblings). + bool isEmptyWalletWarnAcked(const std::string& walletFile) const { + return empty_wallet_warning_acked_.count(walletFile) > 0; + } + void ackEmptyWalletWarn(const std::string& walletFile) { empty_wallet_warning_acked_.insert(walletFile); } + // Persisted the moment deferred (wizard) encryption is requested; cleared only once the wallet is // observed to be actually encrypted. Lets a quit/crash/failed-connect before it applies be detected // and surfaced (W2-2). NEVER stores the passphrase — only the fact that encryption was requested. @@ -580,11 +591,13 @@ private: bool console_line_accents_ = true; // left color accent bars in console output bool console_text_color_ = true; // per-channel text coloring in console output float console_zoom_ = 1.0f; // console output font zoom factor + bool console_auto_focus_ = false; // focus the command input when the Console tab is opened (opt-in) std::set hidden_addresses_; std::set favorite_addresses_; std::map address_meta_; bool wizard_completed_ = false; bool seed_backup_reminded_ = false; + std::set empty_wallet_warning_acked_; // wallet files whose empty-wallet warning was dismissed bool encryption_pending_ = false; long long daemon_update_prompted_size_ = 0; // bundled daemon size last offered via the update prompt std::string active_wallet_file_ = "wallet.dat"; // -wallet= the daemon loads (multi-wallet) diff --git a/src/daemon/daemon_controller.cpp b/src/daemon/daemon_controller.cpp index b357a20..d960975 100644 --- a/src/daemon/daemon_controller.cpp +++ b/src/daemon/daemon_controller.cpp @@ -71,9 +71,11 @@ DaemonController::State DaemonController::state() const return daemon_->getState(); } -const std::string& DaemonController::lastError() const +std::string DaemonController::lastError() const { - return daemon_->getLastError(); + // By value — getLastError() now returns a mutex-locked COPY, so forwarding it by reference would + // dangle (bind a reference to that temporary). (M-04 follow-through) + return daemon_ ? daemon_->getLastError() : std::string(); } int DaemonController::crashCount() const diff --git a/src/daemon/daemon_controller.h b/src/daemon/daemon_controller.h index 21dc804..d55ebfd 100644 --- a/src/daemon/daemon_controller.h +++ b/src/daemon/daemon_controller.h @@ -95,7 +95,7 @@ public: bool externalDaemonDetected() const; void clearExternalDaemonDetected(); State state() const; - const std::string& lastError() const; + std::string lastError() const; // by value: EmbeddedDaemon::getLastError() returns a locked copy (M-04) int crashCount() const; int lastBlockHeight() const; double memoryUsageMB() const; diff --git a/src/daemon/embedded_daemon.cpp b/src/daemon/embedded_daemon.cpp index ac1b8f3..e4487eb 100644 --- a/src/daemon/embedded_daemon.cpp +++ b/src/daemon/embedded_daemon.cpp @@ -224,12 +224,11 @@ std::vector EmbeddedDaemon::getChainParams() void EmbeddedDaemon::setState(State s, const std::string& message) { state_ = s; - if (!message.empty()) { - if (s == State::Error) { - last_error_ = message; - } + if (!message.empty() && s == State::Error) { + std::lock_guard lk(error_mutex_); // dedicated mutex — never taken with output_mutex_ held + last_error_ = message; } - + if (state_callback_) { state_callback_(s, message); } @@ -621,12 +620,28 @@ bool EmbeddedDaemon::start(const std::string& binary_path) // Forward declaration — defined after startProcess static DWORD findProcessByName(const char* name); +// Quote a single argument per the CommandLineToArgvW rules (MSDN) so a value containing a space or a +// quote is delivered as ONE argv token to the daemon instead of splitting/corrupting argv (L-02). +static std::string quoteWinArg(const std::string& arg) { + if (!arg.empty() && arg.find_first_of(" \t\n\v\"") == std::string::npos) return arg; + std::string out = "\""; + for (size_t i = 0; ; ++i) { + size_t nbs = 0; + while (i < arg.size() && arg[i] == '\\') { ++nbs; ++i; } + if (i == arg.size()) { out.append(nbs * 2, '\\'); break; } + if (arg[i] == '"') { out.append(nbs * 2 + 1, '\\'); out.push_back('"'); } + else { out.append(nbs, '\\'); out.push_back(arg[i]); } + } + out.push_back('"'); + return out; +} + bool EmbeddedDaemon::startProcess(const std::string& binary_path, const std::vector& args) { - // Build command line + // Build command line (binary path always quoted; each arg quoted/escaped per Windows rules — L-02) std::string cmd = "\"" + binary_path + "\""; for (const auto& arg : args) { - cmd += " " + arg; + cmd += " " + quoteWinArg(arg); } DEBUG_LOGF("[INFO] Starting daemon: %s\n", cmd.c_str()); diff --git a/src/daemon/embedded_daemon.h b/src/daemon/embedded_daemon.h index c652843..86d9abc 100644 --- a/src/daemon/embedded_daemon.h +++ b/src/daemon/embedded_daemon.h @@ -79,7 +79,9 @@ public: /** * @brief Get last error message */ - const std::string& getLastError() const { return last_error_; } + // Copy under lock: last_error_ is written from the monitor thread (setState on an unexpected exit) + // while the UI thread reads it — a reference would be a torn-read / use-after-free race (M-04). + std::string getLastError() const { std::lock_guard lk(error_mutex_); return last_error_; } /** * @brief Get dragonxd process output (thread-safe copy) @@ -286,6 +288,7 @@ private: std::atomic state_{State::Stopped}; std::atomic external_daemon_detected_{false}; std::string last_error_; + mutable std::mutex error_mutex_; // protects last_error_ (written by main + monitor threads) mutable std::mutex output_mutex_; // protects process_output_ std::string process_output_; StateCallback state_callback_; diff --git a/src/daemon/xmrig_manager.cpp b/src/daemon/xmrig_manager.cpp index 696b6d3..9ca5a76 100644 --- a/src/daemon/xmrig_manager.cpp +++ b/src/daemon/xmrig_manager.cpp @@ -210,7 +210,7 @@ bool XmrigManager::generateConfig(const Config& cfg, const std::string& outPath) fs::create_directories(fs::path(outPath).parent_path()); std::ofstream ofs(outPath, std::ios::trunc); if (!ofs.is_open()) { - last_error_ = "Cannot write xmrig config: " + outPath; + setLastError("Cannot write xmrig config: " + outPath); DEBUG_LOGF("[ERROR] XmrigManager: %s\n", last_error_.c_str()); return false; } @@ -224,7 +224,7 @@ bool XmrigManager::generateConfig(const Config& cfg, const std::string& outPath) ofs.close(); return true; } catch (const std::exception& e) { - last_error_ = std::string("Config write error: ") + e.what(); + setLastError(std::string("Config write error: ") + e.what()); DEBUG_LOGF("[ERROR] XmrigManager: %s\n", last_error_.c_str()); return false; } @@ -236,19 +236,22 @@ bool XmrigManager::generateConfig(const Config& cfg, const std::string& outPath) bool XmrigManager::start(const Config& cfg) { if (state_ == State::Running || state_ == State::Starting) { - last_error_ = "Already running"; + setLastError("Already running"); DEBUG_LOGF("[WARN] XmrigManager: %s\n", last_error_.c_str()); return false; } state_ = State::Starting; should_stop_ = false; - last_error_.clear(); + setLastError(std::string()); { std::lock_guard lk(output_mutex_); process_output_.clear(); } - stats_ = PoolStats{}; + { + std::lock_guard lk(stats_mutex_); + stats_ = PoolStats{}; + } // Extract pool hostname for stats API queries { @@ -265,7 +268,7 @@ bool XmrigManager::start(const Config& cfg) { // Find binary std::string binary = findXmrigBinary(); if (binary.empty()) { - last_error_ = "xmrig binary not found"; + setLastError("xmrig binary not found"); state_ = State::Error; DEBUG_LOGF("[ERROR] XmrigManager: xmrig binary not found\n"); return false; @@ -368,7 +371,7 @@ bool XmrigManager::startProcess(const std::string& xmrigPath, const std::string& HANDLE hRead = nullptr, hWrite = nullptr; if (!CreatePipe(&hRead, &hWrite, &sa, 0)) { - last_error_ = "CreatePipe failed"; + setLastError("CreatePipe failed"); DEBUG_LOGF("[ERROR] XmrigManager: %s\n", last_error_.c_str()); return false; } @@ -400,7 +403,7 @@ bool XmrigManager::startProcess(const std::string& xmrigPath, const std::string& char errBuf[256]; FormatMessageA(FORMAT_MESSAGE_FROM_SYSTEM | FORMAT_MESSAGE_IGNORE_INSERTS, NULL, err, 0, errBuf, sizeof(errBuf), NULL); - last_error_ = "CreateProcess failed for xmrig (error " + std::to_string(err) + "): " + errBuf; + setLastError("CreateProcess failed for xmrig (error " + std::to_string(err) + "): " + errBuf); DEBUG_LOGF("[ERROR] XmrigManager: %s\nCommand: %s\n", last_error_.c_str(), cmdLine.c_str()); return false; } @@ -451,14 +454,14 @@ void XmrigManager::drainOutput() { bool XmrigManager::startProcess(const std::string& xmrigPath, const std::string& cfgPath, int threads) { int pipefd[2]; if (pipe(pipefd) != 0) { - last_error_ = "pipe() failed"; + setLastError("pipe() failed"); DEBUG_LOGF("[ERROR] XmrigManager: %s\n", last_error_.c_str()); return false; } pid_t pid = fork(); if (pid < 0) { - last_error_ = "fork() failed"; + setLastError("fork() failed"); DEBUG_LOGF("[ERROR] XmrigManager: %s\n", last_error_.c_str()); close(pipefd[0]); close(pipefd[1]); @@ -629,7 +632,7 @@ void XmrigManager::monitorProcess() { if (GetExitCodeProcess(process_handle_, &exitCode) && exitCode != STILL_ACTIVE) { DEBUG_LOGF("[ERROR] XmrigManager: process exited (code %lu)\n", exitCode); state_ = State::Error; - last_error_ = "xmrig process exited unexpectedly"; + setLastError("xmrig process exited unexpectedly"); break; } } @@ -640,7 +643,7 @@ void XmrigManager::monitorProcess() { if (ret == process_pid_ || ret < 0) { DEBUG_LOGF("[ERROR] XmrigManager: process exited (waitpid=%d)\n", ret); state_ = State::Error; - last_error_ = "xmrig process exited unexpectedly"; + setLastError("xmrig process exited unexpectedly"); break; } } diff --git a/src/daemon/xmrig_manager.h b/src/daemon/xmrig_manager.h index 3935daf..54d1834 100644 --- a/src/daemon/xmrig_manager.h +++ b/src/daemon/xmrig_manager.h @@ -86,8 +86,10 @@ public: bool isRunning() const; State getState() const { return state_.load(std::memory_order_relaxed); } - const PoolStats& getStats() const { return stats_; } - const std::string& getLastError() const { return last_error_; } + // Return COPIES under lock: stats_ and last_error_ are mutated by the monitor thread while the UI + // thread reads them, so handing out a reference is a torn-read / use-after-free race (M-03, M-04). + PoolStats getStats() const { std::lock_guard lk(stats_mutex_); return stats_; } + std::string getLastError() const { std::lock_guard lk(error_mutex_); return last_error_; } /// Thread count requested at start() — available immediately, unlike /// PoolStats::threads_active which requires an API response. @@ -156,11 +158,14 @@ private: void monitorProcess(); void drainOutput(); void appendOutput(const char* data, size_t len); + // Set last_error_ under error_mutex_ (writers run on both the main thread and the monitor thread). + void setLastError(std::string e) { std::lock_guard lk(error_mutex_); last_error_ = std::move(e); } void fetchStatsHttp(); // Blocking HTTP call — runs on monitor thread only void fetchPoolApiStats(); // Fetch pool-side stats (hashrate) from pool HTTP API std::atomic state_{State::Stopped}; std::string last_error_; + mutable std::mutex error_mutex_; // guards last_error_ (written by main + monitor threads) mutable std::mutex output_mutex_; std::string process_output_; diff --git a/src/data/wallet_state.h b/src/data/wallet_state.h index 988110d..dc0a00f 100644 --- a/src/data/wallet_state.h +++ b/src/data/wallet_state.h @@ -312,6 +312,7 @@ struct WalletState { // Timestamps for refresh logic int64_t last_balance_update = 0; + int64_t last_address_update = 0; // set when an address-list refresh applies; 0 = never loaded yet int64_t last_tx_update = 0; int64_t last_peer_update = 0; int64_t last_mining_update = 0; @@ -354,7 +355,7 @@ struct WalletState { // refresh" stamps. Otherwise the pre-teardown time survives and, on reconnect, the staleness // badge (and any "updated X ago" reader) briefly reports it as current until the first refresh // re-stamps it. All readers treat 0 as "never" (formatTimeAgoShort/timeAgo return ""). - last_balance_update = last_tx_update = last_peer_update = last_mining_update = 0; + last_balance_update = last_address_update = last_tx_update = last_peer_update = last_mining_update = 0; } // Rebuild combined addresses list from z/t lists diff --git a/src/main.cpp b/src/main.cpp index acfe29a..4e1dbd7 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -2127,6 +2127,7 @@ int main(int argc, char* argv[]) // deadlocks waiting for detached pthreads. On Linux, static // destructors and atexit handlers can also block. _Exit() bypasses // all of that. + app.wipeSecrets(); // _Exit() below bypasses ~App(), so scrub secret buffers here (L-05) fflush(stdout); fflush(stderr); _Exit(0); diff --git a/src/rpc/rpc_client.cpp b/src/rpc/rpc_client.cpp index a1e8c04..72be115 100644 --- a/src/rpc/rpc_client.cpp +++ b/src/rpc/rpc_client.cpp @@ -146,7 +146,11 @@ RPCClient::RPCClient() : impl_(std::make_unique()) { } -RPCClient::~RPCClient() = default; +RPCClient::~RPCClient() { + // Scrub the persistent Basic-auth secret on destruction (disconnect() may not have run). impl_ is + // still destroyed normally afterward (curl cleanup unchanged). (L-04) + if (!auth_.empty()) sodium_memzero(auth_.data(), auth_.size()); +} bool RPCClient::connect(const std::string& host, const std::string& port, const std::string& user, const std::string& password) @@ -166,6 +170,7 @@ bool RPCClient::connect(const std::string& host, const std::string& port, // Create Basic auth header with proper base64 encoding, then wipe the plaintext // "user:password" temporary (std::string does not zero its buffer on destruction). std::string credentials = user + ":" + password; + if (!auth_.empty()) sodium_memzero(auth_.data(), auth_.size()); // wipe any prior secret before overwrite (L-04) auth_ = util::base64_encode(credentials); if (!credentials.empty()) sodium_memzero(credentials.data(), credentials.size()); @@ -193,6 +198,7 @@ bool RPCClient::connect(const std::string& host, const std::string& port, impl_->headers = curl_slist_append(nullptr, "Content-Type: text/plain"); std::string auth_header = "Authorization: Basic " + auth_; impl_->headers = curl_slist_append(impl_->headers, auth_header.c_str()); + if (!auth_header.empty()) sodium_memzero(auth_header.data(), auth_header.size()); // curl copied it (L-04) // Configure curl curl_easy_setopt(impl_->curl, CURLOPT_URL, impl_->url.c_str()); @@ -299,6 +305,7 @@ void RPCClient::disconnect() curl_slist_free_all(impl_->headers); impl_->headers = nullptr; } + if (!auth_.empty()) { sodium_memzero(auth_.data(), auth_.size()); auth_.clear(); } // scrub Basic-auth secret (L-04) } json RPCClient::makePayload(const std::string& method, const json& params) diff --git a/src/services/network_refresh_service.cpp b/src/services/network_refresh_service.cpp index 2abcd31..39755e5 100644 --- a/src/services/network_refresh_service.cpp +++ b/src/services/network_refresh_service.cpp @@ -628,6 +628,7 @@ NetworkRefreshService::AddressRefreshResult NetworkRefreshService::collectAddres } } catch (const std::exception& e) { DEBUG_LOGF("z_listaddresses error: %s\n", e.what()); + result.addressListOk = false; // enumeration failed → the shielded list may be falsely short } try { @@ -652,6 +653,7 @@ NetworkRefreshService::AddressRefreshResult NetworkRefreshService::collectAddres result.transparentAddresses = parseTransparentAddressList(tList); } catch (const std::exception& e) { DEBUG_LOGF("getaddressesbyaccount error: %s\n", e.what()); + result.addressListOk = false; // enumeration failed → the transparent list may be falsely short } try { diff --git a/src/services/network_refresh_service.h b/src/services/network_refresh_service.h index 7fd1b88..26f2215 100644 --- a/src/services/network_refresh_service.h +++ b/src/services/network_refresh_service.h @@ -149,6 +149,10 @@ public: struct AddressRefreshResult { std::vector shieldedAddresses; std::vector transparentAddresses; + // False if either address-enumeration RPC (z_listaddresses / getaddressesbyaccount) threw, so the + // lists may be falsely short. Consumers that treat an empty list as authoritative (e.g. the + // empty-wallet warning) must not trust a 0 count unless this is true. + bool addressListOk = true; }; struct AddressRefreshSnapshot { diff --git a/src/ui/pages/settings_page.cpp b/src/ui/pages/settings_page.cpp index 0b0c227..237c89d 100644 --- a/src/ui/pages/settings_page.cpp +++ b/src/ui/pages/settings_page.cpp @@ -515,6 +515,14 @@ static void renderConsoleColorToggles(App* app) { app->settings()->save(); } if (ImGui::IsItemHovered()) material::Tooltip("%s", TR("console_toggle_text_color")); + // Console behavior (not a GPU effect): focus the command input when the tab opens. Bound straight to + // settings — the App reads it at the page transition; no ConsoleTab static needed. + bool autoFocus = app->settings()->getConsoleAutoFocus(); + if (ImGui::Checkbox(TrId("console_auto_focus", "con_autofocus").c_str(), &autoFocus)) { + app->settings()->setConsoleAutoFocus(autoFocus); + app->settings()->save(); + } + if (ImGui::IsItemHovered()) material::Tooltip("%s", TR("console_toggle_auto_focus")); ImGui::BeginDisabled(s_settingsState.low_spec_mode); } diff --git a/src/ui/windows/chat_tab.cpp b/src/ui/windows/chat_tab.cpp index 3539252..d17e3d9 100644 --- a/src/ui/windows/chat_tab.cpp +++ b/src/ui/windows/chat_tab.cpp @@ -10,6 +10,7 @@ #include "../../data/address_book.h" #include "../../chat/chat_service.h" #include "../../util/i18n.h" +#include "../../util/address_validation.h" // isShieldedAddress — chat requires a z-address recipient #include "../../util/platform.h" // getConfigDir + writeFileAtomically — conversation export (Q11) #include "../../config/settings.h" // per-conversation mute (Q10) #include "../material/colors.h" @@ -59,6 +60,10 @@ bool s_msgsel_dragging = false; // Composer + new-conversation UI state. char s_compose[512] = ""; std::string s_compose_cid; // the conversation s_compose is a draft for; draft is wiped when it changes +// Live byte offset of the composer's text caret, kept in sync by composeInputCallback while the composer +// is active (the callback only fires then). The emoji picker uses it to splice a glyph at the cursor +// instead of always appending. -1 = unknown/never-focused => append at the end. +int s_composeCursor = -1; // On-chain chat body cap in bytes = (512 − len("utf8:"))/2 − secretstream ABYTES (see chat_outgoing.cpp). // The composer hard-caps input to this; the emoji picker respects it too. constexpr int kChatBodyMaxBytes = (512 - 5) / 2 - 17; // = 236 @@ -70,11 +75,16 @@ float s_composerTargetH = 0.0f; // target height measured in the composer block // neither the plain-Enter (submit) nor the Ctrl+Enter shortcut — ImGui does nothing with it. We insert the // newline ourselves here (running under CallbackAlways), respecting the on-chain byte cap. int composeInputCallback(ImGuiInputTextCallbackData* data) { + // Track the live caret so the emoji picker can insert at the cursor. This callback runs under + // CallbackAlways, which ImGui only invokes while the field is active — so when the composer loses + // focus (e.g. to the emoji picker) s_composeCursor keeps the last edit position. + s_composeCursor = data->CursorPos; ImGuiIO& io = ImGui::GetIO(); if (io.KeyShift && (ImGui::IsKeyPressed(ImGuiKey_Enter) || ImGui::IsKeyPressed(ImGuiKey_KeypadEnter)) && data->BufTextLen < kChatBodyMaxBytes) { data->InsertChars(data->CursorPos, "\n"); + s_composeCursor = data->CursorPos; // InsertChars advanced the caret past the newline } return 0; } @@ -625,16 +635,22 @@ static const EmojiEntry kEmoji[] = { // Emoji picker overlay: fills the conversation-list pane (cancel + keyword search at the top, then a // grid). Clicking an emoji appends its UTF-8 bytes to `buf` (the composer), respecting the buffer. void renderEmojiPickerOverlay(char* buf, std::size_t bufSize, ImTextureID drgxTex) { - // Insert a token (emoji glyph or the ":drgx:" shortcode) at the end of the draft, prepending a space - // when the draft isn't empty and doesn't already end in whitespace. Respects the on-chain byte cap. + // Insert a token (emoji glyph or the ":drgx:" shortcode) at the composer's caret (s_composeCursor, + // kept live by composeInputCallback; -1 => end of draft), prepending a space when the char before the + // caret is a non-space word char so the emoji doesn't fuse onto it. Respects the on-chain byte cap. + // The composer is inactive whenever the picker is open, so it renders straight from buf — splicing + // here shows immediately. auto insertToken = [&](const char* tok) { const std::size_t cur = std::strlen(buf), add = std::strlen(tok); - const bool needsSpace = cur > 0 && static_cast(buf[cur - 1]) > ' '; + const std::size_t pos = (s_composeCursor < 0) + ? cur : std::min(static_cast(s_composeCursor), cur); + const bool needsSpace = pos > 0 && static_cast(buf[pos - 1]) > ' '; const std::size_t pad = needsSpace ? 1 : 0; if (cur + pad + add <= static_cast(kChatBodyMaxBytes) && cur + pad + add < bufSize) { - if (needsSpace) buf[cur] = ' '; - std::memcpy(buf + cur + pad, tok, add); - buf[cur + pad + add] = '\0'; + std::memmove(buf + pos + pad + add, buf + pos, (cur - pos) + 1); // shift tail right (incl NUL) + if (needsSpace) buf[pos] = ' '; + std::memcpy(buf + pos + pad, tok, add); + s_composeCursor = static_cast(pos + pad + add); // keep the caret after the inserted token } }; if (ImGui::SmallButton(TR("chat_cancel"))) { s_show_emoji_picker = false; s_emoji_search[0] = '\0'; return; } @@ -747,6 +763,7 @@ void RenderChatTab(App* app) // for one contact can't be sent to another (B5). if (s_selected_cid != s_compose_cid) { sodium_memzero(s_compose, sizeof(s_compose)); + s_composeCursor = -1; // fresh draft — next emoji appends until the caret is known again s_compose_cid = s_selected_cid; s_composerAnimH = 0.0f; // re-arm the first-frame snap so the box doesn't animate-collapse on switch } @@ -1705,6 +1722,7 @@ void RenderChatTab(App* app) if (submit && s_compose[0] != '\0' && !overCap) { app->sendChatMessage(sel->cid, s_compose); sodium_memzero(s_compose, sizeof(s_compose)); + s_composeCursor = -1; s_scroll_to_cid = sel->cid; s_composerAnimH = 0.0f; // snap back to collapsed instead of animating while unfocused // Sending closes the emoji picker (it takes over the conversation-list pane) so the list @@ -1726,6 +1744,17 @@ void RenderChatTab(App* app) if (material::BeginOverlayDialog(ov)) { const float fieldW = ImGui::GetContentRegionAvail().x; material::LabeledInput(TR("chat_new_zaddr"), "##newz", s_new_zaddr, sizeof(s_new_zaddr), fieldW); + // Chat rides on encrypted memos, which only shielded (z) addresses carry — a transparent (t) + // address can't receive one. Contacts can hold t-addresses, so guard the manual field too: + // warn when the entry isn't a valid z-address and keep Send disabled below. + const bool newAddrIsZ = dragonx::util::isShieldedAddress(s_new_zaddr); + if (s_new_zaddr[0] != '\0' && !newAddrIsZ) { + ImGui::PushStyleColor(ImGuiCol_Text, material::Warning()); + ImGui::PushTextWrapPos(0.0f); + ImGui::TextUnformatted(TR("chat_new_needs_zaddr")); + ImGui::PopTextWrapPos(); + ImGui::PopStyleColor(); + } // Or pick from contacts — chat needs a shielded z-address, so only z-addr contacts are listed. // Selecting one fills the field above (manual paste still works). ImGui::SetNextItemWidth(fieldW); @@ -1757,7 +1786,7 @@ void RenderChatTab(App* app) material::LabeledInput(TR("chat_new_message"), "##newm", s_new_msg, sizeof(s_new_msg), fieldW); ImGui::Dummy(ImVec2(0, Layout::spacingMd())); - const bool canSend = s_new_zaddr[0] != '\0' && s_new_msg[0] != '\0'; + const bool canSend = newAddrIsZ && s_new_msg[0] != '\0'; const float actionW = std::max(130.0f * dp, ImGui::CalcTextSize(TR("chat_new_send")).x + ImGui::GetStyle().FramePadding.x * 2.0f + 24.0f * dp); const float actionGap = Layout::spacingSm(); diff --git a/src/ui/windows/console_command_reference.cpp b/src/ui/windows/console_command_reference.cpp index 9ec3ded..575f666 100644 --- a/src/ui/windows/console_command_reference.cpp +++ b/src/ui/windows/console_command_reference.cpp @@ -160,10 +160,10 @@ const ConsoleCommandEntry kWalletCommands[] = { "z_sendmany \"RfromAddr\" [{\"address\":\"zs1toAddr\",\"amount\":1.0}]", "send pay private shielded transfer money", true}, {"z_shieldcoinbase", "Shield transparent coinbase funds to a z-address", "\"fromaddress\" \"tozaddress\" [fee] [limit]", "Moves newly mined (coinbase) transparent funds into a private shielded z-address, since mined rewards must be shielded before they can be spent normally. Runs in the background and returns an operation id.", - "z_shieldcoinbase \"RyourMiningAddr\" \"zs1yourShieldedAddr\"", "shield mining rewards coinbase private hide mined funds move to shielded"}, + "z_shieldcoinbase \"RyourMiningAddr\" \"zs1yourShieldedAddr\"", "shield mining rewards coinbase private hide mined funds move to shielded", true}, {"z_mergetoaddress", "Merge multiple UTXOs/notes to one address", "[\"fromaddress\",...] \"toaddress\" [fee] [limit]", "Combines many small balances (from transparent and/or shielded addresses) into a single destination address in one transaction, to consolidate funds. Runs in the background and returns an operation id.", - "z_mergetoaddress [\"RyourAddr\",\"zs1yourShieldedAddr\"] \"zs1destShieldedAddr\"", "merge combine consolidate funds sweep small balances into one address"}, + "z_mergetoaddress [\"RyourAddr\",\"zs1yourShieldedAddr\"] \"zs1destShieldedAddr\"", "merge combine consolidate funds sweep small balances into one address", true}, {"listtransactions", "List recent wallet transactions", "[\"account\"] [count] [from]", "Your most recent wallet transactions, newest first \xE2\x80\x94 amounts, addresses and confirmations.", "listtransactions", "transactions history recent payments received sent"}, diff --git a/src/ui/windows/console_tab.cpp b/src/ui/windows/console_tab.cpp index d893de7..c57e04f 100644 --- a/src/ui/windows/console_tab.cpp +++ b/src/ui/windows/console_tab.cpp @@ -1406,10 +1406,13 @@ void ConsoleTab::renderInput(ConsoleCommandExecutor& exec) ImGui::PopItemWidth(); ImGui::PopFont(); - // Auto-focus on input - if (reclaim_focus) { + // Auto-focus on input — after submitting a command (reclaim), or once when the Console tab is opened + // (focus_input_pending_, set by requestInputFocus() and gated on the console_auto_focus setting). + // Skip while a command is running: SetKeyboardFocusHere can't focus the disabled field anyway. + if ((reclaim_focus || focus_input_pending_) && !busy) { ImGui::SetKeyboardFocusHere(-1); } + focus_input_pending_ = false; } bool ConsoleTab::submitConsoleCommand(ConsoleCommandExecutor& exec, const std::string& cmd) diff --git a/src/ui/windows/console_tab.h b/src/ui/windows/console_tab.h index b3ba243..13a64d2 100644 --- a/src/ui/windows/console_tab.h +++ b/src/ui/windows/console_tab.h @@ -65,6 +65,10 @@ public: */ void clear(); + // Ask the console to place the keyboard focus in the command input on the next render (consumed once). + // Called when the user switches to the Console tab, gated by the console_auto_focus setting. + void requestInputFocus() { focus_input_pending_ = true; } + // Scanline effect toggle (set from settings) static bool s_scanline_enabled; @@ -156,6 +160,7 @@ private: int history_index_ = -1; char input_buffer_[4096] = {0}; bool stop_confirm_pending_ = false; // 'stop' typed once, awaiting a confirming second 'stop' + bool focus_input_pending_ = false; // one-shot: focus the command input next render (tab-open auto-focus) // (log-ingestion cursors + result queue moved to the ConsoleCommandExecutor) // Auto-scroll state machine (pin-to-bottom, wheel-up cooldown, new-line backlog count). diff --git a/src/ui/windows/send_tab.cpp b/src/ui/windows/send_tab.cpp index 0b3c0f5..f71a515 100644 --- a/src/ui/windows/send_tab.cpp +++ b/src/ui/windows/send_tab.cpp @@ -160,6 +160,20 @@ static bool IsValidTransparentAddr(const char* a) { return a && dragonx::util::isTransparentAddress(a); } +// Source-address balance caption for the "sending from" dropdown: the TOTAL balance is the headline (so +// it matches the Overview figure and a source whose change is still confirming never looks like it lost +// funds), with the confirmed-spendable amount shown as a smaller "(N available)" note ONLY when a pending +// send/receive makes it differ. The Max button + validation still cap spends at the spendable amount. +static std::string FormatSourceBalance(double total, double spendable) { + char b[128]; + if (total - spendable > 1e-9) + snprintf(b, sizeof(b), "%.8f %s (%.8f %s)", total, DRAGONX_TICKER, + spendable, TR("send_available_note")); + else + snprintf(b, sizeof(b), "%.8f %s", total, DRAGONX_TICKER); + return b; +} + static std::string timeAgo(int64_t timestamp) { return dragonx::util::formatTimeAgoShort(timestamp); } @@ -253,8 +267,9 @@ static void RenderSourceDropdown(App* app, float width) { const char* tag = isZ ? "[Z]" : "[T]"; std::string trunc = util::truncateMiddle(addr.address, static_cast(std::max(S.drawElement("tabs.send", "addr-preview-trunc-min").size, width / S.drawElement("tabs.send", "addr-preview-trunc-divisor").size))); - snprintf(buf, sizeof(buf), "%s %s — %.8f %s", - tag, trunc.c_str(), addr.spendableBalance, DRAGONX_TICKER); + snprintf(buf, sizeof(buf), "%s %s — %s", + tag, trunc.c_str(), + FormatSourceBalance(addr.balance, addr.spendableBalance).c_str()); s_source_preview = buf; } else { s_source_preview = TR("send_select_source"); @@ -267,8 +282,17 @@ static void RenderSourceDropdown(App* app, float width) { if (!app->isConnected() || state.addresses.empty()) { ImGui::TextDisabled("%s", TR("no_addresses_available")); } else { - // Sort by balance descending, only show spendable addresses with balance - std::vector sortedIdx = sortedSpendableAddressIndices(state.addresses); + // List every address that HOLDS a balance (confirmed or still-confirming), sorted by total + // descending — so a source whose change is pending stays visible with its real total instead + // of vanishing. The spend gates (Max / validation) below still cap at the spendable amount. + std::vector sortedIdx; + sortedIdx.reserve(state.addresses.size()); + for (size_t i = 0; i < state.addresses.size(); ++i) + if (state.addresses[i].isSpendable() && state.addresses[i].balance > 0.0) + sortedIdx.push_back(i); + std::sort(sortedIdx.begin(), sortedIdx.end(), [&](size_t a, size_t b) { + return state.addresses[a].balance > state.addresses[b].balance; + }); if (sortedIdx.empty()) { ImGui::TextDisabled("%s", TR("send_no_balance")); @@ -283,8 +307,9 @@ static void RenderSourceDropdown(App* app, float width) { const char* tag = isZ ? "[Z]" : "[T]"; std::string trunc = util::truncateMiddle(addr.address, (int)addrTruncLen); - snprintf(buf, sizeof(buf), "%s %s — %.8f %s", - tag, trunc.c_str(), addr.spendableBalance, DRAGONX_TICKER); + snprintf(buf, sizeof(buf), "%s %s — %s", + tag, trunc.c_str(), + FormatSourceBalance(addr.balance, addr.spendableBalance).c_str()); ImGui::PushID(static_cast(i)); if (ImGui::Selectable(buf, isCurrent)) { @@ -293,8 +318,9 @@ static void RenderSourceDropdown(App* app, float width) { addr.address.c_str()); } if (ImGui::IsItemHovered()) { - material::Tooltip("%s\nBalance: %.8f %s", - addr.address.c_str(), addr.spendableBalance, DRAGONX_TICKER); + material::Tooltip("%s\n%s", + addr.address.c_str(), + FormatSourceBalance(addr.balance, addr.spendableBalance).c_str()); } ImGui::PopID(); } diff --git a/src/ui/windows/wallets_dialog.h b/src/ui/windows/wallets_dialog.h index b29daf6..66494b8 100644 --- a/src/ui/windows/wallets_dialog.h +++ b/src/ui/windows/wallets_dialog.h @@ -594,8 +594,9 @@ private: // Each external wallet gets its own STABLE link name derived from its path — so switching between two // of them is a real -wallet= switch (not a no-op on one shared name), and the wallet // index tracks each separately (correct per-wallet rescan + cached data). Hidden from the list. - static constexpr const char* kLinkPrefix = "wallet-ip-"; - static bool isLinkName(const std::string& n) { return n.rfind(kLinkPrefix, 0) == 0; } + // Single source of truth lives in util/wallet_file_probe.h (shared with the offline enumeration helper). + static constexpr const char* kLinkPrefix = util::kInPlaceLinkPrefix; + static bool isLinkName(const std::string& n) { return util::isInPlaceLinkName(n); } // Stable per-target bare link name, e.g. "wallet-ip-1a2b3c4d.dat" (FNV-1a of the absolute path — a // deterministic, cross-platform, cross-run hash, unlike std::hash). Feed it a canonicalOf() path so the diff --git a/src/util/daemon_updater.cpp b/src/util/daemon_updater.cpp index c8a9cc8..d818c55 100644 --- a/src/util/daemon_updater.cpp +++ b/src/util/daemon_updater.cpp @@ -277,6 +277,7 @@ void DaemonUpdater::installResolved(const std::string& targetDir, const DaemonRe // checksum and (b) verify a detached ed25519 signature over the archive bytes against the // pinned key, so a checksum rewritten in a tampered release body is not sufficient to install. setProgress(State::Verifying, "Verifying download…"); + std::string bytes; // kept in scope through extraction so we extract the VERIFIED buffer (I-01) { std::ifstream f(zipPath, std::ios::binary); if (!f) { @@ -284,7 +285,7 @@ void DaemonUpdater::installResolved(const std::string& targetDir, const DaemonRe setProgress(State::Failed, "Could not read the downloaded archive."); return; } - const std::string bytes((std::istreambuf_iterator(f)), std::istreambuf_iterator()); + bytes.assign(std::istreambuf_iterator(f), std::istreambuf_iterator()); if (f.bad()) { fs::remove(zipPath, ec); setProgress(State::Failed, "Could not read the downloaded archive."); @@ -342,7 +343,9 @@ void DaemonUpdater::installResolved(const std::string& targetDir, const DaemonRe const std::string daemonName = wanted.front(); // "dragonxd" / "dragonxd.exe" mz_zip_archive zip{}; - if (!mz_zip_reader_init_file(&zip, zipPath.c_str(), 0)) { + // Extract from the ALREADY-VERIFIED in-memory buffer, not by reopening zipPath — otherwise a fast + // local attacker could swap the file on disk between the hash/signature check and extraction. (I-01) + if (!mz_zip_reader_init_mem(&zip, bytes.data(), bytes.size(), 0)) { fs::remove(zipPath, ec); setProgress(State::Failed, "Could not open the downloaded archive."); return; diff --git a/src/util/i18n.cpp b/src/util/i18n.cpp index c90f996..f5ab0ca 100644 --- a/src/util/i18n.cpp +++ b/src/util/i18n.cpp @@ -223,6 +223,7 @@ void I18n::loadBuiltinEnglish() strings_["chat_send"] = "Send"; strings_["chat_new_title"] = "New chat"; strings_["chat_new_zaddr"] = "Recipient z-address"; + strings_["chat_new_needs_zaddr"] = "Chat needs a shielded (z) address — transparent (t) addresses can't receive encrypted messages."; strings_["chat_new_message"] = "Message"; strings_["chat_new_send"] = "Send request"; strings_["chat_cancel"] = "Cancel"; @@ -1194,6 +1195,19 @@ void I18n::loadBuiltinEnglish() strings_["block_db_reindex_started"] = "Rebuilding the block database from your blocks — this can take a while."; // Wallet auto-recovery warning (the node moved wallet.dat aside and loaded a salvaged copy). + // Empty-active-wallet-with-funded-sibling warning (App::renderEmptyWalletWarningDialog). + strings_["empty_wallet_warning_title"] = "This wallet is empty"; + strings_["empty_wallet_warning_headline"] = "You may have opened the wrong wallet."; + strings_["empty_wallet_warning_body"] = "This wallet has no addresses and no funds, but another wallet file in your DragonX folder holds keys. Your coins are most likely in it, not lost. Open the wallet manager to switch to the wallet that holds your funds."; + strings_["empty_wallet_keys_suffix"] = "keys"; + strings_["empty_wallet_open_manager"] = "Open wallet manager"; + strings_["empty_wallet_warning_dismiss"] = "Don't warn again for this wallet"; + strings_["empty_wallet_warning_dismiss_tip"] = "Stops this warning for the current wallet file only. If you switch to a different empty wallet later, it can warn again."; + // Salvage-backup variant of the same modal (a funded wallet..bak from an earlier auto-repair). + strings_["empty_wallet_salvage_title"] = "Your wallet may have been repaired"; + strings_["empty_wallet_salvage_headline"] = "Your coins are safe in a backup file."; + strings_["empty_wallet_salvage_body"] = "This wallet is empty because an earlier automatic repair set your original wallet aside as a backup. Your coins are almost certainly in that backup, not lost. Restore it to load your funds again — nothing is deleted; the current file is kept aside first."; + strings_["empty_wallet_restore"] = "Restore my wallet"; strings_["wallet_recovered_title"] = "Your wallet file needs a quick repair"; strings_["wallet_recovered_safety"] = "Your coins are safe."; strings_["wallet_recovered_warn"] = "When the app started, it found that your wallet file didn't pass its consistency check — this usually happens after an app update or an unclean shutdown. The app already protected your data: it set the old file aside and loaded a repaired copy so you're not stuck."; @@ -1616,6 +1630,8 @@ void I18n::loadBuiltinEnglish() strings_["console_zoom_out"] = "Zoom out"; strings_["console_toggle_accents"] = "Toggle line color accents"; strings_["console_toggle_text_color"] = "Toggle line text colors"; + strings_["console_auto_focus"] = "Focus input on open"; + strings_["console_toggle_auto_focus"] = "Place the cursor in the command box when you open the Console tab"; strings_["console_accents"] = "Color accents"; strings_["console_text_colors"] = "Text colors"; strings_["console_cat_control"] = "Control"; @@ -2123,6 +2139,7 @@ void I18n::loadBuiltinEnglish() strings_["send_recipient"] = "RECIPIENT"; strings_["send_select_source"] = "Select a source address..."; strings_["send_sending_from"] = "SENDING FROM"; + strings_["send_available_note"] = "available"; strings_["send_submitting"] = "Submitting transaction..."; strings_["send_switch_to_receive"] = "Switch to Receive to get your address and start receiving funds."; strings_["send_tooltip_enter_amount"] = "Enter an amount to send"; diff --git a/src/util/wallet_file_probe.h b/src/util/wallet_file_probe.h index fa22130..b9c5266 100644 --- a/src/util/wallet_file_probe.h +++ b/src/util/wallet_file_probe.h @@ -18,14 +18,57 @@ #include #include #include +#include #include #include +#include #include #include namespace dragonx { namespace util { +// A reserved bare-filename PREFIX for the datadir "in-place link" wallets: an out-of-datadir wallet the +// user opens gets a stable symlink/hardlink under this name in the datadir so the daemon can load it by +// bare -wallet=. These are plumbing, not standalone wallet files, so wallet enumeration hides them. +// Single source of truth shared with the wallets UI (ui/windows/wallets_dialog.h). +constexpr const char* kInPlaceLinkPrefix = "wallet-ip-"; +inline bool isInPlaceLinkName(const std::string& name) { return name.rfind(kInPlaceLinkPrefix, 0) == 0; } + +// Enumerate the standalone wallet-bearing files in a DragonX datadir (TOP-LEVEL only): bare "wallet*.dat" +// files, excluding in-place links and (optionally) one active filename. The "wallet" prefix already excludes +// node artifacts (peers.dat / blk*.dat / asmap.dat / …). With includeSalvageBaks, also returns "wallet*.bak" +// files — the daemon's salvage backups (wallet..bak) that hold the pre-salvage keys. Returns full paths. +// Exception-safe (error_code iteration); never descends into subdirectories. This is the lightweight +// datadir-only counterpart to the wallets dialog's richer scan (which also walks user-added external folders +// and de-dups by canonical path); the default (.dat only, no baks) matches that dialog's semantics. +inline std::vector enumerateDatadirWalletFiles(const std::string& datadir, + const std::string& excludeActiveName = "", + bool includeSalvageBaks = false) { + namespace fs = std::filesystem; + std::vector out; + std::error_code ec; + fs::directory_iterator it(datadir, ec), end; + if (ec) return out; + for (; it != end; it.increment(ec)) { + if (ec) break; + const fs::path p = it->path(); + const std::string name = p.filename().string(); + if (name.size() <= 4) continue; + const std::string ext = name.substr(name.size() - 4); + const bool isDat = (ext == ".dat"); + const bool isBak = includeSalvageBaks && (ext == ".bak"); + if (!isDat && !isBak) continue; // *.dat (+ *.bak) only + if (name.rfind("wallet", 0) != 0) continue; // wallet-prefixed only + if (isInPlaceLinkName(name)) continue; // hide in-place links + if (!excludeActiveName.empty() && name == excludeActiveName) continue; // skip the active wallet + std::error_code fec; + if (!fs::is_regular_file(p, fec)) continue; + out.push_back(p.string()); + } + return out; +} + struct WalletFileProbe { bool isBerkeleyDB = false; ///< file has a valid BDB btree metapage magic (looks like a real wallet.dat) bool encrypted = false; ///< has an "mkey" master-key record → passphrase-encrypted diff --git a/src/util/xmrig_updater.cpp b/src/util/xmrig_updater.cpp index 25e8343..026a115 100644 --- a/src/util/xmrig_updater.cpp +++ b/src/util/xmrig_updater.cpp @@ -265,6 +265,7 @@ void XmrigUpdater::installResolved(const std::string& targetDir, const XmrigRele // the archive bytes against that key, so a checksum rewritten in a tampered release body is // not sufficient to install. setProgress(State::Verifying, "Verifying download…"); + std::string bytes; // kept in scope through extraction so we extract the VERIFIED buffer (I-01) { std::ifstream f(zipPath, std::ios::binary); if (!f) { @@ -272,7 +273,7 @@ void XmrigUpdater::installResolved(const std::string& targetDir, const XmrigRele setProgress(State::Failed, "Could not read the downloaded archive."); return; } - const std::string bytes((std::istreambuf_iterator(f)), std::istreambuf_iterator()); + bytes.assign(std::istreambuf_iterator(f), std::istreambuf_iterator()); if (f.bad()) { fs::remove(zipPath, ec); setProgress(State::Failed, "Could not read the downloaded archive."); @@ -333,7 +334,9 @@ void XmrigUpdater::installResolved(const std::string& targetDir, const XmrigRele const std::string minerName = wanted.front(); // "xmrig" / "xmrig.exe" mz_zip_archive zip{}; - if (!mz_zip_reader_init_file(&zip, zipPath.c_str(), 0)) { + // Extract from the ALREADY-VERIFIED in-memory buffer, not by reopening zipPath — otherwise a fast + // local attacker could swap the file on disk between the hash/signature check and extraction. (I-01) + if (!mz_zip_reader_init_mem(&zip, bytes.data(), bytes.size(), 0)) { fs::remove(zipPath, ec); setProgress(State::Failed, "Could not open the downloaded archive."); return; diff --git a/src/wallet/lite_wallet_controller.cpp b/src/wallet/lite_wallet_controller.cpp index 1b0d891..f5971e0 100644 --- a/src/wallet/lite_wallet_controller.cpp +++ b/src/wallet/lite_wallet_controller.cpp @@ -856,6 +856,13 @@ bool LiteWalletController::runConsoleCommand(std::string commandLine) r.ok = call.ok; r.response = call.ok ? call.value : (call.error.empty() ? "command failed" : call.error); + // send/shield/import mutate wallet state and the backend does NOT auto-save (same reason + // doSend/doShield call persistAfterBroadcast). Persist so a console-driven tx survives a + // restart instead of only being re-derived on the next full sync. (M-02) + if (call.ok && (command == "send" || command == "shield" || + command == "import" || command == "timport")) { + persistAfterBroadcast(*bridge); + } } else { r.response = "lite backend unavailable"; }