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.<ts>.bak routes to
the recovery/Restore flow; a funded sibling .dat routes to the wallet manager.
Per-wallet-file dismissal; gated on synced + address-list-loaded to avoid false
positives on warm reconnect / spent-down wallets.

UX fixes:
- send: show the TOTAL balance (with a spendable "available" note) in the source
  dropdown and keep pending-change addresses visible.
- chat: insert emoji at the cursor position; restrict new-chat recipients to
  shielded (z) addresses.
- console: optional auto-focus of the command input on tab open (off by default).
- shutdown: when "stop external daemon" is on, keep the shutdown screen up until
  the external node actually exits, showing live status.

Adversarially reviewed; verified across full-node, lite, and Windows builds; tests pass.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-08-18 13:18:49 -05:00
parent ea26c0cbbb
commit 6ee81a5abe
40 changed files with 745 additions and 90 deletions

View File

@@ -213,7 +213,7 @@ include(FetchContent)
FetchContent_Declare( FetchContent_Declare(
json json
GIT_REPOSITORY https://github.com/nlohmann/json.git 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 GIT_SHALLOW TRUE
) )
FetchContent_MakeAvailable(json) FetchContent_MakeAvailable(json)
@@ -222,7 +222,7 @@ FetchContent_MakeAvailable(json)
FetchContent_Declare( FetchContent_Declare(
tomlplusplus tomlplusplus
GIT_REPOSITORY https://github.com/marzer/tomlplusplus.git 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 GIT_SHALLOW TRUE
) )
FetchContent_MakeAvailable(tomlplusplus) FetchContent_MakeAvailable(tomlplusplus)
@@ -289,7 +289,7 @@ message(STATUS "Fetching libwebp (decode-only, static)...")
FetchContent_Declare( FetchContent_Declare(
libwebp libwebp
GIT_REPOSITORY https://github.com/webmproject/libwebp.git 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 GIT_SHALLOW TRUE
# libwebp's cpu.cmake applies -mno-sse2/-mno-sse4.1 to its scalar reference DSP # 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 # files when it can't probe SSE support. Under a macOS universal build

Binary file not shown.

View File

@@ -485,6 +485,17 @@
"download_bootstrap": "Bootstrap herunterladen", "download_bootstrap": "Bootstrap herunterladen",
"dragonx_green": "DragonX (Grün)", "dragonx_green": "DragonX (Grün)",
"edit": "Bearbeiten", "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_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_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...", "enc_encrypting": "Wallet wird verschlüsselt...",

View File

@@ -485,6 +485,17 @@
"download_bootstrap": "Descargar Bootstrap", "download_bootstrap": "Descargar Bootstrap",
"dragonx_green": "DragonX (Verde)", "dragonx_green": "DragonX (Verde)",
"edit": "Editar", "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_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_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...", "enc_encrypting": "Cifrando el monedero...",

View File

@@ -485,6 +485,17 @@
"download_bootstrap": "Télécharger Bootstrap", "download_bootstrap": "Télécharger Bootstrap",
"dragonx_green": "DragonX (Vert)", "dragonx_green": "DragonX (Vert)",
"edit": "Modifier", "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_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_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...", "enc_encrypting": "Chiffrement du portefeuille...",

View File

@@ -485,6 +485,17 @@
"download_bootstrap": "ブートストラップをダウンロード", "download_bootstrap": "ブートストラップをダウンロード",
"dragonx_green": "DragonXグリーン", "dragonx_green": "DragonXグリーン",
"edit": "編集", "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_confirm": "確認:",
"enc_desc": "ウォレットを暗号化すると、パスフレーズで秘密鍵が保護されます。暗号化後、デーモンが再起動します。", "enc_desc": "ウォレットを暗号化すると、パスフレーズで秘密鍵が保護されます。暗号化後、デーモンが再起動します。",
"enc_encrypting": "ウォレットを暗号化しています...", "enc_encrypting": "ウォレットを暗号化しています...",

View File

@@ -485,6 +485,17 @@
"download_bootstrap": "부트스트랩 다운로드", "download_bootstrap": "부트스트랩 다운로드",
"dragonx_green": "DragonX(그린)", "dragonx_green": "DragonX(그린)",
"edit": "편집", "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_confirm": "확인:",
"enc_desc": "지갑을 암호화하면 암호로 개인 키를 보호합니다. 암호화 후 데몬이 다시 시작됩니다.", "enc_desc": "지갑을 암호화하면 암호로 개인 키를 보호합니다. 암호화 후 데몬이 다시 시작됩니다.",
"enc_encrypting": "지갑을 암호화하는 중...", "enc_encrypting": "지갑을 암호화하는 중...",

View File

@@ -485,6 +485,17 @@
"download_bootstrap": "Baixar Bootstrap", "download_bootstrap": "Baixar Bootstrap",
"dragonx_green": "DragonX (Verde)", "dragonx_green": "DragonX (Verde)",
"edit": "Editar", "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_confirm": "Confirmar:",
"enc_desc": "Criptografar sua carteira protege suas chaves privadas com uma senha. Após a criptografia, o daemon será reiniciado.", "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...", "enc_encrypting": "Criptografando a carteira...",

View File

@@ -485,6 +485,17 @@
"download_bootstrap": "Скачать бутстрап", "download_bootstrap": "Скачать бутстрап",
"dragonx_green": "DragonX (Зелёная)", "dragonx_green": "DragonX (Зелёная)",
"edit": "Редактировать", "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_confirm": "Подтвердите:",
"enc_desc": "Шифрование кошелька защищает ваши приватные ключи паролем. После шифрования демон перезапустится.", "enc_desc": "Шифрование кошелька защищает ваши приватные ключи паролем. После шифрования демон перезапустится.",
"enc_encrypting": "Шифрование кошелька...", "enc_encrypting": "Шифрование кошелька...",

View File

@@ -485,6 +485,17 @@
"download_bootstrap": "下载引导程序", "download_bootstrap": "下载引导程序",
"dragonx_green": "DragonX绿色", "dragonx_green": "DragonX绿色",
"edit": "编辑", "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_confirm": "确认:",
"enc_desc": "加密钱包会用密码短语保护您的私钥。加密后,守护进程将重新启动。", "enc_desc": "加密钱包会用密码短语保护您的私钥。加密后,守护进程将重新启动。",
"enc_encrypting": "正在加密钱包...", "enc_encrypting": "正在加密钱包...",

View File

@@ -127,14 +127,24 @@ App::App()
// Seed the auto-balance RNG once per run so weighted-random pool selection isn't // Seed the auto-balance RNG once per run so weighted-random pool selection isn't
// deterministic across launches. // deterministic across launches.
balance_rng_.seed(std::random_device{}()); balance_rng_.seed(std::random_device{}());
// Purge any plaintext key export left behind by a crashed/interrupted decrypt flow. (H-02)
sweepStaleDecryptExports();
} }
App::~App() 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()) if (!seed_migration_seed_.empty())
sodium_memzero(&seed_migration_seed_[0], seed_migration_seed_.size()); sodium_memzero(&seed_migration_seed_[0], seed_migration_seed_.size());
if (!seed_backup_phrase_.empty()) if (!seed_backup_phrase_.empty())
sodium_memzero(&seed_backup_phrase_[0], seed_backup_phrase_.size()); 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 { namespace {
@@ -824,6 +834,10 @@ void App::update()
// One-time reminder to back up the wallet's seed phrase (mnemonic wallets only). // One-time reminder to back up the wallet's seed phrase (mnemonic wallets only).
maybeRemindSeedBackup(); 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.<ts>.bak).
maybeWarnEmptyWalletWithFundedSiblings();
// Classify the wallet's mnemonic status (once per connect) so the Migrate-to-seed button can // Classify the wallet's mnemonic status (once per connect) so the Migrate-to-seed button can
// glow for a legacy, pre-seed-phrase wallet. // glow for a legacy, pre-seed-phrase wallet.
probeWalletSeedStatus(); probeWalletSeedStatus();
@@ -957,7 +971,7 @@ void App::update()
if (xmrig_poll_tick && xmrig_manager_ && xmrig_manager_->isRunning()) { if (xmrig_poll_tick && xmrig_manager_ && xmrig_manager_->isRunning()) {
xmrig_manager_->pollStats(); xmrig_manager_->pollStats();
auto& ps = state_.pool_mining; 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.xmrig_running = true;
ps.hashrate_10s = xs.hashrate_10s; ps.hashrate_10s = xs.hashrate_10s;
ps.hashrate_60s = xs.hashrate_60s; ps.hashrate_60s = xs.hashrate_60s;
@@ -1777,6 +1791,12 @@ void App::render()
// Page transition: detect change, ramp alpha // Page transition: detect change, ramp alpha
if (current_page_ != prev_page_) { if (current_page_ != prev_page_) {
page_alpha_ = (ui::effects::isLowSpecMode() || (settings_ && settings_->getReduceMotion())) ? 1.0f : 0.0f; 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_; prev_page_ = current_page_;
} }
if (page_alpha_ < 1.0f) { if (page_alpha_ < 1.0f) {
@@ -2148,6 +2168,7 @@ void App::render()
renderSwitchStopDaemonDialog(); renderSwitchStopDaemonDialog();
renderBlockDbReindexDialog(); renderBlockDbReindexDialog();
renderWalletRecoveredDialog(); renderWalletRecoveredDialog();
renderEmptyWalletWarningDialog();
// Render notifications (toast messages) // Render notifications (toast messages)
ui::Notifications::instance().render(); ui::Notifications::instance().render();
@@ -4657,6 +4678,129 @@ void App::renderWalletRecoveredDialog()
ui::material::EndOverlayDialog(); 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.<ts>.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 // 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. // -reindex rebuild instead of leaving the wallet stuck on a silent zero balance.
void App::renderBlockDbReindexDialog() void App::renderBlockDbReindexDialog()
@@ -5043,6 +5187,12 @@ void App::stopEmbeddedDaemon()
return; 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 // Send RPC "stop" command — this is the graceful path that lets the
// daemon flush state, save block indexes, close sockets, etc. // daemon flush state, save block indexes, close sockets, etc.
bool stop_sent = false; bool stop_sent = false;
@@ -5099,6 +5249,27 @@ void App::stopEmbeddedDaemon()
// 20s grace period for the RPC "stop" to complete (LevelDB flush). // 20s grace period for the RPC "stop" to complete (LevelDB flush).
// Only after that does stop() escalate to SIGTERM, then SIGKILL. // Only after that does stop() escalate to SIGTERM, then SIGKILL.
daemon_controller_->stop(20000); 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 bool App::isEmbeddedDaemonRunning() const
@@ -5497,7 +5668,8 @@ void App::renderShutdownScreen()
// it's never impossible to escape. // it's never impossible to escape.
static std::string s_lastShutStatus; static std::string s_lastShutStatus;
static float s_shutStallTimer = 0.0f; 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; else s_shutStallTimer += ImGui::GetIO().DeltaTime;
const bool shutdownStalled = s_shutStallTimer >= 8.0f; const bool shutdownStalled = s_shutStallTimer >= 8.0f;
const bool allowForceQuit = shutdownStalled || shutdown_timer_ >= 20.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) // 3. Phase status (what the shutdown thread is doing)
// ------------------------------------------------------------------- // -------------------------------------------------------------------
if (!shutdown_status_.empty()) { if (!curShut.empty()) {
ImVec2 ts = ImGui::CalcTextSize(shutdown_status_.c_str()); ImVec2 ts = ImGui::CalcTextSize(curShut.c_str());
ImGui::SetCursorPosX(cx - ts.x * 0.5f); ImGui::SetCursorPosX(cx - ts.x * 0.5f);
ImGui::PushStyleColor(ImGuiCol_Text, ImVec4(0.75f, 0.75f, 0.75f, 1.0f)); 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(); ImGui::PopStyleColor();
} }
@@ -5631,8 +5803,8 @@ void App::renderShutdownScreen()
ImGui::Spacing(); ImGui::Spacing();
// State-aware caution: while the status is a daemon flush/exit step, force-quitting risks the // State-aware caution: while the status is a daemon flush/exit step, force-quitting risks the
// chainstate; say so instead of a bare button. // chainstate; say so instead of a bare button.
if (shutdownStalled && !shutdown_status_.empty()) { if (shutdownStalled && !curShut.empty()) {
std::string stalledMsg = "Still \"" + shutdown_status_ + "\" — force quitting now may corrupt chain data."; std::string stalledMsg = "Still \"" + curShut + "\" — force quitting now may corrupt chain data.";
ImVec2 ms = ImGui::CalcTextSize(stalledMsg.c_str()); ImVec2 ms = ImGui::CalcTextSize(stalledMsg.c_str());
ImGui::SetCursorPosX(cx - ms.x * 0.5f); ImGui::SetCursorPosX(cx - ms.x * 0.5f);
ImGui::PushStyleColor(ImGuiCol_Text, ImGui::ColorConvertU32ToFloat4(Warning())); ImGui::PushStyleColor(ImGuiCol_Text, ImGui::ColorConvertU32ToFloat4(Warning()));

View File

@@ -71,14 +71,28 @@ enum class EncryptDialogPhase {
Done // Finished — close dialog 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<std::mutex> lk(m_); v_ = std::move(v); return *this; }
std::string get() const { std::lock_guard<std::mutex> lk(m_); return v_; }
private:
mutable std::mutex m_;
std::string v_;
};
/** /**
* @brief Main application class * @brief Main application class
* *
* Manages application state, RPC connection, and coordinates UI rendering. * Manages application state, RPC connection, and coordinates UI rendering.
*/ */
class App { class App {
public: public:
App(); App();
void wipeSecrets(); // scrub all resident secret buffers; called from ~App() AND the forced-exit path (L-05)
~App(); ~App();
// Non-copyable // Non-copyable
@@ -798,6 +812,10 @@ private:
// One-time nudge: on a full-node wallet that has a mnemonic, remind the user (once per // 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. // install) to back up their seed phrase. Cheap early-outs keep it idle until it can act.
void maybeRemindSeedBackup(); 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). // 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 void beginCreateSeedWallet(); // starts the isolated create on a background thread
@@ -869,7 +887,7 @@ private:
std::atomic<bool> shutting_down_{false}; std::atomic<bool> shutting_down_{false};
std::atomic<bool> shutdown_complete_{false}; std::atomic<bool> shutdown_complete_{false};
bool address_list_dirty_ = false; // P8: dedup rebuildAddressList 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_; std::thread shutdown_thread_;
float shutdown_timer_ = 0.0f; float shutdown_timer_ = 0.0f;
bool force_quit_confirm_ = false; bool force_quit_confirm_ = false;
@@ -906,6 +924,16 @@ private:
bool wallet_auto_recovered_ = false; // a salvage happened this session bool wallet_auto_recovered_ = false; // a salvage happened this session
bool wallet_auto_recovered_warned_ = false; // guard: only surface it once per session bool wallet_auto_recovered_warned_ = false; // guard: only surface it once per session
bool show_wallet_recovered_dialog_ = false; // auto-shown warning dialog 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.<ts>.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<FundedSibling> empty_wallet_funded_siblings_; // scan result (main-thread only)
// The recovery dialog is the ONE authoritative surface: it stays open through the async rebuild/ // 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 // restore, driven Offer → Working → Done/Failed (pumpWalletRestore sets the outcome). Presentation
// only — the fund-safety file ops in rebuildWalletDatabase()/restoreOriginalWallet() are unchanged. // only — the fund-safety file ops in rebuildWalletDatabase()/restoreOriginalWallet() are unchanged.
@@ -1264,8 +1292,8 @@ private:
services::WalletSecurityWorkflow wallet_security_workflow_; services::WalletSecurityWorkflow wallet_security_workflow_;
// Wizard: stopping an external daemon before bootstrap // Wizard: stopping an external daemon before bootstrap
bool wizard_stopping_external_ = false; std::atomic<bool> wizard_stopping_external_{false}; // written by the stop worker, read by the UI (L-06)
std::string wizard_stop_status_; GuardedStatus wizard_stop_status_; // thread-safe: written by the stop worker, read by the UI (L-06)
// PIN vault // PIN vault
std::unique_ptr<util::SecureVault> vault_; std::unique_ptr<util::SecureVault> vault_;
@@ -1358,6 +1386,7 @@ private:
void renderBlockDbReindexDialog(); // offer to rebuild an unreadable block database (-reindex) void renderBlockDbReindexDialog(); // offer to rebuild an unreadable block database (-reindex)
void reindexBlockDatabase(); // restart the daemon with -reindex to rebuild the block DB void reindexBlockDatabase(); // restart the daemon with -reindex to rebuild the block DB
void renderWalletRecoveredDialog(); // warn that the node auto-recovered/salvaged wallet.dat 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 detectWalletAutoRecovery(); // scan daemon output for a salvage; fire the warning once/session
void restoreOriginalWallet(); // swap the wallet.<ts>.bak back over the salvaged copy + restart void restoreOriginalWallet(); // swap the wallet.<ts>.bak back over the salvaged copy + restart
void pumpWalletRestore(); // main-thread: surface the restore/rebuild op's result void pumpWalletRestore(); // main-thread: surface the restore/rebuild op's result

View File

@@ -620,6 +620,13 @@ void App::onConnected()
detectWalletAutoRecovery(); // also runs every tryConnect tick — catches a salvage even if we never connect 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 + // 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). // address count fill in on the first address refresh (addresses aren't loaded yet here).
updateWalletIndexForActiveWallet(/*markOpened=*/true); updateWalletIndexForActiveWallet(/*markOpened=*/true);
@@ -1761,7 +1768,12 @@ void App::refreshAddressData()
auto result = NetworkRefreshService::collectAddressRefreshResult(refreshRpc, addressSnapshot); auto result = NetworkRefreshService::collectAddressRefreshResult(refreshRpc, addressSnapshot);
return [this, previousAddressCount, previousWalletIdentity, result = std::move(result)]() mutable { return [this, previousAddressCount, previousWalletIdentity, result = std::move(result)]() mutable {
const bool addrListOk = result.addressListOk; // capture before the move
NetworkRefreshService::applyAddressRefreshResult(state_, std::move(result)); 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); applyPendingSendBalanceDeltas(false);
address_validation_cache_dirty_ = false; address_validation_cache_dirty_ = false;
address_list_dirty_ = true; 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.<ts>.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.<ts>.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<FundedSibling> funded; // funded plain-.dat wallets → the "switch wallet" modal
bool hasSalvageBak = false; // a funded wallet.<ts>.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.<ts>.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 // 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 // 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 // classification as the migration Intro pre-flight, but proactive and cached. Reads no secret past

View File

@@ -238,6 +238,41 @@ private:
// daemon off the main thread (to avoid stalling the UI), or ask the user to // daemon off the main thread (to avoid stalling the UI), or ask the user to
// restart an external daemon. Shared by encryptWalletWithPassphrase() and // restart an external daemon. Shared by encryptWalletWithPassphrase() and
// processDeferredEncryption(); must be called on the main thread. // 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<char> zeros(static_cast<size_t>(sz), 0);
scrub.write(zeros.data(), static_cast<std::streamsize>(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) { void App::restartDaemonAfterEncryption(const char* taskName, bool announceRestartStatus) {
if (isUsingEmbeddedDaemon()) { if (isUsingEmbeddedDaemon()) {
if (announceRestartStatus) { if (announceRestartStatus) {
@@ -1344,9 +1379,13 @@ void App::renderEncryptWalletDialog() {
enc_dlg_pin_status_.clear(); enc_dlg_pin_status_.clear();
std::string savedPass = enc_dlg_saved_passphrase_; std::string savedPass = enc_dlg_saved_passphrase_;
if (worker_ && vault_) { 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) // Argon2id runs here (worker thread)
bool ok = vault_->store(pinStr, savedPass); 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]() { return [this, ok]() {
if (ok) { if (ok) {
settings_->setPinEnabled(true); settings_->setPinEnabled(true);
@@ -1588,6 +1627,11 @@ void App::renderDecryptWalletDialog() {
std::chrono::steady_clock::now()); std::chrono::steady_clock::now());
auto restartAndImport = [this, exportPath](const util::AsyncTaskManager::Token& token) { 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); WalletSecurityDaemonAdapter daemonAdapter(*this, token);
WalletSecurityDecryptRpcAdapter decryptRpc(rpc_.get(), WalletSecurityDecryptRpcAdapter decryptRpc(rpc_.get(),
[this](rpc::RPCClient& client, const char* context) { [this](rpc::RPCClient& client, const char* context) {
@@ -1641,26 +1685,7 @@ void App::renderDecryptWalletDialog() {
WalletSecurityImportRpcAdapter importAdapter(rpc_.get(), saved_config_); WalletSecurityImportRpcAdapter importAdapter(rpc_.get(), saved_config_);
auto importResult = services::WalletSecurityWorkflowExecutor::importWallet( auto importResult = services::WalletSecurityWorkflowExecutor::importWallet(
importAdapter, exportPath); importAdapter, exportPath);
// (exportScrub scrubs + deletes the plaintext key export on scope exit — H-02)
// 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<char> zeros(static_cast<size_t>(sz), 0);
scrub.write(zeros.data(), static_cast<std::streamsize>(sz));
scrub.flush();
}
}
std::filesystem::remove(exportPath, delEc);
DEBUG_LOGF("[decrypt] removed plaintext key export after import\n");
}
if (!importResult.ok) { if (!importResult.ok) {
std::string err = importResult.error; std::string err = importResult.error;
@@ -1930,12 +1955,14 @@ void App::renderPinDialogs() {
memset(pin_confirm_buf_, 0, sizeof(pin_confirm_buf_)); memset(pin_confirm_buf_, 0, sizeof(pin_confirm_buf_));
if (rpc_ && rpc_->isConnected() && worker_) { 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) // Verify passphrase via RPC (worker thread)
try { try {
rpc::RPCClient::TraceScope trace("Security / PIN setup"); rpc::RPCClient::TraceScope trace("Security / PIN setup");
rpc_->call("walletpassphrase", {passphrase, 5}); rpc_->call("walletpassphrase", {passphrase, 5});
} catch (const std::exception& e) { } 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]() { return [this]() {
pin_status_ = "Incorrect passphrase"; pin_status_ = "Incorrect passphrase";
pin_in_progress_ = false; pin_in_progress_ = false;
@@ -1944,6 +1971,9 @@ void App::renderPinDialogs() {
// Passphrase correct — store in vault (Argon2id, worker thread) // Passphrase correct — store in vault (Argon2id, worker thread)
bool storeOk = vault_ && vault_->store(pin, passphrase); 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 // Lock wallet back
try { try {

View File

@@ -889,8 +889,9 @@ void App::renderFirstRunWizard() {
} }
if (wizard_stopping_external_) { if (wizard_stopping_external_) {
const std::string ws = wizard_stop_status_.get();
dl->AddText(captionFont, captionFont->LegacySize, ImVec2(cx, cy), dimCol, dl->AddText(captionFont, captionFont->LegacySize, ImVec2(cx, cy), dimCol,
wizard_stop_status_.c_str()); ws.c_str());
cy += captionFont->LegacySize + 8.0f * dp; cy += captionFont->LegacySize + 8.0f * dp;
} else { } else {
float stopW = 150.0f * dp; float stopW = 150.0f * dp;
@@ -1376,6 +1377,13 @@ void App::renderFirstRunWizard() {
encrypt_status_ = TR("wiz_skip_confirm"); encrypt_status_ = TR("wiz_skip_confirm");
} else { } else {
s_skipEncConfirm = false; 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; wizard_phase_ = WizardPhase::Done;
settings_->setWizardCompleted(true); settings_->setWizardCompleted(true);
settings_->save(); settings_->save();

View File

@@ -204,6 +204,7 @@ bool Settings::load(const std::string& path)
loadScalar(j, "console_text_color", console_text_color_); loadScalar(j, "console_text_color", console_text_color_);
loadScalar(j, "console_zoom", console_zoom_); loadScalar(j, "console_zoom", console_zoom_);
if (!(console_zoom_ >= 0.25f && console_zoom_ <= 4.0f)) console_zoom_ = 1.0f; // guard bad/NaN 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()) { if (j.contains("hidden_addresses") && j["hidden_addresses"].is_array()) {
hidden_addresses_.clear(); hidden_addresses_.clear();
for (const auto& a : j["hidden_addresses"]) 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, "wizard_completed", wizard_completed_);
loadScalar(j, "seed_backup_reminded", seed_backup_reminded_); 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<std::string>());
}
loadScalar(j, "encryption_pending", encryption_pending_); loadScalar(j, "encryption_pending", encryption_pending_);
loadScalar(j, "daemon_update_prompted_size", daemon_update_prompted_size_); loadScalar(j, "daemon_update_prompted_size", daemon_update_prompted_size_);
loadScalar(j, "active_wallet_file", active_wallet_file_); 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_line_accents"] = console_line_accents_;
j["console_text_color"] = console_text_color_; j["console_text_color"] = console_text_color_;
j["console_zoom"] = console_zoom_; j["console_zoom"] = console_zoom_;
j["console_auto_focus"] = console_auto_focus_;
j["hidden_addresses"] = json::array(); j["hidden_addresses"] = json::array();
for (const auto& addr : hidden_addresses_) for (const auto& addr : hidden_addresses_)
j["hidden_addresses"].push_back(addr); j["hidden_addresses"].push_back(addr);
@@ -499,6 +506,9 @@ bool Settings::save(const std::string& path)
} }
j["wizard_completed"] = wizard_completed_; j["wizard_completed"] = wizard_completed_;
j["seed_backup_reminded"] = seed_backup_reminded_; 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["encryption_pending"] = encryption_pending_;
j["daemon_update_prompted_size"] = daemon_update_prompted_size_; j["daemon_update_prompted_size"] = daemon_update_prompted_size_;
j["active_wallet_file"] = active_wallet_file_; j["active_wallet_file"] = active_wallet_file_;

View File

@@ -256,6 +256,9 @@ public:
void setConsoleTextColor(bool v) { console_text_color_ = v; } void setConsoleTextColor(bool v) { console_text_color_ = v; }
float getConsoleZoom() const { return console_zoom_; } float getConsoleZoom() const { return console_zoom_; }
void setConsoleZoom(float v) { console_zoom_ = v; } 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) // Hidden addresses (addresses hidden from the UI by the user)
const std::set<std::string>& getHiddenAddresses() const { return hidden_addresses_; } const std::set<std::string>& getHiddenAddresses() const { return hidden_addresses_; }
@@ -327,6 +330,14 @@ public:
bool getSeedBackupReminded() const { return seed_backup_reminded_; } bool getSeedBackupReminded() const { return seed_backup_reminded_; }
void setSeedBackupReminded(bool v) { seed_backup_reminded_ = v; } 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 // 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 // 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. // 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_line_accents_ = true; // left color accent bars in console output
bool console_text_color_ = true; // per-channel text coloring 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 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<std::string> hidden_addresses_; std::set<std::string> hidden_addresses_;
std::set<std::string> favorite_addresses_; std::set<std::string> favorite_addresses_;
std::map<std::string, AddressMeta> address_meta_; std::map<std::string, AddressMeta> address_meta_;
bool wizard_completed_ = false; bool wizard_completed_ = false;
bool seed_backup_reminded_ = false; bool seed_backup_reminded_ = false;
std::set<std::string> empty_wallet_warning_acked_; // wallet files whose empty-wallet warning was dismissed
bool encryption_pending_ = false; bool encryption_pending_ = false;
long long daemon_update_prompted_size_ = 0; // bundled daemon size last offered via the update prompt long long daemon_update_prompted_size_ = 0; // bundled daemon size last offered via the update prompt
std::string active_wallet_file_ = "wallet.dat"; // -wallet=<name> the daemon loads (multi-wallet) std::string active_wallet_file_ = "wallet.dat"; // -wallet=<name> the daemon loads (multi-wallet)

View File

@@ -71,9 +71,11 @@ DaemonController::State DaemonController::state() const
return daemon_->getState(); 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 int DaemonController::crashCount() const

View File

@@ -95,7 +95,7 @@ public:
bool externalDaemonDetected() const; bool externalDaemonDetected() const;
void clearExternalDaemonDetected(); void clearExternalDaemonDetected();
State state() const; 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 crashCount() const;
int lastBlockHeight() const; int lastBlockHeight() const;
double memoryUsageMB() const; double memoryUsageMB() const;

View File

@@ -224,12 +224,11 @@ std::vector<std::string> EmbeddedDaemon::getChainParams()
void EmbeddedDaemon::setState(State s, const std::string& message) void EmbeddedDaemon::setState(State s, const std::string& message)
{ {
state_ = s; state_ = s;
if (!message.empty()) { if (!message.empty() && s == State::Error) {
if (s == State::Error) { std::lock_guard<std::mutex> lk(error_mutex_); // dedicated mutex — never taken with output_mutex_ held
last_error_ = message; last_error_ = message;
}
} }
if (state_callback_) { if (state_callback_) {
state_callback_(s, message); state_callback_(s, message);
} }
@@ -621,12 +620,28 @@ bool EmbeddedDaemon::start(const std::string& binary_path)
// Forward declaration — defined after startProcess // Forward declaration — defined after startProcess
static DWORD findProcessByName(const char* name); 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<std::string>& args) bool EmbeddedDaemon::startProcess(const std::string& binary_path, const std::vector<std::string>& 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 + "\""; std::string cmd = "\"" + binary_path + "\"";
for (const auto& arg : args) { for (const auto& arg : args) {
cmd += " " + arg; cmd += " " + quoteWinArg(arg);
} }
DEBUG_LOGF("[INFO] Starting daemon: %s\n", cmd.c_str()); DEBUG_LOGF("[INFO] Starting daemon: %s\n", cmd.c_str());

View File

@@ -79,7 +79,9 @@ public:
/** /**
* @brief Get last error message * @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<std::mutex> lk(error_mutex_); return last_error_; }
/** /**
* @brief Get dragonxd process output (thread-safe copy) * @brief Get dragonxd process output (thread-safe copy)
@@ -286,6 +288,7 @@ private:
std::atomic<State> state_{State::Stopped}; std::atomic<State> state_{State::Stopped};
std::atomic<bool> external_daemon_detected_{false}; std::atomic<bool> external_daemon_detected_{false};
std::string last_error_; 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_ mutable std::mutex output_mutex_; // protects process_output_
std::string process_output_; std::string process_output_;
StateCallback state_callback_; StateCallback state_callback_;

View File

@@ -210,7 +210,7 @@ bool XmrigManager::generateConfig(const Config& cfg, const std::string& outPath)
fs::create_directories(fs::path(outPath).parent_path()); fs::create_directories(fs::path(outPath).parent_path());
std::ofstream ofs(outPath, std::ios::trunc); std::ofstream ofs(outPath, std::ios::trunc);
if (!ofs.is_open()) { 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()); DEBUG_LOGF("[ERROR] XmrigManager: %s\n", last_error_.c_str());
return false; return false;
} }
@@ -224,7 +224,7 @@ bool XmrigManager::generateConfig(const Config& cfg, const std::string& outPath)
ofs.close(); ofs.close();
return true; return true;
} catch (const std::exception& e) { } 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()); DEBUG_LOGF("[ERROR] XmrigManager: %s\n", last_error_.c_str());
return false; return false;
} }
@@ -236,19 +236,22 @@ bool XmrigManager::generateConfig(const Config& cfg, const std::string& outPath)
bool XmrigManager::start(const Config& cfg) { bool XmrigManager::start(const Config& cfg) {
if (state_ == State::Running || state_ == State::Starting) { if (state_ == State::Running || state_ == State::Starting) {
last_error_ = "Already running"; setLastError("Already running");
DEBUG_LOGF("[WARN] XmrigManager: %s\n", last_error_.c_str()); DEBUG_LOGF("[WARN] XmrigManager: %s\n", last_error_.c_str());
return false; return false;
} }
state_ = State::Starting; state_ = State::Starting;
should_stop_ = false; should_stop_ = false;
last_error_.clear(); setLastError(std::string());
{ {
std::lock_guard<std::mutex> lk(output_mutex_); std::lock_guard<std::mutex> lk(output_mutex_);
process_output_.clear(); process_output_.clear();
} }
stats_ = PoolStats{}; {
std::lock_guard<std::mutex> lk(stats_mutex_);
stats_ = PoolStats{};
}
// Extract pool hostname for stats API queries // Extract pool hostname for stats API queries
{ {
@@ -265,7 +268,7 @@ bool XmrigManager::start(const Config& cfg) {
// Find binary // Find binary
std::string binary = findXmrigBinary(); std::string binary = findXmrigBinary();
if (binary.empty()) { if (binary.empty()) {
last_error_ = "xmrig binary not found"; setLastError("xmrig binary not found");
state_ = State::Error; state_ = State::Error;
DEBUG_LOGF("[ERROR] XmrigManager: xmrig binary not found\n"); DEBUG_LOGF("[ERROR] XmrigManager: xmrig binary not found\n");
return false; return false;
@@ -368,7 +371,7 @@ bool XmrigManager::startProcess(const std::string& xmrigPath, const std::string&
HANDLE hRead = nullptr, hWrite = nullptr; HANDLE hRead = nullptr, hWrite = nullptr;
if (!CreatePipe(&hRead, &hWrite, &sa, 0)) { if (!CreatePipe(&hRead, &hWrite, &sa, 0)) {
last_error_ = "CreatePipe failed"; setLastError("CreatePipe failed");
DEBUG_LOGF("[ERROR] XmrigManager: %s\n", last_error_.c_str()); DEBUG_LOGF("[ERROR] XmrigManager: %s\n", last_error_.c_str());
return false; return false;
} }
@@ -400,7 +403,7 @@ bool XmrigManager::startProcess(const std::string& xmrigPath, const std::string&
char errBuf[256]; char errBuf[256];
FormatMessageA(FORMAT_MESSAGE_FROM_SYSTEM | FORMAT_MESSAGE_IGNORE_INSERTS, FormatMessageA(FORMAT_MESSAGE_FROM_SYSTEM | FORMAT_MESSAGE_IGNORE_INSERTS,
NULL, err, 0, errBuf, sizeof(errBuf), NULL); 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()); DEBUG_LOGF("[ERROR] XmrigManager: %s\nCommand: %s\n", last_error_.c_str(), cmdLine.c_str());
return false; return false;
} }
@@ -451,14 +454,14 @@ void XmrigManager::drainOutput() {
bool XmrigManager::startProcess(const std::string& xmrigPath, const std::string& cfgPath, int threads) { bool XmrigManager::startProcess(const std::string& xmrigPath, const std::string& cfgPath, int threads) {
int pipefd[2]; int pipefd[2];
if (pipe(pipefd) != 0) { if (pipe(pipefd) != 0) {
last_error_ = "pipe() failed"; setLastError("pipe() failed");
DEBUG_LOGF("[ERROR] XmrigManager: %s\n", last_error_.c_str()); DEBUG_LOGF("[ERROR] XmrigManager: %s\n", last_error_.c_str());
return false; return false;
} }
pid_t pid = fork(); pid_t pid = fork();
if (pid < 0) { if (pid < 0) {
last_error_ = "fork() failed"; setLastError("fork() failed");
DEBUG_LOGF("[ERROR] XmrigManager: %s\n", last_error_.c_str()); DEBUG_LOGF("[ERROR] XmrigManager: %s\n", last_error_.c_str());
close(pipefd[0]); close(pipefd[0]);
close(pipefd[1]); close(pipefd[1]);
@@ -629,7 +632,7 @@ void XmrigManager::monitorProcess() {
if (GetExitCodeProcess(process_handle_, &exitCode) && exitCode != STILL_ACTIVE) { if (GetExitCodeProcess(process_handle_, &exitCode) && exitCode != STILL_ACTIVE) {
DEBUG_LOGF("[ERROR] XmrigManager: process exited (code %lu)\n", exitCode); DEBUG_LOGF("[ERROR] XmrigManager: process exited (code %lu)\n", exitCode);
state_ = State::Error; state_ = State::Error;
last_error_ = "xmrig process exited unexpectedly"; setLastError("xmrig process exited unexpectedly");
break; break;
} }
} }
@@ -640,7 +643,7 @@ void XmrigManager::monitorProcess() {
if (ret == process_pid_ || ret < 0) { if (ret == process_pid_ || ret < 0) {
DEBUG_LOGF("[ERROR] XmrigManager: process exited (waitpid=%d)\n", ret); DEBUG_LOGF("[ERROR] XmrigManager: process exited (waitpid=%d)\n", ret);
state_ = State::Error; state_ = State::Error;
last_error_ = "xmrig process exited unexpectedly"; setLastError("xmrig process exited unexpectedly");
break; break;
} }
} }

View File

@@ -86,8 +86,10 @@ public:
bool isRunning() const; bool isRunning() const;
State getState() const { return state_.load(std::memory_order_relaxed); } State getState() const { return state_.load(std::memory_order_relaxed); }
const PoolStats& getStats() const { return stats_; } // Return COPIES under lock: stats_ and last_error_ are mutated by the monitor thread while the UI
const std::string& getLastError() const { return last_error_; } // 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<std::mutex> lk(stats_mutex_); return stats_; }
std::string getLastError() const { std::lock_guard<std::mutex> lk(error_mutex_); return last_error_; }
/// Thread count requested at start() — available immediately, unlike /// Thread count requested at start() — available immediately, unlike
/// PoolStats::threads_active which requires an API response. /// PoolStats::threads_active which requires an API response.
@@ -156,11 +158,14 @@ private:
void monitorProcess(); void monitorProcess();
void drainOutput(); void drainOutput();
void appendOutput(const char* data, size_t len); 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<std::mutex> lk(error_mutex_); last_error_ = std::move(e); }
void fetchStatsHttp(); // Blocking HTTP call — runs on monitor thread only void fetchStatsHttp(); // Blocking HTTP call — runs on monitor thread only
void fetchPoolApiStats(); // Fetch pool-side stats (hashrate) from pool HTTP API void fetchPoolApiStats(); // Fetch pool-side stats (hashrate) from pool HTTP API
std::atomic<State> state_{State::Stopped}; std::atomic<State> state_{State::Stopped};
std::string last_error_; std::string last_error_;
mutable std::mutex error_mutex_; // guards last_error_ (written by main + monitor threads)
mutable std::mutex output_mutex_; mutable std::mutex output_mutex_;
std::string process_output_; std::string process_output_;

View File

@@ -312,6 +312,7 @@ struct WalletState {
// Timestamps for refresh logic // Timestamps for refresh logic
int64_t last_balance_update = 0; 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_tx_update = 0;
int64_t last_peer_update = 0; int64_t last_peer_update = 0;
int64_t last_mining_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 // 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 // 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 ""). // 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 // Rebuild combined addresses list from z/t lists

View File

@@ -2127,6 +2127,7 @@ int main(int argc, char* argv[])
// deadlocks waiting for detached pthreads. On Linux, static // deadlocks waiting for detached pthreads. On Linux, static
// destructors and atexit handlers can also block. _Exit() bypasses // destructors and atexit handlers can also block. _Exit() bypasses
// all of that. // all of that.
app.wipeSecrets(); // _Exit() below bypasses ~App(), so scrub secret buffers here (L-05)
fflush(stdout); fflush(stdout);
fflush(stderr); fflush(stderr);
_Exit(0); _Exit(0);

View File

@@ -146,7 +146,11 @@ RPCClient::RPCClient() : impl_(std::make_unique<Impl>())
{ {
} }
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, bool RPCClient::connect(const std::string& host, const std::string& port,
const std::string& user, const std::string& password) 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 // Create Basic auth header with proper base64 encoding, then wipe the plaintext
// "user:password" temporary (std::string does not zero its buffer on destruction). // "user:password" temporary (std::string does not zero its buffer on destruction).
std::string credentials = user + ":" + password; 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); auth_ = util::base64_encode(credentials);
if (!credentials.empty()) sodium_memzero(credentials.data(), credentials.size()); 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"); impl_->headers = curl_slist_append(nullptr, "Content-Type: text/plain");
std::string auth_header = "Authorization: Basic " + auth_; std::string auth_header = "Authorization: Basic " + auth_;
impl_->headers = curl_slist_append(impl_->headers, auth_header.c_str()); 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 // Configure curl
curl_easy_setopt(impl_->curl, CURLOPT_URL, impl_->url.c_str()); curl_easy_setopt(impl_->curl, CURLOPT_URL, impl_->url.c_str());
@@ -299,6 +305,7 @@ void RPCClient::disconnect()
curl_slist_free_all(impl_->headers); curl_slist_free_all(impl_->headers);
impl_->headers = nullptr; 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) json RPCClient::makePayload(const std::string& method, const json& params)

View File

@@ -628,6 +628,7 @@ NetworkRefreshService::AddressRefreshResult NetworkRefreshService::collectAddres
} }
} catch (const std::exception& e) { } catch (const std::exception& e) {
DEBUG_LOGF("z_listaddresses error: %s\n", e.what()); DEBUG_LOGF("z_listaddresses error: %s\n", e.what());
result.addressListOk = false; // enumeration failed → the shielded list may be falsely short
} }
try { try {
@@ -652,6 +653,7 @@ NetworkRefreshService::AddressRefreshResult NetworkRefreshService::collectAddres
result.transparentAddresses = parseTransparentAddressList(tList); result.transparentAddresses = parseTransparentAddressList(tList);
} catch (const std::exception& e) { } catch (const std::exception& e) {
DEBUG_LOGF("getaddressesbyaccount error: %s\n", e.what()); DEBUG_LOGF("getaddressesbyaccount error: %s\n", e.what());
result.addressListOk = false; // enumeration failed → the transparent list may be falsely short
} }
try { try {

View File

@@ -149,6 +149,10 @@ public:
struct AddressRefreshResult { struct AddressRefreshResult {
std::vector<AddressInfo> shieldedAddresses; std::vector<AddressInfo> shieldedAddresses;
std::vector<AddressInfo> transparentAddresses; std::vector<AddressInfo> 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 { struct AddressRefreshSnapshot {

View File

@@ -515,6 +515,14 @@ static void renderConsoleColorToggles(App* app) {
app->settings()->save(); app->settings()->save();
} }
if (ImGui::IsItemHovered()) material::Tooltip("%s", TR("console_toggle_text_color")); 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); ImGui::BeginDisabled(s_settingsState.low_spec_mode);
} }

View File

@@ -10,6 +10,7 @@
#include "../../data/address_book.h" #include "../../data/address_book.h"
#include "../../chat/chat_service.h" #include "../../chat/chat_service.h"
#include "../../util/i18n.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 "../../util/platform.h" // getConfigDir + writeFileAtomically — conversation export (Q11)
#include "../../config/settings.h" // per-conversation mute (Q10) #include "../../config/settings.h" // per-conversation mute (Q10)
#include "../material/colors.h" #include "../material/colors.h"
@@ -59,6 +60,10 @@ bool s_msgsel_dragging = false;
// Composer + new-conversation UI state. // Composer + new-conversation UI state.
char s_compose[512] = ""; char s_compose[512] = "";
std::string s_compose_cid; // the conversation s_compose is a draft for; draft is wiped when it changes 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). // 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. // The composer hard-caps input to this; the emoji picker respects it too.
constexpr int kChatBodyMaxBytes = (512 - 5) / 2 - 17; // = 236 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 // 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. // newline ourselves here (running under CallbackAlways), respecting the on-chain byte cap.
int composeInputCallback(ImGuiInputTextCallbackData* data) { 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(); ImGuiIO& io = ImGui::GetIO();
if (io.KeyShift if (io.KeyShift
&& (ImGui::IsKeyPressed(ImGuiKey_Enter) || ImGui::IsKeyPressed(ImGuiKey_KeypadEnter)) && (ImGui::IsKeyPressed(ImGuiKey_Enter) || ImGui::IsKeyPressed(ImGuiKey_KeypadEnter))
&& data->BufTextLen < kChatBodyMaxBytes) { && data->BufTextLen < kChatBodyMaxBytes) {
data->InsertChars(data->CursorPos, "\n"); data->InsertChars(data->CursorPos, "\n");
s_composeCursor = data->CursorPos; // InsertChars advanced the caret past the newline
} }
return 0; 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 // 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. // 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) { 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 // Insert a token (emoji glyph or the ":drgx:" shortcode) at the composer's caret (s_composeCursor,
// when the draft isn't empty and doesn't already end in whitespace. Respects the on-chain byte cap. // 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) { auto insertToken = [&](const char* tok) {
const std::size_t cur = std::strlen(buf), add = std::strlen(tok); const std::size_t cur = std::strlen(buf), add = std::strlen(tok);
const bool needsSpace = cur > 0 && static_cast<unsigned char>(buf[cur - 1]) > ' '; const std::size_t pos = (s_composeCursor < 0)
? cur : std::min(static_cast<std::size_t>(s_composeCursor), cur);
const bool needsSpace = pos > 0 && static_cast<unsigned char>(buf[pos - 1]) > ' ';
const std::size_t pad = needsSpace ? 1 : 0; const std::size_t pad = needsSpace ? 1 : 0;
if (cur + pad + add <= static_cast<std::size_t>(kChatBodyMaxBytes) && cur + pad + add < bufSize) { if (cur + pad + add <= static_cast<std::size_t>(kChatBodyMaxBytes) && cur + pad + add < bufSize) {
if (needsSpace) buf[cur] = ' '; std::memmove(buf + pos + pad + add, buf + pos, (cur - pos) + 1); // shift tail right (incl NUL)
std::memcpy(buf + cur + pad, tok, add); if (needsSpace) buf[pos] = ' ';
buf[cur + pad + add] = '\0'; std::memcpy(buf + pos + pad, tok, add);
s_composeCursor = static_cast<int>(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; } 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). // for one contact can't be sent to another (B5).
if (s_selected_cid != s_compose_cid) { if (s_selected_cid != s_compose_cid) {
sodium_memzero(s_compose, sizeof(s_compose)); 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_compose_cid = s_selected_cid;
s_composerAnimH = 0.0f; // re-arm the first-frame snap so the box doesn't animate-collapse on switch 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) { if (submit && s_compose[0] != '\0' && !overCap) {
app->sendChatMessage(sel->cid, s_compose); app->sendChatMessage(sel->cid, s_compose);
sodium_memzero(s_compose, sizeof(s_compose)); sodium_memzero(s_compose, sizeof(s_compose));
s_composeCursor = -1;
s_scroll_to_cid = sel->cid; s_scroll_to_cid = sel->cid;
s_composerAnimH = 0.0f; // snap back to collapsed instead of animating while unfocused 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 // 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)) { if (material::BeginOverlayDialog(ov)) {
const float fieldW = ImGui::GetContentRegionAvail().x; const float fieldW = ImGui::GetContentRegionAvail().x;
material::LabeledInput(TR("chat_new_zaddr"), "##newz", s_new_zaddr, sizeof(s_new_zaddr), fieldW); 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. // 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). // Selecting one fills the field above (manual paste still works).
ImGui::SetNextItemWidth(fieldW); 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); material::LabeledInput(TR("chat_new_message"), "##newm", s_new_msg, sizeof(s_new_msg), fieldW);
ImGui::Dummy(ImVec2(0, Layout::spacingMd())); 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, const float actionW = std::max(130.0f * dp,
ImGui::CalcTextSize(TR("chat_new_send")).x + ImGui::GetStyle().FramePadding.x * 2.0f + 24.0f * dp); ImGui::CalcTextSize(TR("chat_new_send")).x + ImGui::GetStyle().FramePadding.x * 2.0f + 24.0f * dp);
const float actionGap = Layout::spacingSm(); const float actionGap = Layout::spacingSm();

View File

@@ -160,10 +160,10 @@ const ConsoleCommandEntry kWalletCommands[] = {
"z_sendmany \"RfromAddr\" [{\"address\":\"zs1toAddr\",\"amount\":1.0}]", "send pay private shielded transfer money", true}, "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]", {"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.", "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]", {"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.", "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]", {"listtransactions", "List recent wallet transactions", "[\"account\"] [count] [from]",
"Your most recent wallet transactions, newest first \xE2\x80\x94 amounts, addresses and confirmations.", "Your most recent wallet transactions, newest first \xE2\x80\x94 amounts, addresses and confirmations.",
"listtransactions", "transactions history recent payments received sent"}, "listtransactions", "transactions history recent payments received sent"},

View File

@@ -1406,10 +1406,13 @@ void ConsoleTab::renderInput(ConsoleCommandExecutor& exec)
ImGui::PopItemWidth(); ImGui::PopItemWidth();
ImGui::PopFont(); ImGui::PopFont();
// Auto-focus on input // Auto-focus on input — after submitting a command (reclaim), or once when the Console tab is opened
if (reclaim_focus) { // (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); ImGui::SetKeyboardFocusHere(-1);
} }
focus_input_pending_ = false;
} }
bool ConsoleTab::submitConsoleCommand(ConsoleCommandExecutor& exec, const std::string& cmd) bool ConsoleTab::submitConsoleCommand(ConsoleCommandExecutor& exec, const std::string& cmd)

View File

@@ -65,6 +65,10 @@ public:
*/ */
void clear(); 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) // Scanline effect toggle (set from settings)
static bool s_scanline_enabled; static bool s_scanline_enabled;
@@ -156,6 +160,7 @@ private:
int history_index_ = -1; int history_index_ = -1;
char input_buffer_[4096] = {0}; char input_buffer_[4096] = {0};
bool stop_confirm_pending_ = false; // 'stop' typed once, awaiting a confirming second 'stop' 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) // (log-ingestion cursors + result queue moved to the ConsoleCommandExecutor)
// Auto-scroll state machine (pin-to-bottom, wheel-up cooldown, new-line backlog count). // Auto-scroll state machine (pin-to-bottom, wheel-up cooldown, new-line backlog count).

View File

@@ -160,6 +160,20 @@ static bool IsValidTransparentAddr(const char* a) {
return a && dragonx::util::isTransparentAddress(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) { static std::string timeAgo(int64_t timestamp) {
return dragonx::util::formatTimeAgoShort(timestamp); return dragonx::util::formatTimeAgoShort(timestamp);
} }
@@ -253,8 +267,9 @@ static void RenderSourceDropdown(App* app, float width) {
const char* tag = isZ ? "[Z]" : "[T]"; const char* tag = isZ ? "[Z]" : "[T]";
std::string trunc = util::truncateMiddle(addr.address, std::string trunc = util::truncateMiddle(addr.address,
static_cast<int>(std::max(S.drawElement("tabs.send", "addr-preview-trunc-min").size, width / S.drawElement("tabs.send", "addr-preview-trunc-divisor").size))); static_cast<int>(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", snprintf(buf, sizeof(buf), "%s %s — %s",
tag, trunc.c_str(), addr.spendableBalance, DRAGONX_TICKER); tag, trunc.c_str(),
FormatSourceBalance(addr.balance, addr.spendableBalance).c_str());
s_source_preview = buf; s_source_preview = buf;
} else { } else {
s_source_preview = TR("send_select_source"); s_source_preview = TR("send_select_source");
@@ -267,8 +282,17 @@ static void RenderSourceDropdown(App* app, float width) {
if (!app->isConnected() || state.addresses.empty()) { if (!app->isConnected() || state.addresses.empty()) {
ImGui::TextDisabled("%s", TR("no_addresses_available")); ImGui::TextDisabled("%s", TR("no_addresses_available"));
} else { } else {
// Sort by balance descending, only show spendable addresses with balance // List every address that HOLDS a balance (confirmed or still-confirming), sorted by total
std::vector<size_t> sortedIdx = sortedSpendableAddressIndices(state.addresses); // 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<size_t> 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()) { if (sortedIdx.empty()) {
ImGui::TextDisabled("%s", TR("send_no_balance")); ImGui::TextDisabled("%s", TR("send_no_balance"));
@@ -283,8 +307,9 @@ static void RenderSourceDropdown(App* app, float width) {
const char* tag = isZ ? "[Z]" : "[T]"; const char* tag = isZ ? "[Z]" : "[T]";
std::string trunc = util::truncateMiddle(addr.address, (int)addrTruncLen); std::string trunc = util::truncateMiddle(addr.address, (int)addrTruncLen);
snprintf(buf, sizeof(buf), "%s %s — %.8f %s", snprintf(buf, sizeof(buf), "%s %s — %s",
tag, trunc.c_str(), addr.spendableBalance, DRAGONX_TICKER); tag, trunc.c_str(),
FormatSourceBalance(addr.balance, addr.spendableBalance).c_str());
ImGui::PushID(static_cast<int>(i)); ImGui::PushID(static_cast<int>(i));
if (ImGui::Selectable(buf, isCurrent)) { if (ImGui::Selectable(buf, isCurrent)) {
@@ -293,8 +318,9 @@ static void RenderSourceDropdown(App* app, float width) {
addr.address.c_str()); addr.address.c_str());
} }
if (ImGui::IsItemHovered()) { if (ImGui::IsItemHovered()) {
material::Tooltip("%s\nBalance: %.8f %s", material::Tooltip("%s\n%s",
addr.address.c_str(), addr.spendableBalance, DRAGONX_TICKER); addr.address.c_str(),
FormatSourceBalance(addr.balance, addr.spendableBalance).c_str());
} }
ImGui::PopID(); ImGui::PopID();
} }

View File

@@ -594,8 +594,9 @@ private:
// Each external wallet gets its own STABLE link name derived from its path — so switching between two // Each external wallet gets its own STABLE link name derived from its path — so switching between two
// of them is a real -wallet=<different name> switch (not a no-op on one shared name), and the wallet // of them is a real -wallet=<different name> 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. // index tracks each separately (correct per-wallet rescan + cached data). Hidden from the list.
static constexpr const char* kLinkPrefix = "wallet-ip-"; // Single source of truth lives in util/wallet_file_probe.h (shared with the offline enumeration helper).
static bool isLinkName(const std::string& n) { return n.rfind(kLinkPrefix, 0) == 0; } 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 // 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 // deterministic, cross-platform, cross-run hash, unlike std::hash). Feed it a canonicalOf() path so the

View File

@@ -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 // 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. // pinned key, so a checksum rewritten in a tampered release body is not sufficient to install.
setProgress(State::Verifying, "Verifying download…"); 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); std::ifstream f(zipPath, std::ios::binary);
if (!f) { if (!f) {
@@ -284,7 +285,7 @@ void DaemonUpdater::installResolved(const std::string& targetDir, const DaemonRe
setProgress(State::Failed, "Could not read the downloaded archive."); setProgress(State::Failed, "Could not read the downloaded archive.");
return; return;
} }
const std::string bytes((std::istreambuf_iterator<char>(f)), std::istreambuf_iterator<char>()); bytes.assign(std::istreambuf_iterator<char>(f), std::istreambuf_iterator<char>());
if (f.bad()) { if (f.bad()) {
fs::remove(zipPath, ec); fs::remove(zipPath, ec);
setProgress(State::Failed, "Could not read the downloaded archive."); 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" const std::string daemonName = wanted.front(); // "dragonxd" / "dragonxd.exe"
mz_zip_archive zip{}; 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); fs::remove(zipPath, ec);
setProgress(State::Failed, "Could not open the downloaded archive."); setProgress(State::Failed, "Could not open the downloaded archive.");
return; return;

View File

@@ -223,6 +223,7 @@ void I18n::loadBuiltinEnglish()
strings_["chat_send"] = "Send"; strings_["chat_send"] = "Send";
strings_["chat_new_title"] = "New chat"; strings_["chat_new_title"] = "New chat";
strings_["chat_new_zaddr"] = "Recipient z-address"; 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_message"] = "Message";
strings_["chat_new_send"] = "Send request"; strings_["chat_new_send"] = "Send request";
strings_["chat_cancel"] = "Cancel"; 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."; 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). // 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.<ts>.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_title"] = "Your wallet file needs a quick repair";
strings_["wallet_recovered_safety"] = "Your coins are safe."; 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."; 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_zoom_out"] = "Zoom out";
strings_["console_toggle_accents"] = "Toggle line color accents"; strings_["console_toggle_accents"] = "Toggle line color accents";
strings_["console_toggle_text_color"] = "Toggle line text colors"; 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_accents"] = "Color accents";
strings_["console_text_colors"] = "Text colors"; strings_["console_text_colors"] = "Text colors";
strings_["console_cat_control"] = "Control"; strings_["console_cat_control"] = "Control";
@@ -2123,6 +2139,7 @@ void I18n::loadBuiltinEnglish()
strings_["send_recipient"] = "RECIPIENT"; strings_["send_recipient"] = "RECIPIENT";
strings_["send_select_source"] = "Select a source address..."; strings_["send_select_source"] = "Select a source address...";
strings_["send_sending_from"] = "SENDING FROM"; strings_["send_sending_from"] = "SENDING FROM";
strings_["send_available_note"] = "available";
strings_["send_submitting"] = "Submitting transaction..."; strings_["send_submitting"] = "Submitting transaction...";
strings_["send_switch_to_receive"] = "Switch to Receive to get your address and start receiving funds."; 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"; strings_["send_tooltip_enter_amount"] = "Enter an amount to send";

View File

@@ -18,14 +18,57 @@
#include <cstddef> #include <cstddef>
#include <cstdint> #include <cstdint>
#include <cstring> #include <cstring>
#include <filesystem>
#include <fstream> #include <fstream>
#include <string> #include <string>
#include <system_error>
#include <utility> #include <utility>
#include <vector> #include <vector>
namespace dragonx { namespace dragonx {
namespace util { 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=<name>. 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.<ts>.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<std::string> enumerateDatadirWalletFiles(const std::string& datadir,
const std::string& excludeActiveName = "",
bool includeSalvageBaks = false) {
namespace fs = std::filesystem;
std::vector<std::string> 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 { struct WalletFileProbe {
bool isBerkeleyDB = false; ///< file has a valid BDB btree metapage magic (looks like a real wallet.dat) 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 bool encrypted = false; ///< has an "mkey" master-key record → passphrase-encrypted

View File

@@ -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 // the archive bytes against that key, so a checksum rewritten in a tampered release body is
// not sufficient to install. // not sufficient to install.
setProgress(State::Verifying, "Verifying download…"); 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); std::ifstream f(zipPath, std::ios::binary);
if (!f) { if (!f) {
@@ -272,7 +273,7 @@ void XmrigUpdater::installResolved(const std::string& targetDir, const XmrigRele
setProgress(State::Failed, "Could not read the downloaded archive."); setProgress(State::Failed, "Could not read the downloaded archive.");
return; return;
} }
const std::string bytes((std::istreambuf_iterator<char>(f)), std::istreambuf_iterator<char>()); bytes.assign(std::istreambuf_iterator<char>(f), std::istreambuf_iterator<char>());
if (f.bad()) { if (f.bad()) {
fs::remove(zipPath, ec); fs::remove(zipPath, ec);
setProgress(State::Failed, "Could not read the downloaded archive."); 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" const std::string minerName = wanted.front(); // "xmrig" / "xmrig.exe"
mz_zip_archive zip{}; 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); fs::remove(zipPath, ec);
setProgress(State::Failed, "Could not open the downloaded archive."); setProgress(State::Failed, "Could not open the downloaded archive.");
return; return;

View File

@@ -856,6 +856,13 @@ bool LiteWalletController::runConsoleCommand(std::string commandLine)
r.ok = call.ok; r.ok = call.ok;
r.response = call.ok ? call.value r.response = call.ok ? call.value
: (call.error.empty() ? "command failed" : call.error); : (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 { } else {
r.response = "lite backend unavailable"; r.response = "lite backend unavailable";
} }