diff --git a/.gitignore b/.gitignore
index 134ccab..47b4daa 100644
--- a/.gitignore
+++ b/.gitignore
@@ -33,6 +33,10 @@ imgui.ini
*.bak*
*.params
asmap.dat
+# Wallet files hold PRIVATE KEYS — never commit them
+wallet.dat
+wallet-*.dat
+wallet.dat.*
/external/drg-xmrig
/memory
/todo.md
diff --git a/CHANGELOG.md b/CHANGELOG.md
new file mode 100644
index 0000000..65b951c
--- /dev/null
+++ b/CHANGELOG.md
@@ -0,0 +1,58 @@
+# Changelog
+
+All notable user-facing changes to ObsidianDragon are documented here. The format loosely
+follows [Keep a Changelog](https://keepachangelog.com/); the project uses Conventional Commits.
+
+## [Unreleased]
+
+### ⚠️ Breaking changes
+
+- **Remote RPC over plain HTTP is now refused by default.** If your wallet is configured to
+ reach a **remote** `rpchost`/`rpcconnect` **without TLS**, it will no longer connect — it
+ previously sent your `rpcuser`/`rpcpassword` in cleartext (capturable by anyone on the
+ network path) after only a dismissible warning. To reconnect, either:
+ - add **`rpctls=1`** to `DRAGONX.conf` (preferred, if your daemon supports TLS), or
+ - add **`rpcallowplaintext=1`** to `DRAGONX.conf` to explicitly accept the plaintext link.
+
+ Local and embedded daemons (`127.0.0.0/8`, `localhost`, `::1`) are unaffected.
+
+### Security
+
+- Refuse remote plaintext RPC credential transmission by default (see Breaking changes above).
+- Tightened localhost detection: a hostname that merely *starts* with `127.` (e.g.
+ `127.evil.com`) is no longer mistaken for a loopback address, so it can no longer bypass the
+ plaintext-RPC protection.
+- Sapling parameters are now integrity-checked (SHA-256) against pinned canonical digests
+ before use, instead of only checking that the files exist. A truncated or corrupt parameter
+ file is caught up front rather than surfacing later as a confusing shielded-operation failure.
+ (Cached via a `size:mtime` marker so it doesn't re-hash ~48 MB on every launch.)
+
+### Fixed
+
+- Daemon crashes are no longer occasionally missed: a race between the UI thread and the
+ process monitor could consume the daemon's exit status, hiding a crash and defeating the
+ automatic-restart cap. The monitor is now the sole reaper.
+- A daemon that fails to launch (missing execute permission, wrong architecture, corrupt
+ binary) now reports a precise error immediately instead of briefly showing "running" and
+ then a generic "exited unexpectedly (exit code 127)".
+- A quick stop→start no longer triggers a restart storm: the wallet now waits briefly for a
+ previous daemon to release the data-directory lock and shows a clear, non-crash message
+ instead of exhausting the crash-restart budget.
+- Failures while writing the daemon binaries or Sapling parameters (disk full, permission
+ denied) are now surfaced clearly up front instead of failing opaquely when the daemon later
+ can't start.
+- Directory-creation failures on startup (read-only home, permission denied) now produce a
+ clear "Cannot create
" message instead of a confusing downstream "config missing" /
+ "binary not found" error (or, in one path, an uncaught exception).
+
+### Added
+
+- A "Taking longer than expected" notice now appears if the daemon is reachable but hasn't
+ finished initializing after ~45 s (configurable via `ui.toml`), with guidance to restart the
+ daemon or open the Console — instead of an indefinite silent spinner. It clears itself
+ automatically once the daemon connects.
+
+---
+
+Engineering detail and the finding-by-finding rationale for this batch live in
+`docs/daemon-startup-hardening.md`.
diff --git a/CMakeLists.txt b/CMakeLists.txt
index 5570548..70df660 100644
--- a/CMakeLists.txt
+++ b/CMakeLists.txt
@@ -15,7 +15,7 @@ if(APPLE)
endif()
project(ObsidianDragon
- VERSION 2.0.0
+ VERSION 2.0.1
LANGUAGES C CXX
DESCRIPTION "DragonX Cryptocurrency Wallet"
)
@@ -26,7 +26,7 @@ set(DRAGONX_VERSION_SUFFIX "")
# ObsidianDragonLite is versioned INDEPENDENTLY of the full-node app above. The active variant's
# version flows to the generated header, the Windows .rc/manifest, and build.sh's release names via
# DRAGONX_APP_VERSION* (resolved in the lite/full block below).
-set(DRAGONX_LITE_VERSION "1.0.0")
+set(DRAGONX_LITE_VERSION "1.1.0")
set(DRAGONX_LITE_VERSION_SUFFIX "")
# C++17 standard
@@ -213,7 +213,7 @@ include(FetchContent)
FetchContent_Declare(
json
GIT_REPOSITORY https://github.com/nlohmann/json.git
- GIT_TAG v3.11.3
+ GIT_TAG 9cca280a4d0ccf0c08f47a99aa71d1b0e52f8d03 # v3.11.3 — pinned to immutable commit (L-08); tags are mutable
GIT_SHALLOW TRUE
)
FetchContent_MakeAvailable(json)
@@ -222,7 +222,7 @@ FetchContent_MakeAvailable(json)
FetchContent_Declare(
tomlplusplus
GIT_REPOSITORY https://github.com/marzer/tomlplusplus.git
- GIT_TAG v3.4.0
+ GIT_TAG 30172438cee64926dc41fdd9c11fb3ba5b2ba9de # v3.4.0 — pinned to immutable commit (L-08); tags are mutable
GIT_SHALLOW TRUE
)
FetchContent_MakeAvailable(tomlplusplus)
@@ -289,7 +289,7 @@ message(STATUS "Fetching libwebp (decode-only, static)...")
FetchContent_Declare(
libwebp
GIT_REPOSITORY https://github.com/webmproject/libwebp.git
- GIT_TAG v1.4.0
+ GIT_TAG 845d5476a866141ba35ac133f856fa62f0b7445f # v1.4.0 — pinned to immutable commit (L-08); tags are mutable
GIT_SHALLOW TRUE
# libwebp's cpu.cmake applies -mno-sse2/-mno-sse4.1 to its scalar reference DSP
# files when it can't probe SSE support. Under a macOS universal build
@@ -521,6 +521,8 @@ set(APP_SOURCES
src/ui/windows/settings_window.cpp
src/ui/pages/settings_page.cpp
src/ui/windows/about_dialog.cpp
+ src/ui/windows/faq_dialog.cpp
+ src/ui/windows/faq_content.cpp
src/ui/windows/key_export_dialog.cpp
src/ui/windows/transaction_details_dialog.cpp
src/ui/windows/qr_popup_dialog.cpp
@@ -544,6 +546,7 @@ set(APP_SOURCES
src/util/async_task_manager.cpp
src/util/amount_format.cpp
src/util/address_validation.cpp
+ src/util/seed_phrase.cpp
src/util/base64.cpp
src/util/single_instance.cpp
src/util/i18n.cpp
@@ -656,6 +659,8 @@ set(APP_HEADERS
src/ui/windows/console_tab_helpers.h
src/ui/windows/settings_window.h
src/ui/windows/about_dialog.h
+ src/ui/windows/faq_dialog.h
+ src/ui/windows/faq_content.h
src/ui/windows/key_export_dialog.h
src/ui/windows/transaction_details_dialog.h
src/ui/windows/qr_popup_dialog.h
@@ -1074,6 +1079,32 @@ install(DIRECTORY ${CMAKE_RUNTIME_OUTPUT_DIRECTORY}/res
OPTIONAL
)
+# -----------------------------------------------------------------------------
+# dragonx-wallet-rebuild — offline recovery helper for a BDB-inconsistent wallet.dat.
+# Bundled next to the daemon; the app spawns it out-of-process. It is the ONLY thing that links
+# Berkeley DB, so the AGPLv3 BDB never contaminates the GPLv3 GUI (same boundary as the daemon).
+# Release builds should point DRAGONX_BDB_ROOT at the vendored static libdb (external/dragonx/depends);
+# a dev build falls back to the system Berkeley DB. Skipped (with a note) if no BDB is found.
+# -----------------------------------------------------------------------------
+find_path(BDB_INCLUDE_DIR db.h HINTS ${DRAGONX_BDB_ROOT}/include /usr/include /usr/local/include)
+find_library(BDB_LIBRARY NAMES db-6.2 db-6.0 db-5.3 db libdb
+ HINTS ${DRAGONX_BDB_ROOT}/lib /usr/lib /usr/local/lib /usr/lib/x86_64-linux-gnu)
+if(BDB_INCLUDE_DIR AND BDB_LIBRARY)
+ add_executable(dragonx-wallet-rebuild tools/wallet_rebuild/main.cpp)
+ target_include_directories(dragonx-wallet-rebuild PRIVATE ${CMAKE_SOURCE_DIR}/src ${BDB_INCLUDE_DIR})
+ target_link_libraries(dragonx-wallet-rebuild PRIVATE ${BDB_LIBRARY})
+ if(WIN32)
+ target_link_libraries(dragonx-wallet-rebuild PRIVATE ws2_32) # static libdb-6.2 pulls in winsock
+ else()
+ find_package(Threads REQUIRED)
+ target_link_libraries(dragonx-wallet-rebuild PRIVATE Threads::Threads ${CMAKE_DL_LIBS}) # static libdb needs pthread/dl
+ endif()
+ set_target_properties(dragonx-wallet-rebuild PROPERTIES RUNTIME_OUTPUT_DIRECTORY ${CMAKE_BINARY_DIR}/bin)
+ message(STATUS "wallet-rebuild helper: ON (Berkeley DB ${BDB_LIBRARY})")
+else()
+ message(STATUS "wallet-rebuild helper: OFF (no Berkeley DB found; set DRAGONX_BDB_ROOT for release builds)")
+endif()
+
# -----------------------------------------------------------------------------
# Tests
# -----------------------------------------------------------------------------
@@ -1122,6 +1153,7 @@ if(BUILD_TESTING)
src/util/payment_uri.cpp
src/util/amount_format.cpp
src/util/address_validation.cpp
+ src/util/seed_phrase.cpp
src/util/i18n.cpp
src/util/text_format.cpp
src/data/wallet_state.cpp
@@ -1129,6 +1161,7 @@ if(BUILD_TESTING)
src/data/address_book.cpp
src/data/wallet_index.cpp
src/daemon/lifecycle_adapters.cpp
+ src/daemon/embedded_daemon.cpp
src/rpc/connection.cpp
src/config/settings.cpp
src/resources/embedded_resources.cpp
diff --git a/build.sh b/build.sh
index d8681a4..e76cc82 100755
--- a/build.sh
+++ b/build.sh
@@ -195,6 +195,24 @@ should_bundle_full_node_assets() {
! $DO_LITE
}
+# The offline wallet-rebuild helper is the ONLY thing that repairs a genuinely BDB-inconsistent
+# wallet.dat — plain "Restore" just re-triggers the daemon's salvage cascade. A full-node release must
+# NEVER ship without it (the in-app "Repair automatically" option silently disappears otherwise), so
+# treat a missing helper as a HARD build failure instead of degrading recovery to Restore-only.
+# $1 = built helper path (e.g. bin/dragonx-wallet-rebuild[.exe]); $2 = the BDB depends dir for the hint.
+require_wallet_rebuild_helper() {
+ local helper="$1" depends="$2"
+ should_bundle_full_node_assets || return 0 # lite builds have no BDB wallet.dat to rebuild
+ if [[ ! -f "$helper" ]]; then
+ err "wallet-rebuild helper was NOT built: $helper"
+ err " → the recovery 'Repair automatically' option would be MISSING from this release."
+ err " Cause: the vendored Berkeley DB depends are absent, so CMake skipped the dragonx-wallet-rebuild target."
+ err " Fix: provide ${depends}/{lib/libdb-6.2.a,include/db.h} (same static libdb the daemon links), then rebuild."
+ exit 1
+ fi
+ info " wallet-rebuild helper present: $helper"
+}
+
# ── Helper: find resource files ──────────────────────────────────────────────
find_sapling_params() {
local dirs=(
@@ -286,6 +304,9 @@ bundle_linux_daemon() {
# asmap.dat
find_asmap && cp "$ASMAP_DAT" "$dest/asmap.dat" && info " Bundled asmap.dat"
+ # (The dragonx-wallet-rebuild recovery helper is built into bin/ by CMake and packaged explicitly
+ # by each release path — required via require_wallet_rebuild_helper — so it is not copied here.)
+
return $found
}
@@ -336,11 +357,24 @@ build_release_linux() {
mkdir -p "$bd" && cd "$bd"
# ── Compile ──────────────────────────────────────────────────────────────
+ # Point the wallet-rebuild helper at the vendored static Berkeley DB (same libdb the daemon links)
+ # so its output is a v6.2 btree the bundled dragonxd reads. Pass the paths EXPLICITLY (bypasses
+ # find_library + its cache); the helper target is simply not built if the depends tree is absent.
+ local lin_bdb="$SCRIPT_DIR/external/dragonx/depends/x86_64-unknown-linux-gnu"
+ local BDB_ARGS=()
+ if [[ -f "$lin_bdb/lib/libdb-6.2.a" && -f "$lin_bdb/include/db.h" ]]; then
+ BDB_ARGS=( -DBDB_INCLUDE_DIR="$lin_bdb/include" -DBDB_LIBRARY="$lin_bdb/lib/libdb-6.2.a" )
+ elif should_bundle_full_node_assets; then
+ err "Vendored Berkeley DB depends missing at $lin_bdb — the wallet-rebuild recovery helper cannot be built."
+ err " A full-node release must ship it; aborting rather than degrading recovery to Restore-only."
+ exit 1
+ fi
info "Configuring ..."
cmake "$SCRIPT_DIR" \
-DCMAKE_BUILD_TYPE=Release \
-DCMAKE_CXX_FLAGS_RELEASE="-O3 -DNDEBUG" \
-DDRAGONX_USE_SYSTEM_SDL3=ON \
+ "${BDB_ARGS[@]}" \
"${CMAKE_LITE_ARGS[@]}"
info "Building with $JOBS jobs ..."
@@ -348,8 +382,12 @@ build_release_linux() {
[[ -f "bin/${APP_BASENAME}" ]] || { err "Linux build failed"; exit 1; }
+ # A full-node release MUST include the recovery helper — fail loudly, never ship without it.
+ require_wallet_rebuild_helper "bin/dragonx-wallet-rebuild" "$lin_bdb"
+
info "Stripping ..."
strip "bin/${APP_BASENAME}"
+ [[ -f "bin/dragonx-wallet-rebuild" ]] && strip "bin/dragonx-wallet-rebuild"
info "Binary: $(du -h "bin/${APP_BASENAME}" | cut -f1)"
if should_bundle_full_node_assets; then
@@ -384,6 +422,9 @@ build_release_linux() {
[[ -f bin/asmap.dat ]] && cp bin/asmap.dat "$dist_dir/"
[[ -f bin/sapling-spend.params ]] && cp bin/sapling-spend.params "$dist_dir/"
[[ -f bin/sapling-output.params ]] && cp bin/sapling-output.params "$dist_dir/"
+ # Offline wallet-rebuild recovery helper — required (asserted above); ships next to the app.
+ cp bin/dragonx-wallet-rebuild "$dist_dir/" && chmod +x "$dist_dir/dragonx-wallet-rebuild"
+ info " Bundled dragonx-wallet-rebuild"
fi
# Bundle xmrig for mining support
local XMRIG_LINUX="$SCRIPT_DIR/prebuilt-binaries/drg-xmrig/xmrig"
@@ -417,6 +458,8 @@ build_release_linux() {
[[ -f bin/asmap.dat ]] && cp bin/asmap.dat "$APPDIR/usr/bin/"
[[ -f bin/sapling-spend.params ]] && cp bin/sapling-spend.params "$APPDIR/usr/bin/"
[[ -f bin/sapling-output.params ]] && cp bin/sapling-output.params "$APPDIR/usr/bin/"
+ # Offline wallet-rebuild recovery helper — required (asserted above); ships next to the app.
+ cp bin/dragonx-wallet-rebuild "$APPDIR/usr/bin/" && chmod +x "$APPDIR/usr/bin/dragonx-wallet-rebuild"
fi
# Bundle xmrig for mining support
local XMRIG_LINUX_AI="$SCRIPT_DIR/prebuilt-binaries/drg-xmrig/xmrig"
@@ -638,6 +681,30 @@ HDR
info "Lite mode: skipping embedded daemon binaries"
fi
+ # ── Wallet-rebuild recovery helper ───────────────────────────────
+ # Built in-tree (not a prebuilt like the daemon), so compile it standalone HERE — before the
+ # main app compiles embedded_resources.cpp — and INCBIN it, so a bare, self-extracting
+ # ObsidianDragon.exe carries the recovery tool exactly like it does the daemon.
+ if should_bundle_full_node_assets; then
+ local WBDB="$SCRIPT_DIR/external/dragonx/depends/x86_64-w64-mingw32"
+ if [[ -f "$WBDB/lib/libdb-6.2.a" && -f "$WBDB/include/db.h" ]]; then
+ info "Compiling + embedding wallet-rebuild helper ..."
+ x86_64-w64-mingw32-g++ -std=c++17 -O2 -static -static-libgcc -static-libstdc++ \
+ -I"$SCRIPT_DIR/src" -I"$WBDB/include" \
+ "$SCRIPT_DIR/tools/wallet_rebuild/main.cpp" \
+ "$WBDB/lib/libdb-6.2.a" -lws2_32 \
+ -o "$RES/dragonx-wallet-rebuild.exe" \
+ || { err "wallet-rebuild helper failed to compile for embedding"; exit 1; }
+ x86_64-w64-mingw32-strip "$RES/dragonx-wallet-rebuild.exe" 2>/dev/null || true
+ echo -e "\n#define HAS_EMBEDDED_WALLET_REBUILD 1" >> "$GEN/embedded_data.h"
+ echo "INCBIN(dragonx_wallet_rebuild_exe, \"$RES/dragonx-wallet-rebuild.exe\");" >> "$GEN/embedded_data.h"
+ info " Embedded dragonx-wallet-rebuild.exe ($(du -h "$RES/dragonx-wallet-rebuild.exe" | cut -f1))"
+ else
+ err "Vendored mingw Berkeley DB missing at $WBDB — cannot embed the wallet-rebuild recovery helper."
+ exit 1
+ fi
+ fi
+
# ── xmrig binary (from prebuilt-binaries/drg-xmrig/) ────────────────
local XMRIG_DIR="$SCRIPT_DIR/prebuilt-binaries/drg-xmrig"
# The published DRG-XMRig archives ship the binary inside a versioned subdir, not as a flat
@@ -750,11 +817,24 @@ HDR
fi
# ── CMake + build ────────────────────────────────────────────────────────
+ # The wallet-rebuild helper links the vendored mingw static Berkeley DB (the mingw toolchain's
+ # find_library is sysroot-only, so pass the depends paths EXPLICITLY to bypass the search). Only
+ # enabled if the depends tree is present; guarded with -DBDB_* left empty otherwise.
+ local win_bdb="$SCRIPT_DIR/external/dragonx/depends/x86_64-w64-mingw32"
+ local BDB_ARGS=()
+ if [[ -f "$win_bdb/lib/libdb-6.2.a" && -f "$win_bdb/include/db.h" ]]; then
+ BDB_ARGS=( -DBDB_INCLUDE_DIR="$win_bdb/include" -DBDB_LIBRARY="$win_bdb/lib/libdb-6.2.a" )
+ elif should_bundle_full_node_assets; then
+ err "Vendored Berkeley DB depends missing at $win_bdb — the wallet-rebuild recovery helper cannot be built."
+ err " A full-node release must ship it; aborting rather than degrading recovery to Restore-only."
+ exit 1
+ fi
info "Configuring (cross-compile) ..."
cmake "$SCRIPT_DIR" \
-DCMAKE_TOOLCHAIN_FILE="$bd/mingw-toolchain.cmake" \
-DCMAKE_BUILD_TYPE=Release \
-DDRAGONX_USE_SYSTEM_SDL3=OFF \
+ "${BDB_ARGS[@]}" \
"${FT_CMAKE_ARG[@]}" \
"${CMAKE_LITE_ARGS[@]}"
@@ -762,8 +842,16 @@ HDR
cmake --build . -j "$JOBS"
[[ -f "bin/${APP_BASENAME}.exe" ]] || { err "Windows build failed"; exit 1; }
+ # Strip the app exe — the Linux and macOS release paths already strip theirs, and even the Windows
+ # helper (dragonx-wallet-rebuild.exe) is stripped, but the main app exe was shipping unstripped
+ # (~5MB of symbols on the full node, ~19MB on the params-heavy lite build). Best-effort.
+ x86_64-w64-mingw32-strip --strip-all "bin/${APP_BASENAME}.exe" 2>/dev/null \
+ || warn " strip unavailable — shipping unstripped ${APP_BASENAME}.exe"
info "Binary: $(du -h "bin/${APP_BASENAME}.exe" | cut -f1)"
+ # A full-node release MUST include the recovery helper — fail loudly, never ship without it.
+ require_wallet_rebuild_helper "bin/dragonx-wallet-rebuild.exe" "$win_bdb"
+
# ── Package: release/windows/ ────────────────────────────────────────────
# Remove only THIS variant's prior artifacts so full-node and lite releases coexist here.
mkdir -p "$out"
@@ -779,6 +867,9 @@ HDR
for f in dragonxd.exe dragonx-cli.exe dragonx-tx.exe; do
[[ -f "$DD/$f" ]] && cp "$DD/$f" "$dist_dir/"
done
+ # dragonx-wallet-rebuild helper (offline recovery for a BDB-inconsistent wallet.dat) — required.
+ cp "bin/dragonx-wallet-rebuild.exe" "$dist_dir/" && info " Bundled dragonx-wallet-rebuild.exe"
+ [[ -f "$dist_dir/dragonx-wallet-rebuild.exe" ]] || { err "Failed to bundle dragonx-wallet-rebuild.exe"; exit 1; }
# Bundle Sapling params + asmap for the zip distribution
# (The single-file exe has these embedded via INCBIN, but the zip
@@ -1034,6 +1125,11 @@ TOOLCHAIN
[[ -f "bin/${APP_BASENAME}" ]] || { err "macOS build failed"; exit 1; }
+ # A full-node release MUST include the recovery helper. macOS needs a static libdb-6.2 (Homebrew
+ # berkeley-db for a native build, or a vendored external/dragonx/depends/) — otherwise CMake
+ # skips the target and this fails loudly rather than shipping a mac release with no recovery option.
+ require_wallet_rebuild_helper "bin/dragonx-wallet-rebuild" "$SCRIPT_DIR/external/dragonx/depends/aarch64-apple-darwin"
+
# Strip — use osxcross strip for cross-builds
if $IS_CROSS; then
local STRIP_CMD="${OSXCROSS}/target/bin/${OSXCROSS_TRIPLE}-strip"
@@ -1106,6 +1202,8 @@ TOOLCHAIN
else
warn "prebuilt-binaries/dragonxd-mac/ not found — place macOS daemon binaries there for bundling"
fi
+ # Offline wallet-rebuild recovery helper — required (asserted after build); next to the daemon.
+ cp "bin/dragonx-wallet-rebuild" "$MACOS/" && chmod +x "$MACOS/dragonx-wallet-rebuild" && info " Bundled dragonx-wallet-rebuild"
else
info "Lite mode: skipping macOS daemon and Sapling/asmap bundling"
fi
diff --git a/docs/daemon-startup-hardening.md b/docs/daemon-startup-hardening.md
new file mode 100644
index 0000000..d4a2818
--- /dev/null
+++ b/docs/daemon-startup-hardening.md
@@ -0,0 +1,549 @@
+# Daemon Startup Hardening — Implementation Plan
+
+Eight verified edge-case defects in how ObsidianDragon brings up (and watches) the
+`dragonxd` daemon at launch. Each entry is a buildable fix: the defect (with exact
+line references), the chosen approach, the call sites, a representative change, and how
+to verify it.
+
+- **Scope:** full-node startup path (`--lite` excludes the embedded daemon entirely).
+- **Source:** line references are exact against branch `dev` @ `45b652f`.
+- **Provenance:** findings verified by direct source read; each fix designed by an
+ independent agent grounded in the cited files, with a sequencing pass for ordering,
+ shared helpers, and merge conflicts.
+
+**Severity:** 2 High, 6 Medium · **Effort:** ≈ 25–35 engineering-hours · **7 landing steps.**
+
+Status legend: ☐ not started · ◐ in progress · ☑ landed & verified
+
+**Status: all 8 landed & verified** (build-clean, `ctest` green after each) across four commits on
+`dev` — lifecycle cluster (F1/F2/F4), filesystem+params cluster (F7/F6/F5), F3, and F8. Six new
+pure-helper unit tests added.
+
+**Wrap-up done:** release notes added (`CHANGELOG.md`, F8 breaking change front and center); i18n
+back-fill applied additively to `res/lang/*.json` (42 keys — all 6 for es/de/fr/pt/ru; 6 zh/ja/ko
+entries whose glyphs aren't in the current `NotoSansCJK-Subset.ttf` were left on English fallback
+rather than render as tofu).
+
+**Still owed before release:** a **CJK subset-font rebuild** (`scripts/build_cjk_subset.py`, needs
+the Noto CJK source font) to cover the 6 deferred zh/ja/ko strings. *(F1 and F2 now have headless
+integration-test coverage — see the progress log — so their GUI repros are optional, not blocking.)*
+
+---
+
+## Recommended rollout sequence
+
+A real dependency order, not a checklist. The daemon-lifecycle cluster lands first
+because it makes the `State::Error` / `crash_count_` contract trustworthy — which the
+connect-stall panel and the lock gate both build on. The filesystem cluster lands
+around a single shared helper. The connectivity-breaking security flip lands last.
+
+| Step | Finding(s) | Site | Why here | Status |
+|------|-----------|------|----------|--------|
+| 1 | **F1** | `embedded_daemon.cpp` · `isRunning()` | Smallest/highest-severity; establishes the reliable Error/crash-count transition steps 3 & 6 depend on. | ☑ |
+| 2 | **F2** | `embedded_daemon.cpp` · `startProcess()` | Same file family, different function; test the F1+F2 pair together with `kill -SEGV` / bad-binary repros. | ☑ |
+| 3 | **F4** | `embedded_daemon.cpp` · `start()` | After F1/F2 so crash-count semantics are settled; its bail deliberately stays out of the crash path. | ☑ |
+| 4 | **F7** | `util/platform` · `connection.cpp` | Structural owner of the fs-error idiom + `ConnectionConfig` that F5/F6/F8 reuse. | ☑ |
+| 5 | **F6 + F5** | `app.cpp` · `verifySaplingParams()` | Same `startEmbeddedDaemon` / `verifySaplingParams` block; land together. | ☑ |
+| 6 | **F3** | `app.cpp` · `renderLoadingOverlay()` | After F1 — panel is guarded off during `State::Error` (owned by the crash-count hint). | ☑ |
+| 7 | **F8** | `connection.cpp` · `tryConnect()` | Largest; only connectivity-breaking default flip — land last, with release notes. | ☑ |
+
+---
+
+## F1 — Double-`waitpid` race can swallow a daemon crash
+
+**Severity:** High · **Effort:** S (~1–2h) · **Status:** ☑ landed & verified
+
+### The defect
+`EmbeddedDaemon::isRunning()` (`embedded_daemon.cpp:1136`, POSIX branch) calls
+`waitpid(WNOHANG)` — from the **UI thread, nearly every frame** — racing
+`monitorProcess()`'s own reap at `:1244`. `waitpid` is one-shot: if the UI thread wins,
+the monitor never decodes the exit, so `crash_count_` never increments, `State::Error`
+never fires, and the 3-strike auto-restart cap (`app_network.cpp:479`) is defeated. The
+sibling `XmrigManager::isRunning()` (`xmrig_manager.cpp:512`) already fixed exactly this
+with an atomic read.
+
+### The fix
+Make `isRunning()` read the existing `std::atomic state_` (member at
+`embedded_daemon.h:253`) instead of calling `waitpid`, leaving `monitorProcess()` as the
+sole reaper. Predicate is `Running || Stopping` — `Stopping` must stay "alive" because
+`stop()`'s graceful/SIGTERM wait loops poll `isRunning()` before the process has exited.
+
+### Files touched
+- `src/daemon/embedded_daemon.cpp` — `isRunning()`, POSIX branch (~1136)
+
+### Core change
+```cpp
+bool EmbeddedDaemon::isRunning() const // POSIX branch
+{
+ // Read the atomic state_ instead of waitpid() — monitorProcess() is the
+ // sole reaper. Previously both threads reaped; if the UI thread won, the
+ // monitor never saw the exit (crash_count_ / exit code / Error all lost).
+ if (process_pid_ <= 0) return false;
+
+ State s = state_.load(std::memory_order_relaxed);
+ // Stopping stays "alive": stop()'s wait loops poll isRunning() while
+ // state_ == Stopping, before the process has actually terminated.
+ return (s == State::Running || s == State::Stopping);
+}
+```
+
+### Verification
+- Manual: `kill -SEGV` the daemon 10–20×; the monitor must report the exit and increment `crash_count_` every time (previously intermittent).
+- Regression: a normal Settings-driven stop still escalates SIGTERM→SIGKILL (the `Stopping` predicate).
+- Not unit-testable (real fork/exec/waitpid) — consistent with the no-process-spawn harness.
+
+### Dependencies
+Mirrors `XmrigManager::isRunning()`. Flags a separate latent hazard (out of scope):
+`stop()`'s final blocking `waitpid` (`:1220`) can still race a mid-sleep monitor
+iteration — file as its own ticket.
+
+---
+
+## F2 — exec-after-fork silent failure: "Running" for a daemon that never started
+
+**Severity:** High · **Effort:** S (~2–3h) · **Status:** ☑ landed & verified
+
+### The defect
+In `startProcess()` (`embedded_daemon.cpp:957–1061`, POSIX) the parent runs
+`process_pid_ = pid; return true;` **unconditionally** after `fork()` — with no
+exec-status handshake. On a non-executable / wrong-arch / corrupt binary the child's
+`execv` fails and it `_exit(127)`s, but `start()` has already set `State::Running`
+(`:565`). The real cause never reaches `last_error_`; it surfaces later, generically,
+as "exited unexpectedly (exit code 127)".
+
+### The fix
+Add a **close-on-exec self-pipe** handshake — `pipe() + fcntl(FD_CLOEXEC)`, deliberately
+**not** `pipe2()` (macOS lacks it; the POSIX branch is shared). The child writes `errno`
+only on `execv` failure; a successful exec closes the write end for free. Parent reads:
+EOF ⇒ success; 4 bytes ⇒ reap the zombie, set a precise `last_error_` ("not executable
+or wrong architecture"), and return `false` so `start()` never reports Running. EINTR-safe
+on both ends. Also comments the unchecked parent-side `setpgid` at `:1053`.
+
+### Files touched
+- `src/daemon/embedded_daemon.cpp` — `startProcess()` parent read path
+- `src/daemon/embedded_daemon.cpp` — child `execv`-failure write (~1043)
+- `src/daemon/embedded_daemon.cpp` — `setpgid` best-effort comment (~1053)
+
+### Core change
+```cpp
+// Self-pipe exec handshake (pipe()+FD_CLOEXEC; NOT pipe2 — macOS lacks it).
+int execpipe[2]; pipe(execpipe);
+fcntl(execpipe[0], F_SETFD, FD_CLOEXEC);
+fcntl(execpipe[1], F_SETFD, FD_CLOEXEC);
+
+pid_t pid = fork();
+if (pid == 0) { // child
+ close(execpipe[0]);
+ /* setpgid / chdir / dup2 / argv … */
+ execv(binary_path.c_str(), argv.data());
+ int e = errno; // execv failed
+ while (write(execpipe[1], &e, sizeof e) < 0 && errno == EINTR) {}
+ _exit(127);
+}
+
+close(execpipe[1]); // parent: must close or read() never EOFs
+int child_errno = 0, total = 0;
+for (;;) { // EOF ⇒ exec ok; 4 bytes ⇒ exec failed
+ ssize_t n = read(execpipe[0], (char*)&child_errno + total, sizeof(int) - total);
+ if (n == 0) break;
+ if (n < 0) { if (errno == EINTR) continue; break; }
+ if ((total += n) >= (int)sizeof(int)) break;
+}
+close(execpipe[0]);
+if (total >= (int)sizeof(int)) { // exec never happened
+ waitpid(pid, nullptr, 0); // reap the zombie
+ last_error_ = "dragonxd could not be executed: " +
+ std::string(strerror(child_errno)) +
+ " — not executable or wrong architecture";
+ return false; // start() no longer reports Running
+}
+```
+
+### Verification
+- Point at a `chmod -x` / wrong-arch file → `start()` returns false immediately, precise message, no leftover zombie.
+- Success path: real binary still starts with no perceptible added latency.
+- Optional pure `formatExecFailureError(errno)` helper for a `test_phase4.cpp` unit test.
+
+### Dependencies
+F1 (same function family; sequence F1→F2). **Highest-risk mistake:** forgetting
+`FD_CLOEXEC` makes every successful start hang the parent read forever.
+
+---
+
+## F4 — Stale datadir-lock start → restart storm that wedges the UI
+
+**Severity:** Medium · **Effort:** S (~3–5h) · **Status:** ☑ landed & verified
+
+### The defect
+`start()` (`embedded_daemon.cpp:466`) gates only on the RPC port (`:482`), never on
+`isDaemonProcessRunning()` (`:1292`). A graceful shutdown frees the port but keeps the
+datadir `.lock` for up to ~90s. A rapid stop→start spawns a daemon that dies "Cannot
+obtain a lock on data directory" — routed to the generic crash path. With a ~4s retry
+cadence, **three lock races in ~12s exhaust the 3-strike budget** and wedge the UI long
+before the lock actually clears.
+
+### The fix
+Fail-fast with a **short bounded local wait (~300ms), not a 90s block**. After the port
+bail, consult `isDaemonProcessRunning()` — gated by `!skip_port_check_` and exempt when
+`override_datadir_` is set, so the isolated migrate-to-seed daemon still works. A pure
+`evaluateDatadirLockGate()` returns a **distinct non-crash Error** that never increments
+`crash_count_`. The connect loop's own retry then absorbs the transient.
+
+### Files touched
+- `src/daemon/embedded_daemon.h` — decision struct, helper decl, poll constants
+- `src/daemon/embedded_daemon.cpp` — `start()` gate + `evaluateDatadirLockGate()`
+
+### Core change
+```cpp
+static StartLockGateDecision evaluateDatadirLockGate(
+ bool skipPortCheck, bool isolatedOverride, bool stillRunningAfterWait) {
+ if (skipPortCheck || isolatedOverride) return {true, ""}; // migrate-to-seed exempt
+ if (!stillRunningAfterWait) return {true, ""};
+ return {false, "A previous dragonxd is still shutting down and holding the "
+ "data directory lock. Retrying shortly…"};
+}
+
+// start() — after the isPortInUse() bail, before setState(Starting):
+if (!skip_port_check_ && override_datadir_.empty()) {
+ bool stillLocked = false; // ~300ms bounded wait, NOT ~90s
+ for (int i = 0; i < kDatadirLockWaitMaxPolls; ++i) {
+ if (!isDaemonProcessRunning()) { stillLocked = false; break; }
+ stillLocked = true;
+ std::this_thread::sleep_for(std::chrono::milliseconds(kDatadirLockWaitPollMs));
+ }
+ auto gate = evaluateDatadirLockGate(false, false, stillLocked);
+ if (!gate.proceed) { setState(State::Error, gate.errorMessage); return false; }
+}
+```
+
+### Verification
+- Unit: `evaluateDatadirLockGate()` across the skip / isolated / still-running matrix.
+- Manual: rapid restart into a lingering lock → distinct message, no crash-cap wedge.
+- Migrate-to-seed second daemon still starts (isolated exemption).
+
+### Dependencies
+F1/F2 (must not touch `crash_count_`; wording must not collide with the monitor's
+"exited unexpectedly"). Same TU, different function.
+
+---
+
+## F5 — Extraction / copy write-failures never surfaced up front
+
+**Severity:** Medium · **Effort:** S (~2–3h) · **Status:** ☑ landed & verified
+
+### The defect
+`startEmbeddedDaemon()` discards `extractEmbeddedResources()`'s `bool` return
+(`app.cpp:4152`) and the second copy-fallback loop drops `copy_file`'s `error_code`
+entirely (`:4236`). Only Sapling params **existence** is re-checked — never the daemon
+binary/CLI/tx/asmap. A disk-full or truncated `dragonxd` write falls straight through to
+spawn and fails opaquely. The innermost write already returns `false`
+(`embedded_resources.cpp:307`) — the signal is simply thrown away.
+
+### The fix
+Minimal, surgical wiring — no new abstraction. Capture the extraction return and, on
+failure, set `daemon_status_ = TR("sb_daemon_extract_failed")` and `return false` before
+spawning. In the second copy loop, check `ec` after each `copy_file`, track `copyFailed`,
+and abort with a dir-parameterized `sb_daemon_files_failed`. An **absent source** stays
+fine (optional files); only an actual `error_code` counts. Written so F6/F7 slot in later
+without re-touching this control flow.
+
+### Files touched
+- `src/app.cpp` — `startEmbeddedDaemon()` extraction check (~4152)
+- `src/app.cpp` — second copy-fallback loop (~4210–4242)
+- `src/util/i18n.cpp` + `res/lang/*.json` — 2 additive keys
+
+### Core change
+```cpp
+// stop discarding the extraction result (~4152)
+if (!resources::extractEmbeddedResources()) {
+ daemon_status_ = TR("sb_daemon_extract_failed"); // disk full / permission denied
+ return false; // abort before spawning
+}
+
+// second copy-fallback loop — was dropping ec entirely (~4236)
+bool copyFailed = false;
+for (const char* name : { "asmap.dat", "dragonxd", "dragonx-cli", "dragonx-tx" }) {
+ fs::path dst = fs::path(daemon_dir) / name;
+ if (fs::exists(dst)) continue; // already present — skip
+ for (const auto& dir : searchDirs) {
+ fs::path src = fs::path(dir) / name;
+ if (!fs::exists(src)) continue; // absent source is OK, not a failure
+ fs::copy_file(src, dst, ec);
+ if (ec) { copyFailed = true; ec.clear(); }
+ break;
+ }
+}
+if (copyFailed) {
+ char buf[512];
+ snprintf(buf, sizeof buf, TR("sb_daemon_files_failed"), daemon_dir.c_str());
+ daemon_status_ = buf;
+ return false; // don't fall through to spawn
+}
+```
+
+### Verification
+- Unit: `extractEmbeddedResources()` returns false without embedded resources.
+- Extract the copy loop into a testable helper; force one dst write to fail (dst is an existing directory).
+- Manual: near-full tmpfs / read-only dir → clear status, daemon controller never constructed.
+
+### Dependencies
+Shares the `daemon_status_` surfacing convention with F6; its early-return pattern is the
+template F7 matches. Open item: remove truncated dst files so a retry re-copies.
+
+---
+
+## F6 — Sapling params validated by existence/size only, never hashed
+
+**Severity:** Medium · **Effort:** S (~3–5h) · **Status:** ☑ landed & verified
+
+> **As-built note.** `verifySaplingParams()` now delegates to a public, injectable
+> `verifySaplingParamsIn(dir, digests)` so the integrity + marker-cache logic is unit-testable
+> with synthetic small files (the real 48 MB params aren't in the repo). i18n keys for F5 were
+> added to `i18n.cpp` (English source of truth); the `res/lang/*.json` back-fill via
+> `scripts/add_missing_translations.py` is deferred to a single run at the end of the batch,
+> per the cross-cutting note. Non-English locales fall back to English until then.
+
+### The defect
+`verifySaplingParams()` (`connection.cpp:123`) only calls `fs::exists()`;
+`resourceNeedsUpdate()` (`embedded_resources.cpp:250`) is size-only. On Linux (no
+embedded resources) a **truncated-but-present** param passes and is handed to the daemon,
+which then fails to build shielded proofs mid-operation — far from the real cause.
+
+### The fix
+Add a pinned `{ filename → size, sha256 }` table (one source of truth, cross-referenced
+to `scripts/build-lite-backend-artifact.sh`) and hash-check each param after the
+existence check, reusing the existing `util::sha256Hex` (no second implementation). Since
+these are ~48 MB, **cache the result** via a `.sapling_verified` marker keyed on
+`size:mtime` — re-hash only when the stat line changes, so startup isn't slowed.
+
+### Files touched
+- `src/rpc/connection.h` — `verifySaplingParams` decl
+- `src/rpc/connection.cpp` — digest table, marker helpers, rewrite
+
+### Core change
+```cpp
+// connection.cpp — pinned known-good digests
+// (source of truth: scripts/build-lite-backend-artifact.sh ensure_sapling_params)
+constexpr SaplingParamDigest kSaplingParamDigests[] = {
+ { "sapling-spend.params", 47958396, "8e48ffd2…efc13" },
+ { "sapling-output.params", 3592860, "2f0ebbcb…fb0e4" },
+};
+
+bool Connection::verifySaplingParams() {
+ // existence check (unchanged) …
+ // cache: skip re-hashing a ~48 MB file unless size:mtime changed
+ if (readMarkerMatches(marker, statLines)) return true;
+ for (auto& d : kSaplingParamDigests)
+ if (util::sha256Hex(bytes) != d.sha256) return false; // reuse existing helper
+ writeMarker(marker, statLines);
+ return true;
+}
+```
+
+### Verification
+- Unit: good params pass; truncated / wrong-bytes rejected; marker cache short-circuits re-hash unless size/mtime changed. Real temp-file fixtures (matches existing `sha256Hex` tests).
+
+### Dependencies
+F7 (reuse fs-error idiom; shares the `startEmbeddedDaemon`/`verifySaplingParams` block).
+Third caller of the existing `util::sha256Hex`.
+
+---
+
+## F7 — Directory-create errors universally ignored on the daemon-env path
+
+**Severity:** Medium · **Effort:** S (~3–4h) · **Status:** ☑ landed & verified
+
+> **As-built notes.** Two deviations from the original design, both confirmed against the code:
+> (1) `embedded_resources.cpp:270` already checks its `error_code` and returns `false` on failure — it was **not** a bug, so it is left untouched.
+> (2) Of the four `autoDetectConfig` callers, only the primary connect path (`app_network.cpp:243`) was wired to check `dir_error`; the other three degrade gracefully on their own — `app.cpp:4306` and `app_wizard.cpp:912` are stop paths that already gate on empty creds, and `settings_page.cpp:434` is read-only display. `dir_error` is set by `autoDetectConfig`, so they can be wired later if desired.
+
+### The defect
+Five startup directory-create sites either drop the `error_code` or use the throwing
+overload with no `catch`: `main.cpp:730`, `connection.cpp:216` (can throw **uncaught**
+through its callers), `embedded_resources.cpp:270`, `app.cpp:4172`/`4218`. A read-only
+home or permission-denied yields a confusing "conf missing" / "binary not found"
+downstream — or an uncaught `filesystem_error` — instead of a clear cause.
+
+### The fix
+One shared, non-throwing `Platform::ensureDirectory(dir, outError)` in
+`util/platform.{h,cpp}` that produces a single consistent message. Replace all five
+sites; `autoDetectConfig()` moves off the throwing overload and sets a new
+`ConnectionConfig::dir_error` that its four callers check and bail on. This is the
+**structural owner** of the fs-error idiom that F5 and F6 reuse.
+
+### Files touched
+- `src/util/platform.h` / `.cpp` — `ensureDirectory()`
+- `src/rpc/connection.h` / `.cpp` — `dir_error` + `autoDetectConfig`
+- `main.cpp`, `app.cpp`, `app_network.cpp`, `app_wizard.cpp`, `settings_page.cpp`, `embedded_resources.cpp` — 5 sites + 4 callers
+- `tests/test_phase4.cpp` — `TestPlatformEnsureDirectory`
+
+### Core change
+```cpp
+// util/platform.cpp — one shared, non-throwing helper
+bool Platform::ensureDirectory(const std::string& dir, std::string* outError) {
+ std::error_code ec;
+ if (std::filesystem::is_directory(dir, ec)) return true;
+ ec.clear();
+ std::filesystem::create_directories(dir, ec);
+ if (ec) {
+ if (outError)
+ *outError = "Cannot create " + dir + ": " + ec.message() +
+ ". Check permissions / free space.";
+ return false;
+ }
+ return true;
+}
+// Replaces 5 ad-hoc sites; autoDetectConfig() now sets ConnectionConfig::dir_error,
+// and its 4 callers bail on it.
+```
+
+### Verification
+- Unit `TestPlatformEnsureDirectory`: existing dir → true; fresh nested → created; POSIX unwritable → false + message.
+- All four `autoDetectConfig` callers tolerate `dir_error`. Pre-App-init site (main.cpp) reports via stderr / MessageBox.
+
+### Dependencies
+**Owns** `Platform::ensureDirectory` (used by F5, F6) and the `ConnectionConfig`
+extension (coordinated with F8). Land before F5/F6/F8.
+
+---
+
+## F8 — Plaintext-remote RPC credential transmission is warn-only
+
+**Severity:** Medium · **Effort:** M (~6–9h) · **Status:** ☑ landed & verified
+
+> **⚠️ RELEASE NOTES REQUIRED — breaking default flip.** A wallet configured to talk to a
+> **remote** `rpchost` over **plain HTTP** (no `rpctls=1`) will now be **refused** at connect
+> time instead of warned. Affected users must add **`rpcallowplaintext=1`** to `DRAGONX.conf`
+> (or switch to `rpctls=1`) to reconnect. Local/embedded daemons (`127.0.0.0/8`, `localhost`,
+> `::1`) are unaffected. Call this out prominently in the release notes.
+>
+> **As-built note.** Shipped the security-complete core: `isLocalHost` tightened to exact
+> loopback (`isExactIPv4Loopback` — `127.evil.com` no longer passes), refuse-by-default in
+> `tryConnect`, and the `rpcallowplaintext` conf-key opt-in. The **Settings toggle UI was
+> deferred** — the RPC section of `settings_page.cpp` is read-only display and a security
+> toggle there is riskier surface; the conf-key opt-in fully covers recovery, and the refusal
+> status/notification tells the user exactly what to add. The toggle can be added later
+> (persist a `Settings` flag and OR it into `allowsPlaintextRemote`).
+
+### The defect
+A remote `rpchost` without `rpctls=1` sends Basic-auth `rpcuser:rpcpassword` over
+cleartext HTTP. `tryConnect()` (`app_network.cpp:314`) only shows a **dismissible
+warning** then proceeds — a local-network MITM sees the credentials. Compounding it,
+`isLocalHost()`'s naive `rfind("127.",0)==0` misclassifies `127.evil.com` as local,
+suppressing even the warning.
+
+### The fix
+Change the policy to **refuse-by-default with an explicit, persisted opt-in** — a
+`rpcallowplaintext=1` conf key (for hand-editors) and a Settings toggle. Block the
+connect and show a **blocking modal** explaining the risk and how to enable TLS or opt
+in; localhost is unaffected. Tighten `isLocalHost()` to exact `127.x.y.z` / `::1` /
+`localhost` via `isExactIPv4Loopback()`. **Back-compat:** default off ⇒ existing remote
+users hit a hard stop until they opt in — **ship with prominent release notes.**
+
+### Files touched
+- `src/rpc/connection.h` / `.cpp` — `isLocalHost`, `allow_plaintext_remote`, `parseConfFile`
+- `src/config/settings.h` / `.cpp` — persisted opt-in
+- `src/app_network.cpp`, `src/app.h` — refuse + modal dispatch
+- `src/ui/windows/plaintext_remote_rpc_dialog.h` — new blocking modal
+- `src/ui/pages/settings_page.cpp` — toggle UI
+
+### Core change
+```cpp
+// Tightened loopback test — "127.evil.com" is NOT local
+bool Connection::isLocalHost(const std::string& host) {
+ std::string h = stripBrackets(lowercase(host));
+ return h == "localhost" || h == "::1" || isExactIPv4Loopback(h); // exact 127.x.y.z
+}
+
+// Refuse-by-default with an explicit, persisted opt-in
+const bool plaintextRemote = rpc::Connection::usesPlaintextRemote(config);
+const bool plaintextAllowed = config.allow_plaintext_remote // rpcallowplaintext=1
+ || settings_.getAllowPlaintextRemoteRpc(); // Settings toggle
+if (plaintextRemote && !plaintextAllowed) {
+ connection_status_ = TR("sb_plaintext_remote_blocked");
+ showPlaintextRemoteRpcDialog(config.host + ":" + config.port); // blocking modal
+ return; // no creds sent
+}
+```
+
+### Verification
+- Unit: `isLocalHost` — `127.evil.com` false, `127.0.0.1`/`::1`/`localhost` true; `allowsPlaintextRemote` honors conf key + settings flag.
+- Manual: remote plaintext blocked; modal fires; opt-in persists across restart.
+
+### Dependencies
+F7 (second extender of `ConnectionConfig`/`parseConfFile`; land after so the struct grows
+once). Wire `renderPlaintextRemoteRpcDialog` into the app modal-dispatch list.
+
+---
+
+## Shared helpers & coordination points
+
+| Helper | Purpose | Used by |
+|--------|---------|---------|
+| `Platform::ensureDirectory()` | Single non-throwing directory-create with one consistent message; replaces five ad-hoc sites. Owned by F7. | F7, F5, F6 |
+| `ConnectionConfig` extension | Coordination point, not a function: F7 adds `dir_error`, F8 adds `allow_plaintext_remote`. Land F7→F8 so it grows once per step. | F7, F8 |
+| `util::sha256Hex` *(existing)* | Already-compiled, curl-free SHA-256. F6 becomes its third caller — no second hash routine. | F6 |
+| `connectHasStalled()` *(new, pure)* | Stall predicate split out of the ImGui/App code for unit testing, per the `*_updater_core.cpp` precedent. | F3 |
+| `evaluateDatadirLockGate()` *(new, pure)* | Lock-gate decision as `{proceed, message}` from three booleans — unit-testable without real process/fs I/O. | F4 |
+
+## F3 — Unbounded connect spinner (deferred to step 6)
+
+**Severity:** Medium · **Effort:** S (~3–5h) · **Status:** ☑ landed & verified
+
+> **As-built note.** `renderLoadingOverlay()` is a pure draw-list overlay with **no interactive
+> widgets** (the existing crash case at ~5289 already communicates via guidance *text*, relying on
+> the sidebar staying reachable). So rather than inject `ActionButton`s — which would fight the
+> non-interactive overlay — the stall notice follows that same idiom: a "Taking longer than
+> expected" title + a reassuring body (with elapsed seconds) + a full-node-gated hint ("Open
+> Settings → Restart Daemon, or check the Console"). This let me drop the planned
+> `WalletState::connect_stalled` flag too: the stalled state is computed locally in the overlay
+> from `connect_stall_since_`, so the only new member is `App::connect_stall_since_`.
+
+The connect loop retries forever while `!state_.connected` (`app.cpp:1239`);
+`loading_timer_` only animates the spinner. Stamp `connect_stall_since_` when
+"reachable but not ready" is first seen; a pure `connectHasStalled()` helper (new
+`util/connect_stall.h`, default 45s from `ui.toml`) flips `state_.connect_stalled` at
+threshold, and `renderLoadingOverlay()` shows a "Taking longer than expected" panel with
+Retry / Restart daemon / Open console (full-node gated). The background retry keeps
+firing — recovery clears the panel automatically. Guarded off while the daemon is in
+`State::Error` (owned by F1's crash-count hint). Full detail lives in the sequencing/
+design record; see the shared-helper table above.
+
+---
+
+## Cross-cutting notes
+
+- **One TU, three functions.** `embedded_daemon.cpp` is edited by F1 (`isRunning`),
+ F2 (`startProcess`) and F4 (`start`) — no literal hunk overlap, but land in order to
+ keep "monitorProcess is the sole reaper" coherent.
+- **Connection struct grows twice.** `connection.h/.cpp` is touched by F6, F7 and F8;
+ F7 and F8 both extend `ConnectionConfig` and `parseConfFile` — highest collision risk.
+ Sequence F7→F6→F8.
+- **Testability split.** The three new pure predicates all get `tests/test_phase4.cpp`
+ coverage. F1/F2's fork/exec/waitpid changes are **not** unit-testable — they rely on
+ manual `kill` / non-executable-binary repros, consistent with the no-process-spawn harness.
+- **i18n is additive-only.** Add each finding's English keys to `strings_`, then run
+ `scripts/add_missing_translations.py` **once at the very end**
+ (`json.dump indent=4, sort_keys=True, ensure_ascii=False`) — never bulk-regenerate a
+ `res/lang/*.json`.
+- **F8 is a breaking default flip.** Refuse-plaintext-by-default stops existing
+ remote-RPC users cold until they opt in. Lands last, gated behind a persisted opt-in,
+ with release notes calling out the new `rpcallowplaintext` key and the Settings toggle.
+- **Latent hazard, out of scope.** F1 surfaces (but doesn't fix) a second
+ double-`waitpid` window between `stop()`'s final blocking reap (`:1220`) and a
+ mid-sleep monitor iteration — file it as its own ticket.
+
+---
+
+## Progress log
+
+- **F1/F2 integration tests** — ☑ added `testExecFailureReported` (F2) and `testDaemonCrashDetected` (F1) to `test_phase4.cpp`, driving the **real** `EmbeddedDaemon` fork/exec/waitpid code headlessly (POSIX; required linking `embedded_daemon.cpp` into the test target — its deps were already there). The F1 test hammers `isRunning()` from the test thread while the child exits, so it's a genuine regression test for the reap race. **The F2 test caught a real bug:** `start()`'s failure branch overwrote `startProcess()`'s precise `last_error_` ("…not executable or wrong architecture") with a generic "Failed to start dragonxd process" (because `setState(Error, …)` stores its message into `last_error_`), so the precise reason never reached `getLastError()`/the UI — **fixed** to preserve the detail (now also surfaced via the state callback / crash panel). Build-clean; `ctest` 1/1.
+
+- **F1** — ☑ landed: `isRunning()` (POSIX) now reads the atomic `state_` (predicate `Running || Stopping`) instead of calling `waitpid`, leaving `monitorProcess()` the sole reaper. Clean build (all targets link); `ctest` 1/1 passing. Not unit-testable — needs the manual `kill -SEGV` repro before release.
+- **F2** — ☑ landed: `startProcess()` (POSIX) now creates a `FD_CLOEXEC` self-pipe before `fork()`; the child writes `errno` to it on `execv` failure, the parent reads EOF-vs-errno and, on failure, reaps the zombie + sets a precise `last_error_` ("not executable or wrong architecture") + returns `false` (so `start()` no longer reports `Running` for a daemon that never started). Parent-side `setpgid` is now best-effort with a `DEBUG_LOGF` on failure. Clean build; `ctest` 1/1 passing. Not unit-testable — needs the manual non-executable / wrong-arch-binary repro before release.
+- **F8** — ☑ landed: `isLocalHost()` tightened to exact loopback via `isExactIPv4Loopback` (a `127.`-prefixed *hostname* like `127.evil.com` is no longer misclassified as local). `tryConnect()` now **refuses** a plaintext connection to a remote host instead of warn-and-proceeding — a local-network MITM can no longer capture `rpcuser:rpcpassword` — unless the user opts in with `rpcallowplaintext=1` in `DRAGONX.conf` (new `ConnectionConfig::allow_plaintext_remote` + `allowsPlaintextRemote()` policy). The refusal surfaces via status line + a one-time notification. New `testIsLocalHost` (12 assertions) + `testAllowsPlaintextRemote` (5). Clean build; `ctest` 1/1 passing. **Breaking — needs release notes; Settings-toggle UI deferred (see as-built note).**
+- **F3** — ☑ landed: the connect loop now stamps `connect_stall_since_ = ImGui::GetTime()` the moment the daemon first goes "reachable but not ready" (warmup branch + `applyDaemonInitStatus`), and clears it in `onConnected` / `onDisconnected` / warmup-complete — all in `app_network.cpp`. The pure `util::connectHasStalled(stallSince, now, threshold)` helper (new `util/connect_stall.h`, default 45 s from `ui.toml`) drives a draw-list "Taking longer than expected" notice in `renderLoadingOverlay()` (title + elapsed-seconds body + full-node hint), guarded off while the daemon is in `State::Error`. Background retry continues, so the notice self-clears on connect. New `testConnectHasStalled` unit test (7 assertions). Clean build; `ctest` 1/1 passing. (Draw-list text, not buttons — see as-built note above.)
+- **F6** — ☑ landed: `verifySaplingParams()` now hash-verifies each Sapling param against its pinned canonical SHA-256 (from `build-lite-backend-artifact.sh`), replacing the existence-only check, so a truncated/corrupt-but-present param is rejected instead of failing later on a shielded op. A `/.sapling_verified` marker keyed on `size:mtime` skips re-hashing ~48 MB on every startup. Logic extracted to the injectable `verifySaplingParamsIn(dir, digests)`; new `testVerifySaplingParams` unit test (valid / marker fast-path / wrong-hash / truncated / missing). Clean build; `ctest` 1/1 passing.
+- **F5** — ☑ landed: `startEmbeddedDaemon()` now checks `extractEmbeddedResources()`'s return (abort with `sb_daemon_extract_failed` on failure) and the previously-dropped `copy_file` `error_code` in the daemon-binary fallback loop (abort with `sb_daemon_files_failed` incl. the dir), so a disk-full / truncated `dragonxd` write is surfaced up front instead of failing opaquely at spawn. An absent source file stays non-fatal. Two i18n keys added to `i18n.cpp`. Clean build; `ctest` 1/1 passing.
+- **F7** — ☑ landed: new non-throwing `Platform::ensureDirectory(dir, outError)` in `util/platform.{h,cpp}` with one consistent message. Replaces the unchecked/throwing directory-create sites at `main.cpp:730` (pre-init: now logs + `MessageBoxA` on Windows + `return 1`), `connection.cpp:216` (autoDetectConfig now uses the ec overload — **no more uncaught `filesystem_error`** — and sets the new `ConnectionConfig::dir_error`), and both `app.cpp` daemon-dir sites (surface via `daemon_status_` + `return false`). Primary connect path (`app_network.cpp:243`) checks `dir_error` and bails to the status line instead of mislabelling it "waiting for config". `embedded_resources.cpp:270` left as-is (already correct). New `testPlatformEnsureDirectory` unit test (existing-dir / fresh-nested / empty / parent-is-file). Clean build; `ctest` 1/1 passing.
+- **F4** — ☑ landed: `start()` now gates on a lingering datadir lock after the port bail. When `!skip_port_check_ && override_datadir_.empty()`, it polls `isDaemonProcessRunning()` with a bounded ~300 ms wait (3 × 100 ms, breaks early), then a pure header-inline `evaluateDatadirLockGate()` decides: if a sibling `dragonxd` is still alive it bails with a distinct **non-crash** `State::Error` ("…holding the data directory lock. Retrying shortly…") that never touches `crash_count_`, so the 3-strike cap can't trip; the connect loop's retry resumes once the lock clears. Isolated migrate-to-seed starts are exempt. New `testDatadirLockGate` unit test (5 assertions, proceed/bail/2× exempt) added to `test_phase4.cpp`. Clean build; `ctest` 1/1 passing.
diff --git a/docs/wallet-hardening.md b/docs/wallet-hardening.md
new file mode 100644
index 0000000..79994ad
--- /dev/null
+++ b/docs/wallet-hardening.md
@@ -0,0 +1,167 @@
+# Wallet Loading & Management — Hardening Plan
+
+Prioritized, grouped remediation for the wallet loading/management audit (33 verified findings +
+diagnosability QoL). Companion to the findings artifact. Line references are against `dev`.
+
+- **Provenance:** 7 parallel subsystem finders, each finding adversarially verified against the
+ code; the 3 highest-impact confirmed findings re-checked by hand. 32 confirmed, 1 refuted
+ (W1-5), 1 raised (W5-3 Low→Med).
+- **Severity:** 8 High · 12 Medium · 13 Low.
+
+Status legend: ☐ not started · ◐ in progress · ☑ landed & verified
+
+---
+
+## Roadmap (ordered by risk; shared fixes grouped)
+
+| Phase | Findings | Theme | Status |
+|-------|----------|-------|--------|
+| **P0-A** | W7-1, W2-1, W4-1, W4-3, W2-3, W4-5, W5-3 ✓ | Secret hardening (console redaction + delete-export + memzero + lite encrypt-at-create) | ☑ 7/7 |
+| **P0-B** | W2-2/W4-2, W2-4 | Encryption integrity (never silently unencrypted) | ☑ |
+| **P1-A** | W3-1, W3-2, W3-4, W3-3 ✓ | Migrate-to-seed correctness (fund-adjacent) | ☑ 4/4 (W3-3 pending a live-mainnet run) |
+| **P1-B** | W1-1, W1-2, W1-3, W1-4 ✓ + startup guard | Missing/wrong wallet-file safety | ☑ |
+| **P2** | W5-1, W5-2, W6-1, W6-3, W6-2 ✓ | Stale state & lite save-failure surfacing | ☑ 5/5 |
+| **F** | W7-2, W7-3, W7-4 ✓ · QoL: copy-diag + open-log + node-error-banner + staleness-badge + alert-history ✓ | Diagnostics foundation + QoL bundle | ☑ |
+
+---
+
+## P0-A — Secret hardening
+
+Shared fix: a `SecureString` RAII buffer (zeroes on destruction) retrofitted onto the un-scrubbed
+key/passphrase paths, plus console redaction and deleting the plaintext export.
+
+- **W7-1 (High)** `console_tab.cpp:1419` — RPC console echoes/stores/clipboards raw secrets. Fix: an
+ allowlist of secret-bearing first-tokens (`walletpassphrase`, `walletpassphrasechange`,
+ `encryptwallet`, `importprivkey`, `importwallet`, `z_importkey`, `z_importviewingkey`,
+ `signrawtransaction`, `magicrecoverkey`, lite equivalents); echo `> walletpassphrase ****` and
+ keep the raw text out of `command_history_`. Extract a pure `redactConsoleCommand(cmd)` helper for
+ unit testing. **← implementing first (self-contained + testable).**
+- **W2-1 (High)** `wallet_security_workflow.cpp:66` — delete the `obsidiandecryptexport` plaintext
+ key dump after `z_importwallet` succeeds (overwrite-then-unlink).
+- **W4-3 (High)** `app_network.cpp:4481` — `sodium_memzero` the concatenated all-keys string in
+ `exportAllKeys`; write the backup 0600. (Also unify with `ExportAllKeysDialog` — QoL.)
+- **W4-1 (High)** `app_network.cpp:3801` — zero the key copies in `importPrivateKey`/`sweepPrivateKey`
+ (local + worker-lambda copies).
+- **W2-3 (Med)** `app_security.cpp:1481` — zero the passphrase threaded through the decrypt lambda chain.
+- **W4-5 (Med)** `app.cpp:3577` — the seed-backup `.txt` is a permanent predictable cleartext seed;
+ at minimum warn + offer to delete, ideally discourage file save in favor of the on-screen phrase.
+- **W5-3 (Med)** `lite_wallet_lifecycle_service.cpp:322` — remove the dead `passphrase` field from the
+ lite create/open/restore requests (unused; a secret copied for nothing).
+
+## P0-B — Encryption integrity
+
+- **W2-2 / W4-2 (High)** `wallet_security_controller.h:89` — the wizard's deferred encryption is
+ in-memory only and silently lost if the daemon doesn't connect or the app quits/crashes first, so a
+ wallet the user believes is encrypted stays plaintext. Fix: persist a lightweight
+ `encryption_requested_but_incomplete` settings flag (NEVER the passphrase) when
+ `beginDeferredEncryption` is called; surface a persistent warning banner while it's set; clear it
+ only on confirmed `encryptwallet` success; on next connect, if set, re-prompt for the passphrase to
+ complete it.
+- **W2-4 (Med)** `app_security.cpp:480` — `lockWallet` only sets `locked` on RPC success; log the
+ failure and notify (currently a silent no-op that can leave the wallet unlocked).
+
+## P1-A — Migrate-to-seed correctness (fund-adjacent; verify carefully)
+
+- **W3-1 (High)** `app_network.cpp:4327` — adopt hardcodes `datadir + "/wallet.dat"`; use
+ `settings_->getActiveWalletFile()` so migrating a non-default active wallet swaps the right file.
+- **W3-2 (High)** `seed_wallet_creator.cpp:57` — `remove_all(/seed-migrate)` unconditionally
+ at Phase-1 start; refuse to wipe if a temp `DRAGONX/wallet.dat` already exists (a prior un-adopted
+ swept wallet) and surface it, so swept funds in the temp wallet can't be destroyed by re-entry.
+- **W3-4 (Med)** `app_network.cpp:1124` — block wallet switching while a migration is *pending*
+ (`getSeedMigrationPending()`), not only while the dialog is open.
+- **W3-3 (Med)** `app_network.cpp:4231` — persist the sweep opid so an app-close mid-Sweeping can
+ resume/re-poll it instead of silently dropping the txid.
+
+## P1-B — Missing/wrong wallet-file safety
+
+- **W1-1 (High)** `app_network.cpp:1109` — `fs::exists()`-check the target wallet file in
+ `switchToWallet()` and before the first daemon launch at startup; if missing, block with an explicit
+ "Wallet file not found — moved or deleted?" dialog (browse / create-new) instead of letting the
+ daemon fabricate an empty wallet.
+- **W1-3 (Med)** `app_network.cpp:1095` — defer the `syncedHere=true` stamp to the first successful
+ address/balance readback (idHash non-empty), not bare `onConnected()`.
+- **W1-2 (Med)** `app_network.cpp:198` — split `DB_CORRUPT`-specific strings from the generic "Error
+ loading wallet" fallback; give `DB_TOO_NEW` its own message/action (not a salvage offer).
+- **W1-4 (Low)** `wallets_dialog.h:393` — re-`fs::exists()` the in-datadir row before switching (match
+ the out-of-datadir path).
+
+## P2 — State & lite persistence
+
+- **W6-2 (Med)** `network_refresh_service.cpp:1183` — record a per-field last-success timestamp / a
+ "refresh failed" flag so the UI can show a staleness badge instead of last-good-as-current.
+- **W5-1 / W5-2 (Med)** `lite_wallet_controller.cpp:78,603` — `liteLog()` the failed save and bubble a
+ one-shot UI warning (both call sites currently discard the bool).
+- **W6-1 (Med)** `wallet_state.h:313` — reset `mining`/`pool_mining` in `clear()` (or comment why not).
+- **W6-3 (Low)** `address_book.cpp:46` — per-entry try/catch: skip + count malformed entries instead
+ of discarding the whole list.
+
+## F — Diagnostics foundation + QoL
+
+Land W7-2 first — it unblocks the rest.
+
+- **W7-2 (Med)** `logger.cpp:31` — call `Logger::instance().init(/dragonx-debug.log)` early in
+ `main()` on all platforms; add an "Open log folder" action.
+- **W7-3 (Med)** `main.cpp:144` — add a `sigaction`-based crash handler writing `dragonx-crash.log` on
+ POSIX (mirror the Windows SEH path).
+- **W7-4 (Low)** `logger.cpp:39` — size-cap/rotate the log on `init()`.
+- **QoL** — "Copy diagnostics for support" bundle; persistent alert history; daemon/RPC error banner;
+ refresh-staleness badge; multi-wallet diagnostic panel; refresh-diagnostics panel; structured
+ switch/migration audit logging; restore-from-seed entry point (W4-4, effort L).
+
+---
+
+## Progress log
+
+- **Adversarial review of the 3 diagnostics UI features** — ran a 5-dimension finder → per-finding verify workflow over the node-banner + staleness-badge + alert-history commits (the hand-laid ImGui I couldn't visually verify). 4 confirmed, 1 refuted (banner title never overlaps its button — button is absolutely positioned + title is short), and the dedicated ImGui-stack-balance finder found **no** Push/Pop imbalance. Fixes landed:
+ - **(Med) Alert popup grew off the right edge** — pivot `(0,1)` pinned the panel's *left* edge at the bell (which sits near the window's right edge), so a 320px panel overflowed rightward (an explicit `SetNextWindowPos` pivot skips ImGui's on-screen clamp). Fixed to anchor the bottom-*right* corner at the bell (pivot `(1,1)`, at `bellMax.x`) so it grows left over the canvas.
+ - **(Low) Staleness badge could flash red on reconnect** — `WalletState::clear()` reset everything *except* the four `last_*_update` stamps, so after a reconnect the pre-outage timestamp survived and the badge briefly showed "Updated Nm ago" (red) on the same frame the node banner cleared — the exact contradiction the design forbids. Fixed by zeroing the four stamps in `clear()` (all readers treat 0 as "never"; verified `app_network.cpp:1473` guards on `!= 0`).
+ - **(Low) Banner min-height floor wasn't DPI-scaled** — `std::max(minH, baseH*vScale())` compared a raw-px floor against a scaled value; now `minH * dpiScale()`.
+ - **(Low) New i18n keys weren't in `res/lang/`** — back-filled all 16 diagnostics/QoL keys (this session's node_banner_*/data_stale_*/alerts_*/settings_*/tt_*) into all 8 language files, additively (128 insertions, 0 deletions). zh/ja/ko reworded around 2 glyphs missing from the CJK subset (提醒→通知; ko tooltip avoids 닐) and hard-asserted tofu-free against the subset font.
+- **Foundation QoL / Persistent alert history** — ☑ landed. Toasts fade in 1–4s; there was no way to review what scrolled past. `Notifications` now retains every pushed alert in a capped (100) ring buffer with a wall-clock epoch (`AlertRecord`) — separate from the 5-item live-toast deque — plus a monotonic `total_pushed_` counter. A bell in the status-bar right cluster (`ICON_MD_NOTIFICATIONS`) opens an upward popup listing recent alerts newest-first with a severity icon/colour (reusing the toast palette), the message, and a relative age (`formatTimeAgoShort`), with a Clear-all action. An **unread dot** on the bell (coloured by the most-severe unseen alert) marks alerts that arrived since the panel was last opened — driven by `totalPushed()` deltas so it survives capping/clearing. Thread-safety: every push is on the UI thread (RPC results run as main-thread `MainCb`s), matching the class's existing lock-free model — documented as a no-raw-worker-thread invariant. Build-clean; `ctest` 1/1 (adds `testNotificationHistory`: retention, order, cap, monotonic counter, clear). **This closes the QoL bundle and the Foundation tier.**
+- **W6-2 / Refresh-staleness badge** — ☑ landed. The Total Balance card now shows a small pill on its status line ("Updated 2m ago", amber → red past 3 min) **only when connected but the balance stopped refreshing** — a busy daemon can fail `z_gettotalbalance` without dropping the whole connection (only *both* core RPCs failing 3× triggers a disconnect), leaving stale numbers on screen while the node-status banner stays hidden. No refresh-path changes were needed: `WalletState::last_balance_update` is already stamped only on a successful fetch (`network_refresh_service.cpp:1187`), so the badge just reads it and computes age against the same `std::time` clock (`util::formatTimeAgoShort`). Decision is a pure, unit-tested helper (`ui/staleness_badge.h::evaluateStalenessBadge`, thresholds 45s/180s) gated on `connected` so it never contradicts the banner; hover shows a "may be out of date — check your node connection" tooltip. Build-clean; `ctest` 1/1 (adds `testStalenessBadge`). **This closes P2 (5/5).**
+- **Foundation QoL / Persistent node-status banner** — ☑ landed. A persistent horizontal strip now sits at the top of the content column whenever the wallet can't reach its node — distinct from the transient toasts, so an offline wallet is never silently mistaken for a working one. The show/severity/action decision is a pure function (`ui/node_status_banner.h` → `evaluateNodeStatusBanner`, unit-tested) fed a state snapshot by `App::renderNodeStatusBanner()`. Three cases: **full-node offline** (amber, "Reconnect" → `tryConnect`), **embedded daemon crashed & auto-restart gave up** (red, "Restart node" → `restartDaemon`), **lite wallet failed to open** (red, message-only). Suppressed during the wizard / wallet-switch / daemon-restart / screenshot-sweep / shutdown, and while an expected startup phase (warmup/init/connect-in-progress) already owns the screen. Height in `res/themes/ui.toml` (`banners.node-status`); colours from the material semantic palette; detail text ellipsis-clipped so it can't shove the action button off-screen. Build-clean; `ctest` 1/1 (added `testNodeStatusBanner`). **Remaining QoL:** persistent alert history, and the W6-2 refresh-staleness badge.
+- **Foundation QoL / "Copy diagnostics" + "Open log folder"** — ☑ landed: Settings (logging section) now has two actions. **Open log folder** opens the config dir (`Platform::openFolder`) so users can actually find `dragonx-debug.log`/`dragonx-crash.log`. **Copy diagnostics** copies a plaintext support snapshot to the clipboard via the new `App::buildDiagnosticsReport()` — version, build variant, platform, connection status, active wallet path + existence + size, encryption/lock state, sync heights, daemon status/running/crash-count/lastError (full-node), and the log paths. No secrets. Build-clean; `ctest` 1/1. **Remaining QoL:** persistent alert history, a daemon/RPC error banner, and the W6-2 refresh-staleness badge.
+- **Foundation / W7-2 · W7-3 · W7-4 (diagnostics infrastructure)** — ☑ landed (answers the original "easier to diagnose" ask — the logging/crash foundation now actually works):
+ - **W7-2 (Med, keystone):** the app-level `Logger` file sink was never initialized, so `LOG`/`LOGF`/`VERBOSE_LOGF` went nowhere and `dragonx-debug.log` didn't exist on Linux/macOS at all. `main()` now calls `Logger::init(/dragonx-debug.log)` on all platforms. Also fixed a **latent deadlock** this exposed: `init()` wrote its banner via `write()`, which re-locks the non-recursive `mutex_` it already holds — now written directly. On Windows the raw stdout/stderr `freopen` was moved to a separate `dragonx-stdout.log` so the two writers don't contend. New `testLoggerFileSink` (also a deadlock guard — it would hang if that regressed).
+ - **W7-3 (Med):** no crash handler existed on Linux/macOS. Added an **async-signal-safe** `sigaction` handler (SIGSEGV/ABRT/BUS/FPE/ILL) that writes a signal id + `backtrace_symbols_fd` backtrace to `dragonx-crash.log`, then re-raises the default disposition for a core dump — the POSIX counterpart of the Windows SEH filter.
+ - **W7-4 (Low):** `Logger::init` now rotates the log to a single `.1` backup when it exceeds 10 MB, so a long/verbose session can't grow it unbounded.
+ Build-clean; `ctest` 1/1. **Remaining Foundation:** the QoL bundle (mostly UI) — "copy diagnostics for support", an "open log folder" action, persistent alert history, a daemon/RPC error banner, and the W6-2 refresh-staleness badge.
+- **P2 / W5-1 · W5-2 · W6-1 · W6-3 (localized batch)** — ☑ landed:
+ - **W5-1 (Med):** `persistAfterBroadcast` (lite send/shield save) returned false on a persistent save failure but both callers discarded it and it never logged — completely silent. It now `liteLog`s the failure (the note re-derives on next sync, so it's a robustness gap, not fund loss).
+ - **W5-2 (Med):** the post-**sync** and post-**rescan** `save` results (in the detached scan threads) were ignored; both now `liteLog` on failure (`LiteDiagnostics::log` is mutex-guarded, safe from those threads).
+ - **W6-1 (Med):** `WalletState::clear()` didn't reset `mining`/`pool_mining`, so a wallet switch could briefly show the previous wallet's hashrate/blocks. Now reset in `clear()` (the daemon restarts on switch, so mining genuinely stops).
+ - **W6-3 (Low):** `AddressBook::load()` did `entries_.clear()` then threw on the first non-object element — discarding **every** contact. Now it guards `is_object()` + per-entry try/catch, skipping and counting malformed entries.
+ Build-clean; `ctest` 1/1. **Remaining P2:** W6-2 (surface refresh staleness — the timestamps exist in `WalletState`; this needs the UI "updated Xs ago" badge, which overlaps the diagnostics/QoL Foundation bundle).
+- **P1-B / W1-3 + startup wallet-existence guard** — ☑ landed:
+ - **W1-3 (Med):** `syncedHere` was stamped in the `markOpened` block at bare connect (idHash still empty), letting a freshly-restored wallet skip its needed rescan. It's now stamped only once the identity is verified (idHash non-empty), so it takes effect at the post-address-refresh index update (`updateWalletIndexForActiveWallet` after addresses load), while `lastOpenedEpoch` still records at open.
+ - **Startup guard (the W1-1 launch counterpart):** `App::init` now `exists()`-checks the recorded active wallet before the daemon is configured; a **non-default** active wallet that was moved/deleted between sessions falls back to the default `wallet.dat` with a warning, instead of the daemon silently auto-creating an empty wallet under the missing name. Runs before the PIN-vault init so the vault is scoped to the wallet actually opened.
+ Build-clean; `ctest` 1/1.
+- **P1-A / W3-3 (sweep opid persistence)** — ☑ **implemented + two rounds of adversarial review** (the "live mainnet run" the migration code mandates is the remaining gate — see below). The deferral's core fear (re-tracking a stale opid hangs forever) was **refuted by the code**: the opid poller (`app.cpp:1122`) + `parseOperationStatusPoll` classify a tracked opid absent from a *successful* `z_getoperationstatus` as stale, remove it, and fire the callback `ok=false` — a thrown RPC aborts the poll so there's never a *false* stale. So re-tracking yields at worst one clean failure, never a hang.
+ - **What landed:** a persisted `seed_migration_sweep_opid` setting; the opid is adopted **atomically** with clearing any prior txid in the *same* `settings.save()` **only once the submit succeeds** (torn-write safe; txid always outranks opid on resume). Resume routing is a pure, unit-tested helper (`data/seed_migration_resume.h::decideSeedMigrationResume`): txid → Confirming; opid **and connected** → re-track (`Sweeping`); otherwise → the dismissable Sweep gate. The shared `makeSweepCompletionCallback(resumed)`: success → Confirming; resumed-stale → Sweep gate (re-fetch balance, honest "may have already completed" copy); fresh-fail → Error.
+ - **Round 1 (design review, 4 skeptics)** confirmed both safety facts (no fund loss — adopt gate + never-deleted `.bak` untouched; no hang) and caught 3 real resume-UX traps, all fixed: a missing **connectivity gate** (would trap the user in the buttonless `Sweeping` spinner while offline), a **missing balance re-fetch** on the stale fallback (permanent "Checking balance…"), and honest messaging since a daemon restart makes even a *successful* sweep read "stale".
+ - **Round 2 (implementation review, 3 reviewers)** caught one regression — clearing the old txid at sweep *entry* would forget an already-mined first sweep if a remainder re-sweep's submit failed; fixed by the atomic-on-success swap above. All other fixes verified present + correct.
+ - **⚑ Remaining gate — live mainnet run (user):** per CLAUDE.md this fund-moving path must be exercised once on mainnet before it ships. The self-verifiable parts (build, unit test, both review rounds) are green; a real interrupted-sweep resume on mainnet is the human gate I cannot perform.
+- **P1-B / W1-1 (+ W1-4) · W1-2 (wallet-file safety)** — ☑ landed:
+ - **W1-1 (High):** `switchToWallet` never checked the target wallet file exists, so a moved/deleted file "opened" as a fresh empty wallet (dragonxd auto-creates for a missing `-wallet=`), looking exactly like fund loss. It now `std::filesystem::exists`-checks `datadir + "/" + walletFile` before switching and blocks with a "not found (moved or deleted?)" warning. Placed before the daemon-stop prompt, and — since the check runs no matter how `switchToWallet` is invoked — it also **closes W1-4** (the stale-switcher-row TOCTOU).
+ - **W1-2 (Med):** `walletOutputLooksCorrupt` matched the generic "Error loading wallet" string, so a `DB_TOO_NEW` (newer-version) wallet was offered a `-salvagewallet` repair that can't fix it. Now the generic match is excluded when the output also contains "newer version".
+ Build-clean; `ctest` 1/1. **Remaining P1-B:** W1-3 (defer the `syncedHere` stamp to a verified readback) + the startup-path existence check (`app.cpp` hands `getActiveWalletFile()` to the daemon with no `exists()` check — same silent-empty-wallet risk as W1-1 but at launch).
+- **P1-A / W3-1 · W3-2 · W3-4 (migrate-to-seed correctness)** — ☑ landed (fund-adjacent — reviewed carefully):
+ - **W3-1 (High):** `beginAdoptSeedWallet` hardcoded `datadir + "/wallet.dat"` as the file to swap. With a non-default active wallet (e.g. `wallet-2.dat`), that installed the swept seed wallet into an unloaded `wallet.dat` and left the daemon reloading the emptied legacy — funds only recoverable via the seed phrase. Now swaps `datadir + "/" + getActiveWalletFile()` (captured on the main thread; switching is blocked during migration so it can't race).
+ - **W3-2 (High):** `SeedWalletCreator::create` did `remove_all(/seed-migrate)` unconditionally at the start. A prior migration that swept funds into the temp wallet but was abandoned/crashed before adopting would have that fund-bearing wallet destroyed. It now refuses (with a clear message) when `DRAGONX/wallet.dat` already exists — a completed migration removes the dir on adopt, so a leftover means an unfinished one.
+ - **W3-4 (Med):** `switchToWallet` only blocked switching while the migration *dialog* was open; closing it via "Later" mid-migration dropped the guard. Now also blocks while `getSeedMigrationPending()`.
+ Build-clean; `ctest` 1/1. **Remaining P1-A:** W3-3 (persist the sweep opid so an app-close mid-sweep can resume/re-poll instead of silently dropping the txid).
+
+- **P0-B / W2-2 (deferred encryption silently lost) + W2-4 (auto-lock silent-fail)** — ☑ landed:
+ - **W2-2:** the wizard's deferred encryption was stored only in memory, so a quit/crash or a failed daemon connect before it applied left the wallet unencrypted with **no record it was ever requested** — the user believing it was encrypted. Now a persisted `encryption_pending` settings flag is set the moment encryption is requested (**never the passphrase** — only the fact). `refreshWalletEncryptionState()` reconciles it on every connect: wallet observed **encrypted** → clear the flag; wallet **not** encrypted while the flag is set and no deferred encryption is pending/in-flight → a once-per-session **"your wallet is NOT encrypted — open Settings to finish"** warning (the flag stays set, so it recurs each launch until resolved). We deliberately don't persist the passphrase to auto-complete — surfacing it is the secure choice.
+ - **W2-4:** `lockWallet()`'s continuation only handled success — a failed `walletlock` silently left the wallet **unlocked** (an unfulfilled auto-lock). It now logs and warns once (reset on the next successful lock), so a failing auto-lock is visible instead of leaving the wallet exposed.
+ Touches `settings.{h,cpp}`, `app_wizard.cpp`, `app_security.cpp`, `app.h`. Not unit-testable at this layer (RPC/connect-driven state machine); build-clean, `ctest` 1/1.
+
+- **P0-A / W5-3 (lite create-time passphrase)** — ☑ landed (chose option **(b) wire it up**). The lite create/open/restore passphrase was collected but never consumed by the backend — a "passphrase" field that did nothing. It now has a real meaning for all three operations, in `LiteWalletController`: **create/restore** → `encryptWallet(passphrase)` (the backend encrypts + locks + saves the brand-new wallet); **open** → `unlockWallet(passphrase)`, but only when `encryptionStatus()` reports the existing wallet is actually encrypted+locked (skips a spurious unlock otherwise). Encrypt/unlock take their own copy and wipe it; a post-create encrypt failure is `liteLog`'d (the wallet still exists — the create isn't failed). Six existing lite-controller tests carried an incidental `hunter2` create passphrase from the dead-field era; removed (they test non-encryption flows and want an unencrypted wallet), and added `testLiteWalletControllerCreateEncryptsWithPassphrase` to prove the new behavior. Build-clean; `ctest` 1/1. *(Follow-up UX polish: `settings_page` could show the passphrase field's meaning per operation — "encrypt" for create/restore vs "unlock" for open.)*
+- **P0-A / W4-5 (seed-backup file)** — ☑ landed (proportionate): the seed "Save" already wrote 0600 + zeroed the in-memory buffer, but the success message was a bare "Saved to ". It now reads "**Saved an UNENCRYPTED seed file — move it to secure offline storage and delete this copy**: ", so the plaintext-on-disk risk is called out. `i18n.cpp` (English source; `res/lang` back-fill of this changed key is deferred to the batch i18n pass). A stronger fix (pre-save confirmation, or dropping the file-save in favor of on-screen + Copy) is a follow-up UX decision.
+- **P0-A / W4-1 · W4-3 · W2-3 (memzero cluster)** — ☑ landed, using the file's established `sodium_memzero` pattern (matching the existing lambda-capture scrub at app_network.cpp:2885 and JSON scrub at :4025) rather than a new type, since this is fund-moving code:
+ - **W4-1** `importPrivateKey`/`sweepPrivateKey`: the spending/viewing key is now scrubbed on all paths — the calling-frame copy (after the worker post), the worker-lambda's captured copy (lambda made `mutable`, zeroed after the request is sent), and the JSON request `params` copy.
+ - **W4-3** `exportAllKeys`/`backupWallet`: the concatenated all-keys buffer is zeroed after the consumer uses it, and the backup file is now written via `Platform::writeFileAtomically(..., restrictPermissions=true)` (atomic + 0600) instead of a umask-default `ofstream`.
+ - **W2-3** decrypt-wallet passphrase: `std::move`-captured into the worker lambda (so no plaintext copy is left in the calling frame) and `sodium_memzero`'d right after `unlockWallet` (its only use).
+ Not unit-testable (the scrubbing has no observable RPC effect — the key value sent to the daemon is unchanged; only post-use memory zeroing is added). Build-clean; `ctest` 1/1 (no regression). **Remaining in P0-A:** W5-3 (remove the dead lite `passphrase` field), W4-5 (predictable plaintext seed-backup file).
+- **P0-A / W2-1** — ☑ landed: the decrypt-wallet flow now scrubs (best-effort in-place zero-overwrite) and removes the plaintext key export (`obsidiandecryptexport…`) as soon as the `z_importwallet` attempt resolves — success or failure — so a full cleartext dump of every private key is no longer left on disk forever. Recovery remains the encrypted backup (`wallet.dat.encrypted.bak`). `app_security.cpp` (after the import call). Not unit-testable (fs I/O in a deep lambda); build-clean, `ctest` 1/1 (no regression).
+- **P0-A / W7-1** — ☑ landed: `RedactConsoleCommand`/`ConsoleCommandCarriesSecret` in `console_tab_helpers` redact secret-bearing commands (an allowlist of 13 first-tokens: `walletpassphrase`, `encryptwallet`, `z_importkey`, …) to `> walletpassphrase ****` before they hit the console echo AND the recall history; the real command still executes unredacted. Wired into `submitConsoleCommand` (`console_tab.cpp`). New `testConsoleSecretRedaction` (11 assertions). Clean build; `ctest` 1/1. (Output-secret commands like `z_exportkey` — result redaction — remain a follow-up.)
diff --git a/res/fonts/NotoSansCJK-Subset.ttf b/res/fonts/NotoSansCJK-Subset.ttf
index 6f3f92d..f751d67 100644
Binary files a/res/fonts/NotoSansCJK-Subset.ttf and b/res/fonts/NotoSansCJK-Subset.ttf differ
diff --git a/res/lang/de.json b/res/lang/de.json
index 6a3d24d..2b29fd0 100644
--- a/res/lang/de.json
+++ b/res/lang/de.json
@@ -48,6 +48,10 @@
"advanced": "ERWEITERT",
"advanced_effects": "Erweiterte Effekte...",
"ago": "her",
+ "alerts_clear": "Meldungsverlauf löschen",
+ "alerts_history_tooltip": "Letzte Meldungen",
+ "alerts_none": "Noch keine Meldungen",
+ "alerts_recent": "LETZTE MELDUNGEN",
"all_filter": "Alle",
"allow_custom_fees": "Benutzerdefinierte Gebühren erlauben",
"amount": "Betrag",
@@ -56,6 +60,80 @@
"amount_label": "Betrag:",
"animate_avatars": "Avatare animieren",
"appearance": "ERSCHEINUNGSBILD",
+ "appx_back": "Zurück",
+ "appx_back_up_seed_phrase_title": "Sichern Sie Ihre Seed-Phrase",
+ "appx_birthday_block_height": "Geburtstag (Blockhöhe): %llu — sichern Sie auch dies.",
+ "appx_blockchain_data_deleted": "Blockchain-Daten gelöscht (%d Elemente). Der Daemon wird neu gestartet, um sich erneut mit dem Netzwerk zu synchronisieren.",
+ "appx_blockchain_maintenance_in_progress": "Eine Blockchain-Wartung läuft bereits.",
+ "appx_blockchain_rescan_complete": "Blockchain-Neuscan abgeschlossen",
+ "appx_bootstrap_complete_reconciling": "Bootstrap abgeschlossen — Ihr Wallet wird mit den neuen Chain-Daten abgeglichen.",
+ "appx_cancel": "Abbrechen",
+ "appx_cleaning_up": "Aufräumen...",
+ "appx_confirm_your_backup": "Backup bestätigen",
+ "appx_copied_clipboard_autoclears": "Kopiert — Zwischenablage wird in 45s automatisch geleert",
+ "appx_copy": "Kopieren",
+ "appx_could_not_start_restore": "Wiederherstellung konnte nicht gestartet werden",
+ "appx_create_failed_prefix": "Erstellen fehlgeschlagen: ",
+ "appx_creating_your_wallet": "Ihr Wallet wird erstellt…",
+ "appx_daemon_error": "Daemon-Fehler",
+ "appx_daemon_reinstall_in_progress": "Die Daemon-Neuinstallation läuft bereits.",
+ "appx_disconnecting": "Verbindung wird getrennt...",
+ "appx_done": "Fertig",
+ "appx_dragonxd_output": "dragonxd-Ausgabe",
+ "appx_encrypting_wallet": "Wallet wird verschlüsselt...",
+ "appx_fullnode_lifecycle_unavailable_lite": "Full-Node-Lebenszyklusaktionen sind im Lite-Build nicht verfügbar",
+ "appx_installing_bundled_daemon": "Mitgelieferter Daemon wird installiert — der Node stoppt, aktualisiert und startet neu...",
+ "appx_invalid_payment_uri_prefix": "Ungültige Zahlungs-URI: ",
+ "appx_ive_written_it_down": "Ich habe sie notiert",
+ "appx_keep_node_running_and_quit": "Node weiterlaufen lassen & beenden",
+ "appx_last_block_n": "Letzter Block: %d",
+ "appx_last_used_wallet_not_found_prefix": "Ihre zuletzt verwendete Wallet-Datei (",
+ "appx_last_used_wallet_not_found_suffix": ") wurde nicht gefunden — stattdessen wurde das Standard-Wallet geöffnet. Wenn Sie sie verschoben haben, stellen Sie sie wieder her und wechseln Sie über die Wallet-Liste zurück.",
+ "appx_low_spec_mode_disabled": "Low-Spec-Modus deaktiviert",
+ "appx_low_spec_mode_enabled": "Low-Spec-Modus aktiviert",
+ "appx_miner_stopped_prefix": "Miner gestoppt: ",
+ "appx_miner_stopped_unexpectedly": "Miner wurde unerwartet gestoppt.",
+ "appx_n_min_n_sec": "%d Min %d Sek",
+ "appx_n_seconds": "%d Sekunden",
+ "appx_no_bundled_daemon_to_install": "Dieser Build enthält keinen mitgelieferten Daemon zur Installation",
+ "appx_no_embedded_daemon_to_install": "Dieser Build enthält keinen eingebetteten Daemon zur Installation",
+ "appx_node_busy_restarting": "Der Node ist mit dem Neustart beschäftigt — versuchen Sie es gleich erneut.",
+ "appx_node_rebuilding_witness_cache": "Node baut seinen Witness-Cache neu auf",
+ "appx_not_next_word": " — das ist nicht das nächste Wort",
+ "appx_payment_request_loaded": "Zahlungsanforderung geladen",
+ "appx_pool_miner_connected_and_hashing": "Pool-Miner verbunden und aktiv.",
+ "appx_progress_n_of_n": "Fortschritt: %d / %d",
+ "appx_rebuilding_sapling_note_witnesses": "Sapling-Note-Witnesses werden neu aufgebaut…",
+ "appx_rebuilding_witness_cache_blocks_left": "Witness-Cache wird neu aufgebaut %.0f%% — %d Blöcke verbleiben",
+ "appx_rebuilding_witness_cache_pct": "Witness-Cache wird neu aufgebaut %.0f%%",
+ "appx_recovery_phrase_word_count": "Die Wiederherstellungsphrase sollte 24 Wörter haben — Sie haben %d.",
+ "appx_restarting_daemon_rescan_flag": "Daemon wird mit -rescan-Flag neu gestartet...",
+ "appx_restarting_daemon_zapwallettxes": "Daemon wird mit -zapwallettxes=2 neu gestartet (Wallet-Reparatur)...",
+ "appx_restoring_your_wallet": "Ihr Wallet wird wiederhergestellt…",
+ "appx_seed_backup_warning": "Diese 24 Wörter sind die EINZIGE Möglichkeit, Ihr Wallet wiederherzustellen. Notieren Sie sie in der richtigen Reihenfolge, bewahren Sie sie offline auf und geben Sie sie niemals weiter. Wenn Sie sie verlieren, sind Ihre Guthaben für immer verloren.",
+ "appx_seed_not_backed_up_warning": "Sie haben Ihren Seed nicht gesichert — Guthaben könnten verloren gehen. Trotzdem überspringen?",
+ "appx_sending_stop_command_to_daemon": "Stopp-Befehl wird an Daemon gesendet...",
+ "appx_setting_initial_sapling_witnesses": "Initiale Sapling-Witnesses werden gesetzt %.0f%%",
+ "appx_shutdown_complete": "Herunterfahren abgeschlossen",
+ "appx_simple_background_disabled": "Einfacher Hintergrund deaktiviert",
+ "appx_simple_background_enabled": "Einfacher Hintergrund aktiviert",
+ "appx_skip": "Überspringen",
+ "appx_skip_anyway": "Trotzdem überspringen",
+ "appx_still_status_prefix": "Weiterhin \"",
+ "appx_still_status_suffix": "\" — ein erzwungenes Beenden jetzt kann die Chain-Daten beschädigen.",
+ "appx_stop_anyway_and_quit": "Trotzdem stoppen & beenden",
+ "appx_stopping_daemon_deleting_blockchain": "Daemon wird gestoppt und Blockchain-Daten werden gelöscht...",
+ "appx_stopping_node_discards_rebuild": "Wenn Sie den Node jetzt stoppen, wird der laufende Neuaufbau verworfen und beim nächsten Öffnen des Wallets neu gestartet (mehrere Minuten). Sie können den Node stattdessen weiterlaufen lassen.",
+ "appx_stopping_pool_miner": "Pool-Miner wird gestoppt...",
+ "appx_syncing_pct_block_n_of_n": "Synchronisierung %.1f%% — Block %d / %d",
+ "appx_tap_words_in_order": "Tippen Sie die Wörter in der richtigen Reihenfolge an, um zu bestätigen, dass Sie sie gespeichert haben.",
+ "appx_theme_effects_disabled": "Design-Effekte deaktiviert",
+ "appx_theme_effects_enabled": "Design-Effekte aktiviert",
+ "appx_theme_prefix": "Design: ",
+ "appx_use_settings_restart_daemon_hint": "Verwenden Sie Einstellungen > Daemon neu starten, um es erneut zu versuchen",
+ "appx_waiting_for_daemon_to_encrypt_wallet": "Warten, bis der Daemon das Wallet verschlüsselt...",
+ "appx_wallet_created_and_backed_up": "Wallet erstellt und gesichert.",
+ "appx_wallet_open_failed_prefix": "Öffnen des Wallets fehlgeschlagen: ",
"auto_shield": "Mining automatisch abschirmen",
"av_intro": "Mining-Software wird oft als potenziell unerwünscht eingestuft. Führen Sie diese Schritte aus, um das Pool-Mining zu aktivieren:",
"av_open_security": "Windows-Sicherheit öffnen",
@@ -70,6 +148,9 @@
"av_title": "Windows Defender hat den Miner blockiert",
"available": "Verfügbar",
"backup_backing_up": "Sicherung läuft...",
+ "backup_col_backup": "SICHERUNG",
+ "backup_col_export": "EXPORTIEREN",
+ "backup_col_import": "IMPORTIEREN & WIEDERHERSTELLEN",
"backup_create": "Sicherung erstellen",
"backup_created": "Wallet-Sicherung erstellt",
"backup_data": "SICHERUNG & DATEN",
@@ -88,8 +169,22 @@
"balance": "Guthaben",
"balance_history_collecting": "Guthabenverlauf — Daten werden gesammelt...",
"balance_layout": "Guthaben-Layout",
+ "balance_layout_switched": "Layout: %s",
+ "balance_mining_rate": "Schürfe %s",
"balance_shielded_fmt": "Abgeschirmt: %.8f",
+ "balance_syncing_pct": "Synchronisiere %.1f%%",
"balance_transparent_fmt": "Transparent: %.8f",
+ "baltab_market": "Markt",
+ "baltab_market_price_4dp": "Markt: $%.4f",
+ "baltab_market_price_8dp": "Markt: $%.8f",
+ "baltab_pct_change_24h": "%s%.1f%% 24h",
+ "baltab_pct_of_total_zaddr": "%.0f%% des Gesamtbetrags · %d Z-addr",
+ "baltab_shielded": "Geschützt",
+ "baltab_shielded_amount": "Geschützt %.8f",
+ "baltab_t_addresses_count": "%d T-Adressen",
+ "baltab_total_balance": "Gesamtguthaben",
+ "baltab_transparent": "Transparent",
+ "baltab_transparent_amount": "Transparent %.8f",
"ban": "Sperren",
"banned_peers": "Gesperrte Peers",
"block": "Block",
@@ -128,6 +223,7 @@
"bootstrap_verifying": "Prüfsummen werden überprüft...",
"bootstrap_wallet_protected": "(wallet.dat ist geschützt)",
"bootstrap_warning": "Vorhandene Blockdaten (blocks, chainstate, notarizations) werden gelöscht und ersetzt. Ihre wallet.dat wird NICHT verändert oder gelöscht.",
+ "byte_count_fmt": "%zu / %zu Byte",
"cancel": "Abbrechen",
"change_pass_confirm": "Neue bestätigen:",
"change_pass_current": "Aktuelle Passphrase:",
@@ -403,6 +499,7 @@
"contacts_shape_square": "Quadrat",
"contacts_shape_tab": "Reiter",
"copied": "Kopiert!",
+ "copied_to_clipboard": "In die Zwischenablage kopiert",
"copy": "Kopieren",
"copy_address": "Vollständige Adresse kopieren",
"copy_error": "Fehler kopieren",
@@ -415,6 +512,7 @@
"daemon_bundled": "Gebündelt",
"daemon_install_bundled": "Gebündelten installieren",
"daemon_installed": "Installiert",
+ "daemon_maintenance_label": "WARTUNG",
"daemon_none_bundled": "keiner in diesem Build",
"daemon_not_installed": "nicht installiert",
"daemon_status_differ": "Installierte Binärdatei unterscheidet sich von der gebündelten Version.",
@@ -437,6 +535,7 @@
"daemon_update_latest": "Neueste:",
"daemon_update_loading": "Releases werden geladen…",
"daemon_update_now": "Jetzt aktualisieren",
+ "daemon_update_prompt_title": "Node-Daemon aktualisieren?",
"daemon_update_reinstall": "Neu installieren",
"daemon_update_restart_note": "Starten Sie den Daemon neu, um die neue Version auszuführen.",
"daemon_update_restart_now": "Daemon jetzt neu starten",
@@ -449,8 +548,11 @@
"daemon_update_verify_note": "Der Download wird vor der Installation anhand der veröffentlichten SHA-256-Prüfsumme des Releases und einer fest hinterlegten ed25519-Signatur verifiziert.",
"daemon_update_verifying": "Wird verifiziert…",
"daemon_update_version": "Version:",
+ "daemon_updates_label": "AKTUALISIERUNGEN",
"daemon_version": "Daemon",
"dark": "Dunkel",
+ "data_stale_prefix": "Aktualisiert",
+ "data_stale_tooltip": "Der Kontostand ist möglicherweise veraltet – die Wallet hat kürzlich keine Aktualisierung erhalten. Überprüfe deine Node-Verbindung.",
"date": "Datum",
"date_label": "Datum:",
"debug_logging": "FEHLERPROTOKOLLIERUNG",
@@ -479,6 +581,17 @@
"download_bootstrap": "Bootstrap herunterladen",
"dragonx_green": "DragonX (Grün)",
"edit": "Bearbeiten",
+ "empty_wallet_keys_suffix": "Schlüssel",
+ "empty_wallet_open_manager": "Wallet-Verwaltung öffnen",
+ "empty_wallet_restore": "Mein Wallet wiederherstellen",
+ "empty_wallet_salvage_body": "Dieses Wallet ist leer, weil eine frühere automatische Reparatur Ihr ursprüngliches Wallet als Sicherung beiseitegelegt hat. Ihre Coins befinden sich fast sicher in dieser Sicherung und sind nicht verloren. Stellen Sie sie wieder her, um Ihr Guthaben erneut zu laden — nichts wird gelöscht; die aktuelle Datei wird zuerst beiseitegelegt.",
+ "empty_wallet_salvage_headline": "Ihre Coins sind sicher in einer Sicherungsdatei.",
+ "empty_wallet_salvage_title": "Ihr Wallet wurde möglicherweise repariert",
+ "empty_wallet_warning_body": "Dieses Wallet hat keine Adressen und kein Guthaben, aber eine andere Wallet-Datei in Ihrem DragonX-Ordner enthält Schlüssel. Ihre Coins befinden sich höchstwahrscheinlich dort und sind nicht verloren. Öffnen Sie die Wallet-Verwaltung, um zu dem Wallet mit Ihrem Guthaben zu wechseln.",
+ "empty_wallet_warning_dismiss": "Für dieses Wallet nicht mehr warnen",
+ "empty_wallet_warning_dismiss_tip": "Beendet diese Warnung nur für die aktuelle Wallet-Datei. Wenn Sie später zu einem anderen leeren Wallet wechseln, kann die Warnung erneut erscheinen.",
+ "empty_wallet_warning_headline": "Möglicherweise haben Sie das falsche Wallet geöffnet.",
+ "empty_wallet_warning_title": "Dieses Wallet ist leer",
"enc_confirm": "Bestätigen:",
"enc_desc": "Die Verschlüsselung Ihrer Wallet schützt Ihre privaten Schlüssel mit einer Passphrase. Nach der Verschlüsselung wird der Daemon neu gestartet.",
"enc_encrypting": "Wallet wird verschlüsselt...",
@@ -566,6 +679,63 @@
"general": "Allgemein",
"generating": "Wird generiert",
"go_to_receive": "Zum Empfangen",
+ "grpa_current_block_paren": "(Aktuell: %d)",
+ "grpa_days_ago": "vor %lld Tagen",
+ "grpa_dbg_addrman": "Verfolgung und Verwaltung von Peer-Adressen",
+ "grpa_dbg_alert": "Meldungen des Alarmsystems",
+ "grpa_dbg_bench": "Benchmark-Zeiten für Operationen",
+ "grpa_dbg_coindb": "Lese-/Schreiboperationen der Coin-Datenbank",
+ "grpa_dbg_db": "Berkeley-DB-Operationen",
+ "grpa_dbg_estimatefee": "Algorithmus zur Gebührenschätzung",
+ "grpa_dbg_http": "Aktivität des HTTP-RPC-Servers",
+ "grpa_dbg_libevent": "Libevent-Netzwerkbibliothek",
+ "grpa_dbg_lock": "Debugging von Lock-Konflikten",
+ "grpa_dbg_mempool": "Aktivität des Transaktions-Mempools",
+ "grpa_dbg_net": "Netzwerkverbindungen und -nachrichten",
+ "grpa_dbg_paymentdisclosure": "Zahlungsoffenlegungsprotokoll",
+ "grpa_dbg_pow": "Proof-of-Work-Mining-Aktivität",
+ "grpa_dbg_proxy": "SOCKS5-Proxy-Verbindungen",
+ "grpa_dbg_prune": "Block-Pruning-Operationen",
+ "grpa_dbg_rand": "Zufallszahlengenerierung",
+ "grpa_dbg_reindex": "Fortschritt der Blockchain-Neuindizierung",
+ "grpa_dbg_rpc": "Verarbeitung von RPC-Befehlen",
+ "grpa_dbg_selectcoins": "Coin-Auswahl für Transaktionen",
+ "grpa_dbg_tor": "Tor-Integration und Circuit-Informationen",
+ "grpa_dbg_zmq": "ZeroMQ-Benachrichtigungssystem",
+ "grpa_dbg_zrpc": "Geschützte (z-addr) RPC-Operationen",
+ "grpa_enter_private_key_to_import": "Geben Sie einen privaten Schlüssel zum Importieren ein.",
+ "grpa_error_prefix": "Fehler: ",
+ "grpa_hr_ago": "vor %lld Std",
+ "grpa_invalid_response_from_daemon": "Ungültige Antwort vom Daemon",
+ "grpa_invalid_suffix": " (ungültig)",
+ "grpa_min_ago": "vor %lld Min",
+ "grpa_sec_ago": "vor %lld Sek",
+ "grpa_seed_demo_chat": "Seed-Demo-Chat",
+ "grpa_showing_first_100_of": "... die ersten 100 von %d werden angezeigt",
+ "grpa_tab_about": "Über",
+ "grpa_tab_appearance": "Darstellung",
+ "grpa_tab_backup_data": "Backup & Daten",
+ "grpa_tab_chat": "Chat",
+ "grpa_tab_explorer": "Explorer",
+ "grpa_tab_node_security": "Node & Sicherheit",
+ "grpa_tab_wallet": "Wallet",
+ "grpa_unexpected_getblockhash_result": "unerwartetes getblockhash-Ergebnis",
+ "grpb_copy": "Kopieren",
+ "grpb_max": "Max",
+ "grpb_new_badge_suffix": " [NEU]",
+ "grpb_preview_msg_payment_through": "Ist die Zahlung durchgegangen? 🙂",
+ "grpb_preview_msg_sending_rest": "Sende jetzt den Rest 👍",
+ "grpb_preview_msg_yep_confirmed": "Ja — gerade bestätigt ✅",
+ "grpb_selected_suffix": "\n(ausgewählt)",
+ "grpb_tooltip_address_balance": "%s\nGuthaben: %.8f %s%s",
+ "grpb_undo_clear": "Löschen rückgängig",
+ "grpc_benchmark_inconclusive": "Benchmark nicht aussagekräftig: es wurden keine Hashrate-Werte aufgezeichnet. Prüfen Sie die Pool-Verbindung und versuchen Sie es erneut.",
+ "grpc_benchmark_takes_secs": "Der Benchmark dauert ~%ds und unterbricht das Mining. Erneut klicken zum Starten.",
+ "grpc_bootstrap_failed": "Bootstrap fehlgeschlagen",
+ "grpc_bootstrap_not_initialized": "Bootstrap nicht initialisiert",
+ "grpc_hashrate_fee": "%s %s%% Gebühr",
+ "grpc_key_not_available": "Schlüssel für diese Adresse nicht verfügbar",
+ "grpc_na": "N/V",
"height": "Höhe",
"help": "Hilfe",
"hidden_tag": " (versteckt)",
@@ -640,6 +810,7 @@
"light": "Hell",
"lite_account_label": "Konto",
"lite_action": "Aktion",
+ "lite_backend_unavailable": "Lite-Wallet-Backend nicht verfügbar",
"lite_backup_keys": "Sicherung & Schlüssel",
"lite_birthday_backup": "Geburtstag: %llu (auch diesen sichern)",
"lite_birthday_hint": "Blockhöhe, ab der gescannt werden soll. Bei 0 belassen, falls unbekannt (langsamerer vollständiger Scan).",
@@ -647,9 +818,12 @@
"lite_console_backend_commands": "Backend-Befehle:",
"lite_console_help_passthrough": "Jede andere Eingabe wird als Lite-Wallet-Konsolenbefehl ausgeführt.",
"lite_copy": "Kopieren",
+ "lite_could_not_start": "Der Vorgang konnte nicht gestartet werden",
"lite_could_not_write": "Konnte nicht schreiben ",
"lite_encrypt_wallet": "Wallet verschlüsseln",
"lite_encryption_removed": "Verschlüsselung entfernt",
+ "lite_enter_all_seed_words": "Alle 24 Seed-Wörter zur Wiederherstellung eingeben (%d erhalten)",
+ "lite_enter_wallet_path": "Wallet-Pfad eingeben",
"lite_hide_wipe": "Ausblenden & löschen",
"lite_import": "Importieren",
"lite_import_key_label": "Schlüssel importieren",
@@ -734,6 +908,9 @@
"lite_working": "In Arbeit…",
"loading": "Laden...",
"loading_addresses": "Adressen werden geladen...",
+ "loading_stall_body": "Der Daemon initialisiert seit %.0f s. Das kann nach einem Update oder beim ersten Start normal sein (Laden des Blockindex oder erneutes Scannen) – die Verbindung wird automatisch hergestellt, sobald er bereit ist.",
+ "loading_stall_hint": "Hängt es noch? Öffne die Einstellungen und nutze „Daemon neu starten“ oder sieh in der Konsole nach Details.",
+ "loading_stall_title": "Dauert länger als erwartet",
"loading_transactions": "Transaktionen werden geladen",
"local_hashrate": "Lokale Hashrate",
"low_spec_mode": "Energiesparmodus",
@@ -866,6 +1043,7 @@
"mining_difficulty_copied": "Schwierigkeit kopiert",
"mining_est_block": "Gesch. Block",
"mining_est_daily": "Gesch. täglich",
+ "mining_est_daily_pool_sub": "grobe Solo-Äquivalenz, vor Pool-Gebühr",
"mining_filter_all": "Alle",
"mining_filter_tip_all": "Alle Einnahmen anzeigen",
"mining_filter_tip_pool": "Nur Pool-Einnahmen anzeigen",
@@ -894,10 +1072,12 @@
"mining_open_in_explorer": "Im Explorer öffnen",
"mining_payout_address": "Auszahlungsadresse",
"mining_payout_foreign": "⚠ Diese Auszahlungsadresse befindet sich nicht in Ihrer aktuellen Wallet — geschürfte Belohnungen würden an eine andere Wallet gehen. Aktualisieren Sie sie, wenn Sie die Wallet gewechselt haben.",
+ "mining_payout_invalid": "Keine gültige DragonX-Adresse — vor dem Start korrigieren, sonst gehen die Mining-Belohnungen verloren.",
"mining_payout_tooltip": "Adresse für Mining-Belohnungen",
"mining_pool": "Pool",
"mining_pool_fee": "Gebühr",
"mining_pool_hashrate": "Pool-Hashrate",
+ "mining_pool_needs_payout_tooltip": "Zuerst eine Auszahlungsadresse eingeben (Z-Adresse erzeugen)",
"mining_pool_url": "Pool-URL",
"mining_pools_header": "POOLS",
"mining_recent_blocks": "LETZTE BLÖCKE",
@@ -927,6 +1107,9 @@
"mining_syncing_tooltip": "Blockchain synchronisiert...",
"mining_tag": " · Mining",
"mining_threads": "Mining-Threads",
+ "mining_threads_input_tooltip": "Genaue Thread-Anzahl eingeben (Enter zum Übernehmen)",
+ "mining_threads_minus_tooltip": "Weniger Threads",
+ "mining_threads_plus_tooltip": "Mehr Threads",
"mining_to_save": "zum Speichern",
"mining_today": "Heute",
"mining_uptime": "Laufzeit",
@@ -953,6 +1136,11 @@
"no_transactions": "Keine Transaktionen gefunden",
"no_transactions_yet": "Noch keine Transaktionen",
"node": "KNOTEN",
+ "node_banner_crashed_title": "Der Node wurde unerwartet beendet",
+ "node_banner_lite_open_failed": "Wallet konnte nicht geöffnet werden",
+ "node_banner_offline_title": "Nicht mit dem DragonX-Node verbunden",
+ "node_banner_reconnect": "Erneut verbinden",
+ "node_banner_restart": "Node neu starten",
"node_security": "KNOTEN & SICHERHEIT",
"noise": "Rauschen",
"not_connected": "Nicht mit Daemon verbunden...",
@@ -1091,6 +1279,8 @@
"qr_failed": "QR-Code konnte nicht generiert werden",
"qr_title": "QR-Code",
"qr_unavailable": "QR nicht verfügbar",
+ "quick_receive": "Schnell empfangen",
+ "quick_send": "Schnell senden",
"ram_daemon_gb": "Daemon: %.1f GB (%s)",
"ram_daemon_mb": "Daemon: %.0f MB (%s)",
"ram_system_gb": "System: %.1f / %.0f GB",
@@ -1140,6 +1330,7 @@
"rpc_connection": "RPC-Verbindung...",
"rpc_host": "RPC-Host",
"rpc_pass": "Passwort",
+ "rpc_plaintext_remote_warning": "Die Remote-RPC-Verbindung verwendet unverschlüsseltes HTTP. Füge rpctls=1 zur DRAGONX.conf hinzu, falls dein Daemon TLS unterstützt.",
"rpc_port": "Port",
"rpc_user": "Benutzername",
"save": "Speichern",
@@ -1154,6 +1345,8 @@
"sb_connecting_external": "Verbindung zu externem Daemon...",
"sb_connecting_generic": "Verbindung zum Daemon...",
"sb_daemon_crashed": "Daemon ist %d mal abgestürzt",
+ "sb_daemon_extract_failed": "Daemon-Dateien konnten nicht geschrieben werden – prüfe freien Speicherplatz und Berechtigungen.",
+ "sb_daemon_files_failed": "Daemon-Dateien konnten nicht nach %s geschrieben werden – prüfe freien Speicherplatz und Berechtigungen.",
"sb_daemon_not_found": "Daemon nicht gefunden",
"sb_daemon_start_failed": "dragonxd konnte nicht gestartet werden",
"sb_dragonxd_running": "dragonxd läuft",
@@ -1169,6 +1362,7 @@
"sb_net_mhs": "Netz: %.2f MH/s",
"sb_no_conf": "DRAGONX.conf nicht gefunden",
"sb_peers": "Peers: %zu",
+ "sb_plaintext_remote_blocked": "RPC-Anmeldedaten werden nicht im Klartext an einen entfernten Host gesendet. Füge rpcallowplaintext=1 zu DRAGONX.conf hinzu, um dies zu erlauben, oder aktiviere TLS mit rpctls=1.",
"sb_rescanning": "Neuscan",
"sb_rescanning_pct": "Neuscan %.0f%%",
"sb_restarting_daemon": "Daemon wird neu gestartet...",
@@ -1182,12 +1376,52 @@
"sb_waiting_daemon_err": "Warten auf dragonxd — %s",
"sb_warming_up": "Aufwärmen...",
"sb_witness_cache": "Zeugen werden neu aufgebaut",
+ "scale_effects": "SKALIERUNG & EFFEKTE",
"screenshot_open_dir": "Speicherort öffnen",
"screenshot_sweep": "Screenshot-Durchlauf ausführen",
"screenshot_sweep_desc": "Durchläuft jedes Design über jeden Tab und speichert von jedem einen Screenshot in tab-spezifischen Unterordnern im Screenshots-Ordner des Konfigurationsverzeichnisses (überschreibt den vorherigen Durchlauf). Läuft einige Sekunden.",
"screenshot_sweep_full": "Vollständiger UI-Durchlauf",
"search_icons": "Symbole suchen...",
"search_placeholder": "Suchen...",
+ "sec_changing_passphrase": "Passphrase wird geändert...",
+ "sec_changing_pin": "PIN wird geändert...",
+ "sec_couldnt_lock_wallet": "Wallet konnte nicht gesperrt werden — es ist weiterhin entsperrt. Prüfen Sie die Daemon-Verbindung.",
+ "sec_encrypted_backup_suffix": "\nVerschlüsseltes Backup: wallet.dat.encrypted.bak",
+ "sec_encrypting_wallet": "Wallet wird verschlüsselt...",
+ "sec_encryption_did_not_complete": "Die Wallet-Verschlüsselung wurde nicht abgeschlossen — Ihr Wallet ist NICHT verschlüsselt. Öffnen Sie die Einstellungen, um die Verschlüsselung abzuschließen.",
+ "sec_encryption_failed_prefix": "Verschlüsselung fehlgeschlagen: ",
+ "sec_failed_prefix": "Fehlgeschlagen: ",
+ "sec_failed_to_create_vault": "Tresor konnte nicht erstellt werden",
+ "sec_importing_keys_rescanning": "Schlüssel werden importiert & Blockchain neu gescannt — Wallet ist währenddessen nutzbar",
+ "sec_incorrect_current_pin": "Aktuelle PIN falsch",
+ "sec_incorrect_passphrase_decrypt": "Falsche Passphrase",
+ "sec_incorrect_passphrase_pin_setup": "Falsche Passphrase",
+ "sec_incorrect_pin_remove": "Falsche PIN",
+ "sec_internal_error_change_pin": "Interner Fehler",
+ "sec_internal_error_remove_pin": "Interner Fehler",
+ "sec_mode_passphrase": " Passphrase",
+ "sec_not_connected_to_daemon": "Nicht mit Daemon verbunden",
+ "sec_not_connected_to_daemon_pin": "Nicht mit Daemon verbunden",
+ "sec_passphrase_changed_successfully": "Passphrase erfolgreich geändert",
+ "sec_pin_changed_successfully": "PIN erfolgreich geändert",
+ "sec_pin_removed": "PIN entfernt",
+ "sec_pin_set_successfully": "PIN erfolgreich festgelegt",
+ "sec_restart_daemon_for_encryption": "Bitte starten Sie Ihren Daemon neu, damit die Verschlüsselung wirksam wird.",
+ "sec_too_many_attempts_wait": "Zu viele Versuche. Warten Sie %.0f Sekunden...",
+ "sec_total_elapsed_fmt": "Gesamtdauer: %dm %02ds",
+ "sec_unlock_button": "Entsperren",
+ "sec_unlock_failed_prefix": "Entsperren fehlgeschlagen: ",
+ "sec_unlocking_fmt": "Entsperren%s",
+ "sec_use_passphrase_instead": "Stattdessen Passphrase verwenden",
+ "sec_use_pin_instead": "Stattdessen PIN verwenden",
+ "sec_verifying_passphrase": "Passphrase wird überprüft...",
+ "sec_verifying_pin": "PIN wird überprüft...",
+ "sec_wallet_decrypted_all_keys_imported": "Wallet erfolgreich entschlüsselt! Alle Schlüssel importiert.",
+ "sec_wallet_encrypted_and_pin_set": "Wallet verschlüsselt & PIN festgelegt",
+ "sec_wallet_encrypted_but_pin_vault_failed": "Wallet verschlüsselt, aber PIN-Tresor fehlgeschlagen",
+ "sec_wallet_encrypted_restarting_daemon": "Wallet verschlüsselt. Daemon wird neu gestartet...",
+ "sec_wallet_encrypted_successfully": "Wallet erfolgreich verschlüsselt",
+ "sec_wallet_locked_title": "Wallet gesperrt",
"security": "SICHERHEIT",
"seed_backup_button": "Wiederherstellungsphrase",
"seed_backup_close": "Schließen",
@@ -1246,6 +1480,7 @@
"send_tooltip_not_connected": "Nicht mit Daemon verbunden",
"send_tooltip_select_source": "Wählen Sie zuerst eine Quelladresse",
"send_tooltip_syncing": "Warten Sie auf die Blockchain-Synchronisierung",
+ "send_tooltip_view_only": "Nur-Lese-Adresse — kein Spending Key, Senden nicht möglich",
"send_total": "Gesamt",
"send_transaction": "Transaktion senden",
"send_tx_failed": "Transaktion fehlgeschlagen",
@@ -1265,16 +1500,16 @@
"sent_filter": "Gesendet",
"sent_type": "Gesendet",
"sent_upper": "GESENDET",
- "set_label": "Label setzen...",
+ "set_label": "Label setzen",
"settings": "Einstellungen",
"settings_about_text": "Eine geschirmte Kryptowährungs-Wallet für DragonX (DRGX), erstellt mit Dear ImGui für ein leichtes, portables Erlebnis.",
"settings_acrylic_level": "Acrylstufe:",
- "settings_address_book": "Adressbuch...",
+ "settings_address_book": "Adressbuch…",
"settings_auto_detected": "Automatisch erkannt aus DRAGONX.conf",
"settings_auto_lock": "AUTO-SPERRE",
"settings_auto_shield_desc": "Transparente Guthaben automatisch an geschirmte Adressen verschieben",
"settings_auto_shield_funds": "Transparente Guthaben automatisch abschirmen",
- "settings_backup": "Sicherung...",
+ "settings_backup": "Sicherung…",
"settings_block_explorer_urls": "Block-Explorer-URLs",
"settings_builtin": "Integriert",
"settings_change_passphrase": "Passphrase ändern",
@@ -1285,60 +1520,71 @@
"settings_configure_explorer": "Externe Block-Explorer-Links konfigurieren",
"settings_configure_rpc": "Verbindung zum dragonxd-Daemon konfigurieren",
"settings_connection": "Verbindung",
+ "settings_copy_diagnostics": "Diagnose kopieren",
"settings_copyright": "Copyright 2024-2026 DragonX-Entwickler | GPLv3-Lizenz",
"settings_custom": "Benutzerdefiniert",
- "settings_data_dir": "Datenverzeichnis:",
+ "settings_data_dir": "Datenverzeichnis",
"settings_debug_changed": "Debug-Kategorien geändert — Daemon neu starten zum Anwenden",
"settings_debug_restart_note": "Änderungen werden nach einem Neustart des Daemons wirksam.",
"settings_debug_select": "Kategorien auswählen, um Daemon-Fehlerprotokollierung zu aktivieren (-debug= Flags).",
+ "settings_diagnostics_copied": "Diagnose in die Zwischenablage kopiert",
"settings_encrypt_first_pin": "Verschlüsseln Sie zuerst die Wallet, um PIN zu aktivieren",
"settings_encrypt_wallet": "Wallet verschlüsseln",
"settings_explorer_hint": "URLs sollten einen abschließenden Schrägstrich enthalten. Die txid/Adresse wird angehängt.",
- "settings_export_all": "Alle exportieren...",
- "settings_export_csv": "CSV exportieren...",
- "settings_export_key": "Schlüssel exportieren...",
+ "settings_export_all": "Alle exportieren…",
+ "settings_export_csv": "CSV exportieren…",
+ "settings_export_key": "Schlüssel exportieren…",
"settings_gradient_bg": "Hintergrund-Verlauf",
"settings_gradient_desc": "Strukturierte Hintergründe durch sanfte Verläufe ersetzen",
"settings_idle_after": "nach",
- "settings_import_key": "Privaten Schlüssel importieren...",
- "settings_import_viewkey": "Anzeigeschlüssel importieren...",
+ "settings_import_key": "Privaten Schlüssel importieren…",
+ "settings_import_viewkey": "Anzeigeschlüssel importieren…",
"settings_language_note": "Hinweis: Manche Texte erfordern einen Neustart zur Aktualisierung",
"settings_lock_now": "Jetzt sperren",
"settings_locked": "Gesperrt",
- "settings_merge_to_address": "An Adresse zusammenführen...",
+ "settings_merge_to_address": "An Adresse zusammenführen…",
"settings_noise_opacity": "Rauschdichte:",
+ "settings_not_connected": "Nicht mit dem Daemon verbunden",
"settings_not_encrypted": "Nicht verschlüsselt",
"settings_not_found": "Nicht gefunden",
"settings_open_app_dir": "App-Ordner öffnen",
"settings_open_data_dir": "Datenordner öffnen",
+ "settings_open_log_folder": "Log-Ordner öffnen",
"settings_other": "Sonstiges",
"settings_pin_active": "PIN",
"settings_privacy": "Datenschutz",
"settings_quick_unlock_pin": "Schnell-Entsperr-PIN",
"settings_reduce_transparency": "Transparenz reduzieren",
+ "settings_reloaded": "Einstellungen von der Festplatte neu geladen",
"settings_remove_encryption": "Verschlüsselung entfernen",
"settings_remove_pin": "PIN entfernen",
- "settings_request_payment": "Zahlung anfordern...",
+ "settings_request_payment": "Zahlung anfordern…",
"settings_rescan_desc": "Blockchain nach fehlenden Transaktionen neu scannen",
"settings_restart_daemon": "Daemon neu starten",
"settings_rpc_connection": "RPC-Verbindung",
+ "settings_rpc_error_prefix": "RPC-Fehler: ",
"settings_rpc_note": "Hinweis: Verbindungseinstellungen werden automatisch aus DRAGONX.conf erkannt",
+ "settings_rpc_ok": "RPC-Verbindung OK",
"settings_save_shielded_desc": "Speichert z-addr Transaktionen in einer lokalen Datei zur Ansicht",
"settings_save_shielded_local": "Geschirmten Transaktionsverlauf lokal speichern",
+ "settings_saved": "Einstellungen gespeichert",
"settings_set_pin": "PIN festlegen",
- "settings_shield_mining": "Mining abschirmen...",
+ "settings_shield_mining": "Mining abschirmen…",
"settings_solid_colors_desc": "Feste Farben anstelle von Unschärfe-Effekten verwenden (Barrierefreiheit)",
+ "settings_theme_refreshed": "Themenliste aktualisiert",
"settings_tor_desc": "Alle Verbindungen für erhöhte Privatsphäre über Tor leiten",
"settings_unlocked": "Entsperrt",
"settings_use_tor_network": "Tor für Netzwerkverbindungen verwenden",
- "settings_validate_address": "Adresse überprüfen...",
+ "settings_validate_address": "Adresse überprüfen…",
"settings_visual_effects": "Visuelle Effekte",
"settings_wallet_file_size": "Wallet-Dateigröße: %s",
"settings_wallet_info": "Wallet-Informationen",
"settings_wallet_location": "Wallet-Speicherort: %s",
"settings_wallet_maintenance": "Wallet-Wartung",
"settings_wallet_not_found": "Wallet-Datei nicht gefunden",
- "settings_wallet_size_label": "Wallet-Größe:",
+ "settings_wallet_size_label": "Wallet-Größe",
+ "settings_ztx_cleared": "Z-Transaktionsverlauf gelöscht",
+ "settings_ztx_not_found": "Keine Verlaufsdatei gefunden",
"setup_wizard": "Einrichtungsassistent",
"share": "Teilen",
"shield_check_status": "Status prüfen",
@@ -1397,6 +1643,17 @@
"sweep_to": "Gefegt an:",
"sweep_toggle": "In meine Wallet fegen (Schlüssel nicht behalten)",
"sweep_tx": "Transaktion:",
+ "swin_connection_failed": "Verbindung fehlgeschlagen: ",
+ "swin_connection_successful": "Verbindung erfolgreich!\ndragonxd-Version: ",
+ "swin_invalid_suffix": " (ungültig)",
+ "swin_no_history_file_found": "Keine Verlaufsdatei gefunden",
+ "swin_rescan_failed": "Neuscan fehlgeschlagen: ",
+ "swin_rescan_started_from_block": "Neuscan gestartet ab Block ",
+ "swin_rescan_to": " bis ",
+ "swin_rpc_client_not_initialized": "RPC-Client nicht initialisiert",
+ "swin_settings_saved": "Einstellungen gespeichert",
+ "swin_theme_list_refreshed": "Design-Liste aktualisiert",
+ "swin_ztx_history_cleared": "Z-Transaktionsverlauf gelöscht",
"switch_corrupt_body": "Diese Wallet scheint beschädigt zu sein – der Knoten konnte sie nicht öffnen. Aus einem Backup wiederherstellen, neu erstellen oder eine Reparatur versuchen.",
"switch_corrupt_repair": "Reparatur versuchen (Salvage)",
"switch_progress_background": "Im Hintergrund fortsetzen",
@@ -1421,6 +1678,7 @@
"theme": "Design",
"theme_effects": "Design-Effekte",
"theme_language": "THEMA & SPRACHE",
+ "tile_click_to_open": "Zum Öffnen klicken",
"time_days_ago": "vor %d Tagen",
"time_hours_ago": "vor %d Stunden",
"time_minutes_ago": "vor %d Minuten",
@@ -1435,7 +1693,9 @@
"to_upper": "AN",
"tools": "WERKZEUGE",
"tools_actions": "Werkzeuge & Aktionen...",
+ "tools_actions_hdr": "WERKZEUGE & AKTIONEN",
"total": "Gesamt",
+ "total_balance_label": "Gesamtguthaben",
"transaction_id": "TRANSAKTIONS-ID",
"transaction_sent": "Transaktion erfolgreich gesendet",
"transaction_sent_msg": "Transaktion gesendet!",
@@ -1457,7 +1717,7 @@
"tt_auto_shield": "Transparentes Guthaben automatisch an geschirmte Adressen für Datenschutz verschieben",
"tt_backup": "Eine Sicherungskopie Ihrer wallet.dat erstellen",
"tt_block_explorer": "Den DragonX Block-Explorer im Browser öffnen",
- "tt_blur": "Unschärfe-Stärke (0%% = aus, 100%% = maximum)",
+ "tt_blur": "Unschärfe-Stärke (0% = aus, 100% = maximum)",
"tt_change_pass": "Die Wallet-Verschlüsselungspassphrase ändern",
"tt_change_pin": "Ihre Entsperr-PIN ändern",
"tt_chat_bubble_accent": "Akzentfarbe für deine ausgehenden Nachrichtenblasen (oder dem aktuellen Theme folgen)",
@@ -1470,6 +1730,7 @@
"tt_chat_timestamp": "Zeitstempelformat nur für diesen Tab: der app-weiten Uhr folgen oder 24-hour bzw. 12-hour erzwingen",
"tt_clear_ztx": "Lokal zwischengespeicherten Z-Transaktionsverlauf löschen",
"tt_clock_format": "24- oder 12-Stunden-Uhr, app-weit. Der Chat-Tab kann sie überschreiben.",
+ "tt_copy_diagnostics": "Kopiert eine Support-Übersicht (Version, Daemon-/Wallet-/Log-Status – keine Geheimnisse) in die Zwischenablage",
"tt_custom_fees": "Manuelle Gebühreneingabe beim Senden von Transaktionen aktivieren",
"tt_custom_theme": "Benutzerdefiniertes Theme aktiv",
"tt_daemon_install_bundled": "Node stoppen, den installierten dragonxd mit der in diesem Wallet-Build enthaltenen Version überschreiben und dann neu starten",
@@ -1519,10 +1780,11 @@
"tt_low_spec": "Alle aufwendigen visuellen Effekte deaktivieren\\nHotkey: Ctrl+Shift+Down",
"tt_merge": "Mehrere UTXOs einer Adresse zusammenführen",
"tt_mine_idle": "Mining automatisch starten, wenn das\\nSystem inaktiv ist (keine Tastatur-/Mauseingabe)",
- "tt_noise": "Körnungstextur-Intensität (0%% = aus, 100%% = maximum)",
+ "tt_noise": "Körnungstextur-Intensität (0% = aus, 100% = maximum)",
"tt_open_app_dir": "Den ObsidianDragon-Ordner (Einstellungen, Themes, Logs) im Dateimanager öffnen",
"tt_open_data_dir": "Den Ordner mit Ihren Wallet- und Blockchain-Daten im Dateimanager öffnen",
"tt_open_dir": "Klicken, um im Dateimanager zu öffnen",
+ "tt_open_log_folder": "Öffnet den Ordner mit den Debug- und Absturzprotokollen",
"tt_reduce_motion": "Animierte Übergänge und Saldo-Lerp für Barrierefreiheit deaktivieren",
"tt_remove_encrypt": "Verschlüsselung entfernen und Wallet ungeschützt speichern",
"tt_remove_pin": "PIN entfernen und Passphrase zum Entsperren erfordern",
@@ -1557,7 +1819,7 @@
"tt_theme_hotkey": "Hotkey: Ctrl+Links/Rechts zum Wechseln der Themes",
"tt_tor": "Daemon-Verbindungen für Anonymität über das Tor-Netzwerk leiten",
"tt_tx_url": "Basis-URL zum Anzeigen von Transaktionen in einem Block-Explorer",
- "tt_ui_opacity": "Karten- und Seitenleisten-Deckkraft (100%% = vollständig undurchsichtig, niedriger = durchsichtiger)",
+ "tt_ui_opacity": "Karten- und Seitenleisten-Deckkraft (100% = vollständig undurchsichtig, niedriger = durchsichtiger)",
"tt_validate": "Prüfen, ob eine DragonX-Adresse gültig ist",
"tt_verbose": "Detaillierte Verbindungsdiagnosen,\\nDaemon-Status und Port-Besitzer-Info\\nin der Konsolen-Registerkarte protokollieren",
"tt_wallets_button": "Ihre Wallet-Dateien auflisten und zwischen ihnen wechseln",
@@ -1610,6 +1872,7 @@
"validate_not_mine": "Nicht im Besitz dieser Wallet",
"validate_ownership": "Eigentum:",
"validate_results": "Ergebnisse:",
+ "validate_results_placeholder": "Ergebnisse erscheinen hier",
"validate_shielded_type": "Abgeschirmt (z-Adresse)",
"validate_status": "Status:",
"validate_title": "Adresse validieren",
@@ -1750,6 +2013,7 @@
"xmrig_loading_releases": "Releases werden geladen…",
"xmrig_none": "keiner",
"xmrig_reinstall": "Neu installieren",
+ "xmrig_releases": "xmrig-Releases",
"xmrig_stop_mining_first": "Stoppen Sie das Mining, bevor Sie den Miner aktualisieren.",
"xmrig_unavailable_body": "Für diese Plattform ist kein Miner-Build verfügbar.",
"xmrig_unavailable_title": "Miner-Updates nicht verfügbar",
diff --git a/res/lang/es.json b/res/lang/es.json
index aadc861..8be75dc 100644
--- a/res/lang/es.json
+++ b/res/lang/es.json
@@ -48,6 +48,10 @@
"advanced": "AVANZADO",
"advanced_effects": "Efectos Avanzados...",
"ago": "atrás",
+ "alerts_clear": "Borrar historial de alertas",
+ "alerts_history_tooltip": "Alertas recientes",
+ "alerts_none": "Aún no hay alertas",
+ "alerts_recent": "ALERTAS RECIENTES",
"all_filter": "Todos",
"allow_custom_fees": "Permitir comisiones personalizadas",
"amount": "Cantidad",
@@ -56,6 +60,80 @@
"amount_label": "Cantidad:",
"animate_avatars": "Animar avatares",
"appearance": "APARIENCIA",
+ "appx_back": "Atrás",
+ "appx_back_up_seed_phrase_title": "Haz una copia de tu frase semilla",
+ "appx_birthday_block_height": "Fecha de creación (altura de bloque): %llu — guárdala también.",
+ "appx_blockchain_data_deleted": "Datos de la cadena de bloques eliminados (%d elementos). El daemon se está reiniciando para volver a sincronizar desde la red.",
+ "appx_blockchain_maintenance_in_progress": "Ya hay una operación de mantenimiento de la cadena de bloques en curso.",
+ "appx_blockchain_rescan_complete": "Reescaneo de la cadena de bloques completado",
+ "appx_bootstrap_complete_reconciling": "Arranque inicial completado; reconciliando tu cartera con los nuevos datos de la cadena.",
+ "appx_cancel": "Cancelar",
+ "appx_cleaning_up": "Limpiando...",
+ "appx_confirm_your_backup": "Confirma tu copia de seguridad",
+ "appx_copied_clipboard_autoclears": "Copiado; el portapapeles se borra automáticamente en 45 s",
+ "appx_copy": "Copiar",
+ "appx_could_not_start_restore": "No se pudo iniciar la restauración",
+ "appx_create_failed_prefix": "Error al crear: ",
+ "appx_creating_your_wallet": "Creando tu cartera…",
+ "appx_daemon_error": "Error del daemon",
+ "appx_daemon_reinstall_in_progress": "La reinstalación del daemon ya está en curso.",
+ "appx_disconnecting": "Desconectando...",
+ "appx_done": "Listo",
+ "appx_dragonxd_output": "Salida de dragonxd",
+ "appx_encrypting_wallet": "Cifrando la cartera...",
+ "appx_fullnode_lifecycle_unavailable_lite": "Las acciones de ciclo de vida del nodo completo no están disponibles en la versión lite",
+ "appx_installing_bundled_daemon": "Instalando el daemon incluido; el nodo se detendrá, se actualizará y se reiniciará...",
+ "appx_invalid_payment_uri_prefix": "URI de pago no válida: ",
+ "appx_ive_written_it_down": "Ya la anoté",
+ "appx_keep_node_running_and_quit": "Mantener el nodo y salir",
+ "appx_last_block_n": "Último bloque: %d",
+ "appx_last_used_wallet_not_found_prefix": "Tu último archivo de cartera usado (",
+ "appx_last_used_wallet_not_found_suffix": ") no se encontró; se abrió la cartera predeterminada en su lugar. Si lo moviste, restáuralo y vuelve a él desde la lista de carteras.",
+ "appx_low_spec_mode_disabled": "Modo de bajos recursos desactivado",
+ "appx_low_spec_mode_enabled": "Modo de bajos recursos activado",
+ "appx_miner_stopped_prefix": "Minero detenido: ",
+ "appx_miner_stopped_unexpectedly": "El minero se detuvo inesperadamente.",
+ "appx_n_min_n_sec": "%d min %d s",
+ "appx_n_seconds": "%d segundos",
+ "appx_no_bundled_daemon_to_install": "Esta versión no incluye un daemon para instalar",
+ "appx_no_embedded_daemon_to_install": "Esta versión no incluye un daemon integrado para instalar",
+ "appx_node_busy_restarting": "El nodo está ocupado reiniciándose; inténtalo de nuevo en un momento.",
+ "appx_node_rebuilding_witness_cache": "El nodo está reconstruyendo su caché de testigos",
+ "appx_not_next_word": " — esa no es la siguiente palabra",
+ "appx_payment_request_loaded": "Solicitud de pago cargada",
+ "appx_pool_miner_connected_and_hashing": "Minero de pool conectado y calculando hashes.",
+ "appx_progress_n_of_n": "Progreso: %d / %d",
+ "appx_rebuilding_sapling_note_witnesses": "Reconstruyendo testigos de notas Sapling…",
+ "appx_rebuilding_witness_cache_blocks_left": "Reconstruyendo la caché de testigos %.0f%% — %d bloques restantes",
+ "appx_rebuilding_witness_cache_pct": "Reconstruyendo la caché de testigos %.0f%%",
+ "appx_recovery_phrase_word_count": "La frase de recuperación debe tener 24 palabras; tú tienes %d.",
+ "appx_restarting_daemon_rescan_flag": "Reiniciando el daemon con la opción -rescan...",
+ "appx_restarting_daemon_zapwallettxes": "Reiniciando el daemon con -zapwallettxes=2 (reparación de cartera)...",
+ "appx_restoring_your_wallet": "Restaurando tu cartera…",
+ "appx_seed_backup_warning": "Estas 24 palabras son la ÚNICA forma de restaurar tu cartera. Anótalas en orden, guárdalas sin conexión y nunca las compartas. Si las pierdes, tus fondos se perderán para siempre.",
+ "appx_seed_not_backed_up_warning": "No has hecho una copia de tu semilla; podrías perder tus fondos. ¿Omitir de todos modos?",
+ "appx_sending_stop_command_to_daemon": "Enviando el comando de parada al daemon...",
+ "appx_setting_initial_sapling_witnesses": "Estableciendo testigos Sapling iniciales %.0f%%",
+ "appx_shutdown_complete": "Apagado completado",
+ "appx_simple_background_disabled": "Fondo simple desactivado",
+ "appx_simple_background_enabled": "Fondo simple activado",
+ "appx_skip": "Omitir",
+ "appx_skip_anyway": "Omitir de todos modos",
+ "appx_still_status_prefix": "Aún \"",
+ "appx_still_status_suffix": "\" — forzar el cierre ahora podría dañar los datos de la cadena.",
+ "appx_stop_anyway_and_quit": "Detener de todos modos y salir",
+ "appx_stopping_daemon_deleting_blockchain": "Deteniendo el daemon y eliminando los datos de la cadena de bloques...",
+ "appx_stopping_node_discards_rebuild": "Detener el nodo ahora descarta la reconstrucción en curso y la reinicia (varios minutos) la próxima vez que abras la cartera. También puedes dejar el nodo en ejecución.",
+ "appx_stopping_pool_miner": "Deteniendo el minero de pool...",
+ "appx_syncing_pct_block_n_of_n": "Sincronizando %.1f%% — Bloque %d / %d",
+ "appx_tap_words_in_order": "Toca las palabras en el orden correcto para confirmar que las guardaste.",
+ "appx_theme_effects_disabled": "Efectos de tema desactivados",
+ "appx_theme_effects_enabled": "Efectos de tema activados",
+ "appx_theme_prefix": "Tema: ",
+ "appx_use_settings_restart_daemon_hint": "Usa Ajustes > Reiniciar daemon para intentarlo de nuevo",
+ "appx_waiting_for_daemon_to_encrypt_wallet": "Esperando a que el daemon cifre la cartera...",
+ "appx_wallet_created_and_backed_up": "Cartera creada y respaldada.",
+ "appx_wallet_open_failed_prefix": "Error al abrir la cartera: ",
"auto_shield": "Auto-proteger minería",
"av_intro": "El software de minería suele marcarse como potencialmente no deseado. Sigue estos pasos para habilitar la minería en pool:",
"av_open_security": "Abrir Seguridad de Windows",
@@ -70,6 +148,9 @@
"av_title": "Windows Defender bloqueó el minero",
"available": "Disponible",
"backup_backing_up": "Respaldando...",
+ "backup_col_backup": "COPIA DE SEGURIDAD",
+ "backup_col_export": "EXPORTAR",
+ "backup_col_import": "IMPORTAR Y RESTAURAR",
"backup_create": "Crear Respaldo",
"backup_created": "Respaldo de cartera creado",
"backup_data": "RESPALDO Y DATOS",
@@ -88,8 +169,22 @@
"balance": "Saldo",
"balance_history_collecting": "Historial de saldo — recopilando datos...",
"balance_layout": "Diseño de Saldo",
+ "balance_layout_switched": "Diseño: %s",
+ "balance_mining_rate": "Minando %s",
"balance_shielded_fmt": "Protegido: %.8f",
+ "balance_syncing_pct": "Sincronizando %.1f%%",
"balance_transparent_fmt": "Transparente: %.8f",
+ "baltab_market": "Mercado",
+ "baltab_market_price_4dp": "Mercado: $%.4f",
+ "baltab_market_price_8dp": "Mercado: $%.8f",
+ "baltab_pct_change_24h": "%s%.1f%% 24h",
+ "baltab_pct_of_total_zaddr": "%.0f%% del total · %d Z-addr",
+ "baltab_shielded": "Blindado",
+ "baltab_shielded_amount": "Blindado %.8f",
+ "baltab_t_addresses_count": "%d T-addresses",
+ "baltab_total_balance": "Saldo total",
+ "baltab_transparent": "Transparente",
+ "baltab_transparent_amount": "Transparente %.8f",
"ban": "Bloquear",
"banned_peers": "Nodos Bloqueados",
"block": "Bloque",
@@ -128,6 +223,7 @@
"bootstrap_verifying": "Verificando sumas de comprobación...",
"bootstrap_wallet_protected": "(wallet.dat está protegido)",
"bootstrap_warning": "Los datos de bloques existentes (blocks, chainstate, notarizations) se eliminarán y reemplazarán. Su wallet.dat NO será modificado ni eliminado.",
+ "byte_count_fmt": "%zu / %zu bytes",
"cancel": "Cancelar",
"change_pass_confirm": "Confirmar nueva:",
"change_pass_current": "Frase de contraseña actual:",
@@ -403,6 +499,7 @@
"contacts_shape_square": "Cuadrado",
"contacts_shape_tab": "Pestaña",
"copied": "¡Copiado!",
+ "copied_to_clipboard": "Copiado al portapapeles",
"copy": "Copiar",
"copy_address": "Copiar Dirección Completa",
"copy_error": "Copiar Error",
@@ -415,6 +512,7 @@
"daemon_bundled": "Incluido",
"daemon_install_bundled": "Instalar integrado",
"daemon_installed": "Instalado",
+ "daemon_maintenance_label": "MANTENIMIENTO",
"daemon_none_bundled": "ninguno en esta compilación",
"daemon_not_installed": "no instalado",
"daemon_status_differ": "El binario instalado difiere de la versión incluida.",
@@ -437,6 +535,7 @@
"daemon_update_latest": "Más reciente:",
"daemon_update_loading": "Cargando versiones…",
"daemon_update_now": "Actualizar ahora",
+ "daemon_update_prompt_title": "¿Actualizar el daemon del nodo?",
"daemon_update_reinstall": "Reinstalar",
"daemon_update_restart_note": "Reinicia el daemon para empezar a ejecutar la nueva versión.",
"daemon_update_restart_now": "Reiniciar el daemon ahora",
@@ -449,8 +548,11 @@
"daemon_update_verify_note": "La descarga se verifica frente al SHA-256 publicado de la versión y una firma ed25519 fijada antes de instalarla.",
"daemon_update_verifying": "Verificando…",
"daemon_update_version": "Versión:",
+ "daemon_updates_label": "ACTUALIZACIONES",
"daemon_version": "Daemon",
"dark": "Oscuro",
+ "data_stale_prefix": "Actualizado",
+ "data_stale_tooltip": "El saldo puede estar desactualizado: la cartera no ha recibido una actualización reciente. Comprueba la conexión con tu nodo.",
"date": "Fecha",
"date_label": "Fecha:",
"debug_logging": "REGISTRO DE DEPURACIÓN",
@@ -479,6 +581,17 @@
"download_bootstrap": "Descargar Bootstrap",
"dragonx_green": "DragonX (Verde)",
"edit": "Editar",
+ "empty_wallet_keys_suffix": "claves",
+ "empty_wallet_open_manager": "Abrir administrador de carteras",
+ "empty_wallet_restore": "Restaurar mi cartera",
+ "empty_wallet_salvage_body": "Esta cartera está vacía porque una reparación automática anterior apartó tu cartera original como copia de seguridad. Tus monedas casi con certeza están en esa copia, no perdidas. Restáurala para volver a cargar tus fondos: no se elimina nada; primero se aparta el archivo actual.",
+ "empty_wallet_salvage_headline": "Tus monedas están a salvo en un archivo de copia de seguridad.",
+ "empty_wallet_salvage_title": "Es posible que tu cartera haya sido reparada",
+ "empty_wallet_warning_body": "Esta cartera no tiene direcciones ni fondos, pero otro archivo de cartera en tu carpeta de DragonX contiene claves. Lo más probable es que tus monedas estén ahí, no perdidas. Abre el administrador de carteras para cambiar a la cartera que tiene tus fondos.",
+ "empty_wallet_warning_dismiss": "No volver a avisar para esta cartera",
+ "empty_wallet_warning_dismiss_tip": "Detiene este aviso solo para el archivo de cartera actual. Si más tarde cambias a otra cartera vacía, podría avisarte de nuevo.",
+ "empty_wallet_warning_headline": "Es posible que haya abierto la cartera equivocada.",
+ "empty_wallet_warning_title": "Esta cartera está vacía",
"enc_confirm": "Confirmar:",
"enc_desc": "Cifrar tu monedero protege tus claves privadas con una frase de contraseña. Tras el cifrado, el daemon se reiniciará.",
"enc_encrypting": "Cifrando el monedero...",
@@ -566,6 +679,63 @@
"general": "General",
"generating": "Generando",
"go_to_receive": "Ir a Recibir",
+ "grpa_current_block_paren": "(Actual: %d)",
+ "grpa_days_ago": "hace %lld días",
+ "grpa_dbg_addrman": "Seguimiento y gestión de direcciones de pares",
+ "grpa_dbg_alert": "Mensajes del sistema de alertas",
+ "grpa_dbg_bench": "Tiempos de referencia de las operaciones",
+ "grpa_dbg_coindb": "Operaciones de lectura/escritura de la base de datos de monedas",
+ "grpa_dbg_db": "Operaciones de Berkeley DB",
+ "grpa_dbg_estimatefee": "Algoritmo de estimación de comisiones",
+ "grpa_dbg_http": "Actividad del servidor RPC HTTP",
+ "grpa_dbg_libevent": "Biblioteca de red Libevent",
+ "grpa_dbg_lock": "Depuración de contención de bloqueos",
+ "grpa_dbg_mempool": "Actividad del pool de memoria de transacciones",
+ "grpa_dbg_net": "Conexiones y mensajes de red",
+ "grpa_dbg_paymentdisclosure": "Protocolo de divulgación de pagos",
+ "grpa_dbg_pow": "Actividad de minería por prueba de trabajo",
+ "grpa_dbg_proxy": "Conexiones de proxy SOCKS5",
+ "grpa_dbg_prune": "Operaciones de poda de bloques",
+ "grpa_dbg_rand": "Generación de números aleatorios",
+ "grpa_dbg_reindex": "Progreso de reindexación de la cadena de bloques",
+ "grpa_dbg_rpc": "Procesamiento de comandos RPC",
+ "grpa_dbg_selectcoins": "Selección de monedas para transacciones",
+ "grpa_dbg_tor": "Integración de Tor e información de circuitos",
+ "grpa_dbg_zmq": "Sistema de notificaciones ZeroMQ",
+ "grpa_dbg_zrpc": "Operaciones RPC blindadas (z-addr)",
+ "grpa_enter_private_key_to_import": "Introduce una clave privada para importar.",
+ "grpa_error_prefix": "Error: ",
+ "grpa_hr_ago": "hace %lld h",
+ "grpa_invalid_response_from_daemon": "Respuesta no válida del daemon",
+ "grpa_invalid_suffix": " (no válido)",
+ "grpa_min_ago": "hace %lld min",
+ "grpa_sec_ago": "hace %lld s",
+ "grpa_seed_demo_chat": "Chat de demostración con semilla",
+ "grpa_showing_first_100_of": "... mostrando los primeros 100 de %d",
+ "grpa_tab_about": "Acerca de",
+ "grpa_tab_appearance": "Apariencia",
+ "grpa_tab_backup_data": "Copia de seguridad y datos",
+ "grpa_tab_chat": "Chat",
+ "grpa_tab_explorer": "Explorador",
+ "grpa_tab_node_security": "Nodo y seguridad",
+ "grpa_tab_wallet": "Cartera",
+ "grpa_unexpected_getblockhash_result": "resultado inesperado de getblockhash",
+ "grpb_copy": "Copiar",
+ "grpb_max": "Máx.",
+ "grpb_new_badge_suffix": " [NUEVO]",
+ "grpb_preview_msg_payment_through": "¿Se realizó el pago? 🙂",
+ "grpb_preview_msg_sending_rest": "Enviando el resto ahora 👍",
+ "grpb_preview_msg_yep_confirmed": "Sí — acabo de confirmarlo ✅",
+ "grpb_selected_suffix": "\n(seleccionado)",
+ "grpb_tooltip_address_balance": "%s\nSaldo: %.8f %s%s",
+ "grpb_undo_clear": "Deshacer borrado",
+ "grpc_benchmark_inconclusive": "Prueba de rendimiento no concluyente: no se registraron muestras de tasa de hash. Comprueba la conexión con el pool e inténtalo de nuevo.",
+ "grpc_benchmark_takes_secs": "La prueba de rendimiento tarda ~%ds e interrumpe la minería. Haz clic de nuevo para empezar.",
+ "grpc_bootstrap_failed": "Error en el arranque inicial",
+ "grpc_bootstrap_not_initialized": "Arranque inicial no inicializado",
+ "grpc_hashrate_fee": "%s %s%% de comisión",
+ "grpc_key_not_available": "Clave no disponible para esta dirección",
+ "grpc_na": "N/D",
"height": "Altura",
"help": "Ayuda",
"hidden_tag": " (oculto)",
@@ -640,6 +810,7 @@
"light": "Claro",
"lite_account_label": "Cuenta",
"lite_action": "Acción",
+ "lite_backend_unavailable": "Backend de billetera Lite no disponible",
"lite_backup_keys": "Respaldo y claves",
"lite_birthday_backup": "Cumpleaños: %llu (respalda esto también)",
"lite_birthday_hint": "Altura de bloque desde la que empezar a escanear. Deja 0 si se desconoce (escaneo completo más lento).",
@@ -647,9 +818,12 @@
"lite_console_backend_commands": "Comandos del backend:",
"lite_console_help_passthrough": "Cualquier otra entrada se ejecuta como un comando de consola de la cartera lite.",
"lite_copy": "Copiar",
+ "lite_could_not_start": "No se pudo iniciar la operación",
"lite_could_not_write": "No se pudo escribir ",
"lite_encrypt_wallet": "Cifrar cartera",
"lite_encryption_removed": "Cifrado eliminado",
+ "lite_enter_all_seed_words": "Ingrese las 24 palabras semilla para restaurar (se obtuvieron %d)",
+ "lite_enter_wallet_path": "Ingrese una ruta de billetera",
"lite_hide_wipe": "Ocultar y borrar",
"lite_import": "Importar",
"lite_import_key_label": "Importar clave",
@@ -734,6 +908,9 @@
"lite_working": "Trabajando…",
"loading": "Cargando...",
"loading_addresses": "Cargando direcciones...",
+ "loading_stall_body": "El daemon lleva %.0f s inicializándose. Esto puede ser normal tras una actualización o en el primer inicio (cargando el índice de bloques o reescaneando); se conectará automáticamente cuando esté listo.",
+ "loading_stall_hint": "¿Sigue bloqueado? Abre Ajustes y usa Reiniciar daemon, o revisa la Consola para más detalles.",
+ "loading_stall_title": "Está tardando más de lo esperado",
"loading_transactions": "Cargando transacciones",
"local_hashrate": "Tasa Hash Local",
"low_spec_mode": "Modo bajo rendimiento",
@@ -866,6 +1043,7 @@
"mining_difficulty_copied": "Dificultad copiada",
"mining_est_block": "Bloque Est.",
"mining_est_daily": "Diario Est.",
+ "mining_est_daily_pool_sub": "equivalente solo aproximado, antes de la comisión del pool",
"mining_filter_all": "Todos",
"mining_filter_tip_all": "Mostrar todas las ganancias",
"mining_filter_tip_pool": "Mostrar solo ganancias del pool",
@@ -894,10 +1072,12 @@
"mining_open_in_explorer": "Abrir en explorador",
"mining_payout_address": "Dirección de Pago",
"mining_payout_foreign": "⚠ Esta dirección de pago no está en tu cartera actual — las recompensas minadas irían a otra cartera. Actualízala si cambiaste de cartera.",
+ "mining_payout_invalid": "No es una dirección DragonX válida — corrígela antes de empezar, o se pierden las recompensas de minería.",
"mining_payout_tooltip": "Dirección para recibir recompensas de minería",
"mining_pool": "Pool",
"mining_pool_fee": "Comisión",
"mining_pool_hashrate": "Hashrate del Pool",
+ "mining_pool_needs_payout_tooltip": "Ingrese primero una dirección de pago (genere una dirección Z)",
"mining_pool_url": "URL del Pool",
"mining_pools_header": "POOLS",
"mining_recent_blocks": "BLOQUES RECIENTES",
@@ -927,6 +1107,9 @@
"mining_syncing_tooltip": "El blockchain está sincronizando...",
"mining_tag": " · Minería",
"mining_threads": "Hilos de Minería",
+ "mining_threads_input_tooltip": "Escribe un número exacto de hilos (pulsa Enter para aplicar)",
+ "mining_threads_minus_tooltip": "Menos hilos",
+ "mining_threads_plus_tooltip": "Más hilos",
"mining_to_save": "para guardar",
"mining_today": "Hoy",
"mining_uptime": "Tiempo activo",
@@ -953,6 +1136,11 @@
"no_transactions": "No se encontraron transacciones",
"no_transactions_yet": "Aún no hay transacciones",
"node": "NODO",
+ "node_banner_crashed_title": "El nodo se detuvo inesperadamente",
+ "node_banner_lite_open_failed": "No se pudo abrir tu monedero",
+ "node_banner_offline_title": "No conectado al nodo DragonX",
+ "node_banner_reconnect": "Reconectar",
+ "node_banner_restart": "Reiniciar nodo",
"node_security": "NODO Y SEGURIDAD",
"noise": "Ruido",
"not_connected": "No conectado al daemon...",
@@ -1091,6 +1279,8 @@
"qr_failed": "Error al generar código QR",
"qr_title": "Código QR",
"qr_unavailable": "QR no disponible",
+ "quick_receive": "Recepción rápida",
+ "quick_send": "Envío rápido",
"ram_daemon_gb": "Daemon: %.1f GB (%s)",
"ram_daemon_mb": "Daemon: %.0f MB (%s)",
"ram_system_gb": "Sistema: %.1f / %.0f GB",
@@ -1140,6 +1330,7 @@
"rpc_connection": "Conexión RPC...",
"rpc_host": "Host RPC",
"rpc_pass": "Contraseña",
+ "rpc_plaintext_remote_warning": "El RPC remoto está usando HTTP sin cifrar. Agregue rpctls=1 a DRAGONX.conf si su daemon admite TLS.",
"rpc_port": "Puerto",
"rpc_user": "Usuario",
"save": "Guardar",
@@ -1154,6 +1345,8 @@
"sb_connecting_external": "Conectando a daemon externo...",
"sb_connecting_generic": "Conectando al daemon...",
"sb_daemon_crashed": "El daemon se bloqueó %d veces",
+ "sb_daemon_extract_failed": "No se pudieron escribir los archivos del daemon: comprueba el espacio libre en disco y los permisos.",
+ "sb_daemon_files_failed": "No se pudieron escribir los archivos del daemon en %s: comprueba el espacio libre en disco y los permisos.",
"sb_daemon_not_found": "Daemon no encontrado",
"sb_daemon_start_failed": "No se pudo iniciar dragonxd",
"sb_dragonxd_running": "dragonxd ejecutándose",
@@ -1169,6 +1362,7 @@
"sb_net_mhs": "Red: %.2f MH/s",
"sb_no_conf": "DRAGONX.conf no encontrado",
"sb_peers": "Pares: %zu",
+ "sb_plaintext_remote_blocked": "Se rechaza enviar credenciales RPC en texto plano a un host remoto. Añade rpcallowplaintext=1 a DRAGONX.conf para permitirlo, o habilita TLS con rpctls=1.",
"sb_rescanning": "Reescaneando",
"sb_rescanning_pct": "Reescaneando %.0f%%",
"sb_restarting_daemon": "Reiniciando daemon...",
@@ -1182,12 +1376,52 @@
"sb_waiting_daemon_err": "Esperando a dragonxd — %s",
"sb_warming_up": "Calentando...",
"sb_witness_cache": "Reconstruyendo testigos",
+ "scale_effects": "ESCALA Y EFECTOS",
"screenshot_open_dir": "Abrir ubicación",
"screenshot_sweep": "Ejecutar barrido de capturas",
"screenshot_sweep_desc": "Recorre cada tema en cada pestaña y guarda una captura de pantalla de cada una en subcarpetas por pestaña dentro de la carpeta de capturas del directorio de configuración (sobrescribiendo el barrido anterior). Se ejecuta durante unos segundos.",
"screenshot_sweep_full": "Barrido completo de la interfaz",
"search_icons": "Buscar iconos...",
"search_placeholder": "Buscar...",
+ "sec_changing_passphrase": "Cambiando la frase de contraseña...",
+ "sec_changing_pin": "Cambiando el PIN...",
+ "sec_couldnt_lock_wallet": "No se pudo bloquear la cartera; sigue desbloqueada. Comprueba la conexión con el daemon.",
+ "sec_encrypted_backup_suffix": "\nCopia de seguridad cifrada: wallet.dat.encrypted.bak",
+ "sec_encrypting_wallet": "Cifrando la cartera...",
+ "sec_encryption_did_not_complete": "El cifrado de la cartera no se completó; tu cartera NO está cifrada. Abre Ajustes para terminar de cifrarla.",
+ "sec_encryption_failed_prefix": "Error de cifrado: ",
+ "sec_failed_prefix": "Error: ",
+ "sec_failed_to_create_vault": "No se pudo crear la bóveda",
+ "sec_importing_keys_rescanning": "Importando claves y volviendo a escanear la cadena de bloques; la cartera se puede usar mientras tanto",
+ "sec_incorrect_current_pin": "PIN actual incorrecto",
+ "sec_incorrect_passphrase_decrypt": "Frase de contraseña incorrecta",
+ "sec_incorrect_passphrase_pin_setup": "Frase de contraseña incorrecta",
+ "sec_incorrect_pin_remove": "PIN incorrecto",
+ "sec_internal_error_change_pin": "Error interno",
+ "sec_internal_error_remove_pin": "Error interno",
+ "sec_mode_passphrase": " Frase de contraseña",
+ "sec_not_connected_to_daemon": "Sin conexión con el daemon",
+ "sec_not_connected_to_daemon_pin": "Sin conexión con el daemon",
+ "sec_passphrase_changed_successfully": "Frase de contraseña cambiada correctamente",
+ "sec_pin_changed_successfully": "PIN cambiado correctamente",
+ "sec_pin_removed": "PIN eliminado",
+ "sec_pin_set_successfully": "PIN configurado correctamente",
+ "sec_restart_daemon_for_encryption": "Reinicia el daemon para que el cifrado surta efecto.",
+ "sec_too_many_attempts_wait": "Demasiados intentos. Espera %.0f segundos...",
+ "sec_total_elapsed_fmt": "Tiempo total: %dm %02ds",
+ "sec_unlock_button": "Desbloquear",
+ "sec_unlock_failed_prefix": "Error al desbloquear: ",
+ "sec_unlocking_fmt": "Desbloqueando%s",
+ "sec_use_passphrase_instead": "Usar frase de contraseña en su lugar",
+ "sec_use_pin_instead": "Usar PIN en su lugar",
+ "sec_verifying_passphrase": "Verificando la frase de contraseña...",
+ "sec_verifying_pin": "Verificando el PIN...",
+ "sec_wallet_decrypted_all_keys_imported": "¡Cartera descifrada correctamente! Todas las claves importadas.",
+ "sec_wallet_encrypted_and_pin_set": "Cartera cifrada y PIN configurado",
+ "sec_wallet_encrypted_but_pin_vault_failed": "Cartera cifrada, pero falló la bóveda del PIN",
+ "sec_wallet_encrypted_restarting_daemon": "Cartera cifrada. Reiniciando el daemon...",
+ "sec_wallet_encrypted_successfully": "Cartera cifrada correctamente",
+ "sec_wallet_locked_title": "Cartera bloqueada",
"security": "SEGURIDAD",
"seed_backup_button": "Frase de recuperación",
"seed_backup_close": "Cerrar",
@@ -1246,6 +1480,7 @@
"send_tooltip_not_connected": "No conectado al daemon",
"send_tooltip_select_source": "Selecciona una dirección de origen primero",
"send_tooltip_syncing": "Espera a que se sincronice el blockchain",
+ "send_tooltip_view_only": "Dirección de solo vista — sin clave de gasto, no se puede enviar",
"send_total": "Total",
"send_transaction": "Enviar Transacción",
"send_tx_failed": "Error en la transacción",
@@ -1265,16 +1500,16 @@
"sent_filter": "Enviado",
"sent_type": "Enviado",
"sent_upper": "ENVIADO",
- "set_label": "Establecer Etiqueta...",
+ "set_label": "Establecer Etiqueta",
"settings": "Ajustes",
"settings_about_text": "Una billetera de criptomonedas blindada para DragonX (DRGX), creada con Dear ImGui para una experiencia ligera y portátil.",
"settings_acrylic_level": "Nivel de acrílico:",
- "settings_address_book": "Libreta de direcciones...",
+ "settings_address_book": "Libreta de direcciones…",
"settings_auto_detected": "Autodetectado de DRAGONX.conf",
"settings_auto_lock": "BLOQUEO AUTOMÁTICO",
"settings_auto_shield_desc": "Mover automáticamente fondos transparentes a direcciones blindadas",
"settings_auto_shield_funds": "Blindar fondos transparentes automáticamente",
- "settings_backup": "Respaldo...",
+ "settings_backup": "Respaldo…",
"settings_block_explorer_urls": "URLs del explorador de bloques",
"settings_builtin": "Integrado",
"settings_change_passphrase": "Cambiar contraseña",
@@ -1285,60 +1520,71 @@
"settings_configure_explorer": "Configurar enlaces de explorador de bloques externo",
"settings_configure_rpc": "Configurar conexión al daemon dragonxd",
"settings_connection": "Conexión",
+ "settings_copy_diagnostics": "Copiar diagnósticos",
"settings_copyright": "Copyright 2024-2026 Desarrolladores de DragonX | Licencia GPLv3",
"settings_custom": "Personalizado",
- "settings_data_dir": "Dir. de datos:",
+ "settings_data_dir": "Dir. de datos",
"settings_debug_changed": "Categorías de depuración cambiadas — reinicie el daemon para aplicar",
"settings_debug_restart_note": "Los cambios surten efecto después de reiniciar el daemon.",
"settings_debug_select": "Seleccione categorías para habilitar el registro de depuración del daemon (flags -debug=).",
+ "settings_diagnostics_copied": "Diagnósticos copiados al portapapeles",
"settings_encrypt_first_pin": "Primero cifre la billetera para habilitar el PIN",
"settings_encrypt_wallet": "Cifrar billetera",
"settings_explorer_hint": "Las URLs deben incluir una barra final. Se añadirá el txid/dirección.",
- "settings_export_all": "Exportar todo...",
- "settings_export_csv": "Exportar CSV...",
- "settings_export_key": "Exportar clave...",
+ "settings_export_all": "Exportar todo…",
+ "settings_export_csv": "Exportar CSV…",
+ "settings_export_key": "Exportar clave…",
"settings_gradient_bg": "Fondo degradado",
"settings_gradient_desc": "Reemplazar fondos con texturas por degradados suaves",
"settings_idle_after": "después de",
- "settings_import_key": "Importar Clave Privada...",
- "settings_import_viewkey": "Importar clave de visualización...",
+ "settings_import_key": "Importar Clave Privada…",
+ "settings_import_viewkey": "Importar clave de visualización…",
"settings_language_note": "Nota: Parte del texto requiere reinicio para actualizarse",
"settings_lock_now": "Bloquear ahora",
"settings_locked": "Bloqueado",
- "settings_merge_to_address": "Fusionar a dirección...",
+ "settings_merge_to_address": "Fusionar a dirección…",
"settings_noise_opacity": "Opacidad de ruido:",
+ "settings_not_connected": "No conectado al daemon",
"settings_not_encrypted": "Sin cifrar",
"settings_not_found": "No encontrado",
"settings_open_app_dir": "Abrir carpeta de la aplicación",
"settings_open_data_dir": "Abrir carpeta de datos",
+ "settings_open_log_folder": "Abrir carpeta de registros",
"settings_other": "Otros",
"settings_pin_active": "PIN",
"settings_privacy": "Privacidad",
"settings_quick_unlock_pin": "PIN de desbloqueo rápido",
"settings_reduce_transparency": "Reducir transparencia",
+ "settings_reloaded": "Configuración recargada desde el disco",
"settings_remove_encryption": "Quitar cifrado",
"settings_remove_pin": "Quitar PIN",
- "settings_request_payment": "Solicitar pago...",
+ "settings_request_payment": "Solicitar pago…",
"settings_rescan_desc": "Reescanear la cadena de bloques en busca de transacciones faltantes",
"settings_restart_daemon": "Reiniciar daemon",
"settings_rpc_connection": "Conexión RPC",
+ "settings_rpc_error_prefix": "Error de RPC: ",
"settings_rpc_note": "Nota: Los ajustes de conexión se detectan automáticamente desde DRAGONX.conf",
+ "settings_rpc_ok": "Conexión RPC correcta",
"settings_save_shielded_desc": "Almacena transacciones z-addr en un archivo local para visualización",
"settings_save_shielded_local": "Guardar historial de transacciones blindadas localmente",
+ "settings_saved": "Configuración guardada",
"settings_set_pin": "Establecer PIN",
- "settings_shield_mining": "Blindar minería...",
+ "settings_shield_mining": "Blindar minería…",
"settings_solid_colors_desc": "Usar colores sólidos en lugar de efectos de desenfoque (accesibilidad)",
+ "settings_theme_refreshed": "Lista de temas actualizada",
"settings_tor_desc": "Enrutar todas las conexiones a través de Tor para mayor privacidad",
"settings_unlocked": "Desbloqueado",
"settings_use_tor_network": "Usar Tor para conexiones de red",
- "settings_validate_address": "Validar dirección...",
+ "settings_validate_address": "Validar dirección…",
"settings_visual_effects": "Efectos visuales",
"settings_wallet_file_size": "Tamaño del archivo de billetera: %s",
"settings_wallet_info": "Información de billetera",
"settings_wallet_location": "Ubicación de billetera: %s",
"settings_wallet_maintenance": "Mantenimiento de billetera",
"settings_wallet_not_found": "Archivo de billetera no encontrado",
- "settings_wallet_size_label": "Tamaño de billetera:",
+ "settings_wallet_size_label": "Tamaño de billetera",
+ "settings_ztx_cleared": "Historial de transacciones Z borrado",
+ "settings_ztx_not_found": "No se encontró archivo de historial",
"setup_wizard": "Asistente de Configuración",
"share": "Compartir",
"shield_check_status": "Verificar Estado",
@@ -1397,6 +1643,17 @@
"sweep_to": "Barrido a:",
"sweep_toggle": "Barrer a mi monedero (no conservar la clave)",
"sweep_tx": "Transacción:",
+ "swin_connection_failed": "Error de conexión: ",
+ "swin_connection_successful": "¡Conexión correcta!\nVersión de dragonxd: ",
+ "swin_invalid_suffix": " (no válido)",
+ "swin_no_history_file_found": "No se encontró ningún archivo de historial",
+ "swin_rescan_failed": "Error en el reescaneo: ",
+ "swin_rescan_started_from_block": "Reescaneo iniciado desde el bloque ",
+ "swin_rescan_to": " hasta ",
+ "swin_rpc_client_not_initialized": "Cliente RPC no inicializado",
+ "swin_settings_saved": "Ajustes guardados",
+ "swin_theme_list_refreshed": "Lista de temas actualizada",
+ "swin_ztx_history_cleared": "Historial de transacciones Z borrado",
"switch_corrupt_body": "Esta cartera parece dañada: el nodo no pudo abrirla. Restáurala desde una copia de seguridad, vuelve a crearla o intenta repararla.",
"switch_corrupt_repair": "Intentar reparar (salvage)",
"switch_progress_background": "Continuar en segundo plano",
@@ -1421,6 +1678,7 @@
"theme": "Tema",
"theme_effects": "Efectos de tema",
"theme_language": "TEMA E IDIOMA",
+ "tile_click_to_open": "Clic para abrir",
"time_days_ago": "hace %d días",
"time_hours_ago": "hace %d horas",
"time_minutes_ago": "hace %d minutos",
@@ -1435,7 +1693,9 @@
"to_upper": "PARA",
"tools": "HERRAMIENTAS",
"tools_actions": "Herramientas y Acciones...",
+ "tools_actions_hdr": "HERRAMIENTAS Y ACCIONES",
"total": "Total",
+ "total_balance_label": "Saldo Total",
"transaction_id": "ID DE TRANSACCIÓN",
"transaction_sent": "Transacción enviada exitosamente",
"transaction_sent_msg": "¡Transacción enviada!",
@@ -1457,7 +1717,7 @@
"tt_auto_shield": "Mover automáticamente el saldo transparente a direcciones blindadas para privacidad",
"tt_backup": "Crear una copia de seguridad de su wallet.dat",
"tt_block_explorer": "Abrir el explorador de bloques DragonX en su navegador",
- "tt_blur": "Cantidad de desenfoque (0%% = apagado, 100%% = máximo)",
+ "tt_blur": "Cantidad de desenfoque (0% = apagado, 100% = máximo)",
"tt_change_pass": "Cambiar la contraseña de cifrado de la billetera",
"tt_change_pin": "Cambiar su PIN de desbloqueo",
"tt_chat_bubble_accent": "Color de acento para tus burbujas de mensaje salientes (o sigue el tema actual)",
@@ -1470,6 +1730,7 @@
"tt_chat_timestamp": "Formato de marca de tiempo solo para esta pestaña: seguir el reloj de toda la app, o forzar 24-hour o 12-hour",
"tt_clear_ztx": "Eliminar historial de z-transacciones en caché local",
"tt_clock_format": "Reloj de 24 o 12 horas, en toda la app. El chat puede anularlo.",
+ "tt_copy_diagnostics": "Copia al portapapeles un resumen para soporte (versión, estado de daemon/cartera/registros, sin datos secretos)",
"tt_custom_fees": "Habilitar entrada manual de comisiones al enviar transacciones",
"tt_custom_theme": "Tema personalizado activo",
"tt_daemon_install_bundled": "Detiene el nodo, sobrescribe el dragonxd instalado con la versión incluida en esta compilación de la cartera y luego lo reinicia",
@@ -1519,10 +1780,11 @@
"tt_low_spec": "Desactivar todos los efectos visuales pesados\\nAtajo: Ctrl+Shift+Down",
"tt_merge": "Consolidar múltiples UTXOs en una dirección",
"tt_mine_idle": "Iniciar minería automáticamente cuando el\\nsistema esté inactivo (sin entrada de teclado/ratón)",
- "tt_noise": "Intensidad de textura granulada (0%% = apagado, 100%% = máximo)",
+ "tt_noise": "Intensidad de textura granulada (0% = apagado, 100% = máximo)",
"tt_open_app_dir": "Abrir la carpeta de ObsidianDragon (configuración, temas, registros) en el explorador de archivos",
"tt_open_data_dir": "Abre en el gestor de archivos la carpeta con los datos de tu cartera y de la blockchain",
"tt_open_dir": "Clic para abrir en explorador de archivos",
+ "tt_open_log_folder": "Abre la carpeta que contiene los registros de depuración y de fallos",
"tt_reduce_motion": "Desactivar transiciones animadas y lerp de saldo para accesibilidad",
"tt_remove_encrypt": "Quitar cifrado y almacenar la billetera sin protección",
"tt_remove_pin": "Quitar PIN y requerir contraseña para desbloquear",
@@ -1557,7 +1819,7 @@
"tt_theme_hotkey": "Atajo: Ctrl+Izquierda/Derecha para cambiar temas",
"tt_tor": "Enrutar conexiones del daemon a través de la red Tor para anonimato",
"tt_tx_url": "URL base para ver transacciones en un explorador de bloques",
- "tt_ui_opacity": "Opacidad de tarjetas y barra lateral (100%% = totalmente opaco, menor = más transparente)",
+ "tt_ui_opacity": "Opacidad de tarjetas y barra lateral (100% = totalmente opaco, menor = más transparente)",
"tt_validate": "Comprobar si una dirección DragonX es válida",
"tt_verbose": "Registrar diagnósticos detallados de conexión,\\nestado del daemon e info de propietario de puerto\\nen la pestaña de Consola",
"tt_wallets_button": "Enumera tus archivos de cartera y cambia entre ellos",
@@ -1610,6 +1872,7 @@
"validate_not_mine": "No es propiedad de esta cartera",
"validate_ownership": "Propiedad:",
"validate_results": "Resultados:",
+ "validate_results_placeholder": "Los resultados aparecerán aquí",
"validate_shielded_type": "Protegida (dirección z)",
"validate_status": "Estado:",
"validate_title": "Validar Dirección",
@@ -1750,6 +2013,7 @@
"xmrig_loading_releases": "Cargando versiones…",
"xmrig_none": "ninguno",
"xmrig_reinstall": "Reinstalar",
+ "xmrig_releases": "versiones de xmrig",
"xmrig_stop_mining_first": "Detén la minería antes de actualizar el minero.",
"xmrig_unavailable_body": "No hay ninguna versión del minero disponible para esta plataforma.",
"xmrig_unavailable_title": "Actualizaciones del minero no disponibles",
diff --git a/res/lang/fr.json b/res/lang/fr.json
index e26f9a2..80e2b96 100644
--- a/res/lang/fr.json
+++ b/res/lang/fr.json
@@ -48,6 +48,10 @@
"advanced": "AVANCÉ",
"advanced_effects": "Effets avancés...",
"ago": "passé",
+ "alerts_clear": "Effacer l'historique des alertes",
+ "alerts_history_tooltip": "Alertes récentes",
+ "alerts_none": "Aucune alerte pour l'instant",
+ "alerts_recent": "ALERTES RÉCENTES",
"all_filter": "Tout",
"allow_custom_fees": "Autoriser les frais personnalisés",
"amount": "Montant",
@@ -56,6 +60,80 @@
"amount_label": "Montant :",
"animate_avatars": "Animer les avatars",
"appearance": "APPARENCE",
+ "appx_back": "Retour",
+ "appx_back_up_seed_phrase_title": "Sauvegardez votre phrase de récupération",
+ "appx_birthday_block_height": "Naissance (hauteur de bloc) : %llu — sauvegardez-la également.",
+ "appx_blockchain_data_deleted": "Données de la blockchain supprimées (%d éléments). Le démon redémarre pour se resynchroniser depuis le réseau.",
+ "appx_blockchain_maintenance_in_progress": "Une opération de maintenance de la blockchain est déjà en cours.",
+ "appx_blockchain_rescan_complete": "Nouvelle analyse de la blockchain terminée",
+ "appx_bootstrap_complete_reconciling": "Amorçage terminé — réconciliation de votre portefeuille avec les nouvelles données de la chaîne.",
+ "appx_cancel": "Annuler",
+ "appx_cleaning_up": "Nettoyage...",
+ "appx_confirm_your_backup": "Confirmez votre sauvegarde",
+ "appx_copied_clipboard_autoclears": "Copié — le presse-papiers s'efface automatiquement dans 45 s",
+ "appx_copy": "Copier",
+ "appx_could_not_start_restore": "Impossible de démarrer la restauration",
+ "appx_create_failed_prefix": "Échec de la création : ",
+ "appx_creating_your_wallet": "Création de votre portefeuille…",
+ "appx_daemon_error": "Erreur du démon",
+ "appx_daemon_reinstall_in_progress": "La réinstallation du démon est déjà en cours.",
+ "appx_disconnecting": "Déconnexion...",
+ "appx_done": "Terminé",
+ "appx_dragonxd_output": "Sortie de dragonxd",
+ "appx_encrypting_wallet": "Chiffrement du portefeuille...",
+ "appx_fullnode_lifecycle_unavailable_lite": "Les actions de cycle de vie du nœud complet ne sont pas disponibles dans la version allégée",
+ "appx_installing_bundled_daemon": "Installation du démon intégré — le nœud va s'arrêter, se mettre à jour, puis redémarrer...",
+ "appx_invalid_payment_uri_prefix": "URI de paiement invalide : ",
+ "appx_ive_written_it_down": "Je les ai notés",
+ "appx_keep_node_running_and_quit": "Laisser le nœud actif et quitter",
+ "appx_last_block_n": "Dernier bloc : %d",
+ "appx_last_used_wallet_not_found_prefix": "Votre dernier fichier de portefeuille utilisé (",
+ "appx_last_used_wallet_not_found_suffix": ") est introuvable — le portefeuille par défaut a été ouvert à la place. Si vous l'avez déplacé, restaurez-le et revenez à celui-ci depuis la liste des portefeuilles.",
+ "appx_low_spec_mode_disabled": "Mode faibles ressources désactivé",
+ "appx_low_spec_mode_enabled": "Mode faibles ressources activé",
+ "appx_miner_stopped_prefix": "Mineur arrêté : ",
+ "appx_miner_stopped_unexpectedly": "Le mineur s'est arrêté de manière inattendue.",
+ "appx_n_min_n_sec": "%d min %d s",
+ "appx_n_seconds": "%d secondes",
+ "appx_no_bundled_daemon_to_install": "Cette version ne contient aucun démon intégré à installer",
+ "appx_no_embedded_daemon_to_install": "Cette version ne contient aucun démon embarqué à installer",
+ "appx_node_busy_restarting": "Le nœud est occupé à redémarrer — réessayez dans un instant.",
+ "appx_node_rebuilding_witness_cache": "Le nœud reconstruit son cache de témoins",
+ "appx_not_next_word": " — ce n'est pas le mot suivant",
+ "appx_payment_request_loaded": "Demande de paiement chargée",
+ "appx_pool_miner_connected_and_hashing": "Mineur de pool connecté et en cours de hachage.",
+ "appx_progress_n_of_n": "Progression : %d / %d",
+ "appx_rebuilding_sapling_note_witnesses": "Reconstruction des témoins de notes Sapling…",
+ "appx_rebuilding_witness_cache_blocks_left": "Reconstruction du cache de témoins %.0f%% — %d blocs restants",
+ "appx_rebuilding_witness_cache_pct": "Reconstruction du cache de témoins %.0f%%",
+ "appx_recovery_phrase_word_count": "La phrase de récupération doit comporter 24 mots — vous en avez %d.",
+ "appx_restarting_daemon_rescan_flag": "Redémarrage du démon avec l'option -rescan...",
+ "appx_restarting_daemon_zapwallettxes": "Redémarrage du démon avec -zapwallettxes=2 (réparation du portefeuille)...",
+ "appx_restoring_your_wallet": "Restauration de votre portefeuille…",
+ "appx_seed_backup_warning": "Ces 24 mots sont le SEUL moyen de restaurer votre portefeuille. Notez-les dans l'ordre, conservez-les hors ligne et ne les partagez jamais. Si vous les perdez, vos fonds seront perdus à jamais.",
+ "appx_seed_not_backed_up_warning": "Vous n'avez pas sauvegardé votre phrase de récupération — des fonds pourraient être perdus. Ignorer quand même ?",
+ "appx_sending_stop_command_to_daemon": "Envoi de la commande d'arrêt au démon...",
+ "appx_setting_initial_sapling_witnesses": "Définition des témoins Sapling initiaux %.0f%%",
+ "appx_shutdown_complete": "Arrêt terminé",
+ "appx_simple_background_disabled": "Arrière-plan simple désactivé",
+ "appx_simple_background_enabled": "Arrière-plan simple activé",
+ "appx_skip": "Ignorer",
+ "appx_skip_anyway": "Ignorer quand même",
+ "appx_still_status_prefix": "Toujours « ",
+ "appx_still_status_suffix": " » — forcer la fermeture maintenant peut corrompre les données de la chaîne.",
+ "appx_stop_anyway_and_quit": "Arrêter quand même et quitter",
+ "appx_stopping_daemon_deleting_blockchain": "Arrêt du démon et suppression des données de la blockchain...",
+ "appx_stopping_node_discards_rebuild": "Arrêter le nœud maintenant abandonne la reconstruction en cours et la relance (plusieurs minutes) à la prochaine ouverture du portefeuille. Vous pouvez plutôt laisser le nœud en marche.",
+ "appx_stopping_pool_miner": "Arrêt du mineur de pool...",
+ "appx_syncing_pct_block_n_of_n": "Synchronisation %.1f%% — Bloc %d / %d",
+ "appx_tap_words_in_order": "Touchez les mots dans le bon ordre pour confirmer que vous les avez enregistrés.",
+ "appx_theme_effects_disabled": "Effets de thème désactivés",
+ "appx_theme_effects_enabled": "Effets de thème activés",
+ "appx_theme_prefix": "Thème : ",
+ "appx_use_settings_restart_daemon_hint": "Utilisez Paramètres > Redémarrer le démon pour réessayer",
+ "appx_waiting_for_daemon_to_encrypt_wallet": "En attente du chiffrement du portefeuille par le démon...",
+ "appx_wallet_created_and_backed_up": "Portefeuille créé et sauvegardé.",
+ "appx_wallet_open_failed_prefix": "Échec de l'ouverture du portefeuille : ",
"auto_shield": "Auto-blindage du minage",
"av_intro": "Les logiciels de minage sont souvent signalés comme potentiellement indésirables. Suivez ces étapes pour activer le minage en pool :",
"av_open_security": "Ouvrir Sécurité Windows",
@@ -70,6 +148,9 @@
"av_title": "Windows Defender a bloqué le mineur",
"available": "Disponible",
"backup_backing_up": "Sauvegarde en cours...",
+ "backup_col_backup": "SAUVEGARDE",
+ "backup_col_export": "EXPORTER",
+ "backup_col_import": "IMPORTER ET RESTAURER",
"backup_create": "Créer une sauvegarde",
"backup_created": "Sauvegarde du portefeuille créée",
"backup_data": "SAUVEGARDE & DONNÉES",
@@ -88,8 +169,22 @@
"balance": "Solde",
"balance_history_collecting": "Historique du solde — collecte des données...",
"balance_layout": "Disposition du solde",
+ "balance_layout_switched": "Disposition : %s",
+ "balance_mining_rate": "Minage %s",
"balance_shielded_fmt": "Blindé : %.8f",
+ "balance_syncing_pct": "Synchronisation %.1f%%",
"balance_transparent_fmt": "Transparent : %.8f",
+ "baltab_market": "Marché",
+ "baltab_market_price_4dp": "Marché : %.4f $",
+ "baltab_market_price_8dp": "Marché : %.8f $",
+ "baltab_pct_change_24h": "%s%.1f%% 24h",
+ "baltab_pct_of_total_zaddr": "%.0f%% du total · %d Z-addr",
+ "baltab_shielded": "Protégé",
+ "baltab_shielded_amount": "Protégé %.8f",
+ "baltab_t_addresses_count": "%d T-addresses",
+ "baltab_total_balance": "Solde total",
+ "baltab_transparent": "Transparent",
+ "baltab_transparent_amount": "Transparent %.8f",
"ban": "Bannir",
"banned_peers": "Pairs bannis",
"block": "Bloc",
@@ -128,6 +223,7 @@
"bootstrap_verifying": "Vérification des sommes de contrôle...",
"bootstrap_wallet_protected": "(wallet.dat est protégé)",
"bootstrap_warning": "Les données de blocs existantes (blocks, chainstate, notarizations) seront supprimées et remplacées. Votre wallet.dat ne sera PAS modifié ni supprimé.",
+ "byte_count_fmt": "%zu / %zu octets",
"cancel": "Annuler",
"change_pass_confirm": "Confirmer la nouvelle :",
"change_pass_current": "Phrase secrète actuelle :",
@@ -403,6 +499,7 @@
"contacts_shape_square": "Carré",
"contacts_shape_tab": "Onglet",
"copied": "Copié !",
+ "copied_to_clipboard": "Copié dans le presse-papiers",
"copy": "Copier",
"copy_address": "Copier l'adresse complète",
"copy_error": "Copier l'erreur",
@@ -415,6 +512,7 @@
"daemon_bundled": "Intégré",
"daemon_install_bundled": "Installer la version intégrée",
"daemon_installed": "Installé",
+ "daemon_maintenance_label": "MAINTENANCE",
"daemon_none_bundled": "aucun dans cette version",
"daemon_not_installed": "non installé",
"daemon_status_differ": "Le binaire installé diffère de la version intégrée.",
@@ -437,6 +535,7 @@
"daemon_update_latest": "Dernière :",
"daemon_update_loading": "Chargement des versions…",
"daemon_update_now": "Mettre à jour",
+ "daemon_update_prompt_title": "Mettre à jour le démon du nœud ?",
"daemon_update_reinstall": "Réinstaller",
"daemon_update_restart_note": "Redémarrez le daemon pour lancer la nouvelle version.",
"daemon_update_restart_now": "Redémarrer le daemon maintenant",
@@ -449,8 +548,11 @@
"daemon_update_verify_note": "Le téléchargement est vérifié par rapport au SHA-256 publié de la version et à une signature ed25519 épinglée avant l'installation.",
"daemon_update_verifying": "Vérification…",
"daemon_update_version": "Version :",
+ "daemon_updates_label": "MISES À JOUR",
"daemon_version": "Daemon",
"dark": "Sombre",
+ "data_stale_prefix": "Mis à jour",
+ "data_stale_tooltip": "Le solde est peut-être obsolète — le portefeuille n'a pas reçu de mise à jour récente. Vérifiez la connexion à votre nœud.",
"date": "Date",
"date_label": "Date :",
"debug_logging": "JOURNALISATION DE DÉBOGAGE",
@@ -479,6 +581,17 @@
"download_bootstrap": "Télécharger Bootstrap",
"dragonx_green": "DragonX (Vert)",
"edit": "Modifier",
+ "empty_wallet_keys_suffix": "clés",
+ "empty_wallet_open_manager": "Ouvrir le gestionnaire de portefeuilles",
+ "empty_wallet_restore": "Restaurer mon portefeuille",
+ "empty_wallet_salvage_body": "Ce portefeuille est vide car une réparation automatique antérieure a mis votre portefeuille d'origine de côté comme sauvegarde. Vos pièces se trouvent presque certainement dans cette sauvegarde, elles ne sont pas perdues. Restaurez-la pour recharger vos fonds — rien n'est supprimé ; le fichier actuel est d'abord mis de côté.",
+ "empty_wallet_salvage_headline": "Vos pièces sont en sécurité dans un fichier de sauvegarde.",
+ "empty_wallet_salvage_title": "Votre portefeuille a peut-être été réparé",
+ "empty_wallet_warning_body": "Ce portefeuille n'a aucune adresse ni fonds, mais un autre fichier de portefeuille dans votre dossier DragonX contient des clés. Vos pièces s'y trouvent très probablement, elles ne sont pas perdues. Ouvrez le gestionnaire de portefeuilles pour passer au portefeuille qui contient vos fonds.",
+ "empty_wallet_warning_dismiss": "Ne plus avertir pour ce portefeuille",
+ "empty_wallet_warning_dismiss_tip": "Arrête cet avertissement uniquement pour le fichier de portefeuille actuel. Si vous passez plus tard à un autre portefeuille vide, il pourra avertir à nouveau.",
+ "empty_wallet_warning_headline": "Vous avez peut-être ouvert le mauvais portefeuille.",
+ "empty_wallet_warning_title": "Ce portefeuille est vide",
"enc_confirm": "Confirmer :",
"enc_desc": "Chiffrer votre portefeuille protège vos clés privées avec une phrase secrète. Après le chiffrement, le daemon redémarrera.",
"enc_encrypting": "Chiffrement du portefeuille...",
@@ -566,6 +679,63 @@
"general": "Général",
"generating": "Génération",
"go_to_receive": "Aller à Recevoir",
+ "grpa_current_block_paren": "(Actuel : %d)",
+ "grpa_days_ago": "il y a %lld jours",
+ "grpa_dbg_addrman": "Suivi et gestion des adresses des pairs",
+ "grpa_dbg_alert": "Messages du système d'alerte",
+ "grpa_dbg_bench": "Chronométrage des performances des opérations",
+ "grpa_dbg_coindb": "Opérations de lecture/écriture de la base de données des pièces",
+ "grpa_dbg_db": "Opérations Berkeley DB",
+ "grpa_dbg_estimatefee": "Algorithme d'estimation des frais",
+ "grpa_dbg_http": "Activité du serveur RPC HTTP",
+ "grpa_dbg_libevent": "Bibliothèque réseau Libevent",
+ "grpa_dbg_lock": "Débogage de la contention des verrous",
+ "grpa_dbg_mempool": "Activité du pool mémoire des transactions",
+ "grpa_dbg_net": "Connexions et messages réseau",
+ "grpa_dbg_paymentdisclosure": "Protocole de divulgation de paiement",
+ "grpa_dbg_pow": "Activité de minage par preuve de travail",
+ "grpa_dbg_proxy": "Connexions proxy SOCKS5",
+ "grpa_dbg_prune": "Opérations d'élagage des blocs",
+ "grpa_dbg_rand": "Génération de nombres aléatoires",
+ "grpa_dbg_reindex": "Progression de la réindexation de la blockchain",
+ "grpa_dbg_rpc": "Traitement des commandes RPC",
+ "grpa_dbg_selectcoins": "Sélection des pièces pour les transactions",
+ "grpa_dbg_tor": "Intégration de Tor et informations de circuit",
+ "grpa_dbg_zmq": "Système de notification ZeroMQ",
+ "grpa_dbg_zrpc": "Opérations RPC protégées (z-addr)",
+ "grpa_enter_private_key_to_import": "Saisissez une clé privée à importer.",
+ "grpa_error_prefix": "Erreur : ",
+ "grpa_hr_ago": "il y a %lld h",
+ "grpa_invalid_response_from_daemon": "Réponse invalide du démon",
+ "grpa_invalid_suffix": " (invalide)",
+ "grpa_min_ago": "il y a %lld min",
+ "grpa_sec_ago": "il y a %lld s",
+ "grpa_seed_demo_chat": "Chat de démonstration seed",
+ "grpa_showing_first_100_of": "... affichage des 100 premiers sur %d",
+ "grpa_tab_about": "À propos",
+ "grpa_tab_appearance": "Apparence",
+ "grpa_tab_backup_data": "Sauvegarde et données",
+ "grpa_tab_chat": "Chat",
+ "grpa_tab_explorer": "Explorateur",
+ "grpa_tab_node_security": "Nœud et sécurité",
+ "grpa_tab_wallet": "Portefeuille",
+ "grpa_unexpected_getblockhash_result": "résultat getblockhash inattendu",
+ "grpb_copy": "Copier",
+ "grpb_max": "Max",
+ "grpb_new_badge_suffix": " [NOUVEAU]",
+ "grpb_preview_msg_payment_through": "Le paiement est-il passé ? 🙂",
+ "grpb_preview_msg_sending_rest": "J'envoie le reste maintenant 👍",
+ "grpb_preview_msg_yep_confirmed": "Oui — je viens de confirmer ✅",
+ "grpb_selected_suffix": "\n(sélectionné)",
+ "grpb_tooltip_address_balance": "%s\nSolde : %.8f %s%s",
+ "grpb_undo_clear": "Annuler l'effacement",
+ "grpc_benchmark_inconclusive": "Benchmark non concluant : aucun échantillon de taux de hachage n'a été enregistré. Vérifiez la connexion au pool et réessayez.",
+ "grpc_benchmark_takes_secs": "Le benchmark prend environ %d s et interrompt le minage. Cliquez de nouveau pour démarrer.",
+ "grpc_bootstrap_failed": "Échec de l'amorçage",
+ "grpc_bootstrap_not_initialized": "Amorçage non initialisé",
+ "grpc_hashrate_fee": "%s %s%% de frais",
+ "grpc_key_not_available": "Clé non disponible pour cette adresse",
+ "grpc_na": "N/D",
"height": "Hauteur",
"help": "Aide",
"hidden_tag": " (masqué)",
@@ -640,6 +810,7 @@
"light": "Clair",
"lite_account_label": "Compte",
"lite_action": "Action",
+ "lite_backend_unavailable": "Backend du portefeuille léger indisponible",
"lite_backup_keys": "Sauvegarde et clés",
"lite_birthday_backup": "Date de création : %llu (à sauvegarder également)",
"lite_birthday_hint": "Hauteur de bloc à partir de laquelle commencer l'analyse. Laissez 0 si inconnue (analyse complète plus lente).",
@@ -647,9 +818,12 @@
"lite_console_backend_commands": "Commandes du backend :",
"lite_console_help_passthrough": "Toute autre entrée est exécutée comme une commande de la console du portefeuille lite.",
"lite_copy": "Copier",
+ "lite_could_not_start": "Impossible de démarrer l'opération",
"lite_could_not_write": "Impossible d'écrire ",
"lite_encrypt_wallet": "Chiffrer le portefeuille",
"lite_encryption_removed": "Chiffrement supprimé",
+ "lite_enter_all_seed_words": "Entrez les 24 mots de la phrase de récupération (%d obtenus)",
+ "lite_enter_wallet_path": "Entrez un chemin de portefeuille",
"lite_hide_wipe": "Masquer et effacer",
"lite_import": "Importer",
"lite_import_key_label": "Importer une clé",
@@ -734,6 +908,9 @@
"lite_working": "En cours…",
"loading": "Chargement...",
"loading_addresses": "Chargement des adresses...",
+ "loading_stall_body": "Le démon s'initialise depuis %.0f s. Cela peut être normal après une mise à jour ou au premier lancement (chargement de l'index des blocs ou nouvelle analyse) — la connexion se fera automatiquement une fois prêt.",
+ "loading_stall_hint": "Toujours bloqué ? Ouvrez les Paramètres et utilisez Redémarrer le démon, ou consultez la Console pour plus de détails.",
+ "loading_stall_title": "Cela prend plus de temps que prévu",
"loading_transactions": "Chargement des transactions",
"local_hashrate": "Hashrate local",
"low_spec_mode": "Mode économie",
@@ -866,6 +1043,7 @@
"mining_difficulty_copied": "Difficulté copiée",
"mining_est_block": "Bloc est.",
"mining_est_daily": "Est. quotidien",
+ "mining_est_daily_pool_sub": "équivalent solo approximatif, avant les frais du pool",
"mining_filter_all": "Tout",
"mining_filter_tip_all": "Afficher tous les gains",
"mining_filter_tip_pool": "Afficher uniquement les gains du pool",
@@ -894,10 +1072,12 @@
"mining_open_in_explorer": "Ouvrir dans l'explorateur",
"mining_payout_address": "Adresse de paiement",
"mining_payout_foreign": "⚠ Cette adresse de paiement ne fait pas partie de votre portefeuille actuel — les récompenses minées iraient vers un autre portefeuille. Mettez-la à jour si vous avez changé de portefeuille.",
+ "mining_payout_invalid": "Adresse DragonX invalide — corrigez-la avant de démarrer, sinon les récompenses de minage sont perdues.",
"mining_payout_tooltip": "Adresse pour recevoir les récompenses de minage",
"mining_pool": "Pool",
"mining_pool_fee": "Frais",
"mining_pool_hashrate": "Hashrate du pool",
+ "mining_pool_needs_payout_tooltip": "Entrez d'abord une adresse de paiement (générez une adresse Z)",
"mining_pool_url": "URL du pool",
"mining_pools_header": "POOLS",
"mining_recent_blocks": "BLOCS RÉCENTS",
@@ -927,6 +1107,9 @@
"mining_syncing_tooltip": "La blockchain se synchronise...",
"mining_tag": " · Minage",
"mining_threads": "Threads de minage",
+ "mining_threads_input_tooltip": "Saisissez un nombre exact de threads (Entrée pour appliquer)",
+ "mining_threads_minus_tooltip": "Moins de threads",
+ "mining_threads_plus_tooltip": "Plus de threads",
"mining_to_save": "pour enregistrer",
"mining_today": "Aujourd'hui",
"mining_uptime": "Temps de fonctionnement",
@@ -953,6 +1136,11 @@
"no_transactions": "Aucune transaction trouvée",
"no_transactions_yet": "Aucune transaction pour le moment",
"node": "NŒUD",
+ "node_banner_crashed_title": "Le nœud s'est arrêté de façon inattendue",
+ "node_banner_lite_open_failed": "Impossible d'ouvrir votre portefeuille",
+ "node_banner_offline_title": "Non connecté au nœud DragonX",
+ "node_banner_reconnect": "Reconnecter",
+ "node_banner_restart": "Redémarrer le nœud",
"node_security": "NŒUD & SÉCURITÉ",
"noise": "Bruit",
"not_connected": "Non connecté au daemon...",
@@ -1091,6 +1279,8 @@
"qr_failed": "Échec de la génération du code QR",
"qr_title": "Code QR",
"qr_unavailable": "QR indisponible",
+ "quick_receive": "Réception rapide",
+ "quick_send": "Envoi rapide",
"ram_daemon_gb": "Daemon : %.1f Go (%s)",
"ram_daemon_mb": "Daemon : %.0f Mo (%s)",
"ram_system_gb": "Système : %.1f / %.0f Go",
@@ -1140,6 +1330,7 @@
"rpc_connection": "Connexion RPC...",
"rpc_host": "Hôte RPC",
"rpc_pass": "Mot de passe",
+ "rpc_plaintext_remote_warning": "Le RPC distant utilise du HTTP en clair. Ajoutez rpctls=1 à DRAGONX.conf si votre démon prend en charge TLS.",
"rpc_port": "Port",
"rpc_user": "Nom d'utilisateur",
"save": "Enregistrer",
@@ -1154,6 +1345,8 @@
"sb_connecting_external": "Connexion au daemon externe...",
"sb_connecting_generic": "Connexion au daemon...",
"sb_daemon_crashed": "Le daemon a planté %d fois",
+ "sb_daemon_extract_failed": "Échec de l'écriture des fichiers du démon — vérifiez l'espace disque libre et les permissions.",
+ "sb_daemon_files_failed": "Échec de l'écriture des fichiers du démon dans %s — vérifiez l'espace disque libre et les permissions.",
"sb_daemon_not_found": "Daemon introuvable",
"sb_daemon_start_failed": "Impossible de démarrer dragonxd",
"sb_dragonxd_running": "dragonxd en cours",
@@ -1169,6 +1362,7 @@
"sb_net_mhs": "Rés: %.2f MH/s",
"sb_no_conf": "DRAGONX.conf introuvable",
"sb_peers": "Pairs : %zu",
+ "sb_plaintext_remote_blocked": "Refus d'envoyer les identifiants RPC en clair vers un hôte distant. Ajoutez rpcallowplaintext=1 à DRAGONX.conf pour l'autoriser, ou activez TLS avec rpctls=1.",
"sb_rescanning": "Rescan",
"sb_rescanning_pct": "Rescan %.0f%%",
"sb_restarting_daemon": "Redémarrage du daemon...",
@@ -1182,12 +1376,52 @@
"sb_waiting_daemon_err": "En attente de dragonxd — %s",
"sb_warming_up": "Démarrage...",
"sb_witness_cache": "Reconstruction des témoins",
+ "scale_effects": "ÉCHELLE ET EFFETS",
"screenshot_open_dir": "Ouvrir l'emplacement",
"screenshot_sweep": "Lancer la capture d'écran",
"screenshot_sweep_desc": "Parcourt chaque thème sur chaque onglet et enregistre une capture d'écran de chacun dans des sous-dossiers par onglet, sous le dossier screenshots du répertoire de configuration (en écrasant le balayage précédent). Dure quelques secondes.",
"screenshot_sweep_full": "Balayage complet de l'interface",
"search_icons": "Rechercher des icônes...",
"search_placeholder": "Rechercher...",
+ "sec_changing_passphrase": "Modification de la phrase secrète...",
+ "sec_changing_pin": "Modification du PIN...",
+ "sec_couldnt_lock_wallet": "Impossible de verrouiller le portefeuille — il reste déverrouillé. Vérifiez la connexion au démon.",
+ "sec_encrypted_backup_suffix": "\nSauvegarde chiffrée : wallet.dat.encrypted.bak",
+ "sec_encrypting_wallet": "Chiffrement du portefeuille...",
+ "sec_encryption_did_not_complete": "Le chiffrement du portefeuille ne s'est pas terminé — votre portefeuille N'est PAS chiffré. Ouvrez les Paramètres pour terminer le chiffrement.",
+ "sec_encryption_failed_prefix": "Échec du chiffrement : ",
+ "sec_failed_prefix": "Échec : ",
+ "sec_failed_to_create_vault": "Échec de la création du coffre",
+ "sec_importing_keys_rescanning": "Importation des clés et nouvelle analyse de la blockchain — le portefeuille reste utilisable pendant l'opération",
+ "sec_incorrect_current_pin": "PIN actuel incorrect",
+ "sec_incorrect_passphrase_decrypt": "Phrase secrète incorrecte",
+ "sec_incorrect_passphrase_pin_setup": "Phrase secrète incorrecte",
+ "sec_incorrect_pin_remove": "PIN incorrect",
+ "sec_internal_error_change_pin": "Erreur interne",
+ "sec_internal_error_remove_pin": "Erreur interne",
+ "sec_mode_passphrase": " Phrase secrète",
+ "sec_not_connected_to_daemon": "Non connecté au démon",
+ "sec_not_connected_to_daemon_pin": "Non connecté au démon",
+ "sec_passphrase_changed_successfully": "Phrase secrète modifiée avec succès",
+ "sec_pin_changed_successfully": "PIN modifié avec succès",
+ "sec_pin_removed": "PIN supprimé",
+ "sec_pin_set_successfully": "PIN défini avec succès",
+ "sec_restart_daemon_for_encryption": "Veuillez redémarrer votre démon pour que le chiffrement prenne effet.",
+ "sec_too_many_attempts_wait": "Trop de tentatives. Patientez %.0f secondes...",
+ "sec_total_elapsed_fmt": "Temps total écoulé : %dm %02ds",
+ "sec_unlock_button": "Déverrouiller",
+ "sec_unlock_failed_prefix": "Échec du déverrouillage : ",
+ "sec_unlocking_fmt": "Déverrouillage%s",
+ "sec_use_passphrase_instead": "Utiliser une phrase secrète",
+ "sec_use_pin_instead": "Utiliser un PIN",
+ "sec_verifying_passphrase": "Vérification de la phrase secrète...",
+ "sec_verifying_pin": "Vérification du PIN...",
+ "sec_wallet_decrypted_all_keys_imported": "Portefeuille déchiffré avec succès ! Toutes les clés ont été importées.",
+ "sec_wallet_encrypted_and_pin_set": "Portefeuille chiffré et PIN défini",
+ "sec_wallet_encrypted_but_pin_vault_failed": "Portefeuille chiffré mais échec du coffre PIN",
+ "sec_wallet_encrypted_restarting_daemon": "Portefeuille chiffré. Redémarrage du démon...",
+ "sec_wallet_encrypted_successfully": "Portefeuille chiffré avec succès",
+ "sec_wallet_locked_title": "Portefeuille verrouillé",
"security": "SÉCURITÉ",
"seed_backup_button": "Phrase de récupération",
"seed_backup_close": "Fermer",
@@ -1246,6 +1480,7 @@
"send_tooltip_not_connected": "Non connecté au daemon",
"send_tooltip_select_source": "Sélectionnez d'abord une adresse source",
"send_tooltip_syncing": "Attendez la synchronisation de la blockchain",
+ "send_tooltip_view_only": "Adresse en lecture seule — pas de clé de dépense, envoi impossible",
"send_total": "Total",
"send_transaction": "Envoyer la transaction",
"send_tx_failed": "Transaction échouée",
@@ -1265,16 +1500,16 @@
"sent_filter": "Envoyé",
"sent_type": "Envoyé",
"sent_upper": "ENVOYÉ",
- "set_label": "Définir le libellé...",
+ "set_label": "Définir le libellé",
"settings": "Paramètres",
"settings_about_text": "Un portefeuille de cryptomonnaie blindé pour DragonX (DRGX), construit avec Dear ImGui pour une expérience légère et portable.",
"settings_acrylic_level": "Niveau acrylique :",
- "settings_address_book": "Carnet d'adresses...",
+ "settings_address_book": "Carnet d'adresses…",
"settings_auto_detected": "Détecté automatiquement depuis DRAGONX.conf",
"settings_auto_lock": "VERROUILLAGE AUTO",
"settings_auto_shield_desc": "Déplacer automatiquement les fonds transparents vers des adresses blindées",
"settings_auto_shield_funds": "Blindage automatique des fonds transparents",
- "settings_backup": "Sauvegarde...",
+ "settings_backup": "Sauvegarde…",
"settings_block_explorer_urls": "URLs de l'explorateur de blocs",
"settings_builtin": "Intégré",
"settings_change_passphrase": "Changer la phrase secrète",
@@ -1285,60 +1520,71 @@
"settings_configure_explorer": "Configurer les liens vers l'explorateur de blocs externe",
"settings_configure_rpc": "Configurer la connexion au daemon dragonxd",
"settings_connection": "Connexion",
+ "settings_copy_diagnostics": "Copier les diagnostics",
"settings_copyright": "Copyright 2024-2026 Développeurs DragonX | Licence GPLv3",
"settings_custom": "Personnalisé",
- "settings_data_dir": "Rép. de données :",
+ "settings_data_dir": "Rép. de données ",
"settings_debug_changed": "Catégories de débogage modifiées — redémarrez le daemon pour appliquer",
"settings_debug_restart_note": "Les modifications prennent effet après le redémarrage du daemon.",
"settings_debug_select": "Sélectionnez les catégories pour activer la journalisation de débogage du daemon (flags -debug=).",
+ "settings_diagnostics_copied": "Diagnostics copiés dans le presse-papiers",
"settings_encrypt_first_pin": "Chiffrez d'abord le portefeuille pour activer le PIN",
"settings_encrypt_wallet": "Chiffrer le portefeuille",
"settings_explorer_hint": "Les URLs doivent inclure une barre oblique finale. Le txid/adresse sera ajouté.",
- "settings_export_all": "Tout exporter...",
- "settings_export_csv": "Exporter CSV...",
- "settings_export_key": "Exporter la clé...",
+ "settings_export_all": "Tout exporter…",
+ "settings_export_csv": "Exporter CSV…",
+ "settings_export_key": "Exporter la clé…",
"settings_gradient_bg": "Fond dégradé",
"settings_gradient_desc": "Remplacer les arrière-plans texturés par des dégradés lisses",
"settings_idle_after": "après",
- "settings_import_key": "Importer une clé privée...",
- "settings_import_viewkey": "Importer la clé de visualisation...",
+ "settings_import_key": "Importer une clé privée…",
+ "settings_import_viewkey": "Importer la clé de visualisation…",
"settings_language_note": "Remarque : Certains textes nécessitent un redémarrage pour se mettre à jour",
"settings_lock_now": "Verrouiller maintenant",
"settings_locked": "Verrouillé",
- "settings_merge_to_address": "Fusionner vers l'adresse...",
+ "settings_merge_to_address": "Fusionner vers l'adresse…",
"settings_noise_opacity": "Opacité du bruit :",
+ "settings_not_connected": "Non connecté au démon",
"settings_not_encrypted": "Non chiffré",
"settings_not_found": "Non trouvé",
"settings_open_app_dir": "Ouvrir le dossier de l'application",
"settings_open_data_dir": "Ouvrir le dossier de données",
+ "settings_open_log_folder": "Ouvrir le dossier des journaux",
"settings_other": "Autres",
"settings_pin_active": "PIN",
"settings_privacy": "Confidentialité",
"settings_quick_unlock_pin": "PIN de déverrouillage rapide",
"settings_reduce_transparency": "Réduire la transparence",
+ "settings_reloaded": "Paramètres rechargés depuis le disque",
"settings_remove_encryption": "Supprimer le chiffrement",
"settings_remove_pin": "Supprimer le PIN",
- "settings_request_payment": "Demander un paiement...",
+ "settings_request_payment": "Demander un paiement…",
"settings_rescan_desc": "Rescanner la blockchain pour les transactions manquantes",
"settings_restart_daemon": "Redémarrer le daemon",
"settings_rpc_connection": "Connexion RPC",
+ "settings_rpc_error_prefix": "Erreur RPC : ",
"settings_rpc_note": "Remarque : Les paramètres de connexion sont généralement détectés automatiquement depuis DRAGONX.conf",
+ "settings_rpc_ok": "Connexion RPC OK",
"settings_save_shielded_desc": "Stocke les transactions z-addr dans un fichier local pour consultation",
"settings_save_shielded_local": "Enregistrer l'historique des transactions blindées localement",
+ "settings_saved": "Paramètres enregistrés",
"settings_set_pin": "Définir le PIN",
- "settings_shield_mining": "Blindage minage...",
+ "settings_shield_mining": "Blindage minage…",
"settings_solid_colors_desc": "Utiliser des couleurs unies au lieu des effets de flou (accessibilité)",
+ "settings_theme_refreshed": "Liste des thèmes actualisée",
"settings_tor_desc": "Acheminer toutes les connexions via Tor pour une confidentialité renforcée",
"settings_unlocked": "Déverrouillé",
"settings_use_tor_network": "Utiliser Tor pour les connexions réseau",
- "settings_validate_address": "Valider l'adresse...",
+ "settings_validate_address": "Valider l'adresse…",
"settings_visual_effects": "Effets visuels",
"settings_wallet_file_size": "Taille du fichier portefeuille : %s",
"settings_wallet_info": "Informations du portefeuille",
"settings_wallet_location": "Emplacement du portefeuille : %s",
"settings_wallet_maintenance": "Maintenance du portefeuille",
"settings_wallet_not_found": "Fichier portefeuille introuvable",
- "settings_wallet_size_label": "Taille du portefeuille :",
+ "settings_wallet_size_label": "Taille du portefeuille ",
+ "settings_ztx_cleared": "Historique des transactions Z effacé",
+ "settings_ztx_not_found": "Aucun fichier d'historique trouvé",
"setup_wizard": "Assistant de configuration",
"share": "Partager",
"shield_check_status": "Vérifier le statut",
@@ -1397,6 +1643,17 @@
"sweep_to": "Balayé vers :",
"sweep_toggle": "Balayer vers mon portefeuille (ne pas conserver la clé)",
"sweep_tx": "Transaction :",
+ "swin_connection_failed": "Échec de la connexion : ",
+ "swin_connection_successful": "Connexion réussie !\nVersion de dragonxd : ",
+ "swin_invalid_suffix": " (invalide)",
+ "swin_no_history_file_found": "Aucun fichier d'historique trouvé",
+ "swin_rescan_failed": "Échec de la nouvelle analyse : ",
+ "swin_rescan_started_from_block": "Nouvelle analyse démarrée à partir du bloc ",
+ "swin_rescan_to": " à ",
+ "swin_rpc_client_not_initialized": "Client RPC non initialisé",
+ "swin_settings_saved": "Paramètres enregistrés",
+ "swin_theme_list_refreshed": "Liste des thèmes actualisée",
+ "swin_ztx_history_cleared": "Historique des transactions Z effacé",
"switch_corrupt_body": "Ce portefeuille semble corrompu — le nœud n'a pas pu l'ouvrir. Restaurez-le depuis une sauvegarde, recréez-le ou tentez de le réparer.",
"switch_corrupt_repair": "Tenter une réparation (salvage)",
"switch_progress_background": "Continuer en arrière-plan",
@@ -1421,6 +1678,7 @@
"theme": "Thème",
"theme_effects": "Effets de thème",
"theme_language": "THÈME & LANGUE",
+ "tile_click_to_open": "Cliquer pour ouvrir",
"time_days_ago": "il y a %d jours",
"time_hours_ago": "il y a %d heures",
"time_minutes_ago": "il y a %d minutes",
@@ -1435,7 +1693,9 @@
"to_upper": "À",
"tools": "OUTILS",
"tools_actions": "Outils & Actions...",
+ "tools_actions_hdr": "OUTILS ET ACTIONS",
"total": "Total",
+ "total_balance_label": "Solde total",
"transaction_id": "ID DE TRANSACTION",
"transaction_sent": "Transaction envoyée avec succès",
"transaction_sent_msg": "Transaction envoyée !",
@@ -1457,7 +1717,7 @@
"tt_auto_shield": "Déplacer automatiquement le solde transparent vers des adresses blindées pour la confidentialité",
"tt_backup": "Créer une sauvegarde de votre wallet.dat",
"tt_block_explorer": "Ouvrir l'explorateur de blocs DragonX dans votre navigateur",
- "tt_blur": "Quantité de flou (0%% = désactivé, 100%% = maximum)",
+ "tt_blur": "Quantité de flou (0% = désactivé, 100% = maximum)",
"tt_change_pass": "Changer la phrase secrète de chiffrement du portefeuille",
"tt_change_pin": "Changer votre PIN de déverrouillage",
"tt_chat_bubble_accent": "Couleur d'accent de vos bulles de message sortantes (ou suivre le thème actuel)",
@@ -1470,6 +1730,7 @@
"tt_chat_timestamp": "Format d'horodatage pour cet onglet uniquement : suivre l'horloge de l'application, ou forcer 24-hour ou 12-hour",
"tt_clear_ztx": "Supprimer l'historique des z-transactions mis en cache localement",
"tt_clock_format": "Horloge 24 h ou 12 h, dans toute l'app. Le chat peut la remplacer.",
+ "tt_copy_diagnostics": "Copie un récapitulatif de support (version, état daemon/portefeuille/journaux — sans données secrètes) dans le presse-papiers",
"tt_custom_fees": "Activer la saisie manuelle des frais lors de l'envoi de transactions",
"tt_custom_theme": "Thème personnalisé actif",
"tt_daemon_install_bundled": "Arrêter le nœud, remplacer le dragonxd installé par la version intégrée dans cette version du portefeuille, puis redémarrer",
@@ -1519,10 +1780,11 @@
"tt_low_spec": "Désactiver tous les effets visuels lourds\\nRaccourci : Ctrl+Shift+Down",
"tt_merge": "Consolider plusieurs UTXOs vers une adresse",
"tt_mine_idle": "Démarrer le minage automatiquement quand le\\nsystème est inactif (aucune entrée clavier/souris)",
- "tt_noise": "Intensité de texture grainée (0%% = désactivé, 100%% = maximum)",
+ "tt_noise": "Intensité de texture grainée (0% = désactivé, 100% = maximum)",
"tt_open_app_dir": "Ouvrir le dossier ObsidianDragon (paramètres, thèmes, journaux) dans le gestionnaire de fichiers",
"tt_open_data_dir": "Ouvrir le dossier contenant les données de votre portefeuille et de la blockchain dans le gestionnaire de fichiers",
"tt_open_dir": "Cliquer pour ouvrir dans l'explorateur de fichiers",
+ "tt_open_log_folder": "Ouvre le dossier contenant les journaux de débogage et de plantage",
"tt_reduce_motion": "Désactiver les transitions animées et le lerp de solde pour l'accessibilité",
"tt_remove_encrypt": "Supprimer le chiffrement et stocker le portefeuille sans protection",
"tt_remove_pin": "Supprimer le PIN et exiger la phrase secrète pour déverrouiller",
@@ -1557,7 +1819,7 @@
"tt_theme_hotkey": "Raccourci : Ctrl+Gauche/Droite pour changer de thème",
"tt_tor": "Acheminer les connexions du daemon via le réseau Tor pour l'anonymat",
"tt_tx_url": "URL de base pour consulter les transactions dans un explorateur de blocs",
- "tt_ui_opacity": "Opacité des cartes et de la barre latérale (100%% = entièrement opaque, plus bas = plus transparent)",
+ "tt_ui_opacity": "Opacité des cartes et de la barre latérale (100% = entièrement opaque, plus bas = plus transparent)",
"tt_validate": "Vérifier si une adresse DragonX est valide",
"tt_verbose": "Journaliser les diagnostics de connexion détaillés,\\nl'état du daemon et les informations de propriétaire de port\\ndans l'onglet Console",
"tt_wallets_button": "Répertoriez vos fichiers de portefeuille et passez de l'un à l'autre",
@@ -1610,6 +1872,7 @@
"validate_not_mine": "N'appartient pas à ce portefeuille",
"validate_ownership": "Propriété :",
"validate_results": "Résultats :",
+ "validate_results_placeholder": "Les résultats apparaîtront ici",
"validate_shielded_type": "Blindée (z-adresse)",
"validate_status": "Statut :",
"validate_title": "Valider l'adresse",
@@ -1750,6 +2013,7 @@
"xmrig_loading_releases": "Chargement des versions…",
"xmrig_none": "aucun",
"xmrig_reinstall": "Réinstaller",
+ "xmrig_releases": "versions de xmrig",
"xmrig_stop_mining_first": "Arrêtez le minage avant de mettre à jour le mineur.",
"xmrig_unavailable_body": "Aucune version du mineur n'est disponible pour cette plateforme.",
"xmrig_unavailable_title": "Mises à jour du mineur indisponibles",
diff --git a/res/lang/ja.json b/res/lang/ja.json
index 20faea3..ed45308 100644
--- a/res/lang/ja.json
+++ b/res/lang/ja.json
@@ -48,6 +48,10 @@
"advanced": "詳細設定",
"advanced_effects": "高度なエフェクト...",
"ago": "前",
+ "alerts_clear": "通知履歴を消去",
+ "alerts_history_tooltip": "最近の通知",
+ "alerts_none": "通知はまだありません",
+ "alerts_recent": "最近の通知",
"all_filter": "すべて",
"allow_custom_fees": "カスタム手数料を許可",
"amount": "金額",
@@ -56,6 +60,80 @@
"amount_label": "金額:",
"animate_avatars": "アバターをアニメーション",
"appearance": "外観",
+ "appx_back": "戻る",
+ "appx_back_up_seed_phrase_title": "シードフレーズをバックアップ",
+ "appx_birthday_block_height": "誕生日(ブロック高): %llu — これもバックアップしてください。",
+ "appx_blockchain_data_deleted": "ブロックチェーンデータを削除しました(%d件)。ネットワークから再同期するため、デーモンを再起動しています。",
+ "appx_blockchain_maintenance_in_progress": "ブロックチェーンのメンテナンス操作がすでに進行中です。",
+ "appx_blockchain_rescan_complete": "ブロックチェーンの再スキャンが完了しました",
+ "appx_bootstrap_complete_reconciling": "ブートストラップが完了しました。新しいチェーンデータとウォレットを照合しています。",
+ "appx_cancel": "キャンセル",
+ "appx_cleaning_up": "クリーンアップしています...",
+ "appx_confirm_your_backup": "バックアップを確認",
+ "appx_copied_clipboard_autoclears": "コピーしました — クリップボードは45秒後に自動的にクリアされます",
+ "appx_copy": "コピー",
+ "appx_could_not_start_restore": "復元を開始できませんでした",
+ "appx_create_failed_prefix": "作成に失敗しました: ",
+ "appx_creating_your_wallet": "ウォレットを作成しています…",
+ "appx_daemon_error": "デーモンエラー",
+ "appx_daemon_reinstall_in_progress": "デーモンの再インストールがすでに進行中です。",
+ "appx_disconnecting": "切断しています...",
+ "appx_done": "完了",
+ "appx_dragonxd_output": "dragonxd の出力",
+ "appx_encrypting_wallet": "ウォレットを暗号化しています...",
+ "appx_fullnode_lifecycle_unavailable_lite": "フルノードのライフサイクル操作はライトビルドでは利用できません",
+ "appx_installing_bundled_daemon": "同梱デーモンをインストールしています。ノードが停止、更新、再起動されます...",
+ "appx_invalid_payment_uri_prefix": "無効な支払いURI: ",
+ "appx_ive_written_it_down": "書き留めました",
+ "appx_keep_node_running_and_quit": "ノードを起動したまま終了",
+ "appx_last_block_n": "最新ブロック: %d",
+ "appx_last_used_wallet_not_found_prefix": "前回使用したウォレットファイル(",
+ "appx_last_used_wallet_not_found_suffix": ")が見つかりませんでした。代わりにデフォルトのウォレットを開きました。移動した場合は、元に戻してからウォレット一覧で切り替えてください。",
+ "appx_low_spec_mode_disabled": "低スペックモードを無効にしました",
+ "appx_low_spec_mode_enabled": "低スペックモードを有効にしました",
+ "appx_miner_stopped_prefix": "マイナーが停止しました: ",
+ "appx_miner_stopped_unexpectedly": "マイナーが予期せず停止しました。",
+ "appx_n_min_n_sec": "%d分 %d秒",
+ "appx_n_seconds": "%d秒",
+ "appx_no_bundled_daemon_to_install": "このビルドにはインストールできる同梱デーモンがありません",
+ "appx_no_embedded_daemon_to_install": "このビルドにはインストールできる組み込みデーモンがありません",
+ "appx_node_busy_restarting": "ノードが再起動中でビジー状態です。少し待ってから再試行してください。",
+ "appx_node_rebuilding_witness_cache": "ノードがウィットネスキャッシュを再構築しています",
+ "appx_not_next_word": " — 次の単語ではありません",
+ "appx_payment_request_loaded": "支払いリクエストを読み込みました",
+ "appx_pool_miner_connected_and_hashing": "プールマイナーが接続し、ハッシュ計算中です。",
+ "appx_progress_n_of_n": "進捗: %d / %d",
+ "appx_rebuilding_sapling_note_witnesses": "Saplingノートウィットネスを再構築しています…",
+ "appx_rebuilding_witness_cache_blocks_left": "ウィットネスキャッシュを再構築中 %.0f%% — 残り%dブロック",
+ "appx_rebuilding_witness_cache_pct": "ウィットネスキャッシュを再構築中 %.0f%%",
+ "appx_recovery_phrase_word_count": "リカバリーフレーズは24単語である必要があります。現在は%d単語です。",
+ "appx_restarting_daemon_rescan_flag": "-rescanフラグ付きでデーモンを再起動しています...",
+ "appx_restarting_daemon_zapwallettxes": "-zapwallettxes=2(ウォレット修復)付きでデーモンを再起動しています...",
+ "appx_restoring_your_wallet": "ウォレットを復元しています…",
+ "appx_seed_backup_warning": "この24個の単語は、ウォレットを復元する唯一の手段です。順番どおりに書き留め、オフラインで保管し、決して他人に教えないでください。紛失すると、資金は永久に失われます。",
+ "appx_seed_not_backed_up_warning": "シードをバックアップしていません。資金を失う可能性があります。それでもスキップしますか?",
+ "appx_sending_stop_command_to_daemon": "デーモンに停止コマンドを送信しています...",
+ "appx_setting_initial_sapling_witnesses": "初期Saplingウィットネスを設定中 %.0f%%",
+ "appx_shutdown_complete": "シャットダウンが完了しました",
+ "appx_simple_background_disabled": "シンプル背景を無効にしました",
+ "appx_simple_background_enabled": "シンプル背景を有効にしました",
+ "appx_skip": "スキップ",
+ "appx_skip_anyway": "それでもスキップ",
+ "appx_still_status_prefix": "まだ「",
+ "appx_still_status_suffix": "」です — 今強制終了するとチェーンデータが破損する可能性があります。",
+ "appx_stop_anyway_and_quit": "それでも停止して終了",
+ "appx_stopping_daemon_deleting_blockchain": "デーモンを停止し、ブロックチェーンデータを削除しています...",
+ "appx_stopping_node_discards_rebuild": "今ノードを停止すると、進行中の再構築が破棄され、次回ウォレットを開いたときに再度実行されます(数分かかります)。代わりにノードを起動したままにすることもできます。",
+ "appx_stopping_pool_miner": "プールマイナーを停止しています...",
+ "appx_syncing_pct_block_n_of_n": "同期中 %.1f%% — ブロック %d / %d",
+ "appx_tap_words_in_order": "保存したことを確認するため、単語を正しい順番でタップしてください。",
+ "appx_theme_effects_disabled": "テーマ効果を無効にしました",
+ "appx_theme_effects_enabled": "テーマ効果を有効にしました",
+ "appx_theme_prefix": "テーマ: ",
+ "appx_use_settings_restart_daemon_hint": "設定 > デーモンを再起動 から再試行してください",
+ "appx_waiting_for_daemon_to_encrypt_wallet": "デーモンによるウォレットの暗号化を待っています...",
+ "appx_wallet_created_and_backed_up": "ウォレットを作成し、バックアップしました。",
+ "appx_wallet_open_failed_prefix": "ウォレットを開けませんでした: ",
"auto_shield": "マイニング自動シールド",
"av_intro": "マイニングソフトウェアは、望ましくない可能性があるものとしてフラグが立てられることがよくあります。プールマイニングを有効にするには、次の手順に従ってください。",
"av_open_security": "Windows セキュリティを開く",
@@ -70,6 +148,9 @@
"av_title": "Windows Defender がマイナーをブロックしました",
"available": "利用可能",
"backup_backing_up": "バックアップ中...",
+ "backup_col_backup": "バックアップ",
+ "backup_col_export": "エクスポート",
+ "backup_col_import": "インポートと復元",
"backup_create": "バックアップを作成",
"backup_created": "ウォレットのバックアップを作成しました",
"backup_data": "バックアップとデータ",
@@ -88,8 +169,22 @@
"balance": "残高",
"balance_history_collecting": "残高履歴 — データを収集中...",
"balance_layout": "残高レイアウト",
+ "balance_layout_switched": "レイアウト: %s",
+ "balance_mining_rate": "マイニング中 %s",
"balance_shielded_fmt": "シールド: %.8f",
+ "balance_syncing_pct": "同期中 %.1f%%",
"balance_transparent_fmt": "透明: %.8f",
+ "baltab_market": "市場",
+ "baltab_market_price_4dp": "市場価格: $%.4f",
+ "baltab_market_price_8dp": "市場価格: $%.8f",
+ "baltab_pct_change_24h": "%s%.1f%% 24h",
+ "baltab_pct_of_total_zaddr": "全体の%.0f%% · %d Z-addr",
+ "baltab_shielded": "シールド",
+ "baltab_shielded_amount": "シールド %.8f",
+ "baltab_t_addresses_count": "%d 個のT-address",
+ "baltab_total_balance": "合計残高",
+ "baltab_transparent": "透明",
+ "baltab_transparent_amount": "透明 %.8f",
"ban": "ブロック",
"banned_peers": "ブロック済みピア",
"block": "ブロック",
@@ -128,6 +223,7 @@
"bootstrap_verifying": "チェックサムを検証中...",
"bootstrap_wallet_protected": "(wallet.dat は保護されています)",
"bootstrap_warning": "既存のブロックデータ(blocks、chainstate、notarizations)は削除され置き換えられます。wallet.dat は変更・削除されません。",
+ "byte_count_fmt": "%zu / %zu バイト",
"cancel": "キャンセル",
"change_pass_confirm": "新しいパスフレーズ(確認):",
"change_pass_current": "現在のパスフレーズ:",
@@ -403,6 +499,7 @@
"contacts_shape_square": "四角",
"contacts_shape_tab": "左タブ",
"copied": "コピーしました!",
+ "copied_to_clipboard": "クリップボードにコピーしました",
"copy": "コピー",
"copy_address": "完全なアドレスをコピー",
"copy_error": "エラーをコピー",
@@ -415,6 +512,7 @@
"daemon_bundled": "バンドル版",
"daemon_install_bundled": "バンドル版をインストール",
"daemon_installed": "インストール済み",
+ "daemon_maintenance_label": "メンテナンス",
"daemon_none_bundled": "このビルドにはなし",
"daemon_not_installed": "未インストール",
"daemon_status_differ": "インストール済みのバイナリはバンドル版と異なります。",
@@ -437,6 +535,7 @@
"daemon_update_latest": "最新:",
"daemon_update_loading": "リリースを読み込み中…",
"daemon_update_now": "今すぐ更新",
+ "daemon_update_prompt_title": "ノードデーモンを更新しますか?",
"daemon_update_reinstall": "再インストール",
"daemon_update_restart_note": "新しいバージョンを実行するにはデーモンを再起動してください。",
"daemon_update_restart_now": "今すぐデーモンを再起動",
@@ -449,8 +548,11 @@
"daemon_update_verify_note": "ダウンロードは、インストール前にリリースで公開された SHA-256 と固定された ed25519 署名で検証されます。",
"daemon_update_verifying": "検証中…",
"daemon_update_version": "バージョン:",
+ "daemon_updates_label": "アップデート",
"daemon_version": "デーモン",
"dark": "ダーク",
+ "data_stale_prefix": "更新",
+ "data_stale_tooltip": "残高が最新でない可能性があります。ウォレットは最近更新を受信していません。ノード接続を確認してください。",
"date": "日付",
"date_label": "日付:",
"debug_logging": "デバッグログ",
@@ -479,6 +581,17 @@
"download_bootstrap": "ブートストラップをダウンロード",
"dragonx_green": "DragonX(グリーン)",
"edit": "編集",
+ "empty_wallet_keys_suffix": "個の鍵",
+ "empty_wallet_open_manager": "ウォレットマネージャーを開く",
+ "empty_wallet_restore": "ウォレットを復元",
+ "empty_wallet_salvage_body": "このウォレットが空なのは、以前の自動修復によって元のウォレットがバックアップとして脇に保存されたためです。コインはほぼ確実にそのバックアップの中にあり、失われていません。復元すれば資金を再び読み込めます。何も削除されません。現在のファイルは先に脇へ保存されます。",
+ "empty_wallet_salvage_headline": "コインはバックアップファイルに安全に保管されています。",
+ "empty_wallet_salvage_title": "ウォレットが修復された可能性があります",
+ "empty_wallet_warning_body": "このウォレットにはアドレスも資金もありませんが、DragonX フォルダー内の別のウォレットファイルに鍵が含まれています。コインはおそらくそちらにあり、失われていません。ウォレットマネージャーを開いて、資金のあるウォレットに切り替えてください。",
+ "empty_wallet_warning_dismiss": "このウォレットでは今後警告しない",
+ "empty_wallet_warning_dismiss_tip": "現在のウォレットファイルに対してのみこの警告を停止します。後で別の空のウォレットに切り替えると、再び警告される場合があります。",
+ "empty_wallet_warning_headline": "間違ったウォレットを開いた可能性があります。",
+ "empty_wallet_warning_title": "このウォレットは空です",
"enc_confirm": "確認:",
"enc_desc": "ウォレットを暗号化すると、パスフレーズで秘密鍵が保護されます。暗号化後、デーモンが再起動します。",
"enc_encrypting": "ウォレットを暗号化しています...",
@@ -566,6 +679,63 @@
"general": "一般",
"generating": "生成中",
"go_to_receive": "受信へ移動",
+ "grpa_current_block_paren": "(現在: %d)",
+ "grpa_days_ago": "%lld日前",
+ "grpa_dbg_addrman": "ピアアドレスの追跡と管理",
+ "grpa_dbg_alert": "アラートシステムのメッセージ",
+ "grpa_dbg_bench": "操作のベンチマーク計測",
+ "grpa_dbg_coindb": "コインデータベースの読み書き操作",
+ "grpa_dbg_db": "Berkeley DB の操作",
+ "grpa_dbg_estimatefee": "手数料見積もりアルゴリズム",
+ "grpa_dbg_http": "HTTP RPCサーバーの動作",
+ "grpa_dbg_libevent": "Libevent ネットワークライブラリ",
+ "grpa_dbg_lock": "ロック競合のデバッグ",
+ "grpa_dbg_mempool": "トランザクションメモリプールの動作",
+ "grpa_dbg_net": "ネットワーク接続とメッセージ",
+ "grpa_dbg_paymentdisclosure": "支払い開示プロトコル",
+ "grpa_dbg_pow": "プルーフオブワークのマイニング動作",
+ "grpa_dbg_proxy": "SOCKS5 プロキシ接続",
+ "grpa_dbg_prune": "ブロックのプルーニング操作",
+ "grpa_dbg_rand": "乱数生成",
+ "grpa_dbg_reindex": "ブロックチェーンの再インデックス進捗",
+ "grpa_dbg_rpc": "RPCコマンドの処理",
+ "grpa_dbg_selectcoins": "トランザクション用のコイン選択",
+ "grpa_dbg_tor": "Tor統合とサーキット情報",
+ "grpa_dbg_zmq": "ZeroMQ 通知システム",
+ "grpa_dbg_zrpc": "シールド(z-addr)RPC操作",
+ "grpa_enter_private_key_to_import": "インポートする秘密鍵を入力してください。",
+ "grpa_error_prefix": "エラー: ",
+ "grpa_hr_ago": "%lld時間前",
+ "grpa_invalid_response_from_daemon": "デーモンからの応答が無効です",
+ "grpa_invalid_suffix": "(無効)",
+ "grpa_min_ago": "%lld分前",
+ "grpa_sec_ago": "%lld秒前",
+ "grpa_seed_demo_chat": "デモチャットのシード",
+ "grpa_showing_first_100_of": "... %d件中の最初の100件を表示",
+ "grpa_tab_about": "情報",
+ "grpa_tab_appearance": "外観",
+ "grpa_tab_backup_data": "バックアップとデータ",
+ "grpa_tab_chat": "チャット",
+ "grpa_tab_explorer": "エクスプローラー",
+ "grpa_tab_node_security": "ノードとセキュリティ",
+ "grpa_tab_wallet": "ウォレット",
+ "grpa_unexpected_getblockhash_result": "予期しない getblockhash の結果",
+ "grpb_copy": "コピー",
+ "grpb_max": "最大",
+ "grpb_new_badge_suffix": " [新規]",
+ "grpb_preview_msg_payment_through": "支払いは完了しましたか? 🙂",
+ "grpb_preview_msg_sending_rest": "残りを今送金します 👍",
+ "grpb_preview_msg_yep_confirmed": "はい — たった今確認できました ✅",
+ "grpb_selected_suffix": "\n(選択済み)",
+ "grpb_tooltip_address_balance": "%s\n残高: %.8f %s%s",
+ "grpb_undo_clear": "消去を元に戻す",
+ "grpc_benchmark_inconclusive": "ベンチマークの結果が不明確です: ハッシュレートのサンプルが記録されませんでした。プール接続を確認して再試行してください。",
+ "grpc_benchmark_takes_secs": "ベンチマークには約%d秒かかり、マイニングが中断されます。開始するにはもう一度クリックしてください。",
+ "grpc_bootstrap_failed": "ブートストラップに失敗しました",
+ "grpc_bootstrap_not_initialized": "ブートストラップが初期化されていません",
+ "grpc_hashrate_fee": "%s 手数料 %s%%",
+ "grpc_key_not_available": "このアドレスの鍵は利用できません",
+ "grpc_na": "該当なし",
"height": "高さ",
"help": "ヘルプ",
"hidden_tag": " (非表示)",
@@ -640,6 +810,7 @@
"light": "ライト",
"lite_account_label": "アカウント",
"lite_action": "アクション",
+ "lite_backend_unavailable": "ライトウォレットのバックエンドが利用できません",
"lite_backup_keys": "バックアップと鍵",
"lite_birthday_backup": "誕生日:%llu (これもバックアップしてください)",
"lite_birthday_hint": "スキャンを開始するブロック高。不明な場合は0のままにしてください(完全スキャンが遅くなります)。",
@@ -647,9 +818,12 @@
"lite_console_backend_commands": "バックエンドコマンド:",
"lite_console_help_passthrough": "その他の入力はライトウォレットのコンソールコマンドとして実行されます。",
"lite_copy": "コピー",
+ "lite_could_not_start": "操作を開始できませんでした",
"lite_could_not_write": "書き込めませんでした ",
"lite_encrypt_wallet": "ウォレットを暗号化",
"lite_encryption_removed": "暗号化を解除しました",
+ "lite_enter_all_seed_words": "復元するには24個のシードワードをすべて入力してください(現在 %d 個)",
+ "lite_enter_wallet_path": "ウォレットのパスを入力してください",
"lite_hide_wipe": "非表示にして消去",
"lite_import": "インポート",
"lite_import_key_label": "鍵をインポート",
@@ -734,6 +908,9 @@
"lite_working": "処理中…",
"loading": "読み込み中...",
"loading_addresses": "アドレスを読み込み中...",
+ "loading_stall_body": "デーモンは %.0f 秒間初期化しています。アップデート後や初回起動時(ブロックインデックスの読み込みや再スキャン)は正常な場合があります。準備ができ次第、自動的に接続します。",
+ "loading_stall_hint": "まだ動かない場合は、設定を開いて「デーモンを再起動」を使うか、コンソールで詳細を確認してください。",
+ "loading_stall_title": "予想より時間がかかっています",
"loading_transactions": "トランザクションを読み込み中",
"local_hashrate": "ローカルハッシュレート",
"low_spec_mode": "省電力モード",
@@ -866,6 +1043,7 @@
"mining_difficulty_copied": "難易度をコピーしました",
"mining_est_block": "予測ブロック",
"mining_est_daily": "予測日収",
+ "mining_est_daily_pool_sub": "おおよそのソロ換算(プール手数料前)",
"mining_filter_all": "すべて",
"mining_filter_tip_all": "すべての収益を表示",
"mining_filter_tip_pool": "プール収益のみ表示",
@@ -894,10 +1072,12 @@
"mining_open_in_explorer": "エクスプローラーで開く",
"mining_payout_address": "支払いアドレス",
"mining_payout_foreign": "⚠ この支払いアドレスは現在のウォレットに含まれていません — マイニング報酬が別のウォレットに送られます。ウォレットを切り替えた場合は更新してください。",
+ "mining_payout_invalid": "有効な DragonX アドレスではありません — 開始前に修正してください。さもないとマイニング報酬が失われます。",
"mining_payout_tooltip": "マイニング報酬の受取アドレス",
"mining_pool": "プール",
"mining_pool_fee": "手数料",
"mining_pool_hashrate": "プールハッシュレート",
+ "mining_pool_needs_payout_tooltip": "先に支払い先アドレスを入力してください(Zアドレスを生成)",
"mining_pool_url": "プールURL",
"mining_pools_header": "プール",
"mining_recent_blocks": "最近のブロック",
@@ -927,6 +1107,9 @@
"mining_syncing_tooltip": "ブロックチェーン同期中...",
"mining_tag": " · マイニング",
"mining_threads": "マイニングスレッド",
+ "mining_threads_input_tooltip": "正確なスレッド数を入力(Enter で適用)",
+ "mining_threads_minus_tooltip": "スレッドを減らす",
+ "mining_threads_plus_tooltip": "スレッドを増やす",
"mining_to_save": "保存する",
"mining_today": "今日",
"mining_uptime": "稼働時間",
@@ -953,6 +1136,11 @@
"no_transactions": "取引が見つかりません",
"no_transactions_yet": "まだ取引がありません",
"node": "ノード",
+ "node_banner_crashed_title": "ノードが予期せず停止しました",
+ "node_banner_lite_open_failed": "ウォレットを開けませんでした",
+ "node_banner_offline_title": "DragonX ノードに接続されていません",
+ "node_banner_reconnect": "再接続",
+ "node_banner_restart": "ノードを再起動",
"node_security": "ノードとセキュリティ",
"noise": "ノイズ",
"not_connected": "デーモンに未接続...",
@@ -1091,6 +1279,8 @@
"qr_failed": "QRコードの生成に失敗しました",
"qr_title": "QRコード",
"qr_unavailable": "QR利用不可",
+ "quick_receive": "クイック受取",
+ "quick_send": "クイック送金",
"ram_daemon_gb": "デーモン:%.1f GB (%s)",
"ram_daemon_mb": "デーモン:%.0f MB (%s)",
"ram_system_gb": "システム:%.1f / %.0f GB",
@@ -1140,6 +1330,7 @@
"rpc_connection": "RPC接続...",
"rpc_host": "RPCホスト",
"rpc_pass": "パスワード",
+ "rpc_plaintext_remote_warning": "リモートRPCは暗号化されていないHTTPを使用しています。デーモンがTLSに対応している場合は、DRAGONX.confにrpctls=1を追加してください。",
"rpc_port": "ポート",
"rpc_user": "ユーザー名",
"save": "保存",
@@ -1182,12 +1373,52 @@
"sb_waiting_daemon_err": "dragonxd を待機中 — %s",
"sb_warming_up": "ウォームアップ中...",
"sb_witness_cache": "ウィットネスを再構築中",
+ "scale_effects": "スケールとエフェクト",
"screenshot_open_dir": "場所を開く",
"screenshot_sweep": "スクリーンショットスイープを実行",
"screenshot_sweep_desc": "すべてのテーマをすべてのタブで巡回し、それぞれのスクリーンショットを設定ディレクトリの screenshots フォルダ内のタブごとのサブフォルダに保存します(前回のスイープを上書きします)。数秒間実行されます。",
"screenshot_sweep_full": "UI全体スイープ",
"search_icons": "アイコンを検索...",
"search_placeholder": "検索...",
+ "sec_changing_passphrase": "パスフレーズを変更しています...",
+ "sec_changing_pin": "PINを変更しています...",
+ "sec_couldnt_lock_wallet": "ウォレットをロックできませんでした。まだロック解除された状態です。デーモンの接続を確認してください。",
+ "sec_encrypted_backup_suffix": "\n暗号化バックアップ: wallet.dat.encrypted.bak",
+ "sec_encrypting_wallet": "ウォレットを暗号化しています...",
+ "sec_encryption_did_not_complete": "ウォレットの暗号化が完了しませんでした。ウォレットは暗号化されていません。設定を開いて暗号化を完了してください。",
+ "sec_encryption_failed_prefix": "暗号化に失敗しました: ",
+ "sec_failed_prefix": "失敗しました: ",
+ "sec_failed_to_create_vault": "ボールトの作成に失敗しました",
+ "sec_importing_keys_rescanning": "鍵をインポートし、ブロックチェーンを再スキャンしています。処理中もウォレットは使用できます",
+ "sec_incorrect_current_pin": "現在のPINが正しくありません",
+ "sec_incorrect_passphrase_decrypt": "パスフレーズが正しくありません",
+ "sec_incorrect_passphrase_pin_setup": "パスフレーズが正しくありません",
+ "sec_incorrect_pin_remove": "PINが正しくありません",
+ "sec_internal_error_change_pin": "内部エラー",
+ "sec_internal_error_remove_pin": "内部エラー",
+ "sec_mode_passphrase": " パスフレーズ",
+ "sec_not_connected_to_daemon": "デーモンに接続されていません",
+ "sec_not_connected_to_daemon_pin": "デーモンに接続されていません",
+ "sec_passphrase_changed_successfully": "パスフレーズを変更しました",
+ "sec_pin_changed_successfully": "PINを変更しました",
+ "sec_pin_removed": "PINを削除しました",
+ "sec_pin_set_successfully": "PINを設定しました",
+ "sec_restart_daemon_for_encryption": "暗号化を有効にするには、デーモンを再起動してください。",
+ "sec_too_many_attempts_wait": "試行回数が多すぎます。%.0f秒お待ちください...",
+ "sec_total_elapsed_fmt": "合計経過時間: %d分 %02d秒",
+ "sec_unlock_button": "ロック解除",
+ "sec_unlock_failed_prefix": "ロック解除に失敗しました: ",
+ "sec_unlocking_fmt": "ロック解除中%s",
+ "sec_use_passphrase_instead": "代わりにパスフレーズを使用",
+ "sec_use_pin_instead": "代わりにPINを使用",
+ "sec_verifying_passphrase": "パスフレーズを確認しています...",
+ "sec_verifying_pin": "PINを確認しています...",
+ "sec_wallet_decrypted_all_keys_imported": "ウォレットの復号に成功しました。すべての鍵をインポートしました。",
+ "sec_wallet_encrypted_and_pin_set": "ウォレットを暗号化し、PINを設定しました",
+ "sec_wallet_encrypted_but_pin_vault_failed": "ウォレットは暗号化されましたが、PINボールトの作成に失敗しました",
+ "sec_wallet_encrypted_restarting_daemon": "ウォレットを暗号化しました。デーモンを再起動しています...",
+ "sec_wallet_encrypted_successfully": "ウォレットを暗号化しました",
+ "sec_wallet_locked_title": "ウォレットがロックされています",
"security": "セキュリティ",
"seed_backup_button": "シードフレーズ",
"seed_backup_close": "閉じる",
@@ -1246,6 +1477,7 @@
"send_tooltip_not_connected": "デーモンに未接続",
"send_tooltip_select_source": "まず送信元アドレスを選択してください",
"send_tooltip_syncing": "ブロックチェーンの同期をお待ちください",
+ "send_tooltip_view_only": "閲覧専用アドレス — 送金鍵がないため送金できません",
"send_total": "合計",
"send_transaction": "取引を送信",
"send_tx_failed": "取引に失敗しました",
@@ -1265,16 +1497,16 @@
"sent_filter": "送信済み",
"sent_type": "送信済み",
"sent_upper": "送信済み",
- "set_label": "ラベルを設定...",
+ "set_label": "ラベルを設定",
"settings": "設定",
"settings_about_text": "DragonX (DRGX) 用のシールド暗号通貨ウォレット。Dear ImGui で構築された軽量でポータブルな体験。",
"settings_acrylic_level": "アクリルレベル:",
- "settings_address_book": "アドレス帳...",
+ "settings_address_book": "アドレス帳…",
"settings_auto_detected": "DRAGONX.conf から自動検出",
"settings_auto_lock": "オートロック",
"settings_auto_shield_desc": "透明資金を自動的にシールドアドレスに移動",
"settings_auto_shield_funds": "透明資金を自動シールド",
- "settings_backup": "バックアップ...",
+ "settings_backup": "バックアップ…",
"settings_block_explorer_urls": "ブロックエクスプローラーURL",
"settings_builtin": "内蔵",
"settings_change_passphrase": "パスフレーズを変更",
@@ -1285,53 +1517,62 @@
"settings_configure_explorer": "外部ブロックエクスプローラーリンクを設定",
"settings_configure_rpc": "dragonxd デーモンへの接続を設定",
"settings_connection": "接続",
+ "settings_copy_diagnostics": "診断情報をコピー",
"settings_copyright": "Copyright 2024-2026 DragonX 開発者 | GPLv3 ライセンス",
"settings_custom": "カスタム",
"settings_data_dir": "データディレクトリ:",
"settings_debug_changed": "デバッグカテゴリが変更されました — デーモンを再起動して適用",
"settings_debug_restart_note": "変更はデーモンの再起動後に有効になります。",
"settings_debug_select": "デーモンのデバッグログを有効にするカテゴリを選択(-debug= フラグ)。",
+ "settings_diagnostics_copied": "診断情報をクリップボードにコピーしました",
"settings_encrypt_first_pin": "PIN を有効にするには、まずウォレットを暗号化してください",
"settings_encrypt_wallet": "ウォレットを暗号化",
"settings_explorer_hint": "URLには末尾のスラッシュを含めてください。txid/アドレスが追加されます。",
- "settings_export_all": "すべてエクスポート...",
- "settings_export_csv": "CSV エクスポート...",
- "settings_export_key": "鍵をエクスポート...",
+ "settings_export_all": "すべてエクスポート…",
+ "settings_export_csv": "CSV エクスポート…",
+ "settings_export_key": "鍵をエクスポート…",
"settings_gradient_bg": "グラデーション背景",
"settings_gradient_desc": "テクスチャ背景を滑らかなグラデーションに置換",
"settings_idle_after": "経過後",
- "settings_import_key": "秘密鍵をインポート...",
- "settings_import_viewkey": "閲覧鍵をインポート...",
+ "settings_import_key": "秘密鍵をインポート…",
+ "settings_import_viewkey": "閲覧鍵をインポート…",
"settings_language_note": "注意:一部のテキストは更新に再起動が必要です",
"settings_lock_now": "今すぐロック",
"settings_locked": "ロック済み",
- "settings_merge_to_address": "アドレスにマージ...",
+ "settings_merge_to_address": "アドレスにマージ…",
"settings_noise_opacity": "ノイズ不透明度:",
+ "settings_not_connected": "デーモンに接続されていません",
"settings_not_encrypted": "暗号化されていません",
"settings_not_found": "見つかりません",
"settings_open_app_dir": "アプリフォルダを開く",
"settings_open_data_dir": "データフォルダを開く",
+ "settings_open_log_folder": "ログフォルダを開く",
"settings_other": "その他",
"settings_pin_active": "PIN",
"settings_privacy": "プライバシー",
"settings_quick_unlock_pin": "クイックアンロック PIN",
"settings_reduce_transparency": "透明度を下げる",
+ "settings_reloaded": "ディスクから設定を再読み込みしました",
"settings_remove_encryption": "暗号化を解除",
"settings_remove_pin": "PIN を削除",
- "settings_request_payment": "支払い請求...",
+ "settings_request_payment": "支払い請求…",
"settings_rescan_desc": "欠落したトランザクションのためにブロックチェーンを再スキャン",
"settings_restart_daemon": "デーモンを再起動",
"settings_rpc_connection": "RPC 接続",
+ "settings_rpc_error_prefix": "RPCエラー: ",
"settings_rpc_note": "注意:接続設定は通常 DRAGONX.conf から自動検出されます",
+ "settings_rpc_ok": "RPC接続は正常です",
"settings_save_shielded_desc": "z-addr トランザクションをローカルファイルに保存して表示",
"settings_save_shielded_local": "シールドトランザクション履歴をローカルに保存",
+ "settings_saved": "設定を保存しました",
"settings_set_pin": "PIN を設定",
- "settings_shield_mining": "マイニングシールド...",
+ "settings_shield_mining": "マイニングシールド…",
"settings_solid_colors_desc": "ぼかし効果の代わりに単色を使用(アクセシビリティ)",
+ "settings_theme_refreshed": "テーマ一覧を更新しました",
"settings_tor_desc": "プライバシー向上のため全接続を Tor 経由にする",
"settings_unlocked": "ロック解除",
"settings_use_tor_network": "ネットワーク接続に Tor を使用",
- "settings_validate_address": "アドレス検証...",
+ "settings_validate_address": "アドレス検証…",
"settings_visual_effects": "視覚効果",
"settings_wallet_file_size": "ウォレットファイルサイズ:%s",
"settings_wallet_info": "ウォレット情報",
@@ -1339,6 +1580,8 @@
"settings_wallet_maintenance": "ウォレットメンテナンス",
"settings_wallet_not_found": "ウォレットファイルが見つかりません",
"settings_wallet_size_label": "ウォレットサイズ:",
+ "settings_ztx_cleared": "Zトランザクション履歴を消去しました",
+ "settings_ztx_not_found": "履歴ファイルが見つかりません",
"setup_wizard": "セットアップウィザード",
"share": "共有",
"shield_check_status": "ステータスを確認",
@@ -1397,6 +1640,17 @@
"sweep_to": "集約先:",
"sweep_toggle": "ウォレットに集約(鍵は保持しない)",
"sweep_tx": "取引:",
+ "swin_connection_failed": "接続に失敗しました: ",
+ "swin_connection_successful": "接続に成功しました!\ndragonxd バージョン: ",
+ "swin_invalid_suffix": "(無効)",
+ "swin_no_history_file_found": "履歴ファイルが見つかりません",
+ "swin_rescan_failed": "再スキャンに失敗しました: ",
+ "swin_rescan_started_from_block": "再スキャンを開始しました。開始ブロック ",
+ "swin_rescan_to": " 〜 ",
+ "swin_rpc_client_not_initialized": "RPCクライアントが初期化されていません",
+ "swin_settings_saved": "設定を保存しました",
+ "swin_theme_list_refreshed": "テーマ一覧を更新しました",
+ "swin_ztx_history_cleared": "Zトランザクション履歴を消去しました",
"switch_corrupt_body": "このウォレットは破損しているようです。ノードが開けませんでした。バックアップから復元するか、作り直すか、修復を試してください。",
"switch_corrupt_repair": "修復を試す(salvage)",
"switch_progress_background": "バックグラウンドで続行",
@@ -1421,6 +1675,7 @@
"theme": "テーマ",
"theme_effects": "テーマ効果",
"theme_language": "テーマと言語",
+ "tile_click_to_open": "クリックして開く",
"time_days_ago": "%d日前",
"time_hours_ago": "%d時間前",
"time_minutes_ago": "%d分前",
@@ -1435,7 +1690,9 @@
"to_upper": "宛先",
"tools": "ツール",
"tools_actions": "ツールとアクション...",
+ "tools_actions_hdr": "ツールと操作",
"total": "合計",
+ "total_balance_label": "総残高",
"transaction_id": "取引ID",
"transaction_sent": "取引の送信に成功しました",
"transaction_sent_msg": "取引を送信しました!",
@@ -1457,7 +1714,7 @@
"tt_auto_shield": "プライバシーのため透明残高を自動的にシールドアドレスに移動",
"tt_backup": "wallet.dat のバックアップを作成",
"tt_block_explorer": "ブラウザで DragonX ブロックエクスプローラーを開く",
- "tt_blur": "ぼかし量(0%% = オフ、100%% = 最大)",
+ "tt_blur": "ぼかし量(0% = オフ、100% = 最大)",
"tt_change_pass": "ウォレットの暗号化パスフレーズを変更",
"tt_change_pin": "アンロック PIN を変更",
"tt_chat_bubble_accent": "送信メッセージの吹き出しのアクセントカラー(または現在のテーマに従う)",
@@ -1470,6 +1727,7 @@
"tt_chat_timestamp": "このタブのみのタイムスタンプ形式:アプリ全体の時計に従うか、24-hourまたは12-hourを強制します",
"tt_clear_ztx": "ローカルにキャッシュされた z-トランザクション履歴を削除",
"tt_clock_format": "24時間または12時間表示(アプリ全体)。チャットで上書きできます。",
+ "tt_copy_diagnostics": "サポート用の概要(バージョン、デーモン/ウォレット/ログの状態 — 秘密情報なし)をクリップボードにコピーします",
"tt_custom_fees": "トランザクション送信時に手動手数料入力を有効化",
"tt_custom_theme": "カスタムテーマがアクティブ",
"tt_daemon_install_bundled": "ノードを停止し、インストール済みの dragonxd をこのウォレットビルドにバンドルされたバージョンで上書きしてから再起動します",
@@ -1519,10 +1777,11 @@
"tt_low_spec": "すべての重い視覚効果を無効化\\nホットキー:Ctrl+Shift+Down",
"tt_merge": "複数の UTXO を一つのアドレスに統合",
"tt_mine_idle": "システムがアイドル状態(キーボード/マウス入力なし)\\nのとき自動的にマイニングを開始",
- "tt_noise": "グレインテクスチャ強度(0%% = オフ、100%% = 最大)",
+ "tt_noise": "グレインテクスチャ強度(0% = オフ、100% = 最大)",
"tt_open_app_dir": "ObsidianDragon フォルダ(設定、テーマ、ログ)をファイルマネージャーで開く",
"tt_open_data_dir": "ファイルマネージャーでウォレットとブロックチェーンデータのフォルダを開きます",
"tt_open_dir": "クリックしてファイルエクスプローラーで開く",
+ "tt_open_log_folder": "デバッグログとクラッシュログが入ったフォルダを開きます",
"tt_reduce_motion": "アクセシビリティのためにアニメーション遷移と残高補間を無効にする",
"tt_remove_encrypt": "暗号化を解除してウォレットを保護なしで保存",
"tt_remove_pin": "PIN を削除しアンロックにパスフレーズを要求",
@@ -1557,7 +1816,7 @@
"tt_theme_hotkey": "ホットキー:Ctrl+左/右でテーマを切り替え",
"tt_tor": "匿名性のためにデーモン接続を Tor ネットワーク経由でルーティング",
"tt_tx_url": "ブロックエクスプローラーでトランザクションを表示するためのベース URL",
- "tt_ui_opacity": "カードとサイドバーの不透明度(100%% = 完全不透明、低い = より透過)",
+ "tt_ui_opacity": "カードとサイドバーの不透明度(100% = 完全不透明、低い = より透過)",
"tt_validate": "DragonX アドレスが有効かどうかを確認",
"tt_verbose": "詳細な接続診断、デーモン状態、\\nポート所有者情報をコンソールタブに記録",
"tt_wallets_button": "ウォレットファイルを一覧表示して切り替えます",
@@ -1610,6 +1869,7 @@
"validate_not_mine": "このウォレットに属していません",
"validate_ownership": "所有者:",
"validate_results": "結果:",
+ "validate_results_placeholder": "ここに結果が表示されます",
"validate_shielded_type": "シールド(zアドレス)",
"validate_status": "ステータス:",
"validate_title": "アドレスを検証",
@@ -1750,6 +2010,7 @@
"xmrig_loading_releases": "リリースを読み込み中…",
"xmrig_none": "なし",
"xmrig_reinstall": "再インストール",
+ "xmrig_releases": "xmrig リリース",
"xmrig_stop_mining_first": "マイナーを更新する前にマイニングを停止してください。",
"xmrig_unavailable_body": "このプラットフォーム向けのマイナービルドは利用できません。",
"xmrig_unavailable_title": "マイナーの更新は利用できません",
diff --git a/res/lang/ko.json b/res/lang/ko.json
index 4bf3704..7615671 100644
--- a/res/lang/ko.json
+++ b/res/lang/ko.json
@@ -48,6 +48,10 @@
"advanced": "고급 설정",
"advanced_effects": "고급 효과...",
"ago": "전",
+ "alerts_clear": "알림 기록 지우기",
+ "alerts_history_tooltip": "최근 알림",
+ "alerts_none": "아직 알림이 없습니다",
+ "alerts_recent": "최근 알림",
"all_filter": "전체",
"allow_custom_fees": "사용자 정의 수수료 허용",
"amount": "금액",
@@ -56,6 +60,80 @@
"amount_label": "금액:",
"animate_avatars": "아바타 애니메이션",
"appearance": "외관",
+ "appx_back": "뒤로",
+ "appx_back_up_seed_phrase_title": "시드 문구 백업",
+ "appx_birthday_block_height": "생성 시점(블록 높이): %llu — 이것도 함께 백업하세요.",
+ "appx_blockchain_data_deleted": "블록체인 데이터를 삭제했습니다(%d개 항목). 네트워크에서 다시 동기화하기 위해 데몬을 재시작합니다.",
+ "appx_blockchain_maintenance_in_progress": "블록체인 유지 관리 작업이 이미 진행 중입니다.",
+ "appx_blockchain_rescan_complete": "블록체인 다시 스캔 완료",
+ "appx_bootstrap_complete_reconciling": "부트스트랩 완료 — 새 체인 데이터와 지갑을 대조하는 중입니다.",
+ "appx_cancel": "취소",
+ "appx_cleaning_up": "정리하는 중...",
+ "appx_confirm_your_backup": "백업 확인",
+ "appx_copied_clipboard_autoclears": "복사됨 — 클립보드가 45초 후 자동으로 지워집니다",
+ "appx_copy": "복사",
+ "appx_could_not_start_restore": "복구를 시작할 수 없습니다",
+ "appx_create_failed_prefix": "생성 실패: ",
+ "appx_creating_your_wallet": "지갑을 생성하는 중…",
+ "appx_daemon_error": "데몬 오류",
+ "appx_daemon_reinstall_in_progress": "데몬 재설치가 이미 진행 중입니다.",
+ "appx_disconnecting": "연결 해제 중...",
+ "appx_done": "완료",
+ "appx_dragonxd_output": "dragonxd 출력",
+ "appx_encrypting_wallet": "지갑 암호화 중...",
+ "appx_fullnode_lifecycle_unavailable_lite": "풀노드 수명 주기 작업은 라이트 빌드에서 사용할 수 없습니다",
+ "appx_installing_bundled_daemon": "번들 데몬을 설치하는 중 — 노드가 중지, 업데이트 후 다시 시작됩니다...",
+ "appx_invalid_payment_uri_prefix": "잘못된 결제 URI: ",
+ "appx_ive_written_it_down": "적어 두었습니다",
+ "appx_keep_node_running_and_quit": "노드 유지하고 종료",
+ "appx_last_block_n": "마지막 블록: %d",
+ "appx_last_used_wallet_not_found_prefix": "마지막으로 사용한 지갑 파일(",
+ "appx_last_used_wallet_not_found_suffix": ")을 찾을 수 없어 기본 지갑을 대신 열었습니다. 옮기셨다면 복원한 후 지갑 목록에서 다시 전환하세요.",
+ "appx_low_spec_mode_disabled": "저사양 모드 비활성화됨",
+ "appx_low_spec_mode_enabled": "저사양 모드 활성화됨",
+ "appx_miner_stopped_prefix": "채굴기 중지됨: ",
+ "appx_miner_stopped_unexpectedly": "채굴기가 예기치 않게 중지되었습니다.",
+ "appx_n_min_n_sec": "%d분 %d초",
+ "appx_n_seconds": "%d초",
+ "appx_no_bundled_daemon_to_install": "이 빌드에는 설치할 번들 데몬이 없습니다",
+ "appx_no_embedded_daemon_to_install": "이 빌드에는 설치할 내장 데몬이 없습니다",
+ "appx_node_busy_restarting": "노드가 재시작 중입니다 — 잠시 후 다시 시도하세요.",
+ "appx_node_rebuilding_witness_cache": "노드가 위트니스 캐시를 재구성하는 중입니다",
+ "appx_not_next_word": " — 다음 단어가 아닙니다",
+ "appx_payment_request_loaded": "결제 요청을 불러왔습니다",
+ "appx_pool_miner_connected_and_hashing": "풀 채굴기가 연결되어 해싱 중입니다.",
+ "appx_progress_n_of_n": "진행 상황: %d / %d",
+ "appx_rebuilding_sapling_note_witnesses": "Sapling 노트 위트니스 재구성 중…",
+ "appx_rebuilding_witness_cache_blocks_left": "위트니스 캐시 재구성 중 %.0f%% — %d개 블록 남음",
+ "appx_rebuilding_witness_cache_pct": "위트니스 캐시 재구성 중 %.0f%%",
+ "appx_recovery_phrase_word_count": "복구 문구는 24개 단어여야 합니다 — 현재 %d개입니다.",
+ "appx_restarting_daemon_rescan_flag": "-rescan 플래그로 데몬을 다시 시작하는 중...",
+ "appx_restarting_daemon_zapwallettxes": "-zapwallettxes=2로 데몬을 다시 시작하는 중(지갑 복구)...",
+ "appx_restoring_your_wallet": "지갑을 복구하는 중…",
+ "appx_seed_backup_warning": "이 24개 단어는 지갑을 복구할 수 있는 유일한 방법입니다. 순서대로 적어 오프라인에 보관하고 절대 공유하지 마세요. 잃어버리면 자금을 영원히 되찾을 수 없습니다.",
+ "appx_seed_not_backed_up_warning": "시드를 아직 백업하지 않았습니다 — 자금을 잃을 수 있습니다. 그래도 건너뛰시겠습니까?",
+ "appx_sending_stop_command_to_daemon": "데몬에 중지 명령을 보내는 중...",
+ "appx_setting_initial_sapling_witnesses": "초기 Sapling 위트니스 설정 중 %.0f%%",
+ "appx_shutdown_complete": "종료 완료",
+ "appx_simple_background_disabled": "단순 배경 비활성화됨",
+ "appx_simple_background_enabled": "단순 배경 활성화됨",
+ "appx_skip": "건너뛰기",
+ "appx_skip_anyway": "그래도 건너뛰기",
+ "appx_still_status_prefix": "아직 \"",
+ "appx_still_status_suffix": "\" 상태입니다 — 지금 강제 종료하면 체인 데이터가 손상될 수 있습니다.",
+ "appx_stop_anyway_and_quit": "그래도 중지하고 종료",
+ "appx_stopping_daemon_deleting_blockchain": "데몬을 중지하고 블록체인 데이터를 삭제하는 중...",
+ "appx_stopping_node_discards_rebuild": "지금 노드를 중지하면 진행 중인 재구성이 취소되며, 다음에 지갑을 열 때 다시 시작됩니다(몇 분 소요). 대신 노드를 계속 실행할 수 있습니다.",
+ "appx_stopping_pool_miner": "풀 채굴기를 중지하는 중...",
+ "appx_syncing_pct_block_n_of_n": "동기화 중 %.1f%% — 블록 %d / %d",
+ "appx_tap_words_in_order": "저장한 내용을 확인하도록 단어를 올바른 순서대로 탭하세요.",
+ "appx_theme_effects_disabled": "테마 효과 비활성화됨",
+ "appx_theme_effects_enabled": "테마 효과 활성화됨",
+ "appx_theme_prefix": "테마: ",
+ "appx_use_settings_restart_daemon_hint": "설정 > 데몬 다시 시작을 사용해 다시 시도하세요",
+ "appx_waiting_for_daemon_to_encrypt_wallet": "데몬이 지갑을 암호화하기를 기다리는 중...",
+ "appx_wallet_created_and_backed_up": "지갑이 생성되고 백업되었습니다.",
+ "appx_wallet_open_failed_prefix": "지갑 열기 실패: ",
"auto_shield": "채굴 자동 차폐",
"av_intro": "채굴 소프트웨어는 종종 잠재적으로 원치 않는 항목으로 표시됩니다. 풀 채굴을 활성화하려면 다음 단계를 따르세요:",
"av_open_security": "Windows 보안 열기",
@@ -70,6 +148,9 @@
"av_title": "Windows Defender가 채굴기를 차단했습니다",
"available": "사용 가능",
"backup_backing_up": "백업 중...",
+ "backup_col_backup": "백업",
+ "backup_col_export": "내보내기",
+ "backup_col_import": "가져오기 및 복원",
"backup_create": "백업 생성",
"backup_created": "지갑 백업이 생성되었습니다",
"backup_data": "백업 및 데이터",
@@ -88,8 +169,22 @@
"balance": "잔액",
"balance_history_collecting": "잔액 내역 — 데이터 수집 중...",
"balance_layout": "잔액 레이아웃",
+ "balance_layout_switched": "레이아웃: %s",
+ "balance_mining_rate": "채굴 중 %s",
"balance_shielded_fmt": "차폐: %.8f",
+ "balance_syncing_pct": "동기화 중 %.1f%%",
"balance_transparent_fmt": "투명: %.8f",
+ "baltab_market": "시세",
+ "baltab_market_price_4dp": "시세: $%.4f",
+ "baltab_market_price_8dp": "시세: $%.8f",
+ "baltab_pct_change_24h": "%s%.1f%% 24시간",
+ "baltab_pct_of_total_zaddr": "전체의 %.0f%% · Z-addr %d개",
+ "baltab_shielded": "실드됨",
+ "baltab_shielded_amount": "실드됨 %.8f",
+ "baltab_t_addresses_count": "T-address %d개",
+ "baltab_total_balance": "총 잔액",
+ "baltab_transparent": "투명",
+ "baltab_transparent_amount": "투명 %.8f",
"ban": "차단",
"banned_peers": "차단된 피어",
"block": "블록",
@@ -128,6 +223,7 @@
"bootstrap_verifying": "체크섬 확인 중...",
"bootstrap_wallet_protected": "(wallet.dat 보호됨)",
"bootstrap_warning": "기존 블록 데이터(blocks, chainstate, notarizations)가 삭제되고 교체됩니다. wallet.dat는 수정되거나 삭제되지 않습니다.",
+ "byte_count_fmt": "%zu / %zu 바이트",
"cancel": "취소",
"change_pass_confirm": "새 암호 확인:",
"change_pass_current": "현재 암호:",
@@ -403,6 +499,7 @@
"contacts_shape_square": "사각형",
"contacts_shape_tab": "왼쪽 탭",
"copied": "복사됨!",
+ "copied_to_clipboard": "클립보드에 복사됨",
"copy": "복사",
"copy_address": "전체 주소 복사",
"copy_error": "오류 복사",
@@ -415,6 +512,7 @@
"daemon_bundled": "번들",
"daemon_install_bundled": "번들 버전 설치",
"daemon_installed": "설치됨",
+ "daemon_maintenance_label": "유지 관리",
"daemon_none_bundled": "이 빌드에 없음",
"daemon_not_installed": "설치되지 않음",
"daemon_status_differ": "설치된 바이너리가 번들 버전과 다릅니다.",
@@ -437,6 +535,7 @@
"daemon_update_latest": "최신:",
"daemon_update_loading": "릴리스 불러오는 중…",
"daemon_update_now": "지금 업데이트",
+ "daemon_update_prompt_title": "노드 데몬을 업데이트하시겠습니까?",
"daemon_update_reinstall": "다시 설치",
"daemon_update_restart_note": "새 버전을 실행하려면 데몬을 재시작하세요.",
"daemon_update_restart_now": "지금 데몬 재시작",
@@ -449,8 +548,11 @@
"daemon_update_verify_note": "다운로드는 설치 전에 릴리스에 게시된 SHA-256과 고정된 ed25519 서명으로 검증됩니다.",
"daemon_update_verifying": "확인 중…",
"daemon_update_version": "버전:",
+ "daemon_updates_label": "업데이트",
"daemon_version": "데몬",
"dark": "다크",
+ "data_stale_prefix": "업데이트",
+ "data_stale_tooltip": "잔액이 오래되었을 수 있습니다 — 지갑이 최근에 업데이트를 받지 못했습니다. 노드 연결을 확인하세요.",
"date": "날짜",
"date_label": "날짜:",
"debug_logging": "디버그 로깅",
@@ -479,6 +581,17 @@
"download_bootstrap": "부트스트랩 다운로드",
"dragonx_green": "DragonX(그린)",
"edit": "편집",
+ "empty_wallet_keys_suffix": "개 키",
+ "empty_wallet_open_manager": "지갑 관리자 열기",
+ "empty_wallet_restore": "내 지갑 복원",
+ "empty_wallet_salvage_body": "이 지갑이 비어 있는 것은 이전의 자동 복구가 원본 지갑을 백업으로 따로 보관했기 때문입니다. 코인은 거의 확실히 그 백업에 있으며 사라지지 않았습니다. 복원하면 자금을 다시 불러올 수 있습니다. 아무것도 삭제되지 않으며, 현재 파일은 먼저 따로 보관됩니다.",
+ "empty_wallet_salvage_headline": "코인은 백업 파일에 안전하게 보관되어 있습니다.",
+ "empty_wallet_salvage_title": "지갑이 복구되었을 수 있습니다",
+ "empty_wallet_warning_body": "이 지갑에는 주소도 자금도 없지만, DragonX 폴더의 다른 지갑 파일에 키가 들어 있습니다. 코인은 대부분 그 안에 있으며 사라진 것이 아닙니다. 지갑 관리자를 열어 자금이 있는 지갑으로 전환하세요.",
+ "empty_wallet_warning_dismiss": "이 지갑에 대해 다시 경고하지 않기",
+ "empty_wallet_warning_dismiss_tip": "현재 지갑 파일에 대해서만 이 경고를 중지합니다. 나중에 다른 빈 지갑으로 전환하면 다시 경고할 수 있습니다.",
+ "empty_wallet_warning_headline": "잘못된 지갑을 열었을 수 있습니다.",
+ "empty_wallet_warning_title": "이 지갑은 비어 있습니다",
"enc_confirm": "확인:",
"enc_desc": "지갑을 암호화하면 암호로 개인 키를 보호합니다. 암호화 후 데몬이 다시 시작됩니다.",
"enc_encrypting": "지갑을 암호화하는 중...",
@@ -566,6 +679,63 @@
"general": "일반",
"generating": "생성 중",
"go_to_receive": "수신으로 이동",
+ "grpa_current_block_paren": "(현재: %d)",
+ "grpa_days_ago": "%lld일 전",
+ "grpa_dbg_addrman": "피어 주소 추적 및 관리",
+ "grpa_dbg_alert": "경고 시스템 메시지",
+ "grpa_dbg_bench": "작업 벤치마크 시간 측정",
+ "grpa_dbg_coindb": "코인 데이터베이스 읽기/쓰기 작업",
+ "grpa_dbg_db": "Berkeley DB 작업",
+ "grpa_dbg_estimatefee": "수수료 추정 알고리즘",
+ "grpa_dbg_http": "HTTP RPC 서버 활동",
+ "grpa_dbg_libevent": "Libevent 네트워킹 라이브러리",
+ "grpa_dbg_lock": "락 경합 디버깅",
+ "grpa_dbg_mempool": "트랜잭션 메모리 풀 활동",
+ "grpa_dbg_net": "네트워크 연결 및 메시지",
+ "grpa_dbg_paymentdisclosure": "결제 공개 프로토콜",
+ "grpa_dbg_pow": "작업 증명 채굴 활동",
+ "grpa_dbg_proxy": "SOCKS5 프록시 연결",
+ "grpa_dbg_prune": "블록 정리 작업",
+ "grpa_dbg_rand": "난수 생성",
+ "grpa_dbg_reindex": "블록체인 재색인 진행 상황",
+ "grpa_dbg_rpc": "RPC 명령 처리",
+ "grpa_dbg_selectcoins": "트랜잭션용 코인 선택",
+ "grpa_dbg_tor": "Tor 연동 및 회로 정보",
+ "grpa_dbg_zmq": "ZeroMQ 알림 시스템",
+ "grpa_dbg_zrpc": "실드(z-addr) RPC 작업",
+ "grpa_enter_private_key_to_import": "가져올 개인 키를 입력하세요.",
+ "grpa_error_prefix": "오류: ",
+ "grpa_hr_ago": "%lld시간 전",
+ "grpa_invalid_response_from_daemon": "데몬으로부터 잘못된 응답",
+ "grpa_invalid_suffix": " (유효하지 않음)",
+ "grpa_min_ago": "%lld분 전",
+ "grpa_sec_ago": "%lld초 전",
+ "grpa_seed_demo_chat": "시드 데모 채팅",
+ "grpa_showing_first_100_of": "... %d개 중 처음 100개 표시",
+ "grpa_tab_about": "정보",
+ "grpa_tab_appearance": "화면 표시",
+ "grpa_tab_backup_data": "백업 및 데이터",
+ "grpa_tab_chat": "채팅",
+ "grpa_tab_explorer": "탐색기",
+ "grpa_tab_node_security": "노드 및 보안",
+ "grpa_tab_wallet": "지갑",
+ "grpa_unexpected_getblockhash_result": "예기치 않은 getblockhash 결과",
+ "grpb_copy": "복사",
+ "grpb_max": "최대",
+ "grpb_new_badge_suffix": " [신규]",
+ "grpb_preview_msg_payment_through": "결제가 완료됐나요? 🙂",
+ "grpb_preview_msg_sending_rest": "지금 나머지를 보낼게요 👍",
+ "grpb_preview_msg_yep_confirmed": "네 — 방금 확인했어요 ✅",
+ "grpb_selected_suffix": "\n(선택됨)",
+ "grpb_tooltip_address_balance": "%s\n잔액: %.8f %s%s",
+ "grpb_undo_clear": "지우기 취소",
+ "grpc_benchmark_inconclusive": "벤치마크 결과가 불확실합니다: 해시레이트 샘플이 기록되지 않았습니다. 풀 연결을 확인한 후 다시 시도하세요.",
+ "grpc_benchmark_takes_secs": "벤치마크는 약 %d초가 걸리며 채굴을 중단합니다. 시작하려면 다시 클릭하세요.",
+ "grpc_bootstrap_failed": "부트스트랩 실패",
+ "grpc_bootstrap_not_initialized": "부트스트랩이 초기화되지 않았습니다",
+ "grpc_hashrate_fee": "%s 수수료 %s%%",
+ "grpc_key_not_available": "이 주소에 대한 키를 사용할 수 없습니다",
+ "grpc_na": "해당 없음",
"height": "높이",
"help": "도움말",
"hidden_tag": " (숨김)",
@@ -640,6 +810,7 @@
"light": "라이트",
"lite_account_label": "계정",
"lite_action": "작업",
+ "lite_backend_unavailable": "라이트 지갑 백엔드를 사용할 수 없습니다",
"lite_backup_keys": "백업 및 키",
"lite_birthday_backup": "생성 블록: %llu (이 값도 백업하세요)",
"lite_birthday_hint": "스캔을 시작할 블록 높이입니다. 모르면 0으로 두세요(전체 스캔이 느려짐).",
@@ -647,9 +818,12 @@
"lite_console_backend_commands": "백엔드 명령:",
"lite_console_help_passthrough": "그 외 입력은 라이트 지갑 콘솔 명령으로 실행됩니다.",
"lite_copy": "복사",
+ "lite_could_not_start": "작업을 시작할 수 없습니다",
"lite_could_not_write": "쓸 수 없습니다: ",
"lite_encrypt_wallet": "지갑 암호화",
"lite_encryption_removed": "암호화가 제거되었습니다",
+ "lite_enter_all_seed_words": "복구하려면 24개의 시드 단어를 모두 입력하세요 (현재 %d개)",
+ "lite_enter_wallet_path": "지갑 경로를 입력하세요",
"lite_hide_wipe": "숨기고 삭제",
"lite_import": "가져오기",
"lite_import_key_label": "키 가져오기",
@@ -734,6 +908,8 @@
"lite_working": "작업 중…",
"loading": "로딩 중...",
"loading_addresses": "주소 로딩 중...",
+ "loading_stall_body": "데몬이 %.0f초 동안 초기화 중입니다. 업데이트 후나 첫 실행 시(블록 인덱스 로드 또는 재스캔)에는 정상일 수 있습니다. 준비되면 자동으로 연결됩니다.",
+ "loading_stall_title": "예상보다 오래 걸리고 있습니다",
"loading_transactions": "거래를 불러오는 중",
"local_hashrate": "로컬 해시레이트",
"low_spec_mode": "저사양 모드",
@@ -866,6 +1042,7 @@
"mining_difficulty_copied": "난이도가 복사되었습니다",
"mining_est_block": "예상 블록",
"mining_est_daily": "예상 일일 수익",
+ "mining_est_daily_pool_sub": "대략적인 솔로 환산, 풀 수수료 전",
"mining_filter_all": "전체",
"mining_filter_tip_all": "모든 수익 표시",
"mining_filter_tip_pool": "풀 수익만 표시",
@@ -894,10 +1071,12 @@
"mining_open_in_explorer": "탐색기에서 열기",
"mining_payout_address": "지급 주소",
"mining_payout_foreign": "⚠ 이 지급 주소는 현재 지갑에 없습니다 — 채굴한 보상이 다른 지갑으로 전송됩니다. 지갑을 전환했다면 주소를 업데이트하세요.",
+ "mining_payout_invalid": "유효한 DragonX 주소가 아닙니다 — 시작하기 전에 수정하세요. 그렇지 않으면 채굴 보상이 사라집니다.",
"mining_payout_tooltip": "채굴 보상 수신 주소",
"mining_pool": "풀",
"mining_pool_fee": "수수료",
"mining_pool_hashrate": "풀 해시레이트",
+ "mining_pool_needs_payout_tooltip": "먼저 지급 주소를 입력하세요 (Z 주소를 생성하세요)",
"mining_pool_url": "풀 URL",
"mining_pools_header": "풀",
"mining_recent_blocks": "최근 블록",
@@ -927,6 +1106,9 @@
"mining_syncing_tooltip": "블록체인 동기화 중...",
"mining_tag": " · 채굴",
"mining_threads": "채굴 스레드",
+ "mining_threads_input_tooltip": "정확한 스레드 수 입력 (Enter로 적용)",
+ "mining_threads_minus_tooltip": "스레드 줄이기",
+ "mining_threads_plus_tooltip": "스레드 늘리기",
"mining_to_save": "저장하려면",
"mining_today": "오늘",
"mining_uptime": "가동 시간",
@@ -953,6 +1135,11 @@
"no_transactions": "거래 내역이 없습니다",
"no_transactions_yet": "아직 거래 내역이 없습니다",
"node": "노드",
+ "node_banner_crashed_title": "노드가 예기치 않게 중지되었습니다",
+ "node_banner_lite_open_failed": "지갑을 열 수 없습니다",
+ "node_banner_offline_title": "DragonX 노드에 연결되지 않음",
+ "node_banner_reconnect": "재연결",
+ "node_banner_restart": "노드 재시작",
"node_security": "노드 및 보안",
"noise": "노이즈",
"not_connected": "데몬에 연결되지 않음...",
@@ -1091,6 +1278,8 @@
"qr_failed": "QR 코드 생성 실패",
"qr_title": "QR 코드",
"qr_unavailable": "QR 사용 불가",
+ "quick_receive": "빠른 받기",
+ "quick_send": "빠른 보내기",
"ram_daemon_gb": "데몬: %.1f GB (%s)",
"ram_daemon_mb": "데몬: %.0f MB (%s)",
"ram_system_gb": "시스템: %.1f / %.0f GB",
@@ -1140,6 +1329,7 @@
"rpc_connection": "RPC 연결...",
"rpc_host": "RPC 호스트",
"rpc_pass": "비밀번호",
+ "rpc_plaintext_remote_warning": "원격 RPC가 암호화되지 않은 HTTP를 사용하고 있습니다. 데몬이 TLS를 지원하면 DRAGONX.conf에 rpctls=1을 추가하세요.",
"rpc_port": "포트",
"rpc_user": "사용자명",
"save": "저장",
@@ -1154,6 +1344,8 @@
"sb_connecting_external": "외부 데몬에 연결 중...",
"sb_connecting_generic": "데몬에 연결 중...",
"sb_daemon_crashed": "데몬이 %d회 충돌함",
+ "sb_daemon_extract_failed": "데몬 파일을 쓰지 못했습니다. 디스크 여유 공간과 권한을 확인하세요.",
+ "sb_daemon_files_failed": "%s에 데몬 파일을 쓰지 못했습니다. 디스크 여유 공간과 권한을 확인하세요.",
"sb_daemon_not_found": "데몬을 찾을 수 없음",
"sb_daemon_start_failed": "dragonxd를 시작할 수 없습니다",
"sb_dragonxd_running": "dragonxd 실행 중",
@@ -1169,6 +1361,7 @@
"sb_net_mhs": "네트: %.2f MH/s",
"sb_no_conf": "DRAGONX.conf를 찾을 수 없음",
"sb_peers": "피어: %zu",
+ "sb_plaintext_remote_blocked": "원격 호스트로 RPC 자격 증명을 평문으로 보내는 것을 거부했습니다. 허용하려면 DRAGONX.conf에 rpcallowplaintext=1을 추가하거나 rpctls=1로 TLS를 활성화하세요.",
"sb_rescanning": "재스캔",
"sb_rescanning_pct": "재스캔 %.0f%%",
"sb_restarting_daemon": "데몬 재시작 중...",
@@ -1182,12 +1375,52 @@
"sb_waiting_daemon_err": "dragonxd 대기 중 — %s",
"sb_warming_up": "워밍업 중...",
"sb_witness_cache": "증인 재구축 중",
+ "scale_effects": "배율 및 효과",
"screenshot_open_dir": "위치 열기",
"screenshot_sweep": "스크린샷 스윕 실행",
"screenshot_sweep_desc": "모든 탭에 대해 모든 테마를 순회하며 각각의 스크린샷을 설정 디렉터리의 screenshots 폴더 아래 탭별 하위 폴더에 저장합니다(이전 스윕을 덮어씀). 몇 초 동안 실행됩니다.",
"screenshot_sweep_full": "전체 UI 스윕",
"search_icons": "아이콘 검색...",
"search_placeholder": "검색...",
+ "sec_changing_passphrase": "암호문 변경 중...",
+ "sec_changing_pin": "PIN 변경 중...",
+ "sec_couldnt_lock_wallet": "지갑을 잠글 수 없습니다 — 아직 잠금 해제 상태입니다. 데몬 연결을 확인하세요.",
+ "sec_encrypted_backup_suffix": "\n암호화된 백업: wallet.dat.encrypted.bak",
+ "sec_encrypting_wallet": "지갑 암호화 중...",
+ "sec_encryption_did_not_complete": "지갑 암호화가 완료되지 않았습니다 — 지갑이 암호화되지 않은 상태입니다. 설정을 열어 암호화를 완료하세요.",
+ "sec_encryption_failed_prefix": "암호화 실패: ",
+ "sec_failed_prefix": "실패: ",
+ "sec_failed_to_create_vault": "볼트 생성에 실패했습니다",
+ "sec_importing_keys_rescanning": "키를 가져오고 블록체인을 다시 스캔하는 중 — 진행 중에도 지갑을 사용할 수 있습니다",
+ "sec_incorrect_current_pin": "현재 PIN이 올바르지 않습니다",
+ "sec_incorrect_passphrase_decrypt": "잘못된 암호문",
+ "sec_incorrect_passphrase_pin_setup": "잘못된 암호문",
+ "sec_incorrect_pin_remove": "잘못된 PIN",
+ "sec_internal_error_change_pin": "내부 오류",
+ "sec_internal_error_remove_pin": "내부 오류",
+ "sec_mode_passphrase": " 암호문",
+ "sec_not_connected_to_daemon": "데몬에 연결되지 않음",
+ "sec_not_connected_to_daemon_pin": "데몬에 연결되지 않음",
+ "sec_passphrase_changed_successfully": "암호문이 성공적으로 변경되었습니다",
+ "sec_pin_changed_successfully": "PIN이 성공적으로 변경되었습니다",
+ "sec_pin_removed": "PIN이 제거되었습니다",
+ "sec_pin_set_successfully": "PIN이 성공적으로 설정되었습니다",
+ "sec_restart_daemon_for_encryption": "암호화를 적용하려면 데몬을 다시 시작하세요.",
+ "sec_too_many_attempts_wait": "시도 횟수가 너무 많습니다. %.0f초 기다리세요...",
+ "sec_total_elapsed_fmt": "총 경과 시간: %d분 %02d초",
+ "sec_unlock_button": "잠금 해제",
+ "sec_unlock_failed_prefix": "잠금 해제 실패: ",
+ "sec_unlocking_fmt": "잠금 해제 중%s",
+ "sec_use_passphrase_instead": "대신 암호문 사용",
+ "sec_use_pin_instead": "대신 PIN 사용",
+ "sec_verifying_passphrase": "암호문 확인 중...",
+ "sec_verifying_pin": "PIN 확인 중...",
+ "sec_wallet_decrypted_all_keys_imported": "지갑 복호화 완료! 모든 키를 가져왔습니다.",
+ "sec_wallet_encrypted_and_pin_set": "지갑 암호화 및 PIN 설정 완료",
+ "sec_wallet_encrypted_but_pin_vault_failed": "지갑은 암호화되었으나 PIN 볼트 설정에 실패했습니다",
+ "sec_wallet_encrypted_restarting_daemon": "지갑이 암호화되었습니다. 데몬을 다시 시작하는 중...",
+ "sec_wallet_encrypted_successfully": "지갑이 성공적으로 암호화되었습니다",
+ "sec_wallet_locked_title": "지갑 잠김",
"security": "보안",
"seed_backup_button": "시드 문구",
"seed_backup_close": "닫기",
@@ -1246,6 +1479,7 @@
"send_tooltip_not_connected": "데몬에 연결되지 않음",
"send_tooltip_select_source": "먼저 보낼 주소를 선택하세요",
"send_tooltip_syncing": "블록체인 동기화를 기다려 주세요",
+ "send_tooltip_view_only": "조회 전용 주소 — 지출 키가 없어 보낼 수 없습니다",
"send_total": "합계",
"send_transaction": "거래 전송",
"send_tx_failed": "거래 실패",
@@ -1265,16 +1499,16 @@
"sent_filter": "전송됨",
"sent_type": "전송됨",
"sent_upper": "전송됨",
- "set_label": "라벨 설정...",
+ "set_label": "라벨 설정",
"settings": "설정",
"settings_about_text": "DragonX (DRGX)용 차폐 암호화폐 지갑으로, Dear ImGui로 제작되어 가볍고 휴대 가능합니다.",
"settings_acrylic_level": "아크릴 레벨:",
- "settings_address_book": "주소록...",
+ "settings_address_book": "주소록…",
"settings_auto_detected": "DRAGONX.conf에서 자동 감지",
"settings_auto_lock": "자동 잠금",
"settings_auto_shield_desc": "투명 자금을 자동으로 차폐 주소로 이동",
"settings_auto_shield_funds": "투명 자금 자동 차폐",
- "settings_backup": "백업...",
+ "settings_backup": "백업…",
"settings_block_explorer_urls": "블록 탐색기 URL",
"settings_builtin": "내장",
"settings_change_passphrase": "비밀번호 변경",
@@ -1285,60 +1519,71 @@
"settings_configure_explorer": "외부 블록 탐색기 링크 구성",
"settings_configure_rpc": "dragonxd 데몬 연결 구성",
"settings_connection": "연결",
+ "settings_copy_diagnostics": "진단 정보 복사",
"settings_copyright": "Copyright 2024-2026 DragonX 개발자 | GPLv3 라이선스",
"settings_custom": "사용자 지정",
- "settings_data_dir": "데이터 디렉터리:",
+ "settings_data_dir": "데이터 디렉터리",
"settings_debug_changed": "디버그 카테고리가 변경되었습니다 — 데몬을 재시작하여 적용",
"settings_debug_restart_note": "변경 사항은 데몬을 다시 시작한 후에 적용됩니다.",
"settings_debug_select": "데몬 디버그 로깅을 활성화할 카테고리를 선택하세요 (-debug= 플래그).",
+ "settings_diagnostics_copied": "진단 정보를 클립보드에 복사했습니다",
"settings_encrypt_first_pin": "PIN을 활성화하려면 먼저 지갑을 암호화하세요",
"settings_encrypt_wallet": "지갑 암호화",
"settings_explorer_hint": "URL에 후행 슬래시를 포함해야 합니다. txid/주소가 추가됩니다.",
- "settings_export_all": "모두 내보내기...",
- "settings_export_csv": "CSV 내보내기...",
- "settings_export_key": "키 내보내기...",
+ "settings_export_all": "모두 내보내기…",
+ "settings_export_csv": "CSV 내보내기…",
+ "settings_export_key": "키 내보내기…",
"settings_gradient_bg": "그라데이션 배경",
"settings_gradient_desc": "텍스처 배경을 부드러운 그라데이션으로 교체",
"settings_idle_after": "후",
- "settings_import_key": "개인 키 가져오기...",
- "settings_import_viewkey": "조회 키 가져오기...",
+ "settings_import_key": "개인 키 가져오기…",
+ "settings_import_viewkey": "조회 키 가져오기…",
"settings_language_note": "참고: 일부 텍스트는 업데이트하려면 다시 시작해야 합니다",
"settings_lock_now": "지금 잠금",
"settings_locked": "잠김",
- "settings_merge_to_address": "주소로 병합...",
+ "settings_merge_to_address": "주소로 병합…",
"settings_noise_opacity": "노이즈 불투명도:",
+ "settings_not_connected": "데몬에 연결되지 않음",
"settings_not_encrypted": "암호화되지 않음",
"settings_not_found": "찾을 수 없음",
"settings_open_app_dir": "앱 폴더 열기",
"settings_open_data_dir": "데이터 폴더 열기",
+ "settings_open_log_folder": "로그 폴더 열기",
"settings_other": "기타",
"settings_pin_active": "PIN",
"settings_privacy": "개인 정보",
"settings_quick_unlock_pin": "빠른 잠금 해제 PIN",
"settings_reduce_transparency": "투명도 줄이기",
+ "settings_reloaded": "디스크에서 설정을 다시 불러왔습니다",
"settings_remove_encryption": "암호화 제거",
"settings_remove_pin": "PIN 제거",
- "settings_request_payment": "결제 요청...",
+ "settings_request_payment": "결제 요청…",
"settings_rescan_desc": "누락된 거래를 찾기 위해 블록체인 재스캔",
"settings_restart_daemon": "데몬 재시작",
"settings_rpc_connection": "RPC 연결",
+ "settings_rpc_error_prefix": "RPC 오류: ",
"settings_rpc_note": "참고: 연결 설정은 보통 DRAGONX.conf에서 자동 감지됩니다",
+ "settings_rpc_ok": "RPC 연결 정상",
"settings_save_shielded_desc": "z-addr 거래를 로컬 파일에 저장하여 조회",
"settings_save_shielded_local": "차폐 거래 기록을 로컬에 저장",
+ "settings_saved": "설정이 저장되었습니다",
"settings_set_pin": "PIN 설정",
- "settings_shield_mining": "채굴 차폐...",
+ "settings_shield_mining": "채굴 차폐…",
"settings_solid_colors_desc": "블러 효과 대신 단색 사용 (접근성)",
+ "settings_theme_refreshed": "테마 목록을 새로고침했습니다",
"settings_tor_desc": "향상된 개인 정보 보호를 위해 모든 연결을 Tor를 통해 라우팅",
"settings_unlocked": "잠금 해제",
"settings_use_tor_network": "네트워크 연결에 Tor 사용",
- "settings_validate_address": "주소 확인...",
+ "settings_validate_address": "주소 확인…",
"settings_visual_effects": "시각 효과",
"settings_wallet_file_size": "지갑 파일 크기: %s",
"settings_wallet_info": "지갑 정보",
"settings_wallet_location": "지갑 위치: %s",
"settings_wallet_maintenance": "지갑 유지보수",
"settings_wallet_not_found": "지갑 파일을 찾을 수 없음",
- "settings_wallet_size_label": "지갑 크기:",
+ "settings_wallet_size_label": "지갑 크기",
+ "settings_ztx_cleared": "Z-거래 내역이 삭제되었습니다",
+ "settings_ztx_not_found": "내역 파일을 찾을 수 없습니다",
"setup_wizard": "설정 마법사",
"share": "공유",
"shield_check_status": "상태 확인",
@@ -1397,6 +1642,17 @@
"sweep_to": "쓸어담은 주소:",
"sweep_toggle": "내 지갑으로 쓸어담기 (키 보관 안 함)",
"sweep_tx": "거래:",
+ "swin_connection_failed": "연결 실패: ",
+ "swin_connection_successful": "연결에 성공했습니다!\ndragonxd 버전: ",
+ "swin_invalid_suffix": " (유효하지 않음)",
+ "swin_no_history_file_found": "기록 파일을 찾을 수 없습니다",
+ "swin_rescan_failed": "다시 스캔 실패: ",
+ "swin_rescan_started_from_block": "다시 스캔이 시작된 블록 ",
+ "swin_rescan_to": " ~ ",
+ "swin_rpc_client_not_initialized": "RPC 클라이언트가 초기화되지 않았습니다",
+ "swin_settings_saved": "설정이 저장되었습니다",
+ "swin_theme_list_refreshed": "테마 목록을 새로고침했습니다",
+ "swin_ztx_history_cleared": "Z-트랜잭션 기록이 삭제되었습니다",
"switch_corrupt_body": "이 지갑이 손상된 것 같습니다. 노드가 열 수 없습니다. 백업에서 복원하거나 다시 만들거나 복구를 시도하세요.",
"switch_corrupt_repair": "복구 시도(salvage)",
"switch_progress_background": "백그라운드에서 계속",
@@ -1421,6 +1677,7 @@
"theme": "테마",
"theme_effects": "테마 효과",
"theme_language": "테마 및 언어",
+ "tile_click_to_open": "클릭하여 열기",
"time_days_ago": "%d일 전",
"time_hours_ago": "%d시간 전",
"time_minutes_ago": "%d분 전",
@@ -1435,7 +1692,9 @@
"to_upper": "받는 곳",
"tools": "도구",
"tools_actions": "도구 및 작업...",
+ "tools_actions_hdr": "도구 및 작업",
"total": "합계",
+ "total_balance_label": "총 잔액",
"transaction_id": "거래 ID",
"transaction_sent": "거래 전송 성공",
"transaction_sent_msg": "거래가 전송되었습니다!",
@@ -1457,7 +1716,7 @@
"tt_auto_shield": "개인 정보 보호를 위해 투명 잔액을 자동으로 차폐 주소로 이동",
"tt_backup": "wallet.dat 백업 만들기",
"tt_block_explorer": "브라우저에서 DragonX 블록 탐색기 열기",
- "tt_blur": "블러 양 (0%% = 끔, 100%% = 최대)",
+ "tt_blur": "블러 양 (0% = 끔, 100% = 최대)",
"tt_change_pass": "지갑 암호화 비밀번호 변경",
"tt_change_pin": "잠금 해제 PIN 변경",
"tt_chat_bubble_accent": "보내는 메시지 말풍선의 강조 색상(또는 현재 테마를 따름)",
@@ -1470,6 +1729,7 @@
"tt_chat_timestamp": "이 탭에만 적용되는 타임스탬프 형식: 앱 전체 시계를 따르거나 24-hour 또는 12-hour로 강제합니다",
"tt_clear_ztx": "로컬에 캐시된 z-트랜잭션 기록 삭제",
"tt_clock_format": "24시간 또는 12시간 형식(앱 전체). 채팅에서 재정의할 수 있습니다.",
+ "tt_copy_diagnostics": "지원용 요약(버전, 데몬/지갑/로그 상태 — 비밀 정보 없음)을 클립보드에 복사합니다",
"tt_custom_fees": "거래 전송 시 수동 수수료 입력 활성화",
"tt_custom_theme": "사용자 지정 테마 활성화됨",
"tt_daemon_install_bundled": "노드를 중지하고 설치된 dragonxd를 이 지갑 빌드에 번들된 버전으로 덮어쓴 다음 재시작합니다",
@@ -1519,10 +1779,11 @@
"tt_low_spec": "모든 고부하 시각 효과 비활성화\\n단축키: Ctrl+Shift+Down",
"tt_merge": "여러 UTXO를 하나의 주소로 통합",
"tt_mine_idle": "시스템이 유휴 상태(키보드/마우스 입력 없음)일 때\\n자동으로 채굴 시작",
- "tt_noise": "그레인 텍스처 강도 (0%% = 끔, 100%% = 최대)",
+ "tt_noise": "그레인 텍스처 강도 (0% = 끔, 100% = 최대)",
"tt_open_app_dir": "파일 관리자에서 ObsidianDragon 폴더(설정, 테마, 로그)를 엽니다",
"tt_open_data_dir": "지갑 및 블록체인 데이터가 있는 폴더를 파일 탐색기에서 엽니다",
"tt_open_dir": "파일 탐색기에서 열려면 클릭",
+ "tt_open_log_folder": "디버그 및 충돌 로그가 있는 폴더를 엽니다",
"tt_reduce_motion": "접근성을 위해 애니메이션 전환 및 잔액 보간 비활성화",
"tt_remove_encrypt": "암호화를 제거하고 지갑을 보호 없이 저장",
"tt_remove_pin": "PIN을 제거하고 잠금 해제 시 비밀번호 요구",
@@ -1557,7 +1818,7 @@
"tt_theme_hotkey": "단축키: Ctrl+왼쪽/오른쪽으로 테마 전환",
"tt_tor": "익명성을 위해 데몬 연결을 Tor 네트워크를 통해 라우팅",
"tt_tx_url": "블록 탐색기에서 거래를 보기 위한 기본 URL",
- "tt_ui_opacity": "카드 및 사이드바 불투명도 (100%% = 완전 불투명, 낮을수록 더 투명)",
+ "tt_ui_opacity": "카드 및 사이드바 불투명도 (100% = 완전 불투명, 낮을수록 더 투명)",
"tt_validate": "DragonX 주소가 유효한지 확인",
"tt_verbose": "콘솔 탭에 상세 연결 진단,\\n데몬 상태 및 포트 소유자 정보 기록",
"tt_wallets_button": "지갑 파일 목록을 보고 전환합니다",
@@ -1610,6 +1871,7 @@
"validate_not_mine": "이 지갑에 속하지 않음",
"validate_ownership": "소유자:",
"validate_results": "결과:",
+ "validate_results_placeholder": "결과가 여기에 표시됩니다",
"validate_shielded_type": "차폐 (z 주소)",
"validate_status": "상태:",
"validate_title": "주소 검증",
@@ -1750,6 +2012,7 @@
"xmrig_loading_releases": "릴리스를 불러오는 중…",
"xmrig_none": "없음",
"xmrig_reinstall": "재설치",
+ "xmrig_releases": "xmrig 릴리스",
"xmrig_stop_mining_first": "채굴기를 업데이트하기 전에 채굴을 중지하세요.",
"xmrig_unavailable_body": "이 플랫폼에서 사용 가능한 채굴기 빌드가 없습니다.",
"xmrig_unavailable_title": "채굴기 업데이트를 사용할 수 없습니다",
diff --git a/res/lang/pt.json b/res/lang/pt.json
index 76b9db0..bc244ea 100644
--- a/res/lang/pt.json
+++ b/res/lang/pt.json
@@ -48,6 +48,10 @@
"advanced": "AVANÇADO",
"advanced_effects": "Efeitos Avançados...",
"ago": "atrás",
+ "alerts_clear": "Limpar histórico de alertas",
+ "alerts_history_tooltip": "Alertas recentes",
+ "alerts_none": "Ainda não há alertas",
+ "alerts_recent": "ALERTAS RECENTES",
"all_filter": "Todos",
"allow_custom_fees": "Permitir taxas personalizadas",
"amount": "Valor",
@@ -56,6 +60,80 @@
"amount_label": "Valor:",
"animate_avatars": "Animar avatares",
"appearance": "APARÊNCIA",
+ "appx_back": "Voltar",
+ "appx_back_up_seed_phrase_title": "Faça o backup da sua frase-semente",
+ "appx_birthday_block_height": "Nascimento (altura do bloco): %llu — faça o backup disto também.",
+ "appx_blockchain_data_deleted": "Dados da blockchain excluídos (%d itens). O daemon está reiniciando para ressincronizar com a rede.",
+ "appx_blockchain_maintenance_in_progress": "Uma operação de manutenção da blockchain já está em andamento.",
+ "appx_blockchain_rescan_complete": "Reescaneamento da blockchain concluído",
+ "appx_bootstrap_complete_reconciling": "Bootstrap concluído — reconciliando sua carteira com os novos dados da chain.",
+ "appx_cancel": "Cancelar",
+ "appx_cleaning_up": "Limpando...",
+ "appx_confirm_your_backup": "Confirme seu backup",
+ "appx_copied_clipboard_autoclears": "Copiado — a área de transferência será limpa em 45s",
+ "appx_copy": "Copiar",
+ "appx_could_not_start_restore": "Não foi possível iniciar a restauração",
+ "appx_create_failed_prefix": "Falha na criação: ",
+ "appx_creating_your_wallet": "Criando sua carteira…",
+ "appx_daemon_error": "Erro do Daemon",
+ "appx_daemon_reinstall_in_progress": "A reinstalação do daemon já está em andamento.",
+ "appx_disconnecting": "Desconectando...",
+ "appx_done": "Concluído",
+ "appx_dragonxd_output": "saída do dragonxd",
+ "appx_encrypting_wallet": "Criptografando a carteira...",
+ "appx_fullnode_lifecycle_unavailable_lite": "As ações de ciclo de vida do nó completo não estão disponíveis na versão lite",
+ "appx_installing_bundled_daemon": "Instalando o daemon incluído — o nó vai parar, atualizar e reiniciar...",
+ "appx_invalid_payment_uri_prefix": "URI de pagamento inválida: ",
+ "appx_ive_written_it_down": "Já anotei",
+ "appx_keep_node_running_and_quit": "Manter o nó em execução e sair",
+ "appx_last_block_n": "Último bloco: %d",
+ "appx_last_used_wallet_not_found_prefix": "Seu último arquivo de carteira usado (",
+ "appx_last_used_wallet_not_found_suffix": ") não foi encontrado — a carteira padrão foi aberta em seu lugar. Se você o moveu, restaure-o e volte a ele pela lista de carteiras.",
+ "appx_low_spec_mode_disabled": "Modo de baixo desempenho desativado",
+ "appx_low_spec_mode_enabled": "Modo de baixo desempenho ativado",
+ "appx_miner_stopped_prefix": "Minerador parado: ",
+ "appx_miner_stopped_unexpectedly": "O minerador parou inesperadamente.",
+ "appx_n_min_n_sec": "%d min %d s",
+ "appx_n_seconds": "%d segundos",
+ "appx_no_bundled_daemon_to_install": "Esta versão não tem daemon incluído para instalar",
+ "appx_no_embedded_daemon_to_install": "Esta versão não tem daemon embutido para instalar",
+ "appx_node_busy_restarting": "O nó está ocupado reiniciando — tente novamente em instantes.",
+ "appx_node_rebuilding_witness_cache": "O nó está reconstruindo seu cache de testemunhas",
+ "appx_not_next_word": " — essa não é a próxima palavra",
+ "appx_payment_request_loaded": "Solicitação de pagamento carregada",
+ "appx_pool_miner_connected_and_hashing": "Minerador de pool conectado e processando hashes.",
+ "appx_progress_n_of_n": "Progresso: %d / %d",
+ "appx_rebuilding_sapling_note_witnesses": "Reconstruindo testemunhas de notas Sapling…",
+ "appx_rebuilding_witness_cache_blocks_left": "Reconstruindo cache de testemunhas %.0f%% — %d blocos restantes",
+ "appx_rebuilding_witness_cache_pct": "Reconstruindo cache de testemunhas %.0f%%",
+ "appx_recovery_phrase_word_count": "A frase de recuperação deve ter 24 palavras — você tem %d.",
+ "appx_restarting_daemon_rescan_flag": "Reiniciando o daemon com a flag -rescan...",
+ "appx_restarting_daemon_zapwallettxes": "Reiniciando o daemon com -zapwallettxes=2 (reparo da carteira)...",
+ "appx_restoring_your_wallet": "Restaurando sua carteira…",
+ "appx_seed_backup_warning": "Estas 24 palavras são a ÚNICA forma de restaurar sua carteira. Anote-as na ordem, guarde-as offline e nunca as compartilhe. Se perdê-las, seus fundos desaparecem para sempre.",
+ "appx_seed_not_backed_up_warning": "Você não fez o backup da sua semente — os fundos podem ser perdidos. Pular mesmo assim?",
+ "appx_sending_stop_command_to_daemon": "Enviando comando de parada ao daemon...",
+ "appx_setting_initial_sapling_witnesses": "Definindo testemunhas Sapling iniciais %.0f%%",
+ "appx_shutdown_complete": "Encerramento concluído",
+ "appx_simple_background_disabled": "Fundo simples desativado",
+ "appx_simple_background_enabled": "Fundo simples ativado",
+ "appx_skip": "Pular",
+ "appx_skip_anyway": "Pular mesmo assim",
+ "appx_still_status_prefix": "Ainda \"",
+ "appx_still_status_suffix": "\" — forçar o encerramento agora pode corromper os dados da chain.",
+ "appx_stop_anyway_and_quit": "Parar mesmo assim e sair",
+ "appx_stopping_daemon_deleting_blockchain": "Parando o daemon e excluindo os dados da blockchain...",
+ "appx_stopping_node_discards_rebuild": "Parar o nó agora descarta a reconstrução em andamento e a reinicia (vários minutos) na próxima vez que você abrir a carteira. Em vez disso, você pode manter o nó em execução.",
+ "appx_stopping_pool_miner": "Parando o minerador de pool...",
+ "appx_syncing_pct_block_n_of_n": "Sincronizando %.1f%% — Bloco %d / %d",
+ "appx_tap_words_in_order": "Toque nas palavras na ordem correta para confirmar que você as salvou.",
+ "appx_theme_effects_disabled": "Efeitos de tema desativados",
+ "appx_theme_effects_enabled": "Efeitos de tema ativados",
+ "appx_theme_prefix": "Tema: ",
+ "appx_use_settings_restart_daemon_hint": "Use Configurações > Reiniciar Daemon para tentar novamente",
+ "appx_waiting_for_daemon_to_encrypt_wallet": "Aguardando o daemon criptografar a carteira...",
+ "appx_wallet_created_and_backed_up": "Carteira criada e com backup feito.",
+ "appx_wallet_open_failed_prefix": "Falha ao abrir a carteira: ",
"auto_shield": "Auto-blindar mineração",
"av_intro": "Softwares de mineração costumam ser sinalizados como potencialmente indesejados. Siga estes passos para habilitar a mineração em pool:",
"av_open_security": "Abrir Segurança do Windows",
@@ -70,6 +148,9 @@
"av_title": "Windows Defender bloqueou o minerador",
"available": "Disponível",
"backup_backing_up": "Fazendo backup...",
+ "backup_col_backup": "BACKUP",
+ "backup_col_export": "EXPORTAR",
+ "backup_col_import": "IMPORTAR E RESTAURAR",
"backup_create": "Criar Backup",
"backup_created": "Backup da carteira criado",
"backup_data": "BACKUP & DADOS",
@@ -88,8 +169,22 @@
"balance": "Saldo",
"balance_history_collecting": "Histórico de saldo — coletando dados...",
"balance_layout": "Layout do Saldo",
+ "balance_layout_switched": "Layout: %s",
+ "balance_mining_rate": "Minerando %s",
"balance_shielded_fmt": "Blindado: %.8f",
+ "balance_syncing_pct": "Sincronizando %.1f%%",
"balance_transparent_fmt": "Transparente: %.8f",
+ "baltab_market": "Mercado",
+ "baltab_market_price_4dp": "Mercado: $%.4f",
+ "baltab_market_price_8dp": "Mercado: $%.8f",
+ "baltab_pct_change_24h": "%s%.1f%% 24h",
+ "baltab_pct_of_total_zaddr": "%.0f%% do total · %d Z-addr",
+ "baltab_shielded": "Blindado",
+ "baltab_shielded_amount": "Blindado %.8f",
+ "baltab_t_addresses_count": "%d T-addresses",
+ "baltab_total_balance": "Saldo Total",
+ "baltab_transparent": "Transparente",
+ "baltab_transparent_amount": "Transparente %.8f",
"ban": "Banir",
"banned_peers": "Pares Banidos",
"block": "Bloco",
@@ -128,6 +223,7 @@
"bootstrap_verifying": "Verificando somas de verificação...",
"bootstrap_wallet_protected": "(wallet.dat está protegido)",
"bootstrap_warning": "Os dados de blocos existentes (blocks, chainstate, notarizations) serão excluídos e substituídos. Seu wallet.dat NÃO será modificado ou excluído.",
+ "byte_count_fmt": "%zu / %zu bytes",
"cancel": "Cancelar",
"change_pass_confirm": "Confirmar nova:",
"change_pass_current": "Senha atual:",
@@ -403,6 +499,7 @@
"contacts_shape_square": "Quadrado",
"contacts_shape_tab": "Aba",
"copied": "Copiado!",
+ "copied_to_clipboard": "Copiado para a área de transferência",
"copy": "Copiar",
"copy_address": "Copiar Endereço Completo",
"copy_error": "Copiar Erro",
@@ -415,6 +512,7 @@
"daemon_bundled": "Empacotado",
"daemon_install_bundled": "Instalar incluído",
"daemon_installed": "Instalado",
+ "daemon_maintenance_label": "MANUTENÇÃO",
"daemon_none_bundled": "nenhum nesta build",
"daemon_not_installed": "não instalado",
"daemon_status_differ": "O binário instalado difere da versão empacotada.",
@@ -437,6 +535,7 @@
"daemon_update_latest": "Mais recente:",
"daemon_update_loading": "Carregando versões…",
"daemon_update_now": "Atualizar agora",
+ "daemon_update_prompt_title": "Atualizar o daemon do node?",
"daemon_update_reinstall": "Reinstalar",
"daemon_update_restart_note": "Reinicie o daemon para começar a executar a nova versão.",
"daemon_update_restart_now": "Reiniciar daemon agora",
@@ -449,8 +548,11 @@
"daemon_update_verify_note": "O download é verificado contra o SHA-256 publicado do lançamento e uma assinatura ed25519 fixada antes da instalação.",
"daemon_update_verifying": "Verificando…",
"daemon_update_version": "Versão:",
+ "daemon_updates_label": "ATUALIZAÇÕES",
"daemon_version": "Daemon",
"dark": "Escuro",
+ "data_stale_prefix": "Atualizado",
+ "data_stale_tooltip": "O saldo pode estar desatualizado — a carteira não recebeu uma atualização recente. Verifique a conexão com o seu nó.",
"date": "Data",
"date_label": "Data:",
"debug_logging": "REGISTRO DE DEPURAÇÃO",
@@ -479,6 +581,17 @@
"download_bootstrap": "Baixar Bootstrap",
"dragonx_green": "DragonX (Verde)",
"edit": "Editar",
+ "empty_wallet_keys_suffix": "chaves",
+ "empty_wallet_open_manager": "Abrir gerenciador de carteiras",
+ "empty_wallet_restore": "Restaurar minha carteira",
+ "empty_wallet_salvage_body": "Esta carteira está vazia porque um reparo automático anterior colocou sua carteira original de lado como backup. Suas moedas quase certamente estão nesse backup, não perdidas. Restaure-o para carregar seus fundos novamente — nada é excluído; o arquivo atual é guardado primeiro.",
+ "empty_wallet_salvage_headline": "Suas moedas estão seguras em um arquivo de backup.",
+ "empty_wallet_salvage_title": "Sua carteira pode ter sido reparada",
+ "empty_wallet_warning_body": "Esta carteira não tem endereços nem fundos, mas outro arquivo de carteira na sua pasta do DragonX contém chaves. Suas moedas provavelmente estão nele, não perdidas. Abra o gerenciador de carteiras para mudar para a carteira que contém seus fundos.",
+ "empty_wallet_warning_dismiss": "Não avisar novamente para esta carteira",
+ "empty_wallet_warning_dismiss_tip": "Interrompe este aviso apenas para o arquivo de carteira atual. Se você mudar para outra carteira vazia mais tarde, poderá avisar novamente.",
+ "empty_wallet_warning_headline": "Você pode ter aberto a carteira errada.",
+ "empty_wallet_warning_title": "Esta carteira está vazia",
"enc_confirm": "Confirmar:",
"enc_desc": "Criptografar sua carteira protege suas chaves privadas com uma senha. Após a criptografia, o daemon será reiniciado.",
"enc_encrypting": "Criptografando a carteira...",
@@ -566,6 +679,63 @@
"general": "Geral",
"generating": "Gerando",
"go_to_receive": "Ir para Receber",
+ "grpa_current_block_paren": "(Atual: %d)",
+ "grpa_days_ago": "há %lld dias",
+ "grpa_dbg_addrman": "Rastreamento e gerenciamento de endereços de pares",
+ "grpa_dbg_alert": "Mensagens do sistema de alertas",
+ "grpa_dbg_bench": "Medições de tempo de benchmark das operações",
+ "grpa_dbg_coindb": "Operações de leitura/gravação do banco de dados de moedas",
+ "grpa_dbg_db": "Operações do Berkeley DB",
+ "grpa_dbg_estimatefee": "Algoritmo de estimativa de taxa",
+ "grpa_dbg_http": "Atividade do servidor RPC HTTP",
+ "grpa_dbg_libevent": "Biblioteca de rede Libevent",
+ "grpa_dbg_lock": "Depuração de contenção de bloqueios",
+ "grpa_dbg_mempool": "Atividade do pool de memória de transações",
+ "grpa_dbg_net": "Conexões e mensagens de rede",
+ "grpa_dbg_paymentdisclosure": "Protocolo de divulgação de pagamento",
+ "grpa_dbg_pow": "Atividade de mineração de prova de trabalho",
+ "grpa_dbg_proxy": "Conexões de proxy SOCKS5",
+ "grpa_dbg_prune": "Operações de poda de blocos",
+ "grpa_dbg_rand": "Geração de números aleatórios",
+ "grpa_dbg_reindex": "Progresso da reindexação da blockchain",
+ "grpa_dbg_rpc": "Processamento de comandos RPC",
+ "grpa_dbg_selectcoins": "Seleção de moedas para transações",
+ "grpa_dbg_tor": "Integração com Tor e informações de circuito",
+ "grpa_dbg_zmq": "Sistema de notificações ZeroMQ",
+ "grpa_dbg_zrpc": "Operações RPC blindadas (z-address)",
+ "grpa_enter_private_key_to_import": "Insira uma chave privada para importar.",
+ "grpa_error_prefix": "Erro: ",
+ "grpa_hr_ago": "há %lld h",
+ "grpa_invalid_response_from_daemon": "Resposta inválida do daemon",
+ "grpa_invalid_suffix": " (inválido)",
+ "grpa_min_ago": "há %lld min",
+ "grpa_sec_ago": "há %lld s",
+ "grpa_seed_demo_chat": "Chat de demonstração da semente",
+ "grpa_showing_first_100_of": "... mostrando os primeiros 100 de %d",
+ "grpa_tab_about": "Sobre",
+ "grpa_tab_appearance": "Aparência",
+ "grpa_tab_backup_data": "Backup e Dados",
+ "grpa_tab_chat": "Chat",
+ "grpa_tab_explorer": "Explorer",
+ "grpa_tab_node_security": "Nó e Segurança",
+ "grpa_tab_wallet": "Carteira",
+ "grpa_unexpected_getblockhash_result": "resultado inesperado de getblockhash",
+ "grpb_copy": "Copiar",
+ "grpb_max": "Máx",
+ "grpb_new_badge_suffix": " [NOVO]",
+ "grpb_preview_msg_payment_through": "O pagamento foi concluído? 🙂",
+ "grpb_preview_msg_sending_rest": "Enviando o restante agora 👍",
+ "grpb_preview_msg_yep_confirmed": "Sim — acabei de confirmar ✅",
+ "grpb_selected_suffix": "\n(selecionado)",
+ "grpb_tooltip_address_balance": "%s\nSaldo: %.8f %s%s",
+ "grpb_undo_clear": "Desfazer Limpar",
+ "grpc_benchmark_inconclusive": "Benchmark inconclusivo: nenhuma amostra de taxa de hash foi registrada. Verifique a conexão com a pool e tente novamente.",
+ "grpc_benchmark_takes_secs": "O benchmark leva cerca de %ds e interrompe a mineração. Clique novamente para iniciar.",
+ "grpc_bootstrap_failed": "Falha no bootstrap",
+ "grpc_bootstrap_not_initialized": "Bootstrap não inicializado",
+ "grpc_hashrate_fee": "%s %s%% de taxa",
+ "grpc_key_not_available": "Chave não disponível para este endereço",
+ "grpc_na": "N/D",
"height": "Altura",
"help": "Ajuda",
"hidden_tag": " (oculto)",
@@ -640,6 +810,7 @@
"light": "Claro",
"lite_account_label": "Conta",
"lite_action": "Ação",
+ "lite_backend_unavailable": "Backend da carteira lite indisponível",
"lite_backup_keys": "Backup e chaves",
"lite_birthday_backup": "Aniversário: %llu (faça o backup disto também)",
"lite_birthday_hint": "Altura do bloco a partir da qual começar a escanear. Deixe 0 se desconhecida (escaneamento completo mais lento).",
@@ -647,9 +818,12 @@
"lite_console_backend_commands": "Comandos do backend:",
"lite_console_help_passthrough": "Qualquer outra entrada é executada como um comando de console da carteira leve.",
"lite_copy": "Copiar",
+ "lite_could_not_start": "Não foi possível iniciar a operação",
"lite_could_not_write": "Não foi possível gravar ",
"lite_encrypt_wallet": "Criptografar carteira",
"lite_encryption_removed": "Criptografia removida",
+ "lite_enter_all_seed_words": "Informe todas as 24 palavras-semente para restaurar (obtidas %d)",
+ "lite_enter_wallet_path": "Informe um caminho para a carteira",
"lite_hide_wipe": "Ocultar e apagar",
"lite_import": "Importar",
"lite_import_key_label": "Importar chave",
@@ -734,6 +908,9 @@
"lite_working": "Processando…",
"loading": "Carregando...",
"loading_addresses": "Carregando endereços...",
+ "loading_stall_body": "O daemon está inicializando há %.0f s. Isso pode ser normal após uma atualização ou no primeiro início (carregando o índice de blocos ou reescaneando) — ele se conectará automaticamente quando estiver pronto.",
+ "loading_stall_hint": "Ainda travado? Abra as Configurações e use Reiniciar daemon, ou verifique o Console para mais detalhes.",
+ "loading_stall_title": "Está demorando mais do que o esperado",
"loading_transactions": "Carregando transações",
"local_hashrate": "Hashrate Local",
"low_spec_mode": "Modo econômico",
@@ -866,6 +1043,7 @@
"mining_difficulty_copied": "Dificuldade copiada",
"mining_est_block": "Bloco Est.",
"mining_est_daily": "Est. Diário",
+ "mining_est_daily_pool_sub": "equivalente solo aproximado, antes da taxa do pool",
"mining_filter_all": "Todos",
"mining_filter_tip_all": "Mostrar todos os ganhos",
"mining_filter_tip_pool": "Mostrar apenas ganhos do pool",
@@ -894,10 +1072,12 @@
"mining_open_in_explorer": "Abrir no explorador",
"mining_payout_address": "Endereço de Pagamento",
"mining_payout_foreign": "⚠ Este endereço de pagamento não está na sua carteira atual — as recompensas mineradas iriam para uma carteira diferente. Atualize-o se você trocou de carteira.",
+ "mining_payout_invalid": "Endereço DragonX inválido — corrija antes de iniciar, ou as recompensas de mineração serão perdidas.",
"mining_payout_tooltip": "Endereço para receber recompensas de mineração",
"mining_pool": "Pool",
"mining_pool_fee": "Taxa",
"mining_pool_hashrate": "Hashrate do Pool",
+ "mining_pool_needs_payout_tooltip": "Informe primeiro um endereço de pagamento (gere um endereço Z)",
"mining_pool_url": "URL do Pool",
"mining_pools_header": "POOLS",
"mining_recent_blocks": "BLOCOS RECENTES",
@@ -927,6 +1107,9 @@
"mining_syncing_tooltip": "Blockchain está sincronizando...",
"mining_tag": " · Mineração",
"mining_threads": "Threads de Mineração",
+ "mining_threads_input_tooltip": "Digite um número exato de threads (Enter para aplicar)",
+ "mining_threads_minus_tooltip": "Menos threads",
+ "mining_threads_plus_tooltip": "Mais threads",
"mining_to_save": "para salvar",
"mining_today": "Hoje",
"mining_uptime": "Tempo Ativo",
@@ -953,6 +1136,11 @@
"no_transactions": "Nenhuma transação encontrada",
"no_transactions_yet": "Nenhuma transação ainda",
"node": "NÓ",
+ "node_banner_crashed_title": "O nó parou inesperadamente",
+ "node_banner_lite_open_failed": "Não foi possível abrir sua carteira",
+ "node_banner_offline_title": "Não conectado ao nó DragonX",
+ "node_banner_reconnect": "Reconectar",
+ "node_banner_restart": "Reiniciar nó",
"node_security": "NÓ & SEGURANÇA",
"noise": "Ruído",
"not_connected": "Não conectado ao daemon...",
@@ -1091,6 +1279,8 @@
"qr_failed": "Falha ao gerar código QR",
"qr_title": "Código QR",
"qr_unavailable": "QR indisponível",
+ "quick_receive": "Recebimento rápido",
+ "quick_send": "Envio rápido",
"ram_daemon_gb": "Daemon: %.1f GB (%s)",
"ram_daemon_mb": "Daemon: %.0f MB (%s)",
"ram_system_gb": "Sistema: %.1f / %.0f GB",
@@ -1140,6 +1330,7 @@
"rpc_connection": "Conexão RPC...",
"rpc_host": "Host RPC",
"rpc_pass": "Senha",
+ "rpc_plaintext_remote_warning": "O RPC remoto está usando HTTP em texto simples. Adicione rpctls=1 ao DRAGONX.conf se o seu daemon suportar TLS.",
"rpc_port": "Porta",
"rpc_user": "Usuário",
"save": "Salvar",
@@ -1154,6 +1345,8 @@
"sb_connecting_external": "Conectando ao daemon externo...",
"sb_connecting_generic": "Conectando ao daemon...",
"sb_daemon_crashed": "O daemon travou %d vezes",
+ "sb_daemon_extract_failed": "Falha ao gravar os arquivos do daemon — verifique o espaço livre em disco e as permissões.",
+ "sb_daemon_files_failed": "Falha ao gravar os arquivos do daemon em %s — verifique o espaço livre em disco e as permissões.",
"sb_daemon_not_found": "Daemon não encontrado",
"sb_daemon_start_failed": "Não foi possível iniciar o dragonxd",
"sb_dragonxd_running": "dragonxd em execução",
@@ -1169,6 +1362,7 @@
"sb_net_mhs": "Rede: %.2f MH/s",
"sb_no_conf": "DRAGONX.conf não encontrado",
"sb_peers": "Pares: %zu",
+ "sb_plaintext_remote_blocked": "Recusando enviar credenciais RPC em texto simples para um host remoto. Adicione rpcallowplaintext=1 ao DRAGONX.conf para permitir, ou habilite TLS com rpctls=1.",
"sb_rescanning": "Reescaneando",
"sb_rescanning_pct": "Reescaneando %.0f%%",
"sb_restarting_daemon": "Reiniciando daemon...",
@@ -1182,12 +1376,52 @@
"sb_waiting_daemon_err": "Aguardando dragonxd — %s",
"sb_warming_up": "Aquecendo...",
"sb_witness_cache": "Reconstruindo testemunhas",
+ "scale_effects": "ESCALA E EFEITOS",
"screenshot_open_dir": "Abrir local",
"screenshot_sweep": "Executar varredura de capturas de tela",
"screenshot_sweep_desc": "Percorre cada tema em cada aba e salva uma captura de tela de cada um em subpastas por aba dentro da pasta de capturas de tela do diretório de configuração (sobrescrevendo a varredura anterior). É executado por alguns segundos.",
"screenshot_sweep_full": "Varredura completa da interface",
"search_icons": "Pesquisar ícones...",
"search_placeholder": "Pesquisar...",
+ "sec_changing_passphrase": "Alterando a frase-senha...",
+ "sec_changing_pin": "Alterando o PIN...",
+ "sec_couldnt_lock_wallet": "Não foi possível bloquear a carteira — ela continua desbloqueada. Verifique a conexão com o daemon.",
+ "sec_encrypted_backup_suffix": "\nBackup criptografado: wallet.dat.encrypted.bak",
+ "sec_encrypting_wallet": "Criptografando a carteira...",
+ "sec_encryption_did_not_complete": "A criptografia da carteira não foi concluída — sua carteira NÃO está criptografada. Abra as Configurações para terminar de criptografá-la.",
+ "sec_encryption_failed_prefix": "Falha na criptografia: ",
+ "sec_failed_prefix": "Falhou: ",
+ "sec_failed_to_create_vault": "Falha ao criar o cofre",
+ "sec_importing_keys_rescanning": "Importando chaves e reescaneando a blockchain — a carteira pode ser usada durante o processo",
+ "sec_incorrect_current_pin": "PIN atual incorreto",
+ "sec_incorrect_passphrase_decrypt": "Frase-senha incorreta",
+ "sec_incorrect_passphrase_pin_setup": "Frase-senha incorreta",
+ "sec_incorrect_pin_remove": "PIN incorreto",
+ "sec_internal_error_change_pin": "Erro interno",
+ "sec_internal_error_remove_pin": "Erro interno",
+ "sec_mode_passphrase": " Frase-senha",
+ "sec_not_connected_to_daemon": "Não conectado ao daemon",
+ "sec_not_connected_to_daemon_pin": "Não conectado ao daemon",
+ "sec_passphrase_changed_successfully": "Frase-senha alterada com sucesso",
+ "sec_pin_changed_successfully": "PIN alterado com sucesso",
+ "sec_pin_removed": "PIN removido",
+ "sec_pin_set_successfully": "PIN definido com sucesso",
+ "sec_restart_daemon_for_encryption": "Reinicie o daemon para que a criptografia entre em vigor.",
+ "sec_too_many_attempts_wait": "Tentativas em excesso. Aguarde %.0f segundos...",
+ "sec_total_elapsed_fmt": "Tempo total: %dm %02ds",
+ "sec_unlock_button": "Desbloquear",
+ "sec_unlock_failed_prefix": "Falha ao desbloquear: ",
+ "sec_unlocking_fmt": "Desbloqueando%s",
+ "sec_use_passphrase_instead": "Usar frase-senha",
+ "sec_use_pin_instead": "Usar PIN",
+ "sec_verifying_passphrase": "Verificando a frase-senha...",
+ "sec_verifying_pin": "Verificando o PIN...",
+ "sec_wallet_decrypted_all_keys_imported": "Carteira descriptografada com sucesso! Todas as chaves importadas.",
+ "sec_wallet_encrypted_and_pin_set": "Carteira criptografada e PIN definido",
+ "sec_wallet_encrypted_but_pin_vault_failed": "Carteira criptografada, mas o cofre do PIN falhou",
+ "sec_wallet_encrypted_restarting_daemon": "Carteira criptografada. Reiniciando o daemon...",
+ "sec_wallet_encrypted_successfully": "Carteira criptografada com sucesso",
+ "sec_wallet_locked_title": "Carteira Bloqueada",
"security": "SEGURANÇA",
"seed_backup_button": "Frase de recuperação",
"seed_backup_close": "Fechar",
@@ -1246,6 +1480,7 @@
"send_tooltip_not_connected": "Não conectado ao daemon",
"send_tooltip_select_source": "Selecione primeiro um endereço de origem",
"send_tooltip_syncing": "Aguarde a sincronização da blockchain",
+ "send_tooltip_view_only": "Endereço somente visualização — sem chave de gasto, não é possível enviar",
"send_total": "Total",
"send_transaction": "Enviar Transação",
"send_tx_failed": "Transação falhou",
@@ -1265,16 +1500,16 @@
"sent_filter": "Enviado",
"sent_type": "Enviado",
"sent_upper": "ENVIADO",
- "set_label": "Definir Rótulo...",
+ "set_label": "Definir Rótulo",
"settings": "Ajustes",
"settings_about_text": "Uma carteira de criptomoeda blindada para DragonX (DRGX), criada com Dear ImGui para uma experiência leve e portátil.",
"settings_acrylic_level": "Nível acrílico:",
- "settings_address_book": "Livro de endereços...",
+ "settings_address_book": "Livro de endereços…",
"settings_auto_detected": "Detectado automaticamente de DRAGONX.conf",
"settings_auto_lock": "BLOQUEIO AUTOMÁTICO",
"settings_auto_shield_desc": "Mover automaticamente fundos transparentes para endereços blindados",
"settings_auto_shield_funds": "Blindar fundos transparentes automaticamente",
- "settings_backup": "Backup...",
+ "settings_backup": "Backup…",
"settings_block_explorer_urls": "URLs do explorador de blocos",
"settings_builtin": "Integrado",
"settings_change_passphrase": "Alterar frase secreta",
@@ -1285,60 +1520,71 @@
"settings_configure_explorer": "Configurar links do explorador de blocos externo",
"settings_configure_rpc": "Configurar conexão ao daemon dragonxd",
"settings_connection": "Conexão",
+ "settings_copy_diagnostics": "Copiar diagnósticos",
"settings_copyright": "Copyright 2024-2026 Desenvolvedores DragonX | Licença GPLv3",
"settings_custom": "Personalizado",
- "settings_data_dir": "Dir. de dados:",
+ "settings_data_dir": "Dir. de dados",
"settings_debug_changed": "Categorias de depuração alteradas — reinicie o daemon para aplicar",
"settings_debug_restart_note": "As alterações entram em vigor após reiniciar o daemon.",
"settings_debug_select": "Selecione categorias para ativar o registro de depuração do daemon (flags -debug=).",
+ "settings_diagnostics_copied": "Diagnósticos copiados para a área de transferência",
"settings_encrypt_first_pin": "Encripte a carteira primeiro para ativar o PIN",
"settings_encrypt_wallet": "Encriptar carteira",
"settings_explorer_hint": "As URLs devem incluir uma barra final. O txid/endereço será adicionado.",
- "settings_export_all": "Exportar tudo...",
- "settings_export_csv": "Exportar CSV...",
- "settings_export_key": "Exportar chave...",
+ "settings_export_all": "Exportar tudo…",
+ "settings_export_csv": "Exportar CSV…",
+ "settings_export_key": "Exportar chave…",
"settings_gradient_bg": "Fundo gradiente",
"settings_gradient_desc": "Substituir fundos texturizados por gradientes suaves",
"settings_idle_after": "após",
- "settings_import_key": "Importar Chave Privada...",
- "settings_import_viewkey": "Importar chave de visualização...",
+ "settings_import_key": "Importar Chave Privada…",
+ "settings_import_viewkey": "Importar chave de visualização…",
"settings_language_note": "Nota: Alguns textos requerem reinício para atualizar",
"settings_lock_now": "Bloquear agora",
"settings_locked": "Bloqueado",
- "settings_merge_to_address": "Fundir para endereço...",
+ "settings_merge_to_address": "Fundir para endereço…",
"settings_noise_opacity": "Opacidade do ruído:",
+ "settings_not_connected": "Não conectado ao daemon",
"settings_not_encrypted": "Não encriptado",
"settings_not_found": "Não encontrado",
"settings_open_app_dir": "Abrir pasta do aplicativo",
"settings_open_data_dir": "Abrir pasta de dados",
+ "settings_open_log_folder": "Abrir pasta de logs",
"settings_other": "Outros",
"settings_pin_active": "PIN",
"settings_privacy": "Privacidade",
"settings_quick_unlock_pin": "PIN de desbloqueio rápido",
"settings_reduce_transparency": "Reduzir transparência",
+ "settings_reloaded": "Configurações recarregadas do disco",
"settings_remove_encryption": "Remover encriptação",
"settings_remove_pin": "Remover PIN",
- "settings_request_payment": "Solicitar pagamento...",
+ "settings_request_payment": "Solicitar pagamento…",
"settings_rescan_desc": "Reescanear a blockchain em busca de transações ausentes",
"settings_restart_daemon": "Reiniciar daemon",
"settings_rpc_connection": "Conexão RPC",
+ "settings_rpc_error_prefix": "Erro de RPC: ",
"settings_rpc_note": "Nota: As configurações de conexão são normalmente detectadas automaticamente do DRAGONX.conf",
+ "settings_rpc_ok": "Conexão RPC OK",
"settings_save_shielded_desc": "Armazena transações z-addr em um arquivo local para visualização",
"settings_save_shielded_local": "Salvar histórico de transações blindadas localmente",
+ "settings_saved": "Configurações salvas",
"settings_set_pin": "Definir PIN",
- "settings_shield_mining": "Blindar mineração...",
+ "settings_shield_mining": "Blindar mineração…",
"settings_solid_colors_desc": "Usar cores sólidas em vez de efeitos de desfoque (acessibilidade)",
+ "settings_theme_refreshed": "Lista de temas atualizada",
"settings_tor_desc": "Rotear todas as conexões através do Tor para maior privacidade",
"settings_unlocked": "Desbloqueado",
"settings_use_tor_network": "Usar Tor para conexões de rede",
- "settings_validate_address": "Validar endereço...",
+ "settings_validate_address": "Validar endereço…",
"settings_visual_effects": "Efeitos visuais",
"settings_wallet_file_size": "Tamanho do arquivo da carteira: %s",
"settings_wallet_info": "Informações da carteira",
"settings_wallet_location": "Localização da carteira: %s",
"settings_wallet_maintenance": "Manutenção da carteira",
"settings_wallet_not_found": "Arquivo da carteira não encontrado",
- "settings_wallet_size_label": "Tamanho da carteira:",
+ "settings_wallet_size_label": "Tamanho da carteira",
+ "settings_ztx_cleared": "Histórico de transações Z limpo",
+ "settings_ztx_not_found": "Nenhum arquivo de histórico encontrado",
"setup_wizard": "Assistente de Configuração",
"share": "Compartilhar",
"shield_check_status": "Verificar Status",
@@ -1397,6 +1643,17 @@
"sweep_to": "Varrido para:",
"sweep_toggle": "Varrer para minha carteira (não manter a chave)",
"sweep_tx": "Transação:",
+ "swin_connection_failed": "Falha na conexão: ",
+ "swin_connection_successful": "Conexão bem-sucedida!\nversão do dragonxd: ",
+ "swin_invalid_suffix": " (inválido)",
+ "swin_no_history_file_found": "Nenhum arquivo de histórico encontrado",
+ "swin_rescan_failed": "Falha no reescaneamento: ",
+ "swin_rescan_started_from_block": "Reescaneamento iniciado a partir do bloco ",
+ "swin_rescan_to": " até ",
+ "swin_rpc_client_not_initialized": "Cliente RPC não inicializado",
+ "swin_settings_saved": "Configurações salvas",
+ "swin_theme_list_refreshed": "Lista de temas atualizada",
+ "swin_ztx_history_cleared": "Histórico de transações Z limpo",
"switch_corrupt_body": "Esta carteira parece corrompida — o nó não conseguiu abri-la. Restaure de um backup, recrie-a ou tente repará-la.",
"switch_corrupt_repair": "Tentar reparar (salvage)",
"switch_progress_background": "Continuar em segundo plano",
@@ -1421,6 +1678,7 @@
"theme": "Tema",
"theme_effects": "Efeitos de tema",
"theme_language": "TEMA E IDIOMA",
+ "tile_click_to_open": "Clique para abrir",
"time_days_ago": "há %d dias",
"time_hours_ago": "há %d horas",
"time_minutes_ago": "há %d minutos",
@@ -1435,7 +1693,9 @@
"to_upper": "PARA",
"tools": "FERRAMENTAS",
"tools_actions": "Ferramentas e Ações...",
+ "tools_actions_hdr": "FERRAMENTAS E AÇÕES",
"total": "Total",
+ "total_balance_label": "Saldo Total",
"transaction_id": "ID DA TRANSAÇÃO",
"transaction_sent": "Transação enviada com sucesso",
"transaction_sent_msg": "Transação enviada!",
@@ -1457,7 +1717,7 @@
"tt_auto_shield": "Mover automaticamente o saldo transparente para endereços blindados para privacidade",
"tt_backup": "Criar um backup do seu wallet.dat",
"tt_block_explorer": "Abrir o explorador de blocos DragonX no seu navegador",
- "tt_blur": "Quantidade de desfoque (0%% = desligado, 100%% = máximo)",
+ "tt_blur": "Quantidade de desfoque (0% = desligado, 100% = máximo)",
"tt_change_pass": "Alterar a frase secreta de encriptação da carteira",
"tt_change_pin": "Alterar seu PIN de desbloqueio",
"tt_chat_bubble_accent": "Cor de destaque para seus balões de mensagem enviados (ou seguir o tema atual)",
@@ -1470,6 +1730,7 @@
"tt_chat_timestamp": "Formato de horário apenas para esta aba: seguir o relógio geral do aplicativo, ou forçar 24-hour ou 12-hour",
"tt_clear_ztx": "Excluir histórico de z-transações em cache local",
"tt_clock_format": "Relógio de 24 ou 12 horas, em todo o app. O chat pode substituí-lo.",
+ "tt_copy_diagnostics": "Copia um resumo para suporte (versão, estado do daemon/carteira/logs — sem segredos) para a área de transferência",
"tt_custom_fees": "Ativar entrada manual de taxas ao enviar transações",
"tt_custom_theme": "Tema personalizado ativo",
"tt_daemon_install_bundled": "Parar o nó, sobrescrever o dragonxd instalado com a versão incluída nesta compilação da carteira e reiniciar",
@@ -1519,10 +1780,11 @@
"tt_low_spec": "Desativar todos os efeitos visuais pesados\\nAtalho: Ctrl+Shift+Down",
"tt_merge": "Consolidar múltiplos UTXOs em um endereço",
"tt_mine_idle": "Iniciar mineração automaticamente quando o\\nsistema estiver ocioso (sem entrada de teclado/mouse)",
- "tt_noise": "Intensidade de textura granulada (0%% = desligado, 100%% = máximo)",
+ "tt_noise": "Intensidade de textura granulada (0% = desligado, 100% = máximo)",
"tt_open_app_dir": "Abrir a pasta ObsidianDragon (configurações, temas, logs) no gerenciador de arquivos",
"tt_open_data_dir": "Abrir a pasta com os dados da sua carteira e da blockchain no gerenciador de arquivos",
"tt_open_dir": "Clique para abrir no explorador de arquivos",
+ "tt_open_log_folder": "Abre a pasta que contém os logs de depuração e de falhas",
"tt_reduce_motion": "Desativar transições animadas e lerp de saldo para acessibilidade",
"tt_remove_encrypt": "Remover encriptação e armazenar a carteira desprotegida",
"tt_remove_pin": "Remover PIN e exigir frase secreta para desbloquear",
@@ -1557,7 +1819,7 @@
"tt_theme_hotkey": "Atalho: Ctrl+Esquerda/Direita para alternar temas",
"tt_tor": "Rotear conexões do daemon através da rede Tor para anonimato",
"tt_tx_url": "URL base para visualizar transações em um explorador de blocos",
- "tt_ui_opacity": "Opacidade de cartões e barra lateral (100%% = totalmente opaco, menor = mais transparente)",
+ "tt_ui_opacity": "Opacidade de cartões e barra lateral (100% = totalmente opaco, menor = mais transparente)",
"tt_validate": "Verificar se um endereço DragonX é válido",
"tt_verbose": "Registrar diagnósticos detalhados de conexão,\\nestado do daemon e info de proprietário de porta\\nna aba Console",
"tt_wallets_button": "Liste os arquivos de carteira e alterne entre eles",
@@ -1610,6 +1872,7 @@
"validate_not_mine": "Não pertence a esta carteira",
"validate_ownership": "Propriedade:",
"validate_results": "Resultados:",
+ "validate_results_placeholder": "Os resultados aparecerão aqui",
"validate_shielded_type": "Blindado (z-endereço)",
"validate_status": "Status:",
"validate_title": "Validar Endereço",
@@ -1750,6 +2013,7 @@
"xmrig_loading_releases": "Carregando versões…",
"xmrig_none": "nenhum",
"xmrig_reinstall": "Reinstalar",
+ "xmrig_releases": "versões do xmrig",
"xmrig_stop_mining_first": "Pare a mineração antes de atualizar o minerador.",
"xmrig_unavailable_body": "Nenhuma versão do minerador está disponível para esta plataforma.",
"xmrig_unavailable_title": "Atualizações do minerador indisponíveis",
diff --git a/res/lang/ru.json b/res/lang/ru.json
index e173208..2c6dd82 100644
--- a/res/lang/ru.json
+++ b/res/lang/ru.json
@@ -48,6 +48,10 @@
"advanced": "ПРОЧЕЕ",
"advanced_effects": "Расширенные эффекты...",
"ago": "назад",
+ "alerts_clear": "Очистить историю оповещений",
+ "alerts_history_tooltip": "Недавние оповещения",
+ "alerts_none": "Пока нет оповещений",
+ "alerts_recent": "НЕДАВНИЕ ОПОВЕЩЕНИЯ",
"all_filter": "Все",
"allow_custom_fees": "Разрешить пользовательские комиссии",
"amount": "Сумма",
@@ -56,6 +60,80 @@
"amount_label": "Сумма:",
"animate_avatars": "Анимировать аватары",
"appearance": "ВНЕШНИЙ ВИД",
+ "appx_back": "Назад",
+ "appx_back_up_seed_phrase_title": "Сохраните seed-фразу",
+ "appx_birthday_block_height": "Дата создания (высота блока): %llu — сохраните и её.",
+ "appx_blockchain_data_deleted": "Данные блокчейна удалены (%d элементов). Демон перезапускается для повторной синхронизации с сетью.",
+ "appx_blockchain_maintenance_in_progress": "Операция обслуживания блокчейна уже выполняется.",
+ "appx_blockchain_rescan_complete": "Повторное сканирование блокчейна завершено",
+ "appx_bootstrap_complete_reconciling": "Начальная загрузка завершена — сверка кошелька с новыми данными цепи.",
+ "appx_cancel": "Отмена",
+ "appx_cleaning_up": "Очистка...",
+ "appx_confirm_your_backup": "Подтвердите резервную копию",
+ "appx_copied_clipboard_autoclears": "Скопировано — буфер обмена очистится через 45 с",
+ "appx_copy": "Копировать",
+ "appx_could_not_start_restore": "Не удалось начать восстановление",
+ "appx_create_failed_prefix": "Ошибка создания: ",
+ "appx_creating_your_wallet": "Создание кошелька…",
+ "appx_daemon_error": "Ошибка демона",
+ "appx_daemon_reinstall_in_progress": "Переустановка демона уже выполняется.",
+ "appx_disconnecting": "Отключение...",
+ "appx_done": "Готово",
+ "appx_dragonxd_output": "вывод dragonxd",
+ "appx_encrypting_wallet": "Шифрование кошелька...",
+ "appx_fullnode_lifecycle_unavailable_lite": "Действия жизненного цикла полного узла недоступны в lite-сборке",
+ "appx_installing_bundled_daemon": "Установка встроенного демона — узел остановится, обновится и перезапустится...",
+ "appx_invalid_payment_uri_prefix": "Неверный платёжный URI: ",
+ "appx_ive_written_it_down": "Я записал её",
+ "appx_keep_node_running_and_quit": "Оставить узел и выйти",
+ "appx_last_block_n": "Последний блок: %d",
+ "appx_last_used_wallet_not_found_prefix": "Ваш последний использованный файл кошелька (",
+ "appx_last_used_wallet_not_found_suffix": ") не найден — вместо него открыт кошелёк по умолчанию. Если вы его переместили, восстановите файл и переключитесь обратно из списка кошельков.",
+ "appx_low_spec_mode_disabled": "Режим слабого оборудования выключен",
+ "appx_low_spec_mode_enabled": "Режим слабого оборудования включён",
+ "appx_miner_stopped_prefix": "Майнер остановлен: ",
+ "appx_miner_stopped_unexpectedly": "Майнер неожиданно остановился.",
+ "appx_n_min_n_sec": "%d мин %d сек",
+ "appx_n_seconds": "%d сек",
+ "appx_no_bundled_daemon_to_install": "В этой сборке нет встроенного демона для установки",
+ "appx_no_embedded_daemon_to_install": "В этой сборке нет встроенного демона для установки",
+ "appx_node_busy_restarting": "Узел занят перезапуском — попробуйте ещё раз через мгновение.",
+ "appx_node_rebuilding_witness_cache": "Узел перестраивает кэш свидетелей",
+ "appx_not_next_word": " — это не следующее слово",
+ "appx_payment_request_loaded": "Запрос на оплату загружен",
+ "appx_pool_miner_connected_and_hashing": "Пул-майнер подключён и вычисляет хеши.",
+ "appx_progress_n_of_n": "Прогресс: %d / %d",
+ "appx_rebuilding_sapling_note_witnesses": "Перестройка свидетелей заметок Sapling…",
+ "appx_rebuilding_witness_cache_blocks_left": "Перестройка кэша свидетелей %.0f%% — осталось блоков: %d",
+ "appx_rebuilding_witness_cache_pct": "Перестройка кэша свидетелей %.0f%%",
+ "appx_recovery_phrase_word_count": "Фраза восстановления должна содержать 24 слова — у вас %d.",
+ "appx_restarting_daemon_rescan_flag": "Перезапуск демона с флагом -rescan...",
+ "appx_restarting_daemon_zapwallettxes": "Перезапуск демона с -zapwallettxes=2 (восстановление кошелька)...",
+ "appx_restoring_your_wallet": "Восстановление кошелька…",
+ "appx_seed_backup_warning": "Эти 24 слова — ЕДИНСТВЕННЫЙ способ восстановить кошелёк. Запишите их по порядку, храните офлайн и никому не сообщайте. Если вы их потеряете, средства пропадут навсегда.",
+ "appx_seed_not_backed_up_warning": "Вы не сохранили seed-фразу — средства могут быть потеряны. Всё равно пропустить?",
+ "appx_sending_stop_command_to_daemon": "Отправка команды остановки демону...",
+ "appx_setting_initial_sapling_witnesses": "Установка начальных свидетелей Sapling %.0f%%",
+ "appx_shutdown_complete": "Завершение работы выполнено",
+ "appx_simple_background_disabled": "Простой фон выключен",
+ "appx_simple_background_enabled": "Простой фон включён",
+ "appx_skip": "Пропустить",
+ "appx_skip_anyway": "Всё равно пропустить",
+ "appx_still_status_prefix": "Всё ещё \"",
+ "appx_still_status_suffix": "\" — принудительный выход сейчас может повредить данные цепи.",
+ "appx_stop_anyway_and_quit": "Всё равно остановить и выйти",
+ "appx_stopping_daemon_deleting_blockchain": "Остановка демона и удаление данных блокчейна...",
+ "appx_stopping_node_discards_rebuild": "Остановка узла сейчас отменит текущую перестройку и запустит её заново (несколько минут) при следующем открытии кошелька. Вместо этого можно оставить узел работать.",
+ "appx_stopping_pool_miner": "Остановка пул-майнера...",
+ "appx_syncing_pct_block_n_of_n": "Синхронизация %.1f%% — Блок %d / %d",
+ "appx_tap_words_in_order": "Нажимайте слова в правильном порядке, чтобы подтвердить, что вы их сохранили.",
+ "appx_theme_effects_disabled": "Эффекты темы выключены",
+ "appx_theme_effects_enabled": "Эффекты темы включены",
+ "appx_theme_prefix": "Тема: ",
+ "appx_use_settings_restart_daemon_hint": "Откройте Настройки > Перезапустить демон, чтобы повторить попытку",
+ "appx_waiting_for_daemon_to_encrypt_wallet": "Ожидание шифрования кошелька демоном...",
+ "appx_wallet_created_and_backed_up": "Кошелёк создан, резервная копия сохранена.",
+ "appx_wallet_open_failed_prefix": "Не удалось открыть кошелёк: ",
"auto_shield": "Авто-экранирование майнинга",
"av_intro": "Программы для майнинга часто помечаются как потенциально нежелательные. Выполните эти шаги, чтобы включить пул-майнинг:",
"av_open_security": "Открыть Безопасность Windows",
@@ -70,6 +148,9 @@
"av_title": "Windows Defender заблокировал майнер",
"available": "Доступно",
"backup_backing_up": "Создание резервной копии...",
+ "backup_col_backup": "РЕЗЕРВНАЯ КОПИЯ",
+ "backup_col_export": "ЭКСПОРТ",
+ "backup_col_import": "ИМПОРТ И ВОССТАНОВЛЕНИЕ",
"backup_create": "Создать резервную копию",
"backup_created": "Резервная копия кошелька создана",
"backup_data": "РЕЗЕРВНОЕ КОПИРОВАНИЕ И ДАННЫЕ",
@@ -88,8 +169,22 @@
"balance": "Баланс",
"balance_history_collecting": "История баланса — сбор данных...",
"balance_layout": "Макет баланса",
+ "balance_layout_switched": "Раскладка: %s",
+ "balance_mining_rate": "Майнинг %s",
"balance_shielded_fmt": "Экранировано: %.8f",
+ "balance_syncing_pct": "Синхронизация %.1f%%",
"balance_transparent_fmt": "Прозрачный: %.8f",
+ "baltab_market": "Рынок",
+ "baltab_market_price_4dp": "Рынок: $%.4f",
+ "baltab_market_price_8dp": "Рынок: $%.8f",
+ "baltab_pct_change_24h": "%s%.1f%% за 24ч",
+ "baltab_pct_of_total_zaddr": "%.0f%% от общего · %d Z-addr",
+ "baltab_shielded": "Скрытый",
+ "baltab_shielded_amount": "Скрытый %.8f",
+ "baltab_t_addresses_count": "%d T-адресов",
+ "baltab_total_balance": "Общий баланс",
+ "baltab_transparent": "Прозрачный",
+ "baltab_transparent_amount": "Прозрачный %.8f",
"ban": "Заблокировать",
"banned_peers": "Заблокированные узлы",
"block": "Блок",
@@ -128,6 +223,7 @@
"bootstrap_verifying": "Проверка контрольных сумм...",
"bootstrap_wallet_protected": "(wallet.dat защищён)",
"bootstrap_warning": "Существующие данные блоков (blocks, chainstate, notarizations) будут удалены и заменены. Ваш wallet.dat НЕ будет изменён или удалён.",
+ "byte_count_fmt": "%zu / %zu байт",
"cancel": "Отмена",
"change_pass_confirm": "Подтвердите новый:",
"change_pass_current": "Текущий пароль:",
@@ -403,6 +499,7 @@
"contacts_shape_square": "Квадрат",
"contacts_shape_tab": "Вкладка",
"copied": "Скопировано!",
+ "copied_to_clipboard": "Скопировано в буфер обмена",
"copy": "Копировать",
"copy_address": "Копировать полный адрес",
"copy_error": "Копировать ошибку",
@@ -415,6 +512,7 @@
"daemon_bundled": "Встроенный",
"daemon_install_bundled": "Установить встроенную",
"daemon_installed": "Установлено",
+ "daemon_maintenance_label": "ОБСЛУЖИВАНИЕ",
"daemon_none_bundled": "нет в этой сборке",
"daemon_not_installed": "не установлен",
"daemon_status_differ": "Установленный бинарный файл отличается от встроенной версии.",
@@ -437,6 +535,7 @@
"daemon_update_latest": "Последняя:",
"daemon_update_loading": "Загрузка релизов…",
"daemon_update_now": "Обновить сейчас",
+ "daemon_update_prompt_title": "Обновить демон узла?",
"daemon_update_reinstall": "Переустановить",
"daemon_update_restart_note": "Перезапустите daemon, чтобы начать работу с новой версией.",
"daemon_update_restart_now": "Перезапустить демон сейчас",
@@ -449,8 +548,11 @@
"daemon_update_verify_note": "Перед установкой загрузка проверяется по опубликованному для релиза SHA-256 и закреплённой подписи ed25519.",
"daemon_update_verifying": "Проверка…",
"daemon_update_version": "Версия:",
+ "daemon_updates_label": "ОБНОВЛЕНИЯ",
"daemon_version": "Демон",
"dark": "Тёмная",
+ "data_stale_prefix": "Обновлено",
+ "data_stale_tooltip": "Баланс может быть устаревшим — кошелёк давно не получал обновлений. Проверьте подключение к узлу.",
"date": "Дата",
"date_label": "Дата:",
"debug_logging": "ЖУРНАЛ ОТЛАДКИ",
@@ -479,6 +581,17 @@
"download_bootstrap": "Скачать бутстрап",
"dragonx_green": "DragonX (Зелёная)",
"edit": "Редактировать",
+ "empty_wallet_keys_suffix": "ключей",
+ "empty_wallet_open_manager": "Открыть менеджер кошельков",
+ "empty_wallet_restore": "Восстановить мой кошелёк",
+ "empty_wallet_salvage_body": "Этот кошелёк пуст, потому что предыдущее автоматическое восстановление отложило ваш исходный кошелёк в качестве резервной копии. Ваши монеты почти наверняка находятся в этой копии и не потеряны. Восстановите её, чтобы снова загрузить средства — ничего не удаляется; текущий файл сначала откладывается в сторону.",
+ "empty_wallet_salvage_headline": "Ваши монеты в безопасности в файле резервной копии.",
+ "empty_wallet_salvage_title": "Возможно, ваш кошелёк был восстановлен",
+ "empty_wallet_warning_body": "В этом кошельке нет адресов и средств, но другой файл кошелька в вашей папке DragonX содержит ключи. Ваши монеты, скорее всего, находятся в нём и не потеряны. Откройте менеджер кошельков, чтобы переключиться на кошелёк с вашими средствами.",
+ "empty_wallet_warning_dismiss": "Больше не предупреждать для этого кошелька",
+ "empty_wallet_warning_dismiss_tip": "Останавливает это предупреждение только для текущего файла кошелька. Если позже вы переключитесь на другой пустой кошелёк, предупреждение может появиться снова.",
+ "empty_wallet_warning_headline": "Возможно, вы открыли не тот кошелёк.",
+ "empty_wallet_warning_title": "Этот кошелёк пуст",
"enc_confirm": "Подтвердите:",
"enc_desc": "Шифрование кошелька защищает ваши приватные ключи паролем. После шифрования демон перезапустится.",
"enc_encrypting": "Шифрование кошелька...",
@@ -566,6 +679,63 @@
"general": "Общие",
"generating": "Генерация",
"go_to_receive": "Перейти к получению",
+ "grpa_current_block_paren": "(Текущий: %d)",
+ "grpa_days_ago": "%lld дн назад",
+ "grpa_dbg_addrman": "Отслеживание и управление адресами узлов",
+ "grpa_dbg_alert": "Сообщения системы оповещений",
+ "grpa_dbg_bench": "Замеры производительности операций",
+ "grpa_dbg_coindb": "Операции чтения/записи базы монет",
+ "grpa_dbg_db": "Операции Berkeley DB",
+ "grpa_dbg_estimatefee": "Алгоритм оценки комиссии",
+ "grpa_dbg_http": "Активность HTTP RPC-сервера",
+ "grpa_dbg_libevent": "Сетевая библиотека Libevent",
+ "grpa_dbg_lock": "Отладка конкуренции блокировок",
+ "grpa_dbg_mempool": "Активность пула транзакций в памяти",
+ "grpa_dbg_net": "Сетевые соединения и сообщения",
+ "grpa_dbg_paymentdisclosure": "Протокол раскрытия платежей",
+ "grpa_dbg_pow": "Активность майнинга proof-of-work",
+ "grpa_dbg_proxy": "Соединения через прокси SOCKS5",
+ "grpa_dbg_prune": "Операции обрезки блоков",
+ "grpa_dbg_rand": "Генерация случайных чисел",
+ "grpa_dbg_reindex": "Прогресс переиндексации блокчейна",
+ "grpa_dbg_rpc": "Обработка RPC-команд",
+ "grpa_dbg_selectcoins": "Выбор монет для транзакций",
+ "grpa_dbg_tor": "Интеграция Tor и сведения о цепочках",
+ "grpa_dbg_zmq": "Система уведомлений ZeroMQ",
+ "grpa_dbg_zrpc": "Скрытые (z-addr) RPC-операции",
+ "grpa_enter_private_key_to_import": "Введите приватный ключ для импорта.",
+ "grpa_error_prefix": "Ошибка: ",
+ "grpa_hr_ago": "%lld ч назад",
+ "grpa_invalid_response_from_daemon": "Неверный ответ от демона",
+ "grpa_invalid_suffix": " (неверный)",
+ "grpa_min_ago": "%lld мин назад",
+ "grpa_sec_ago": "%lld сек назад",
+ "grpa_seed_demo_chat": "Демо-чат seed",
+ "grpa_showing_first_100_of": "... показаны первые 100 из %d",
+ "grpa_tab_about": "О программе",
+ "grpa_tab_appearance": "Внешний вид",
+ "grpa_tab_backup_data": "Резервное копирование и данные",
+ "grpa_tab_chat": "Чат",
+ "grpa_tab_explorer": "Обозреватель",
+ "grpa_tab_node_security": "Узел и безопасность",
+ "grpa_tab_wallet": "Кошелёк",
+ "grpa_unexpected_getblockhash_result": "неожиданный результат getblockhash",
+ "grpb_copy": "Копировать",
+ "grpb_max": "Макс",
+ "grpb_new_badge_suffix": " [НОВОЕ]",
+ "grpb_preview_msg_payment_through": "Платёж прошёл? 🙂",
+ "grpb_preview_msg_sending_rest": "Отправляю остаток 👍",
+ "grpb_preview_msg_yep_confirmed": "Да — только что подтвердил ✅",
+ "grpb_selected_suffix": "\n(выбрано)",
+ "grpb_tooltip_address_balance": "%s\nБаланс: %.8f %s%s",
+ "grpb_undo_clear": "Отменить очистку",
+ "grpc_benchmark_inconclusive": "Бенчмарк не дал результата: не записано ни одной выборки хешрейта. Проверьте соединение с пулом и повторите попытку.",
+ "grpc_benchmark_takes_secs": "Бенчмарк занимает ~%dс и прерывает майнинг. Нажмите ещё раз для запуска.",
+ "grpc_bootstrap_failed": "Ошибка начальной загрузки",
+ "grpc_bootstrap_not_initialized": "Начальная загрузка не инициализирована",
+ "grpc_hashrate_fee": "%s комиссия %s%%",
+ "grpc_key_not_available": "Ключ недоступен для этого адреса",
+ "grpc_na": "Н/Д",
"height": "Высота",
"help": "Справка",
"hidden_tag": " (скрыт)",
@@ -640,6 +810,7 @@
"light": "Светлая",
"lite_account_label": "Аккаунт",
"lite_action": "Действие",
+ "lite_backend_unavailable": "Бэкенд облегчённого кошелька недоступен",
"lite_backup_keys": "Резервная копия и ключи",
"lite_birthday_backup": "Дата рождения: %llu (сохраните её тоже)",
"lite_birthday_hint": "Высота блока, с которой начинать сканирование. Оставьте 0, если неизвестно (медленное полное сканирование).",
@@ -647,9 +818,12 @@
"lite_console_backend_commands": "Команды бэкенда:",
"lite_console_help_passthrough": "Любой другой ввод выполняется как команда консоли лайт-кошелька.",
"lite_copy": "Копировать",
+ "lite_could_not_start": "Не удалось запустить операцию",
"lite_could_not_write": "Не удалось записать ",
"lite_encrypt_wallet": "Зашифровать кошелёк",
"lite_encryption_removed": "Шифрование удалено",
+ "lite_enter_all_seed_words": "Введите все 24 слова seed-фразы для восстановления (введено %d)",
+ "lite_enter_wallet_path": "Введите путь к кошельку",
"lite_hide_wipe": "Скрыть и стереть",
"lite_import": "Импорт",
"lite_import_key_label": "Импортировать ключ",
@@ -734,6 +908,9 @@
"lite_working": "Обработка…",
"loading": "Загрузка...",
"loading_addresses": "Загрузка адресов...",
+ "loading_stall_body": "Демон инициализируется уже %.0f с. Это может быть нормально после обновления или при первом запуске (загрузка индекса блоков или повторное сканирование) — соединение установится автоматически, когда он будет готов.",
+ "loading_stall_hint": "Всё ещё не отвечает? Откройте Настройки и нажмите «Перезапустить демон» или посмотрите подробности в Консоли.",
+ "loading_stall_title": "Занимает больше времени, чем ожидалось",
"loading_transactions": "Загрузка транзакций",
"local_hashrate": "Локальный хешрейт",
"low_spec_mode": "Режим экономии",
@@ -866,6 +1043,7 @@
"mining_difficulty_copied": "Сложность скопирована",
"mining_est_block": "Расч. блок",
"mining_est_daily": "Расч. за день",
+ "mining_est_daily_pool_sub": "примерный соло-эквивалент, до комиссии пула",
"mining_filter_all": "Все",
"mining_filter_tip_all": "Показать все доходы",
"mining_filter_tip_pool": "Показать только доходы пула",
@@ -894,10 +1072,12 @@
"mining_open_in_explorer": "Открыть в обозревателе",
"mining_payout_address": "Адрес выплат",
"mining_payout_foreign": "⚠ Этот адрес выплат отсутствует в вашем текущем кошельке — намайненные вознаграждения будут отправлены в другой кошелёк. Обновите его, если вы сменили кошелёк.",
+ "mining_payout_invalid": "Недействительный адрес DragonX — исправьте перед запуском, иначе награды за майнинг будут потеряны.",
"mining_payout_tooltip": "Адрес для получения вознаграждений за майнинг",
"mining_pool": "Пул",
"mining_pool_fee": "Комиссия",
"mining_pool_hashrate": "Хешрейт пула",
+ "mining_pool_needs_payout_tooltip": "Сначала введите адрес для выплат (создайте Z-адрес)",
"mining_pool_url": "URL пула",
"mining_pools_header": "ПУЛЫ",
"mining_recent_blocks": "ПОСЛЕДНИЕ БЛОКИ",
@@ -927,6 +1107,9 @@
"mining_syncing_tooltip": "Блокчейн синхронизируется...",
"mining_tag": " · Майнинг",
"mining_threads": "Потоки майнинга",
+ "mining_threads_input_tooltip": "Введите точное число потоков (Enter для применения)",
+ "mining_threads_minus_tooltip": "Меньше потоков",
+ "mining_threads_plus_tooltip": "Больше потоков",
"mining_to_save": "для сохранения",
"mining_today": "Сегодня",
"mining_uptime": "Время работы",
@@ -953,6 +1136,11 @@
"no_transactions": "Транзакции не найдены",
"no_transactions_yet": "Транзакций пока нет",
"node": "УЗЕЛ",
+ "node_banner_crashed_title": "Узел неожиданно остановился",
+ "node_banner_lite_open_failed": "Не удалось открыть кошелёк",
+ "node_banner_offline_title": "Нет подключения к узлу DragonX",
+ "node_banner_reconnect": "Переподключить",
+ "node_banner_restart": "Перезапустить узел",
"node_security": "УЗЕЛ И БЕЗОПАСНОСТЬ",
"noise": "Шум",
"not_connected": "Не подключено к daemon...",
@@ -1091,6 +1279,8 @@
"qr_failed": "Не удалось сгенерировать QR-код",
"qr_title": "QR-код",
"qr_unavailable": "QR недоступен",
+ "quick_receive": "Быстрый приём",
+ "quick_send": "Быстрая отправка",
"ram_daemon_gb": "Демон: %.1f ГБ (%s)",
"ram_daemon_mb": "Демон: %.0f МБ (%s)",
"ram_system_gb": "Система: %.1f / %.0f ГБ",
@@ -1140,6 +1330,7 @@
"rpc_connection": "RPC-подключение...",
"rpc_host": "RPC-хост",
"rpc_pass": "Пароль",
+ "rpc_plaintext_remote_warning": "Удалённый RPC использует незашифрованный HTTP. Добавьте rpctls=1 в DRAGONX.conf, если ваш демон поддерживает TLS.",
"rpc_port": "Порт",
"rpc_user": "Имя пользователя",
"save": "Сохранить",
@@ -1154,6 +1345,8 @@
"sb_connecting_external": "Подключение к внешнему демону...",
"sb_connecting_generic": "Подключение к демону...",
"sb_daemon_crashed": "Демон упал %d раз",
+ "sb_daemon_extract_failed": "Не удалось записать файлы демона — проверьте свободное место на диске и права доступа.",
+ "sb_daemon_files_failed": "Не удалось записать файлы демона в %s — проверьте свободное место на диске и права доступа.",
"sb_daemon_not_found": "Демон не найден",
"sb_daemon_start_failed": "Не удалось запустить dragonxd",
"sb_dragonxd_running": "dragonxd запущен",
@@ -1169,6 +1362,7 @@
"sb_net_mhs": "Сеть: %.2f MH/s",
"sb_no_conf": "DRAGONX.conf не найден",
"sb_peers": "Пиры: %zu",
+ "sb_plaintext_remote_blocked": "Отправка учётных данных RPC открытым текстом на удалённый узел запрещена. Добавьте rpcallowplaintext=1 в DRAGONX.conf, чтобы разрешить, или включите TLS с помощью rpctls=1.",
"sb_rescanning": "Пересканирование",
"sb_rescanning_pct": "Пересканирование %.0f%%",
"sb_restarting_daemon": "Перезапуск демона...",
@@ -1182,12 +1376,52 @@
"sb_waiting_daemon_err": "Ожидание dragonxd — %s",
"sb_warming_up": "Прогрев...",
"sb_witness_cache": "Перестроение свидетелей",
+ "scale_effects": "МАСШТАБ И ЭФФЕКТЫ",
"screenshot_open_dir": "Открыть расположение",
"screenshot_sweep": "Запустить прогон скриншотов",
"screenshot_sweep_desc": "Перебирает каждую тему по всем вкладкам и сохраняет скриншот каждой в подпапки по вкладкам внутри папки screenshots в каталоге конфигурации (перезаписывая предыдущий проход). Выполняется несколько секунд.",
"screenshot_sweep_full": "Полный обход интерфейса",
"search_icons": "Поиск значков...",
"search_placeholder": "Поиск...",
+ "sec_changing_passphrase": "Смена парольной фразы...",
+ "sec_changing_pin": "Смена PIN...",
+ "sec_couldnt_lock_wallet": "Не удалось заблокировать кошелёк — он всё ещё разблокирован. Проверьте соединение с демоном.",
+ "sec_encrypted_backup_suffix": "\nЗашифрованная резервная копия: wallet.dat.encrypted.bak",
+ "sec_encrypting_wallet": "Шифрование кошелька...",
+ "sec_encryption_did_not_complete": "Шифрование кошелька не завершено — ваш кошелёк НЕ зашифрован. Откройте настройки, чтобы завершить шифрование.",
+ "sec_encryption_failed_prefix": "Ошибка шифрования: ",
+ "sec_failed_prefix": "Ошибка: ",
+ "sec_failed_to_create_vault": "Не удалось создать хранилище",
+ "sec_importing_keys_rescanning": "Импорт ключей и повторное сканирование блокчейна — кошелёк доступен во время выполнения",
+ "sec_incorrect_current_pin": "Неверный текущий PIN",
+ "sec_incorrect_passphrase_decrypt": "Неверная парольная фраза",
+ "sec_incorrect_passphrase_pin_setup": "Неверная парольная фраза",
+ "sec_incorrect_pin_remove": "Неверный PIN",
+ "sec_internal_error_change_pin": "Внутренняя ошибка",
+ "sec_internal_error_remove_pin": "Внутренняя ошибка",
+ "sec_mode_passphrase": " Парольная фраза",
+ "sec_not_connected_to_daemon": "Нет соединения с демоном",
+ "sec_not_connected_to_daemon_pin": "Нет соединения с демоном",
+ "sec_passphrase_changed_successfully": "Парольная фраза успешно изменена",
+ "sec_pin_changed_successfully": "PIN успешно изменён",
+ "sec_pin_removed": "PIN удалён",
+ "sec_pin_set_successfully": "PIN успешно задан",
+ "sec_restart_daemon_for_encryption": "Перезапустите демон, чтобы шифрование вступило в силу.",
+ "sec_too_many_attempts_wait": "Слишком много попыток. Подождите %.0f сек...",
+ "sec_total_elapsed_fmt": "Всего прошло: %dм %02dс",
+ "sec_unlock_button": "Разблокировать",
+ "sec_unlock_failed_prefix": "Ошибка разблокировки: ",
+ "sec_unlocking_fmt": "Разблокировка%s",
+ "sec_use_passphrase_instead": "Использовать парольную фразу",
+ "sec_use_pin_instead": "Использовать PIN",
+ "sec_verifying_passphrase": "Проверка парольной фразы...",
+ "sec_verifying_pin": "Проверка PIN...",
+ "sec_wallet_decrypted_all_keys_imported": "Кошелёк успешно расшифрован! Все ключи импортированы.",
+ "sec_wallet_encrypted_and_pin_set": "Кошелёк зашифрован, PIN задан",
+ "sec_wallet_encrypted_but_pin_vault_failed": "Кошелёк зашифрован, но не удалось создать хранилище PIN",
+ "sec_wallet_encrypted_restarting_daemon": "Кошелёк зашифрован. Перезапуск демона...",
+ "sec_wallet_encrypted_successfully": "Кошелёк успешно зашифрован",
+ "sec_wallet_locked_title": "Кошелёк заблокирован",
"security": "БЕЗОПАСНОСТЬ",
"seed_backup_button": "Сид-фраза",
"seed_backup_close": "Закрыть",
@@ -1246,6 +1480,7 @@
"send_tooltip_not_connected": "Не подключено к daemon",
"send_tooltip_select_source": "Сначала выберите адрес-источник",
"send_tooltip_syncing": "Дождитесь синхронизации блокчейна",
+ "send_tooltip_view_only": "Адрес только для просмотра — нет ключа расходования, отправка невозможна",
"send_total": "Итого",
"send_transaction": "Отправить транзакцию",
"send_tx_failed": "Транзакция не удалась",
@@ -1265,16 +1500,16 @@
"sent_filter": "Отправлено",
"sent_type": "Отправлено",
"sent_upper": "ОТПРАВЛЕНО",
- "set_label": "Установить метку...",
+ "set_label": "Установить метку",
"settings": "Настройки",
"settings_about_text": "Защищённый криптовалютный кошелёк для DragonX (DRGX), созданный на Dear ImGui для лёгкого и портативного использования.",
"settings_acrylic_level": "Уровень акрила:",
- "settings_address_book": "Адресная книга...",
+ "settings_address_book": "Адресная книга…",
"settings_auto_detected": "Автоопределено из DRAGONX.conf",
"settings_auto_lock": "АВТОБЛОКИРОВКА",
"settings_auto_shield_desc": "Автоматически перемещать прозрачные средства на экранированные адреса",
"settings_auto_shield_funds": "Автоматически экранировать прозрачные средства",
- "settings_backup": "Резервная копия...",
+ "settings_backup": "Резервная копия…",
"settings_block_explorer_urls": "URL-адреса обозревателя блоков",
"settings_builtin": "Встроенные",
"settings_change_passphrase": "Сменить пароль",
@@ -1285,60 +1520,71 @@
"settings_configure_explorer": "Настроить ссылки внешнего обозревателя блоков",
"settings_configure_rpc": "Настроить подключение к демону dragonxd",
"settings_connection": "Подключение",
+ "settings_copy_diagnostics": "Копировать диагностику",
"settings_copyright": "Copyright 2024-2026 Разработчики DragonX | Лицензия GPLv3",
"settings_custom": "Пользовательские",
- "settings_data_dir": "Каталог данных:",
+ "settings_data_dir": "Каталог данных",
"settings_debug_changed": "Категории отладки изменены — перезапустите демон для применения",
"settings_debug_restart_note": "Изменения вступают в силу после перезапуска демона.",
"settings_debug_select": "Выберите категории для включения журнала отладки демона (флаги -debug=).",
+ "settings_diagnostics_copied": "Диагностика скопирована в буфер обмена",
"settings_encrypt_first_pin": "Сначала зашифруйте кошелёк, чтобы включить PIN",
"settings_encrypt_wallet": "Зашифровать кошелёк",
"settings_explorer_hint": "URL-адреса должны заканчиваться косой чертой. Txid/адрес будет добавлен.",
- "settings_export_all": "Экспортировать все...",
- "settings_export_csv": "Экспорт CSV...",
- "settings_export_key": "Экспортировать ключ...",
+ "settings_export_all": "Экспортировать все…",
+ "settings_export_csv": "Экспорт CSV…",
+ "settings_export_key": "Экспортировать ключ…",
"settings_gradient_bg": "Градиент фона",
"settings_gradient_desc": "Заменить текстурные фоны плавными градиентами",
"settings_idle_after": "через",
- "settings_import_key": "Импорт приватного ключа...",
- "settings_import_viewkey": "Импортировать ключ просмотра...",
+ "settings_import_key": "Импорт приватного ключа…",
+ "settings_import_viewkey": "Импортировать ключ просмотра…",
"settings_language_note": "Примечание: Некоторый текст требует перезапуска для обновления",
"settings_lock_now": "Заблокировать сейчас",
"settings_locked": "Заблокирован",
- "settings_merge_to_address": "Объединить на адрес...",
+ "settings_merge_to_address": "Объединить на адрес…",
"settings_noise_opacity": "Непрозрачность шума:",
+ "settings_not_connected": "Нет соединения с демоном",
"settings_not_encrypted": "Не зашифрован",
"settings_not_found": "Не найден",
"settings_open_app_dir": "Открыть папку приложения",
"settings_open_data_dir": "Открыть папку данных",
+ "settings_open_log_folder": "Открыть папку журналов",
"settings_other": "Прочее",
"settings_pin_active": "PIN",
"settings_privacy": "Конфиденциальность",
"settings_quick_unlock_pin": "Быстрый PIN-код разблокировки",
"settings_reduce_transparency": "Уменьшить прозрачность",
+ "settings_reloaded": "Настройки перезагружены с диска",
"settings_remove_encryption": "Удалить шифрование",
"settings_remove_pin": "Удалить PIN",
- "settings_request_payment": "Запросить платёж...",
+ "settings_request_payment": "Запросить платёж…",
"settings_rescan_desc": "Пересканировать блокчейн для поиска пропущенных транзакций",
"settings_restart_daemon": "Перезапустить демон",
"settings_rpc_connection": "RPC-соединение",
+ "settings_rpc_error_prefix": "Ошибка RPC: ",
"settings_rpc_note": "Примечание: Настройки подключения обычно определяются автоматически из DRAGONX.conf",
+ "settings_rpc_ok": "RPC-соединение в порядке",
"settings_save_shielded_desc": "Сохраняет z-addr транзакции в локальном файле для просмотра",
"settings_save_shielded_local": "Сохранять историю защищённых транзакций локально",
+ "settings_saved": "Настройки сохранены",
"settings_set_pin": "Установить PIN",
- "settings_shield_mining": "Экранировать майнинг...",
+ "settings_shield_mining": "Экранировать майнинг…",
"settings_solid_colors_desc": "Использовать сплошные цвета вместо эффектов размытия (доступность)",
+ "settings_theme_refreshed": "Список тем обновлён",
"settings_tor_desc": "Маршрутизировать все соединения через Tor для повышения конфиденциальности",
"settings_unlocked": "Разблокирован",
"settings_use_tor_network": "Использовать Tor для сетевых подключений",
- "settings_validate_address": "Проверить адрес...",
+ "settings_validate_address": "Проверить адрес…",
"settings_visual_effects": "Визуальные эффекты",
"settings_wallet_file_size": "Размер файла кошелька: %s",
"settings_wallet_info": "Информация о кошельке",
"settings_wallet_location": "Расположение кошелька: %s",
"settings_wallet_maintenance": "Обслуживание кошелька",
"settings_wallet_not_found": "Файл кошелька не найден",
- "settings_wallet_size_label": "Размер кошелька:",
+ "settings_wallet_size_label": "Размер кошелька",
+ "settings_ztx_cleared": "История Z-транзакций очищена",
+ "settings_ztx_not_found": "Файл истории не найден",
"setup_wizard": "Мастер настройки",
"share": "Поделиться",
"shield_check_status": "Проверить статус",
@@ -1397,6 +1643,17 @@
"sweep_to": "Переведено на:",
"sweep_toggle": "Перевести в мой кошелёк (не сохранять ключ)",
"sweep_tx": "Транзакция:",
+ "swin_connection_failed": "Ошибка соединения: ",
+ "swin_connection_successful": "Соединение установлено!\nВерсия dragonxd: ",
+ "swin_invalid_suffix": " (неверный)",
+ "swin_no_history_file_found": "Файл истории не найден",
+ "swin_rescan_failed": "Ошибка повторного сканирования: ",
+ "swin_rescan_started_from_block": "Повторное сканирование начато с блока ",
+ "swin_rescan_to": " до ",
+ "swin_rpc_client_not_initialized": "RPC-клиент не инициализирован",
+ "swin_settings_saved": "Настройки сохранены",
+ "swin_theme_list_refreshed": "Список тем обновлён",
+ "swin_ztx_history_cleared": "История Z-транзакций очищена",
"switch_corrupt_body": "Похоже, этот кошелёк повреждён — узел не смог его открыть. Восстановите из резервной копии, создайте заново или попробуйте восстановить.",
"switch_corrupt_repair": "Попробовать восстановить (salvage)",
"switch_progress_background": "Продолжить в фоне",
@@ -1421,6 +1678,7 @@
"theme": "Тема",
"theme_effects": "Эффекты темы",
"theme_language": "ТЕМА И ЯЗЫК",
+ "tile_click_to_open": "Нажмите, чтобы открыть",
"time_days_ago": "%d дней назад",
"time_hours_ago": "%d часов назад",
"time_minutes_ago": "%d минут назад",
@@ -1435,7 +1693,9 @@
"to_upper": "КОМУ",
"tools": "УТИЛИТЫ",
"tools_actions": "Инструменты и действия...",
+ "tools_actions_hdr": "ИНСТРУМЕНТЫ И ДЕЙСТВИЯ",
"total": "Итого",
+ "total_balance_label": "Общий баланс",
"transaction_id": "ID ТРАНЗАКЦИИ",
"transaction_sent": "Транзакция успешно отправлена",
"transaction_sent_msg": "Транзакция отправлена!",
@@ -1457,7 +1717,7 @@
"tt_auto_shield": "Автоматически перемещать прозрачный баланс на экранированные адреса для конфиденциальности",
"tt_backup": "Создать резервную копию вашего wallet.dat",
"tt_block_explorer": "Открыть обозреватель блоков DragonX в браузере",
- "tt_blur": "Степень размытия (0%% = выкл., 100%% = максимум)",
+ "tt_blur": "Степень размытия (0% = выкл., 100% = максимум)",
"tt_change_pass": "Сменить пароль шифрования кошелька",
"tt_change_pin": "Изменить PIN-код разблокировки",
"tt_chat_bubble_accent": "Акцентный цвет для ваших исходящих пузырьков сообщений (или следовать текущей теме)",
@@ -1470,6 +1730,7 @@
"tt_chat_timestamp": "Формат времени только для этой вкладки: следовать общим настройкам часов приложения либо принудительно 24-hour или 12-hour",
"tt_clear_ztx": "Удалить локально кешированную историю z-транзакций",
"tt_clock_format": "24- или 12-часовой формат для всего приложения. Чат может переопределить.",
+ "tt_copy_diagnostics": "Копирует сводку для поддержки (версия, состояние демона/кошелька/журналов — без секретов) в буфер обмена",
"tt_custom_fees": "Включить ручной ввод комиссий при отправке транзакций",
"tt_custom_theme": "Пользовательская тема активна",
"tt_daemon_install_bundled": "Остановить узел, перезаписать установленный dragonxd версией, встроенной в эту сборку кошелька, затем перезапустить",
@@ -1519,10 +1780,11 @@
"tt_low_spec": "Отключить все тяжёлые визуальные эффекты\\nГорячая клавиша: Ctrl+Shift+Down",
"tt_merge": "Объединить несколько UTXO в один адрес",
"tt_mine_idle": "Автоматически начать майнинг при\\nпростое системы (нет ввода с клавиатуры/мыши)",
- "tt_noise": "Интенсивность зернистой текстуры (0%% = выкл., 100%% = максимум)",
+ "tt_noise": "Интенсивность зернистой текстуры (0% = выкл., 100% = максимум)",
"tt_open_app_dir": "Открыть папку ObsidianDragon (настройки, темы, логи) в файловом менеджере",
"tt_open_data_dir": "Открыть в файловом менеджере папку с данными кошелька и блокчейна",
"tt_open_dir": "Нажмите, чтобы открыть в проводнике",
+ "tt_open_log_folder": "Открывает папку с журналами отладки и сбоев",
"tt_reduce_motion": "Отключить анимированные переходы и плавное изменение баланса для доступности",
"tt_remove_encrypt": "Удалить шифрование и хранить кошелёк без защиты",
"tt_remove_pin": "Удалить PIN и требовать пароль для разблокировки",
@@ -1557,7 +1819,7 @@
"tt_theme_hotkey": "Горячая клавиша: Ctrl+Влево/Вправо для переключения тем",
"tt_tor": "Маршрутизировать подключения демона через сеть Tor для анонимности",
"tt_tx_url": "Базовый URL для просмотра транзакций в обозревателе блоков",
- "tt_ui_opacity": "Непрозрачность карточек и боковой панели (100%% = полностью непрозрачно, ниже = прозрачнее)",
+ "tt_ui_opacity": "Непрозрачность карточек и боковой панели (100% = полностью непрозрачно, ниже = прозрачнее)",
"tt_validate": "Проверить, действителен ли адрес DragonX",
"tt_verbose": "Записывать подробную диагностику подключений,\\nсостояние демона и информацию о владельце порта\\nна вкладке Консоль",
"tt_wallets_button": "Показать файлы кошельков и переключаться между ними",
@@ -1610,6 +1872,7 @@
"validate_not_mine": "Не принадлежит этому кошельку",
"validate_ownership": "Принадлежность:",
"validate_results": "Результаты:",
+ "validate_results_placeholder": "Результаты появятся здесь",
"validate_shielded_type": "Экранированный (z-адрес)",
"validate_status": "Статус:",
"validate_title": "Проверить адрес",
@@ -1750,6 +2013,7 @@
"xmrig_loading_releases": "Загрузка релизов…",
"xmrig_none": "нет",
"xmrig_reinstall": "Переустановить",
+ "xmrig_releases": "релизы xmrig",
"xmrig_stop_mining_first": "Остановите майнинг перед обновлением майнера.",
"xmrig_unavailable_body": "Для этой платформы нет доступной сборки майнера.",
"xmrig_unavailable_title": "Обновления майнера недоступны",
diff --git a/res/lang/zh.json b/res/lang/zh.json
index 7eed6a3..3ece838 100644
--- a/res/lang/zh.json
+++ b/res/lang/zh.json
@@ -48,6 +48,10 @@
"advanced": "高级",
"advanced_effects": "高级特效...",
"ago": "前",
+ "alerts_clear": "清除通知历史",
+ "alerts_history_tooltip": "最近通知",
+ "alerts_none": "暂无通知",
+ "alerts_recent": "最近通知",
"all_filter": "全部",
"allow_custom_fees": "允许自定义手续费",
"amount": "金额",
@@ -56,6 +60,80 @@
"amount_label": "金额:",
"animate_avatars": "动画头像",
"appearance": "外观",
+ "appx_back": "返回",
+ "appx_back_up_seed_phrase_title": "备份你的助记词",
+ "appx_birthday_block_height": "生日(区块高度):%llu — 也请一并备份。",
+ "appx_blockchain_data_deleted": "区块链数据已删除(%d 项)。守护进程正在重启,以从网络重新同步。",
+ "appx_blockchain_maintenance_in_progress": "已有区块链维护操作正在进行中。",
+ "appx_blockchain_rescan_complete": "区块链重新扫描完成",
+ "appx_bootstrap_complete_reconciling": "引导数据完成——正在将你的钱包与新的链数据进行核对。",
+ "appx_cancel": "取消",
+ "appx_cleaning_up": "正在清理…",
+ "appx_confirm_your_backup": "确认你的备份",
+ "appx_copied_clipboard_autoclears": "已复制——剪贴板将在 45 秒后自动清空",
+ "appx_copy": "复制",
+ "appx_could_not_start_restore": "无法开始恢复",
+ "appx_create_failed_prefix": "创建失败:",
+ "appx_creating_your_wallet": "正在创建你的钱包…",
+ "appx_daemon_error": "守护进程错误",
+ "appx_daemon_reinstall_in_progress": "守护进程重新安装已在进行中。",
+ "appx_disconnecting": "正在断开连接…",
+ "appx_done": "完成",
+ "appx_dragonxd_output": "dragonxd 输出",
+ "appx_encrypting_wallet": "正在加密钱包…",
+ "appx_fullnode_lifecycle_unavailable_lite": "全节点生命周期操作在轻量版中不可用",
+ "appx_installing_bundled_daemon": "正在安装捆绑守护进程——节点将停止、更新并重启…",
+ "appx_invalid_payment_uri_prefix": "无效的支付 URI:",
+ "appx_ive_written_it_down": "我已抄写完毕",
+ "appx_keep_node_running_and_quit": "保持节点运行并退出",
+ "appx_last_block_n": "最新区块:%d",
+ "appx_last_used_wallet_not_found_prefix": "找不到你上次使用的钱包文件(",
+ "appx_last_used_wallet_not_found_suffix": ")——已改为打开默认钱包。如果你移动了它,请恢复该文件,然后从钱包列表中切换回来。",
+ "appx_low_spec_mode_disabled": "已禁用低配模式",
+ "appx_low_spec_mode_enabled": "已启用低配模式",
+ "appx_miner_stopped_prefix": "矿工已停止:",
+ "appx_miner_stopped_unexpectedly": "矿工意外停止。",
+ "appx_n_min_n_sec": "%d 分 %d 秒",
+ "appx_n_seconds": "%d 秒",
+ "appx_no_bundled_daemon_to_install": "此版本没有可安装的捆绑守护进程",
+ "appx_no_embedded_daemon_to_install": "此版本没有可安装的内嵌守护进程",
+ "appx_node_busy_restarting": "节点正忙于重启——请稍后再试。",
+ "appx_node_rebuilding_witness_cache": "节点正在重建其见证缓存",
+ "appx_not_next_word": " — 这不是下一个词",
+ "appx_payment_request_loaded": "支付请求已加载",
+ "appx_pool_miner_connected_and_hashing": "矿池矿工已连接并正在计算哈希。",
+ "appx_progress_n_of_n": "进度:%d / %d",
+ "appx_rebuilding_sapling_note_witnesses": "正在重建 Sapling 票据见证…",
+ "appx_rebuilding_witness_cache_blocks_left": "正在重建见证缓存 %.0f%% —— 剩余 %d 个区块",
+ "appx_rebuilding_witness_cache_pct": "正在重建见证缓存 %.0f%%",
+ "appx_recovery_phrase_word_count": "助记词应为 24 个——你输入了 %d 个。",
+ "appx_restarting_daemon_rescan_flag": "正在以 -rescan 标志重启守护进程…",
+ "appx_restarting_daemon_zapwallettxes": "正在以 -zapwallettxes=2 重启守护进程(钱包修复)…",
+ "appx_restoring_your_wallet": "正在恢复你的钱包…",
+ "appx_seed_backup_warning": "这 24 个词是恢复钱包的唯一方式。请按顺序抄写、离线保存,切勿泄露给他人。一旦丢失,你的资金将永远无法找回。",
+ "appx_seed_not_backed_up_warning": "你尚未备份助记词——资金可能丢失。仍要跳过吗?",
+ "appx_sending_stop_command_to_daemon": "正在向守护进程发送停止命令…",
+ "appx_setting_initial_sapling_witnesses": "正在设置初始 Sapling 见证 %.0f%%",
+ "appx_shutdown_complete": "关闭完成",
+ "appx_simple_background_disabled": "已禁用简约背景",
+ "appx_simple_background_enabled": "已启用简约背景",
+ "appx_skip": "跳过",
+ "appx_skip_anyway": "仍要跳过",
+ "appx_still_status_prefix": "仍处于“",
+ "appx_still_status_suffix": "”——现在强制退出可能损坏链数据。",
+ "appx_stop_anyway_and_quit": "仍要停止并退出",
+ "appx_stopping_daemon_deleting_blockchain": "正在停止守护进程并删除区块链数据…",
+ "appx_stopping_node_discards_rebuild": "现在停止节点会丢弃正在进行的重建,下次打开钱包时将重新开始(需要几分钟)。你也可以让节点继续运行。",
+ "appx_stopping_pool_miner": "正在停止矿池矿工…",
+ "appx_syncing_pct_block_n_of_n": "正在同步 %.1f%% —— 区块 %d / %d",
+ "appx_tap_words_in_order": "按正确顺序点击这些词,以确认你已保存。",
+ "appx_theme_effects_disabled": "已禁用主题特效",
+ "appx_theme_effects_enabled": "已启用主题特效",
+ "appx_theme_prefix": "主题:",
+ "appx_use_settings_restart_daemon_hint": "请使用 设置 > 重启守护进程 重试",
+ "appx_waiting_for_daemon_to_encrypt_wallet": "正在等待守护进程加密钱包…",
+ "appx_wallet_created_and_backed_up": "钱包已创建并完成备份。",
+ "appx_wallet_open_failed_prefix": "钱包打开失败:",
"auto_shield": "自动屏蔽挖矿",
"av_intro": "挖矿软件经常被标记为潜在有害程序。请按照以下步骤启用矿池挖矿:",
"av_open_security": "打开 Windows 安全中心",
@@ -70,6 +148,9 @@
"av_title": "Windows Defender 已阻止矿工程序",
"available": "可用",
"backup_backing_up": "正在备份...",
+ "backup_col_backup": "备份",
+ "backup_col_export": "导出",
+ "backup_col_import": "导入与恢复",
"backup_create": "创建备份",
"backup_created": "钱包备份已创建",
"backup_data": "备份与数据",
@@ -88,8 +169,22 @@
"balance": "余额",
"balance_history_collecting": "余额历史——正在收集数据…",
"balance_layout": "余额布局",
+ "balance_layout_switched": "布局:%s",
+ "balance_mining_rate": "挖矿中 %s",
"balance_shielded_fmt": "屏蔽:%.8f",
+ "balance_syncing_pct": "同步中 %.1f%%",
"balance_transparent_fmt": "透明:%.8f",
+ "baltab_market": "市价",
+ "baltab_market_price_4dp": "市价:$%.4f",
+ "baltab_market_price_8dp": "市价:$%.8f",
+ "baltab_pct_change_24h": "%s%.1f%% 24小时",
+ "baltab_pct_of_total_zaddr": "占总额 %.0f%% · %d 个 Z-addr",
+ "baltab_shielded": "隐蔽",
+ "baltab_shielded_amount": "隐蔽 %.8f",
+ "baltab_t_addresses_count": "%d 个 T-address",
+ "baltab_total_balance": "总余额",
+ "baltab_transparent": "透明",
+ "baltab_transparent_amount": "透明 %.8f",
"ban": "封禁",
"banned_peers": "已封禁节点",
"block": "区块",
@@ -128,6 +223,7 @@
"bootstrap_verifying": "正在验证校验和...",
"bootstrap_wallet_protected": "(wallet.dat 已受保护)",
"bootstrap_warning": "现有区块数据(blocks、chainstate、notarizations)将被删除并替换。您的 wallet.dat 不会被修改或删除。",
+ "byte_count_fmt": "%zu / %zu 字节",
"cancel": "取消",
"change_pass_confirm": "确认新密码:",
"change_pass_current": "当前密码短语:",
@@ -403,6 +499,7 @@
"contacts_shape_square": "方形",
"contacts_shape_tab": "左标签",
"copied": "已复制!",
+ "copied_to_clipboard": "已复制到剪贴板",
"copy": "复制",
"copy_address": "复制完整地址",
"copy_error": "复制错误",
@@ -415,6 +512,7 @@
"daemon_bundled": "内置",
"daemon_install_bundled": "安装内置版本",
"daemon_installed": "已安装",
+ "daemon_maintenance_label": "维护",
"daemon_none_bundled": "此版本未内置",
"daemon_not_installed": "未安装",
"daemon_status_differ": "已安装的程序文件与内置版本不同。",
@@ -437,6 +535,7 @@
"daemon_update_latest": "最新:",
"daemon_update_loading": "正在加载版本…",
"daemon_update_now": "立即更新",
+ "daemon_update_prompt_title": "是否更新节点守护进程?",
"daemon_update_reinstall": "重新安装",
"daemon_update_restart_note": "重启守护进程以开始运行新版本。",
"daemon_update_restart_now": "立即重启守护进程",
@@ -449,8 +548,11 @@
"daemon_update_verify_note": "在安装前,会根据该版本发布的 SHA-256 和固定的 ed25519 签名对下载内容进行校验。",
"daemon_update_verifying": "正在验证…",
"daemon_update_version": "版本:",
+ "daemon_updates_label": "更新",
"daemon_version": "守护进程",
"dark": "深色",
+ "data_stale_prefix": "更新于",
+ "data_stale_tooltip": "余额可能已过时 — 钱包最近未收到更新。请检查您的节点连接。",
"date": "日期",
"date_label": "日期:",
"debug_logging": "调试日志",
@@ -479,6 +581,17 @@
"download_bootstrap": "下载引导程序",
"dragonx_green": "DragonX(绿色)",
"edit": "编辑",
+ "empty_wallet_keys_suffix": "个密钥",
+ "empty_wallet_open_manager": "打开钱包管理器",
+ "empty_wallet_restore": "恢复我的钱包",
+ "empty_wallet_salvage_body": "此钱包为空,因为先前的一次自动修复已将您的原始钱包作为备份保存到一旁。您的币几乎肯定在该备份中,并未丢失。恢复它即可重新加载您的资金——不会删除任何内容;当前文件会先被保存到一旁。",
+ "empty_wallet_salvage_headline": "您的币安全地存放在备份文件中。",
+ "empty_wallet_salvage_title": "您的钱包可能已被修复",
+ "empty_wallet_warning_body": "此钱包没有地址也没有资金,但您的 DragonX 文件夹中的另一个钱包文件包含密钥。您的币很可能在其中,并未丢失。打开钱包管理器以切换到持有您资金的钱包。",
+ "empty_wallet_warning_dismiss": "不再为此钱包提示",
+ "empty_wallet_warning_dismiss_tip": "仅对当前钱包文件停止此提示。如果您以后切换到另一个空钱包,可能会再次提示。",
+ "empty_wallet_warning_headline": "您可能打开了错误的钱包。",
+ "empty_wallet_warning_title": "此钱包为空",
"enc_confirm": "确认:",
"enc_desc": "加密钱包会用密码短语保护您的私钥。加密后,守护进程将重新启动。",
"enc_encrypting": "正在加密钱包...",
@@ -566,6 +679,63 @@
"general": "常规",
"generating": "正在生成",
"go_to_receive": "前往接收",
+ "grpa_current_block_paren": "(当前:%d)",
+ "grpa_days_ago": "%lld 天前",
+ "grpa_dbg_addrman": "对等节点地址追踪与管理",
+ "grpa_dbg_alert": "警报系统消息",
+ "grpa_dbg_bench": "操作的基准计时",
+ "grpa_dbg_coindb": "币数据库读写操作",
+ "grpa_dbg_db": "Berkeley DB 操作",
+ "grpa_dbg_estimatefee": "手续费估算算法",
+ "grpa_dbg_http": "HTTP RPC 服务器活动",
+ "grpa_dbg_libevent": "Libevent 网络库",
+ "grpa_dbg_lock": "锁竞争调试",
+ "grpa_dbg_mempool": "交易内存池活动",
+ "grpa_dbg_net": "网络连接与消息",
+ "grpa_dbg_paymentdisclosure": "支付披露协议",
+ "grpa_dbg_pow": "工作量证明挖矿活动",
+ "grpa_dbg_proxy": "SOCKS5 代理连接",
+ "grpa_dbg_prune": "区块修剪操作",
+ "grpa_dbg_rand": "随机数生成",
+ "grpa_dbg_reindex": "区块链重新索引进度",
+ "grpa_dbg_rpc": "RPC 命令处理",
+ "grpa_dbg_selectcoins": "交易的币选择",
+ "grpa_dbg_tor": "Tor 集成与线路信息",
+ "grpa_dbg_zmq": "ZeroMQ 通知系统",
+ "grpa_dbg_zrpc": "隐蔽(z-addr)RPC 操作",
+ "grpa_enter_private_key_to_import": "请输入要导入的私钥。",
+ "grpa_error_prefix": "错误:",
+ "grpa_hr_ago": "%lld 小时前",
+ "grpa_invalid_response_from_daemon": "守护进程返回了无效响应",
+ "grpa_invalid_suffix": "(无效)",
+ "grpa_min_ago": "%lld 分钟前",
+ "grpa_sec_ago": "%lld 秒前",
+ "grpa_seed_demo_chat": "种子演示聊天",
+ "grpa_showing_first_100_of": "…显示 %d 项中的前 100 项",
+ "grpa_tab_about": "关于",
+ "grpa_tab_appearance": "外观",
+ "grpa_tab_backup_data": "备份与数据",
+ "grpa_tab_chat": "聊天",
+ "grpa_tab_explorer": "浏览器",
+ "grpa_tab_node_security": "节点与安全",
+ "grpa_tab_wallet": "钱包",
+ "grpa_unexpected_getblockhash_result": "意外的 getblockhash 结果",
+ "grpb_copy": "复制",
+ "grpb_max": "最大",
+ "grpb_new_badge_suffix": " [新]",
+ "grpb_preview_msg_payment_through": "付款到账了吗?🙂",
+ "grpb_preview_msg_sending_rest": "现在把剩下的发过去 👍",
+ "grpb_preview_msg_yep_confirmed": "到了——刚刚确认 ✅",
+ "grpb_selected_suffix": "\n(已选)",
+ "grpb_tooltip_address_balance": "%s\n余额:%.8f %s%s",
+ "grpb_undo_clear": "撤销清除",
+ "grpc_benchmark_inconclusive": "基准测试无结果:未记录到任何算力样本。请检查矿池连接后重试。",
+ "grpc_benchmark_takes_secs": "基准测试约需 %d 秒并会中断挖矿。再次点击以开始。",
+ "grpc_bootstrap_failed": "引导失败",
+ "grpc_bootstrap_not_initialized": "引导未初始化",
+ "grpc_hashrate_fee": "%s %s%% 手续费",
+ "grpc_key_not_available": "此地址无可用密钥",
+ "grpc_na": "不适用",
"height": "高度",
"help": "帮助",
"hidden_tag": " (已隐藏)",
@@ -640,6 +810,7 @@
"light": "浅色",
"lite_account_label": "账户",
"lite_action": "操作",
+ "lite_backend_unavailable": "轻量钱包后端不可用",
"lite_backup_keys": "备份与密钥",
"lite_birthday_backup": "生日区块:%llu (也请一并备份)",
"lite_birthday_hint": "开始扫描的区块高度。如未知请保留 0(完整扫描更慢)。",
@@ -647,9 +818,12 @@
"lite_console_backend_commands": "后端命令:",
"lite_console_help_passthrough": "其他任何输入都将作为轻钱包控制台命令运行。",
"lite_copy": "复制",
+ "lite_could_not_start": "无法启动该操作",
"lite_could_not_write": "无法写入 ",
"lite_encrypt_wallet": "加密钱包",
"lite_encryption_removed": "已移除加密",
+ "lite_enter_all_seed_words": "请输入全部 24 个助记词以恢复(已输入 %d 个)",
+ "lite_enter_wallet_path": "请输入钱包路径",
"lite_hide_wipe": "隐藏并清除",
"lite_import": "导入",
"lite_import_key_label": "导入密钥",
@@ -734,6 +908,8 @@
"lite_working": "处理中…",
"loading": "加载中...",
"loading_addresses": "正在加载地址...",
+ "loading_stall_body": "守护进程已初始化 %.0f 秒。更新后或首次启动时(加载区块索引或重新扫描)这可能是正常现象——就绪后会自动连接。",
+ "loading_stall_title": "耗时超出预期",
"loading_transactions": "正在加载交易",
"local_hashrate": "本地算力",
"low_spec_mode": "低配模式",
@@ -866,6 +1042,7 @@
"mining_difficulty_copied": "难度已复制",
"mining_est_block": "预计区块",
"mining_est_daily": "预计日收益",
+ "mining_est_daily_pool_sub": "粗略的单人挖矿等值,扣除矿池费用前",
"mining_filter_all": "全部",
"mining_filter_tip_all": "显示所有收益",
"mining_filter_tip_pool": "仅显示矿池收益",
@@ -894,10 +1071,12 @@
"mining_open_in_explorer": "在浏览器中打开",
"mining_payout_address": "支付地址",
"mining_payout_foreign": "⚠ 此支付地址不在您当前的钱包中——挖矿奖励将进入另一个钱包。如果您切换过钱包,请更新它。",
+ "mining_payout_invalid": "不是有效的 DragonX 地址——启动前请更正,否则挖矿奖励将丢失。",
"mining_payout_tooltip": "接收挖矿奖励的地址",
"mining_pool": "矿池",
"mining_pool_fee": "费用",
"mining_pool_hashrate": "矿池算力",
+ "mining_pool_needs_payout_tooltip": "请先输入收款地址(生成一个 Z 地址)",
"mining_pool_url": "矿池 URL",
"mining_pools_header": "矿池",
"mining_recent_blocks": "最近区块",
@@ -927,6 +1106,9 @@
"mining_syncing_tooltip": "区块链同步中...",
"mining_tag": " · 挖矿",
"mining_threads": "挖矿线程",
+ "mining_threads_input_tooltip": "输入精确的线程数(按 Enter 应用)",
+ "mining_threads_minus_tooltip": "减少线程",
+ "mining_threads_plus_tooltip": "增加线程",
"mining_to_save": "保存",
"mining_today": "今天",
"mining_uptime": "运行时间",
@@ -953,6 +1135,11 @@
"no_transactions": "未找到交易",
"no_transactions_yet": "尚无交易",
"node": "节点",
+ "node_banner_crashed_title": "节点意外停止",
+ "node_banner_lite_open_failed": "无法打开您的钱包",
+ "node_banner_offline_title": "未连接到 DragonX 节点",
+ "node_banner_reconnect": "重新连接",
+ "node_banner_restart": "重启节点",
"node_security": "节点与安全",
"noise": "噪点",
"not_connected": "未连接到守护进程...",
@@ -1091,6 +1278,8 @@
"qr_failed": "无法生成二维码",
"qr_title": "二维码",
"qr_unavailable": "二维码不可用",
+ "quick_receive": "快速接收",
+ "quick_send": "快速发送",
"ram_daemon_gb": "守护进程:%.1f GB (%s)",
"ram_daemon_mb": "守护进程:%.0f MB (%s)",
"ram_system_gb": "系统:%.1f / %.0f GB",
@@ -1140,6 +1329,7 @@
"rpc_connection": "RPC 连接...",
"rpc_host": "RPC 主机",
"rpc_pass": "密码",
+ "rpc_plaintext_remote_warning": "远程 RPC 正在使用明文 HTTP。如果您的守护进程支持 TLS,请在 DRAGONX.conf 中添加 rpctls=1。",
"rpc_port": "端口",
"rpc_user": "用户名",
"save": "保存",
@@ -1154,6 +1344,8 @@
"sb_connecting_external": "正在连接外部守护进程...",
"sb_connecting_generic": "正在连接守护进程...",
"sb_daemon_crashed": "守护进程崩溃 %d 次",
+ "sb_daemon_extract_failed": "无法写入守护进程文件——请检查磁盘剩余空间和权限。",
+ "sb_daemon_files_failed": "无法将守护进程文件写入 %s——请检查磁盘剩余空间和权限。",
"sb_daemon_not_found": "未找到守护进程",
"sb_daemon_start_failed": "无法启动 dragonxd",
"sb_dragonxd_running": "dragonxd 运行中",
@@ -1182,12 +1374,52 @@
"sb_waiting_daemon_err": "等待 dragonxd — %s",
"sb_warming_up": "正在预热...",
"sb_witness_cache": "正在重建见证",
+ "scale_effects": "缩放与效果",
"screenshot_open_dir": "打开位置",
"screenshot_sweep": "运行截图批处理",
"screenshot_sweep_desc": "遍历每个标签页的每一种主题,并将每一个的截图保存到配置目录 screenshots 文件夹下的各标签页子文件夹中(覆盖上一次的遍历)。运行几秒钟。",
"screenshot_sweep_full": "完整 UI 遍历",
"search_icons": "搜索图标...",
"search_placeholder": "搜索...",
+ "sec_changing_passphrase": "正在更改密码短语…",
+ "sec_changing_pin": "正在更改 PIN…",
+ "sec_couldnt_lock_wallet": "无法锁定钱包——它仍处于解锁状态。请检查守护进程连接。",
+ "sec_encrypted_backup_suffix": "\n加密备份:wallet.dat.encrypted.bak",
+ "sec_encrypting_wallet": "正在加密钱包…",
+ "sec_encryption_did_not_complete": "钱包加密未完成——你的钱包尚未加密。请打开设置以完成加密。",
+ "sec_encryption_failed_prefix": "加密失败:",
+ "sec_failed_prefix": "失败:",
+ "sec_failed_to_create_vault": "创建保险库失败",
+ "sec_importing_keys_rescanning": "正在导入密钥并重新扫描区块链——期间钱包仍可使用",
+ "sec_incorrect_current_pin": "当前 PIN 错误",
+ "sec_incorrect_passphrase_decrypt": "密码短语错误",
+ "sec_incorrect_passphrase_pin_setup": "密码短语错误",
+ "sec_incorrect_pin_remove": "PIN 错误",
+ "sec_internal_error_change_pin": "内部错误",
+ "sec_internal_error_remove_pin": "内部错误",
+ "sec_mode_passphrase": " 密码短语",
+ "sec_not_connected_to_daemon": "未连接到守护进程",
+ "sec_not_connected_to_daemon_pin": "未连接到守护进程",
+ "sec_passphrase_changed_successfully": "密码短语更改成功",
+ "sec_pin_changed_successfully": "PIN 更改成功",
+ "sec_pin_removed": "PIN 已移除",
+ "sec_pin_set_successfully": "PIN 设置成功",
+ "sec_restart_daemon_for_encryption": "请重启守护进程以使加密生效。",
+ "sec_too_many_attempts_wait": "尝试次数过多。请等待 %.0f 秒…",
+ "sec_total_elapsed_fmt": "总耗时:%d 分 %02d 秒",
+ "sec_unlock_button": "解锁",
+ "sec_unlock_failed_prefix": "解锁失败:",
+ "sec_unlocking_fmt": "正在解锁%s",
+ "sec_use_passphrase_instead": "改用密码短语",
+ "sec_use_pin_instead": "改用 PIN",
+ "sec_verifying_passphrase": "正在验证密码短语…",
+ "sec_verifying_pin": "正在验证 PIN…",
+ "sec_wallet_decrypted_all_keys_imported": "钱包解密成功!所有密钥已导入。",
+ "sec_wallet_encrypted_and_pin_set": "钱包已加密并已设置 PIN",
+ "sec_wallet_encrypted_but_pin_vault_failed": "钱包已加密,但 PIN 保险库创建失败",
+ "sec_wallet_encrypted_restarting_daemon": "钱包已加密。正在重启守护进程…",
+ "sec_wallet_encrypted_successfully": "钱包加密成功",
+ "sec_wallet_locked_title": "钱包已锁定",
"security": "安全",
"seed_backup_button": "助记词",
"seed_backup_close": "关闭",
@@ -1246,6 +1478,7 @@
"send_tooltip_not_connected": "未连接到守护进程",
"send_tooltip_select_source": "请先选择来源地址",
"send_tooltip_syncing": "请等待区块链同步",
+ "send_tooltip_view_only": "仅查看地址 — 无花费密钥,无法发送",
"send_total": "合计",
"send_transaction": "发送交易",
"send_tx_failed": "交易失败",
@@ -1265,16 +1498,16 @@
"sent_filter": "已发送",
"sent_type": "已发送",
"sent_upper": "已发送",
- "set_label": "设置标签...",
+ "set_label": "设置标签",
"settings": "设置",
"settings_about_text": "DragonX (DRGX) 屏蔽加密货币钱包,使用 Dear ImGui 构建,提供轻量、便携的体验。",
"settings_acrylic_level": "亚克力级别:",
- "settings_address_book": "地址簿...",
+ "settings_address_book": "地址簿…",
"settings_auto_detected": "从 DRAGONX.conf 自动检测",
"settings_auto_lock": "自动锁定",
"settings_auto_shield_desc": "自动将透明资金转移到屏蔽地址",
"settings_auto_shield_funds": "自动屏蔽透明资金",
- "settings_backup": "备份...",
+ "settings_backup": "备份…",
"settings_block_explorer_urls": "区块浏览器网址",
"settings_builtin": "内置",
"settings_change_passphrase": "更改密码",
@@ -1285,53 +1518,62 @@
"settings_configure_explorer": "配置外部区块浏览器链接",
"settings_configure_rpc": "配置 dragonxd 守护进程连接",
"settings_connection": "连接",
+ "settings_copy_diagnostics": "复制诊断信息",
"settings_copyright": "版权所有 2024-2026 DragonX 开发者 | GPLv3 许可证",
"settings_custom": "自定义",
"settings_data_dir": "数据目录:",
"settings_debug_changed": "调试类别已更改——重启守护进程以应用",
"settings_debug_restart_note": "更改将在重启守护进程后生效。",
"settings_debug_select": "选择要启用的守护进程调试日志类别(-debug= 标志)。",
+ "settings_diagnostics_copied": "诊断信息已复制到剪贴板",
"settings_encrypt_first_pin": "请先加密钱包以启用 PIN",
"settings_encrypt_wallet": "加密钱包",
"settings_explorer_hint": "URL 应包含尾部斜杠。将自动附加 txid/地址。",
- "settings_export_all": "全部导出...",
- "settings_export_csv": "导出 CSV...",
- "settings_export_key": "导出密钥...",
+ "settings_export_all": "全部导出…",
+ "settings_export_csv": "导出 CSV…",
+ "settings_export_key": "导出密钥…",
"settings_gradient_bg": "渐变背景",
"settings_gradient_desc": "用平滑渐变替换纹理背景",
"settings_idle_after": "之后",
- "settings_import_key": "导入私钥...",
- "settings_import_viewkey": "导入查看密钥...",
+ "settings_import_key": "导入私钥…",
+ "settings_import_viewkey": "导入查看密钥…",
"settings_language_note": "注意:部分文本需要重启才能更新",
"settings_lock_now": "立即锁定",
"settings_locked": "已锁定",
- "settings_merge_to_address": "合并到地址...",
+ "settings_merge_to_address": "合并到地址…",
"settings_noise_opacity": "噪点不透明度:",
+ "settings_not_connected": "未连接到守护进程",
"settings_not_encrypted": "未加密",
"settings_not_found": "未找到",
"settings_open_app_dir": "打开应用文件夹",
"settings_open_data_dir": "打开数据文件夹",
+ "settings_open_log_folder": "打开日志文件夹",
"settings_other": "其他",
"settings_pin_active": "PIN",
"settings_privacy": "隐私",
"settings_quick_unlock_pin": "快速解锁 PIN",
"settings_reduce_transparency": "降低透明度",
+ "settings_reloaded": "已从磁盘重新加载设置",
"settings_remove_encryption": "移除加密",
"settings_remove_pin": "移除 PIN",
- "settings_request_payment": "请求付款...",
+ "settings_request_payment": "请求付款…",
"settings_rescan_desc": "重新扫描区块链以查找丢失的交易",
"settings_restart_daemon": "重启守护进程",
"settings_rpc_connection": "RPC 连接",
+ "settings_rpc_error_prefix": "RPC 错误:",
"settings_rpc_note": "注意:连接设置通常从 DRAGONX.conf 自动检测",
+ "settings_rpc_ok": "RPC 连接正常",
"settings_save_shielded_desc": "将 z-addr 交易存储在本地文件中以供查看",
"settings_save_shielded_local": "将屏蔽交易历史保存到本地",
+ "settings_saved": "设置已保存",
"settings_set_pin": "设置 PIN",
- "settings_shield_mining": "屏蔽挖矿...",
+ "settings_shield_mining": "屏蔽挖矿…",
"settings_solid_colors_desc": "使用纯色代替模糊效果(无障碍功能)",
+ "settings_theme_refreshed": "主题列表已刷新",
"settings_tor_desc": "通过 Tor 路由所有连接以增强隐私",
"settings_unlocked": "已解锁",
"settings_use_tor_network": "使用 Tor 进行网络连接",
- "settings_validate_address": "验证地址...",
+ "settings_validate_address": "验证地址…",
"settings_visual_effects": "视觉效果",
"settings_wallet_file_size": "钱包文件大小:%s",
"settings_wallet_info": "钱包信息",
@@ -1339,6 +1581,8 @@
"settings_wallet_maintenance": "钱包维护",
"settings_wallet_not_found": "未找到钱包文件",
"settings_wallet_size_label": "钱包大小:",
+ "settings_ztx_cleared": "Z 交易历史记录已清除",
+ "settings_ztx_not_found": "未找到历史记录文件",
"setup_wizard": "设置向导",
"share": "分享",
"shield_check_status": "检查状态",
@@ -1397,6 +1641,17 @@
"sweep_to": "归集到:",
"sweep_toggle": "归集到我的钱包(不保留密钥)",
"sweep_tx": "交易:",
+ "swin_connection_failed": "连接失败:",
+ "swin_connection_successful": "连接成功!\ndragonxd 版本:",
+ "swin_invalid_suffix": "(无效)",
+ "swin_no_history_file_found": "未找到历史文件",
+ "swin_rescan_failed": "重新扫描失败:",
+ "swin_rescan_started_from_block": "重新扫描已开始,起始区块 ",
+ "swin_rescan_to": " 至 ",
+ "swin_rpc_client_not_initialized": "RPC 客户端未初始化",
+ "swin_settings_saved": "设置已保存",
+ "swin_theme_list_refreshed": "主题列表已刷新",
+ "swin_ztx_history_cleared": "Z 交易历史已清除",
"switch_corrupt_body": "此钱包似乎已损坏——节点无法打开它。请从备份恢复、重新创建,或尝试修复。",
"switch_corrupt_repair": "尝试修复(salvage)",
"switch_progress_background": "在后台继续",
@@ -1421,6 +1676,7 @@
"theme": "主题",
"theme_effects": "主题效果",
"theme_language": "主题与语言",
+ "tile_click_to_open": "点击打开",
"time_days_ago": "%d 天前",
"time_hours_ago": "%d 小时前",
"time_minutes_ago": "%d 分钟前",
@@ -1435,7 +1691,9 @@
"to_upper": "至",
"tools": "工具",
"tools_actions": "工具与操作...",
+ "tools_actions_hdr": "工具与操作",
"total": "合计",
+ "total_balance_label": "总余额",
"transaction_id": "交易 ID",
"transaction_sent": "交易发送成功",
"transaction_sent_msg": "交易已发送!",
@@ -1457,7 +1715,7 @@
"tt_auto_shield": "自动将透明余额转移到屏蔽地址以增强隐私",
"tt_backup": "创建 wallet.dat 的备份",
"tt_block_explorer": "在浏览器中打开 DragonX 区块浏览器",
- "tt_blur": "模糊程度(0%% = 关闭,100%% = 最大)",
+ "tt_blur": "模糊程度(0% = 关闭,100% = 最大)",
"tt_change_pass": "更改钱包加密密码",
"tt_change_pin": "更改您的解锁 PIN",
"tt_chat_bubble_accent": "你发出的消息气泡的强调色(或跟随当前主题)",
@@ -1470,6 +1728,7 @@
"tt_chat_timestamp": "仅此标签页的时间戳格式:跟随全应用时钟,或强制使用 24-hour 或 12-hour",
"tt_clear_ztx": "删除本地缓存的 z-交易历史",
"tt_clock_format": "24 或 12 小时制,应用全局。聊天可覆盖。",
+ "tt_copy_diagnostics": "将支持诊断摘要(版本、守护进程/钱包/日志状态 — 不含机密)复制到剪贴板",
"tt_custom_fees": "发送交易时启用手动费用输入",
"tt_custom_theme": "自定义主题已激活",
"tt_daemon_install_bundled": "停止节点,用此钱包版本内置的 dragonxd 覆盖已安装的版本,然后重启",
@@ -1519,10 +1778,11 @@
"tt_low_spec": "禁用所有重度视觉效果\\n快捷键:Ctrl+Shift+Down",
"tt_merge": "将多个 UTXO 合并到一个地址",
"tt_mine_idle": "系统空闲时自动开始挖矿\\n(无键盘/鼠标输入)",
- "tt_noise": "颗粒纹理强度(0%% = 关闭,100%% = 最大)",
+ "tt_noise": "颗粒纹理强度(0% = 关闭,100% = 最大)",
"tt_open_app_dir": "在文件管理器中打开 ObsidianDragon 文件夹(设置、主题、日志)",
"tt_open_data_dir": "在文件管理器中打开包含您钱包和区块链数据的文件夹",
"tt_open_dir": "点击在文件管理器中打开",
+ "tt_open_log_folder": "打开包含调试和崩溃日志的文件夹",
"tt_reduce_motion": "禁用动画过渡和余额渐变以提高无障碍性",
"tt_remove_encrypt": "移除加密并以未受保护状态存储钱包",
"tt_remove_pin": "移除 PIN 并要求密码解锁",
@@ -1557,7 +1817,7 @@
"tt_theme_hotkey": "快捷键:Ctrl+左/右箭头切换主题",
"tt_tor": "通过 Tor 网络路由守护进程连接以实现匿名",
"tt_tx_url": "在区块浏览器中查看交易的基础 URL",
- "tt_ui_opacity": "卡片和侧边栏不透明度(100%% = 完全不透明,越低越透明)",
+ "tt_ui_opacity": "卡片和侧边栏不透明度(100% = 完全不透明,越低越透明)",
"tt_validate": "检查 DragonX 地址是否有效",
"tt_verbose": "将详细连接诊断、守护进程状态\\n和端口所有者信息记录到控制台选项卡",
"tt_wallets_button": "列出您的钱包文件并在它们之间切换",
@@ -1610,6 +1870,7 @@
"validate_not_mine": "不属于此钱包",
"validate_ownership": "所有权:",
"validate_results": "结果:",
+ "validate_results_placeholder": "结果将显示在此处",
"validate_shielded_type": "屏蔽(z 地址)",
"validate_status": "状态:",
"validate_title": "验证地址",
@@ -1750,6 +2011,7 @@
"xmrig_loading_releases": "正在加载发行版…",
"xmrig_none": "无",
"xmrig_reinstall": "重新安装",
+ "xmrig_releases": "xmrig 版本",
"xmrig_stop_mining_first": "更新矿工程序前请先停止挖矿。",
"xmrig_unavailable_body": "此平台没有可用的矿工构建版本。",
"xmrig_unavailable_title": "矿工更新不可用",
diff --git a/res/themes/ui.toml b/res/themes/ui.toml
index c4db15c..f9cc512 100644
--- a/res/themes/ui.toml
+++ b/res/themes/ui.toml
@@ -700,6 +700,12 @@ status-pill-bg-alpha = { size = 30 }
status-pill-y-offset = { size = 1 }
confirmed-threshold = { size = 10 }
+# Persistent node/RPC error strip at the top of the content column (see App::renderNodeStatusBanner).
+# Slightly taller than the per-tab sync banner so it comfortably holds the Reconnect/Restart action.
+[banners.node-status]
+min-height = { size = 26.0 }
+height = { size = 30.0 }
+
[tabs.transactions]
search-max-width = 300.0
search-width-ratio = 0.3
@@ -959,6 +965,11 @@ edition-label = { position = 120 }
link-button = { width = 100, font = "button-sm" }
close-button = { width = 120, font = "button", align = "center" }
+[dialogs.faq]
+width = 860.0
+height = 660.0
+window = { width = 860, height = 660 }
+
[dialogs.settings]
width = 600.0
height = 550.0
@@ -1503,6 +1514,7 @@ progress-bar = { height = 6.0, radius = 3.0 }
progress-width = { size = 260.0 }
backdrop-alpha = { opacity = 0.80 }
vertical-gap = { size = 8.0 }
+stall-timeout-sec = { size = 45.0 }
# ---------------------------------------------------------------------------
# First-Run Wizard Screens
diff --git a/src/app.cpp b/src/app.cpp
index 9258b5d..4e87716 100644
--- a/src/app.cpp
+++ b/src/app.cpp
@@ -37,6 +37,7 @@
#include "ui/windows/market_tab.h"
#include "ui/windows/settings_window.h"
#include "ui/windows/about_dialog.h"
+#include "ui/windows/faq_dialog.h"
#include "embedded/IconsMaterialDesign.h"
#include "ui/windows/key_export_dialog.h"
#include "ui/windows/transaction_details_dialog.h"
@@ -68,10 +69,13 @@
#include "ui/material/draw_helpers.h"
#include "ui/widgets/copy_field.h"
#include "ui/notifications.h"
+#include "ui/node_status_banner.h"
#include "util/i18n.h"
+#include "util/connect_stall.h"
#include "util/platform.h"
#include "util/text_format.h"
#include "util/payment_uri.h"
+#include "util/seed_phrase.h"
#include "util/texture_loader.h"
#include "util/svg_texture.h"
#include "ui/material/colors.h"
@@ -104,6 +108,7 @@
#include
#include
#include
+#include
#include
#include
#include
@@ -123,14 +128,63 @@ App::App()
// Seed the auto-balance RNG once per run so weighted-random pool selection isn't
// deterministic across launches.
balance_rng_.seed(std::random_device{}());
+ // Purge any plaintext key export left behind by a crashed/interrupted decrypt flow. (H-02)
+ sweepStaleDecryptExports();
}
App::~App()
{
- // Scrub any seed/phrase secret still resident (e.g. app quit with a backup/migration modal open).
+ wipeSecrets();
+}
+
+// Scrub every resident secret buffer. Idempotent + safe to call from the forced-exit path (main.cpp
+// _Exit bypasses destructors), so key/seed material isn't left in freed heap on the real quit path. (L-05)
+void App::wipeSecrets()
+{
if (!seed_migration_seed_.empty())
sodium_memzero(&seed_migration_seed_[0], seed_migration_seed_.size());
if (!seed_backup_phrase_.empty())
sodium_memzero(&seed_backup_phrase_[0], seed_backup_phrase_.size());
+ sodium_memzero(export_result_, sizeof(export_result_)); // exported WIF/z-key (SECRET)
+ sodium_memzero(import_key_input_, sizeof(import_key_input_)); // pasted private key (SECRET)
+}
+
+// Enqueue a blocking xmrig start/stop op onto the dedicated serialized control thread so the render thread
+// never blocks on stop()'s SIGTERM->SIGKILL->join, while start/stop still execute in FIFO order. (M-03/…)
+void App::postMiningControl(std::function job)
+{
+ {
+ std::lock_guard lk(mining_ctl_mutex_);
+ if (mining_ctl_stop_) return; // shutting down — don't enqueue new mining ops
+ if (!mining_ctl_thread_.joinable()) {
+ mining_ctl_thread_ = std::thread([this]() {
+ for (;;) {
+ std::function j;
+ {
+ std::unique_lock lk(mining_ctl_mutex_);
+ mining_ctl_cv_.wait(lk, [this]{ return mining_ctl_stop_ || !mining_ctl_queue_.empty(); });
+ if (mining_ctl_stop_) return; // abandon any pending jobs on shutdown
+ j = std::move(mining_ctl_queue_.front());
+ mining_ctl_queue_.pop_front();
+ }
+ j();
+ }
+ });
+ }
+ mining_ctl_queue_.push_back(std::move(job));
+ }
+ mining_ctl_cv_.notify_one();
+}
+
+// Signal the mining-control thread to stop and join it. Called at shutdown BEFORE xmrig_manager_ is stopped
+// or destroyed, so no control job runs concurrently with teardown. Idempotent.
+void App::stopMiningControlThread()
+{
+ {
+ std::lock_guard lk(mining_ctl_mutex_);
+ mining_ctl_stop_ = true;
+ }
+ mining_ctl_cv_.notify_all();
+ if (mining_ctl_thread_.joinable()) mining_ctl_thread_.join();
}
namespace {
@@ -152,6 +206,7 @@ void App::updatePoolAutoBalance()
if (!supportsPoolMining()) return; // pool mining is available in both builds (solo is full-node only)
if (settings_->getPoolSelectMode() != config::Settings::PoolSelectMode::AutoBalance) return;
if (!settings_->getPoolMode()) return; // only while POOL mode is selected
+ if (ui::IsMiningBenchmarkActive()) return; // don't auto-switch pools mid-benchmark — it restarts xmrig at the wrong thread count (L-12)
const long long now = steadyNowMs();
const bool intervalDue = (last_balance_eval_ms_ == 0) ||
@@ -340,6 +395,28 @@ bool App::init()
// Ensure ObsidianDragon config directory and template files exist
util::Platform::ensureObsidianDragonSetup();
+ // W1-1 (startup): if the recorded active wallet file was moved/deleted between sessions, don't hand a
+ // missing -wallet= to the daemon — it would auto-create a fresh empty wallet under that name,
+ // silently "opening" as a zero-balance wallet at launch. Fall back to the always-present default and
+ // warn. (The default "wallet.dat" is legitimately absent on first run, so it is skipped.) Runs before
+ // the vault init below so the vault is scoped to the wallet actually opened.
+ if (settings_) {
+ const std::string active = settings_->getActiveWalletFile();
+ if (!active.empty() && active != "wallet.dat") {
+ std::error_code walEc;
+ const std::string walPath = util::Platform::getDragonXDataDir() + "/" + active;
+ if (!std::filesystem::exists(walPath, walEc)) {
+ DEBUG_LOGF("[App] active wallet '%s' not found at startup — falling back to wallet.dat\n",
+ active.c_str());
+ settings_->setActiveWalletFile("wallet.dat");
+ settings_->save();
+ ui::Notifications::instance().warning(
+ std::string(TR("appx_last_used_wallet_not_found_prefix")) + active +
+ TR("appx_last_used_wallet_not_found_suffix"), 20.0f);
+ }
+ }
+ }
+
// Initialize PIN vault, scoped to the active wallet so one wallet's stored passphrase is never
// offered for another (the default wallet keeps the legacy vault.dat).
vault_ = std::make_unique(settings_ ? settings_->getActiveWalletFile() : "");
@@ -663,6 +740,10 @@ void App::update()
{
PERF_SCOPE("Update.Total");
ImGuiIO& io = ImGui::GetIO();
+ // Clamp the frame delta: after a long pause (window minimized, or the machine slept) NewFrame reports
+ // a huge DeltaTime that would fire every refresh/animation timer at once. Every timer reads
+ // io.DeltaTime, so one clamp here bounds them all (also caps the real-clock delta fed while minimized).
+ if (io.DeltaTime > 0.25f) io.DeltaTime = 0.25f;
// Full UI screenshot sweep: demo state is injected once and must stay frozen. Skip every live
// op (refresh/connect/pumps) so a real daemon can't clobber it — on Windows a running node's
@@ -725,7 +806,7 @@ void App::update()
const std::string& err = lite_wallet_->lastOpenError();
if (!err.empty() && err != lite_open_error_) {
lite_open_error_ = err;
- ui::Notifications::instance().error(std::string("Wallet open failed: ") + err, 8.0f);
+ ui::Notifications::instance().error(std::string(TR("appx_wallet_open_failed_prefix")) + err, 8.0f);
}
}
// Suppress the status bar's full-node connection-detail line in lite ("" and "Connected"
@@ -798,12 +879,20 @@ void App::update()
// One-time reminder to back up the wallet's seed phrase (mnemonic wallets only).
maybeRemindSeedBackup();
+ // One-time warning if the active wallet loaded empty while a sibling wallet file holds funds
+ // (a prior/unwitnessed salvage likely moved the coins into a wallet..bak).
+ maybeWarnEmptyWalletWithFundedSiblings();
+
+ // One-time nudge if wallet.dat has bloated past the threshold (toast + clickable alert → consolidate).
+ maybeWarnLargeWallet();
+
// Classify the wallet's mnemonic status (once per connect) so the Migrate-to-seed button can
// glow for a legacy, pre-seed-phrase wallet.
probeWalletSeedStatus();
// Pick up progress/result from a running seed-wallet migration (create/sweep/adopt).
pumpSeedMigration();
+ pumpWalletRestore();
// While confirming the sweep, poll the tx confirmations + legacy balance every ~5s.
if (show_seed_migration_ && seed_migration_step_ == SeedMigrationStep::Confirming) {
seed_migration_poll_timer_ -= ImGui::GetIO().DeltaTime;
@@ -835,7 +924,9 @@ void App::update()
// Re-apply the refresh cadence when sync starts/finishes: while syncing we throttle polling to
// a low-impact profile so RPC contention doesn't slow block download (see applyRefreshPolicy).
- if (state_.sync.syncing != refresh_policy_syncing_) {
+ // effectivelySyncing() includes the post-sync settle window, so this also reverts to the normal
+ // per-tab cadence once that window elapses.
+ if (effectivelySyncing() != refresh_policy_syncing_) {
applyRefreshPolicy(current_page_);
}
@@ -898,7 +989,10 @@ void App::update()
// saw the rescan running. Without the confirmed-active gate, the first
// poll (which hits the still-running pre-restart daemon, rescanning=false)
// would fire a false "complete" the instant rescan was clicked.
- ui::Notifications::instance().success("Blockchain rescan complete");
+ if (user_initiated_rescan_) {
+ ui::Notifications::instance().success(TR("appx_blockchain_rescan_complete"));
+ user_initiated_rescan_ = false; // surfaced once; not for background rebuilds
+ }
resetWitnessRescanProgress();
state_.sync.rescan_progress = 1.0f;
}
@@ -924,13 +1018,27 @@ void App::update()
}
}
+ // Surface an unexpected miner exit (crash / OOM-kill / external SIGKILL) once, and clear the stale
+ // running flag so the UI and auto-balance don't keep believing it's still hashing. (L-03, L-10)
+ if (xmrig_manager_ && state_.pool_mining.xmrig_running
+ && xmrig_manager_->getState() == daemon::XmrigManager::State::Error) {
+ state_.pool_mining.xmrig_running = false;
+ state_.pool_mining.hashrate_10s = 0.0;
+ state_.pool_mining.hashrate_60s = 0.0;
+ state_.pool_mining.hashrate_15m = 0.0;
+ pool_starting_.store(false, std::memory_order_relaxed);
+ const std::string err = xmrig_manager_->getLastError();
+ ui::Notifications::instance().error(err.empty() ? TR("appx_miner_stopped_unexpectedly")
+ : (std::string(TR("appx_miner_stopped_prefix")) + err));
+ }
+
// Poll xmrig stats every ~2 seconds (use a simple toggle)
static bool xmrig_poll_tick = false;
xmrig_poll_tick = !xmrig_poll_tick;
if (xmrig_poll_tick && xmrig_manager_ && xmrig_manager_->isRunning()) {
xmrig_manager_->pollStats();
auto& ps = state_.pool_mining;
- auto& xs = xmrig_manager_->getStats();
+ const auto xs = xmrig_manager_->getStats(); // getStats() now returns a locked copy (M-03)
ps.xmrig_running = true;
ps.hashrate_10s = xs.hashrate_10s;
ps.hashrate_60s = xs.hashrate_60s;
@@ -946,7 +1054,7 @@ void App::update()
// Pool mining has a connect delay — announce once it's actually connected/hashing.
if (pool_starting_.load(std::memory_order_relaxed) && (ps.connected || ps.hashrate_10s > 0.0)) {
pool_starting_.store(false, std::memory_order_relaxed);
- ui::Notifications::instance().success("Pool miner connected and hashing.");
+ ui::Notifications::instance().success(TR("appx_pool_miner_connected_and_hashing"));
}
// Get memory directly from OS (more reliable than API)
double memMB = xmrig_manager_->getMemoryUsageMB();
@@ -987,8 +1095,9 @@ void App::update()
// Apply results directly — we are already on the main thread.
const std::string& status = scan.lastStatus;
if (scan.finished) {
- if (state_.sync.rescanning) {
- ui::Notifications::instance().success("Blockchain rescan complete");
+ if (state_.sync.rescanning && user_initiated_rescan_) {
+ ui::Notifications::instance().success(TR("appx_blockchain_rescan_complete"));
+ user_initiated_rescan_ = false; // surfaced once; not for background rebuilds
}
// Witness rebuild finishes with the rescan it's part of.
resetWitnessRescanProgress();
@@ -1103,6 +1212,7 @@ void App::update()
auto* rpc = (fast_rpc_ && fast_rpc_->isConnected()) ? fast_rpc_.get() : rpc_.get();
if (!rpc) return [this](){ opid_poll_in_progress_ = false; };
json result;
+ services::NetworkRefreshService::OperationStatusPollResult parsed;
try {
rpc::RPCClient::TraceScope trace("Send tab / Operation status");
// No per-opid filter: this daemon rejects z_getoperationstatus(["opid"]) with
@@ -1110,10 +1220,12 @@ void App::update()
// "Waiting for operation". The no-arg form returns ALL operations;
// parseOperationStatusPoll() filters down to the opids we're tracking.
result = rpc->call("z_getoperationstatus", json::array());
+ // Parse INSIDE the guard: a malformed/type-anomalous element must never abort the
+ // poll and leave opid_poll_in_progress_ stuck true for the whole connected session.
+ parsed = services::NetworkRefreshService::parseOperationStatusPoll(result, opids);
} catch (...) {
return [this](){ opid_poll_in_progress_ = false; };
}
- auto parsed = services::NetworkRefreshService::parseOperationStatusPoll(result, opids);
return [this, parsed = std::move(parsed)]() mutable {
opid_poll_in_progress_ = false;
@@ -1190,7 +1302,7 @@ void App::update()
!runtime_rescan_active_ && !bootstrap_downloading_ &&
state_.sync.blocks > 1) { // wait until the tip is known so the probe has a real range
post_bootstrap_rescan_pending_ = false;
- ui::Notifications::instance().info("Bootstrap complete — reconciling your wallet with the new chain data.");
+ ui::Notifications::instance().info(TR("appx_bootstrap_complete_reconciling"));
detectLowestAvailableBlockHeight([this](bool ok, int lowest, bool fullHistory) {
if (ok && !fullHistory) {
runtimeRescan(lowest); // bootstrapped/pruned: rescan from the snapshot base
@@ -1212,7 +1324,16 @@ void App::update()
if (!state_.warming_up && !runtime_rescan_active_) {
if (network_refresh_.consumeDue(RefreshTimer::Transactions)) {
if (shouldRunWalletTransactionRefresh() && shouldRefreshTransactions()) {
- refreshTransactionData();
+ // Throttle the routine new-block full history rescan (z_listreceivedbyaddress —
+ // O(mapWallet), holds cs_main) by its measured cost, so a large wallet doesn't
+ // re-scan every block and starve connection near the tip. Bypass when there's an
+ // explicit need: a dirty set, an in-progress multi-cycle shielded scan (which must
+ // continue to completion), or an in-flight send — all need fresh data immediately.
+ const bool txExplicitNeed = transactions_dirty_ || shielded_history_scan_pending_ ||
+ hasTransactionSendProgress() || !send_txids_.empty();
+ if (txExplicitNeed || txRefreshDue()) {
+ refreshTransactionData();
+ }
} else if (walletDataPage && shouldRefreshRecentTransactions()) {
refreshRecentTransactionData();
}
@@ -1228,7 +1349,13 @@ void App::update()
fastScanChatMemos();
}
if (network_refresh_.consumeDue(RefreshTimer::Addresses)) {
- if (walletDataPage || addresses_dirty_ || hasTransactionSendProgress()) {
+ // Explicit need (a changed address set, or an in-flight send tracking its change output)
+ // must refresh now; the routine periodic poll on a wallet page is throttled by the last
+ // address scan's measured cost (addressRefreshDue) — this is the z_listunspent hammering
+ // that was starving cs_main while synced on a large wallet.
+ if (addresses_dirty_ || hasTransactionSendProgress()) {
+ refreshAddressData();
+ } else if (walletDataPage && addressRefreshDue()) {
refreshAddressData();
}
}
@@ -1285,7 +1412,7 @@ void App::handleGlobalShortcuts()
settings_->setSkinId(skins[cur].id);
settings_->save();
}
- ui::Notifications::instance().info("Theme: " + skins[cur].name);
+ ui::Notifications::instance().info(std::string(TR("appx_theme_prefix")) + skins[cur].name);
}
}
}
@@ -1322,7 +1449,7 @@ void App::handleGlobalShortcuts()
ui::effects::ThemeEffects::instance().setReducedTransparency(!settings_->getThemeEffectsEnabled());
}
}
- ui::Notifications::instance().info(newLow ? "Low-spec mode enabled" : "Low-spec mode disabled");
+ ui::Notifications::instance().info(newLow ? TR("appx_low_spec_mode_enabled") : TR("appx_low_spec_mode_disabled"));
}
// Keyboard shortcut: Ctrl+Down to toggle theme effects (Shift excluded)
@@ -1334,7 +1461,7 @@ void App::handleGlobalShortcuts()
settings_->setThemeEffectsEnabled(newState);
settings_->save();
}
- ui::Notifications::instance().info(newState ? "Theme effects enabled" : "Theme effects disabled");
+ ui::Notifications::instance().info(newState ? TR("appx_theme_effects_enabled") : TR("appx_theme_effects_disabled"));
}
// Keyboard shortcut: Ctrl+Up to toggle simple gradient background
@@ -1343,7 +1470,7 @@ void App::handleGlobalShortcuts()
settings_->setGradientBackground(newGrad);
ui::schema::SkinManager::instance().setGradientMode(newGrad);
settings_->save();
- ui::Notifications::instance().info(newGrad ? "Simple background enabled" : "Simple background disabled");
+ ui::Notifications::instance().info(newGrad ? TR("appx_simple_background_enabled") : TR("appx_simple_background_disabled"));
}
// Debug: Ctrl+Shift+W to re-show first-run wizard (set to false to disable)
@@ -1404,16 +1531,11 @@ void App::ensureLogoTexture()
}
}
- // 0) DragonX mark — rasterize the embedded SVG recolored to the theme (body = accent, detail = white)
- // at ~2x the 128px viewBox for crisp downscaling. This is the branding on every skin; the per-skin
- // PNG path below is only a fallback if rasterization ever fails.
- if (util::LoadTextureFromSvg(embedded::kLogoDragonXSvg, 256, logoAccent,
- detailCol, &logo_tex_, &logo_w_, &logo_h_)) {
- DEBUG_LOGF("Rendered DragonX SVG logo (%dx%d, accent %08X)\n", logo_w_, logo_h_, logoAccent);
- return;
- }
+ // The header / top-left / About branding is the ObsidianDragon PRODUCT logo — NOT the DragonX coin
+ // mark (that is coin_logo_tex_ / drgx_emoji_tex_ above). Resolve it below: active-skin override, else
+ // the ui.toml header-icon, else the bundled ObsidianDragon dark/light PNG (disk, then embedded).
- // 1) Fallback — theme-override logo from the active skin
+ // 1) theme-override logo from the active skin
const auto* activeSkin = ui::schema::SkinManager::instance().findById(
ui::schema::SkinManager::instance().activeSkinId());
std::string logoPath;
@@ -1489,8 +1611,9 @@ void App::render()
{
int deletedN = pending_delete_result_.exchange(-1, std::memory_order_relaxed);
if (deletedN >= 0) {
- ui::Notifications::instance().success("Blockchain data deleted (" + std::to_string(deletedN) +
- " items). The daemon is restarting to re-sync from the network.");
+ char deletedMsg[160];
+ snprintf(deletedMsg, sizeof(deletedMsg), TR("appx_blockchain_data_deleted"), deletedN);
+ ui::Notifications::instance().success(deletedMsg);
}
}
@@ -1614,8 +1737,10 @@ void App::render()
float v = ui::schema::UI().drawElement("components.sidebar", key).size;
return (v >= 0 ? v : fb) * dp;
};
- float statusBarH = ui::schema::UI().window("components.status-bar").height;
- if (statusBarH <= 0.0f) statusBarH = 24.0f; // safety fallback
+ // Scale by dp to match the rendered status-bar child height (renderStatusBar), so the reserved
+ // content strip stays in step with the bar at HiDPI instead of under-reserving.
+ float statusBarH = ui::schema::UI().window("components.status-bar").height * dp;
+ if (statusBarH <= 0.0f) statusBarH = 24.0f * dp; // safety fallback
// Content area padding from ui.toml schema
const auto& caWin = ui::schema::UI().window("components.content-area");
const float caMarginTop = ui::schema::UI().drawElement("components.content-area", "margin-top").size;
@@ -1681,7 +1806,10 @@ void App::render()
// long confirmed (the other leg holds the real count). So: a txid with ANY confirmed leg is
// confirmed, and we count UNIQUE unconfirmed txids — otherwise the badge sticks on stale 0-conf
// legs of already-confirmed transactions and double-counts multi-leg ones.
- {
+ // Recompute only when the tx list actually changed (last_tx_update is bumped on every tx refresh;
+ // size() catches same-second content changes). Otherwise this built two unordered_sets over the whole
+ // wallet tx list every frame just to size a badge.
+ if (state_.last_tx_update != sb_unconf_key_ts_ || state_.transactions.size() != sb_unconf_key_n_) {
std::unordered_set confirmedTxids;
for (const auto& tx : state_.transactions) {
if (tx.confirmations >= 1) confirmedTxids.insert(tx.txid);
@@ -1692,35 +1820,27 @@ void App::render()
unconfirmedTxids.insert(tx.txid);
}
}
- sbStatus.unconfirmedTxCount = static_cast(unconfirmedTxids.size());
+ sb_unconf_count_ = static_cast(unconfirmedTxids.size());
+ sb_unconf_key_ts_ = state_.last_tx_update;
+ sb_unconf_key_n_ = state_.transactions.size();
}
+ sbStatus.unconfirmedTxCount = sb_unconf_count_;
- // Sidebar margins from ui.toml schema (DPI-scaled like all sidebar values)
- const float sbMarginTop = sbde("margin-top", 0.0f);
- const float sbMarginBottom = sbde("margin-bottom", 0.0f);
- const float sbMinHeight = sbde("min-height", 360.0f);
+ // Sidebar minimum height from ui.toml schema (DPI-scaled).
+ const float sbMinHeight = sbde("min-height", 360.0f);
- // Ensure sidebar is tall enough to fit all buttons — shrink margins if needed
- float sidebarH = contentH - sbMarginTop - sbMarginBottom;
- float effectiveMarginTop = sbMarginTop;
- if (sidebarH < sbMinHeight) {
- float available = contentH - sbMinHeight;
- if (available > 0.0f) {
- float ratio = available / (sbMarginTop + sbMarginBottom);
- effectiveMarginTop = sbMarginTop * ratio;
- } else {
- effectiveMarginTop = 0.0f;
- }
- sidebarH = std::max(contentH - effectiveMarginTop, sbMinHeight);
- }
-
- // Sidebar navigation
- // Save cursor Y before applying sidebar margin so the content area
- // (placed via SameLine) starts at the original row position, not the
- // margin-shifted one.
+ // Save cursor Y before the sidebar so the content area (restored below) starts at the original row.
float preSidebarCursorY = ImGui::GetCursorPosY();
- if (effectiveMarginTop > 0.0f)
- ImGui::SetCursorPosY(preSidebarCursorY + effectiveMarginTop);
+
+ // Size the sidebar to span from its own top down to the status-bar top, so the nav panel centers
+ // within the TRUE visible area (equal top/bottom gaps). Do NOT derive it from contentH (inset by the
+ // content-area's edge-fade margins) and do NOT apply the legacy sidebar margin-top/-bottom (they are
+ // asymmetric, -12 / +40, and pushed the panel upward). Window-local reference (matches how the status
+ // bar is positioned — GetWindowPos/GetWindowSize, not GetMainViewport) so it is correct on every platform.
+ float sbWindowBottom = ImGui::GetWindowPos().y + ImGui::GetWindowSize().y;
+ float sbStatusTopY = sbWindowBottom - statusBarH - mainPadBot;
+ float sbChildTopY = ImGui::GetCursorScreenPos().y;
+ float sidebarH = std::max(sbMinHeight, sbStatusTopY - sbChildTopY);
bool prevCollapsed = sidebar_collapsed_;
{
PERF_SCOPE("Render.Sidebar");
@@ -1745,6 +1865,16 @@ void App::render()
// Page transition: detect change, ramp alpha
if (current_page_ != prev_page_) {
page_alpha_ = (ui::effects::isLowSpecMode() || (settings_ && settings_->getReduceMotion())) ? 1.0f : 0.0f;
+ // Switching INTO the console → put the cursor in the command box (toggleable). Done here at the
+ // transition (not in the console render) because prev_page_ is updated below; the console renders
+ // later this same frame and consumes the one-shot request.
+ if ((current_page_ == ui::NavPage::Console || current_page_ == ui::NavPage::LiteConsole)
+ && settings_ && settings_->getConsoleAutoFocus())
+ console_tab_.requestInputFocus();
+ // Leaving the Mining tab → cancel a running thread benchmark so the miner isn't abandoned at a
+ // benchmark step. (L-04)
+ if (prev_page_ == ui::NavPage::Mining && current_page_ != ui::NavPage::Mining)
+ ui::CancelMiningBenchmark(this);
prev_page_ = current_page_;
}
if (page_alpha_ < 1.0f) {
@@ -1765,10 +1895,26 @@ void App::render()
float caPadY = caWin.padding[1] > 0.0f ? caWin.padding[1] : ImGui::GetStyle().WindowPadding.y;
ImGui::PushStyleVar(ImGuiStyleVar_WindowPadding, ImVec2(caPadX, caPadY));
- // Capture content area screen position for edge fade mask
+ // Cap the content column to a readable max width and center it in wider windows. Every tab derives
+ // its cards/forms/tables from this child's width, so an uncapped fill stretches them edge-to-edge on
+ // wide/ultrawide displays. No-op below the cap (fills as before).
+ float caAvailW = ImGui::GetContentRegionAvail().x;
+ float caMaxW = ui::Layout::kContentMaxWidth();
+ float caChildW = 0.0f; // 0 -> fill remaining width (default, and whenever the window is below the cap)
+ if (caMaxW > 0.0f && caAvailW > caMaxW) {
+ caChildW = caMaxW;
+ ImGui::SetCursorPosX(ImGui::GetCursorPosX() + (caAvailW - caMaxW) * 0.5f);
+ }
+
+ // Capture content area screen position for edge fade mask (after any centering offset)
ImVec2 caScreenPos = ImGui::GetCursorScreenPos();
- ImGui::BeginChild("##ContentArea", ImVec2(0, contentH), false, contentFlags);
+ ImGui::BeginChild("##ContentArea", ImVec2(caChildW, contentH), false, contentFlags);
+
+ // Persistent node/RPC error banner — drawn first (before the edge-fade vertex capture below,
+ // so it stays fully opaque) and above every page / overlay in the content column. It renders
+ // nothing and consumes no space while the node is reachable.
+ renderNodeStatusBanner();
// Capture vertex start for edge fade mask
ImDrawList* caDL = ImGui::GetWindowDrawList();
@@ -2015,6 +2161,10 @@ void App::render()
ui::RenderAboutDialog(this, &show_about_);
}
+ if (show_faq_) {
+ ui::RenderFaqDialog(this, &show_faq_);
+ }
+
// Lite first-run welcome: prompt to create/restore when no wallet file exists yet.
renderLiteFirstRunPrompt();
// Lite send-time unlock prompt (shown when a spend is attempted on a locked wallet).
@@ -2109,6 +2259,10 @@ void App::render()
renderDecryptWalletDialog();
renderPinDialogs();
renderSwitchStopDaemonDialog();
+ renderDaemonStopConfirm();
+ renderBlockDbReindexDialog();
+ renderWalletRecoveredDialog();
+ renderEmptyWalletWarningDialog();
// Render notifications (toast messages)
ui::Notifications::instance().render();
@@ -2118,17 +2272,276 @@ void App::render()
ui::material::LatchBlurOverlayActive();
}
+void App::renderNodeStatusBanner()
+{
+ namespace m = ui::material;
+
+ // Suppress during flows that legitimately have no connection, so the banner never contradicts
+ // an overlay the app is already showing: the first-run wizard (no daemon started yet), a
+ // wallet switch, an in-flight daemon restart, the screenshot sweep (forces demo state), and
+ // shutdown. tryConnect() sets connection_in_progress_ before the first render on normal
+ // startup, so the ordinary boot path is covered by the evaluator's own in-progress guard.
+ if (capture_mode_ || isShuttingDown()) return;
+ if (getWizardPhase() != WizardPhase::None) return;
+ if (wallet_switch_phase_.load() != 0) return;
+ if (daemon_restarting_.load()) return;
+
+ ui::NodeBannerInputs in;
+ in.lite = isLiteBuild();
+ in.connected = state_.connected;
+ in.warming_up = state_.warming_up;
+ in.daemon_initializing = state_.daemon_initializing;
+ in.connection_in_progress = connection_in_progress_;
+ in.using_embedded_daemon = isUsingEmbeddedDaemon();
+ in.has_daemon_controller = (daemon_controller_ != nullptr);
+ in.daemon_running = isEmbeddedDaemonRunning();
+ in.daemon_crash_count = daemon_controller_ ? daemon_controller_->crashCount() : 0;
+ in.connection_status = connection_status_;
+ in.daemon_last_error = daemon_controller_ ? daemon_controller_->lastError() : std::string();
+ in.lite_open_error = lite_open_error_;
+
+ const ui::NodeBannerState banner = ui::evaluateNodeStatusBanner(in);
+ if (!banner.show) return;
+
+ const auto& S = ui::schema::UI();
+ const float minH = S.drawElement("banners.node-status", "min-height").size;
+ const float baseH = S.drawElement("banners.node-status", "height").size;
+
+ // Fonts up-front so the banner height can never be shorter than the glyph row it vertically
+ // centers — otherwise the title/icon draw above the child's top clip rect and slice off (seen at
+ // HiDPI / large font scale, where the DPI-baked glyphs outgrow the schema height). Metrics scaled.
+ ImFont* icoFont = m::Type().iconSmall();
+ ImFont* txtFont = m::Type().body2();
+ const float glyphH = std::max(icoFont ? icoFont->LegacySize : 0.0f,
+ txtFont ? txtFont->LegacySize : 0.0f);
+ // Both operands must be in scaled px: vScale() already folds in dpiScale(), so the raw min-height
+ // floor needs the same dpiScale() or it under-clamps the banner at HiDPI.
+ float bannerH = std::max(minH * ui::Layout::dpiScale(), baseH * ui::Layout::vScale());
+ bannerH = std::max(bannerH, glyphH + ui::Layout::spacingSm() * 2.0f); // never shorter than text
+
+ const bool isError = (banner.severity == ui::NodeBannerSeverity::Error);
+ const ImU32 sevCol = isError ? m::Error() : m::Warning();
+ const ImU32 bgCol = m::WithAlphaF(sevCol, isError ? 0.20f : 0.15f);
+
+ // Translated headline for the reason; `detail` is the live status text (may be empty).
+ const char* title;
+ const char* icon;
+ switch (banner.reason) {
+ case ui::NodeBannerReason::DaemonCrashed:
+ title = TR("node_banner_crashed_title"); icon = ICON_MD_ERROR; break;
+ case ui::NodeBannerReason::LiteOpenFailed:
+ title = TR("node_banner_lite_open_failed"); icon = ICON_MD_ERROR; break;
+ case ui::NodeBannerReason::FullNodeOffline:
+ default:
+ title = TR("node_banner_offline_title"); icon = ICON_MD_CLOUD_OFF; break;
+ }
+
+ const char* actionLabel = nullptr;
+ if (banner.action == ui::NodeBannerAction::Reconnect) actionLabel = TR("node_banner_reconnect");
+ else if (banner.action == ui::NodeBannerAction::RestartNode) actionLabel = TR("node_banner_restart");
+
+ const float padX = ui::Layout::spacingLg();
+
+ ImGui::PushStyleColor(ImGuiCol_ChildBg, ImGui::ColorConvertU32ToFloat4(bgCol));
+ ImGui::BeginChild("##NodeStatusBanner",
+ ImVec2(ImGui::GetContentRegionAvail().x, bannerH), false,
+ ImGuiWindowFlags_NoScrollbar | ImGuiWindowFlags_NoScrollWithMouse);
+ const float winW = ImGui::GetWindowSize().x;
+
+ // Icon — centered on its own metrics (clamped so it never draws above the child's top).
+ ImGui::SetCursorPos(ImVec2(padX, std::max(0.0f, (bannerH - icoFont->LegacySize) * 0.5f)));
+ ImGui::PushFont(icoFont);
+ ImGui::PushStyleColor(ImGuiCol_Text, sevCol);
+ ImGui::TextUnformatted(icon);
+ ImGui::PopStyleColor();
+ ImGui::PopFont();
+
+ const float txtCy = std::max(0.0f, (bannerH - txtFont->LegacySize) * 0.5f);
+
+ // Right-aligned action button geometry (measured first so the detail text can be clipped to
+ // never run underneath it).
+ float btnW = 0.0f, btnH = 0.0f, actionReserve = 0.0f;
+ if (actionLabel) {
+ btnH = std::max(0.0f, bannerH - ui::Layout::spacingSm() * 2.0f);
+ btnW = ImGui::CalcTextSize(actionLabel).x + ui::Layout::spacingLg() * 1.6f;
+ actionReserve = btnW + padX + ui::Layout::spacingMd();
+ }
+
+ // Title.
+ ImGui::SameLine(0.0f, ui::Layout::spacingSm());
+ ImGui::SetCursorPosY(txtCy);
+ ImGui::PushFont(txtFont);
+ ImGui::PushStyleColor(ImGuiCol_Text, sevCol);
+ ImGui::TextUnformatted(title);
+ ImGui::PopStyleColor();
+
+ // Detail (dim) on the same row, clipped with an ellipsis so it can't push the button off-screen.
+ if (!banner.detail.empty()) {
+ ImGui::SameLine(0.0f, ui::Layout::spacingSm());
+ ImGui::SetCursorPosY(txtCy);
+ const float budget = winW - ImGui::GetCursorPosX() - actionReserve;
+ if (budget > ImGui::CalcTextSize("W").x) {
+ const std::string prefix = "\xC2\xB7 "; // "· "
+ std::string detail = banner.detail;
+ std::string shown = prefix + detail;
+ if (ImGui::CalcTextSize(shown.c_str()).x > budget) {
+ const std::string ell = "\xE2\x80\xA6"; // "…"
+ while (!detail.empty() &&
+ ImGui::CalcTextSize((prefix + detail + ell).c_str()).x > budget) {
+ detail.pop_back();
+ while (!detail.empty() &&
+ (static_cast(detail.back()) & 0xC0) == 0x80)
+ detail.pop_back(); // drop the whole trailing UTF-8 code point
+ }
+ shown = prefix + detail + ell;
+ }
+ ImGui::PushStyleColor(ImGuiCol_Text, m::OnSurfaceMedium());
+ ImGui::TextUnformatted(shown.c_str());
+ ImGui::PopStyleColor();
+ }
+ }
+ ImGui::PopFont();
+
+ // Action button.
+ if (actionLabel) {
+ ImGui::SetCursorPos(ImVec2(winW - btnW - padX, std::max(0.0f, (bannerH - btnH) * 0.5f)));
+ if (m::TactileButton(actionLabel, ImVec2(btnW, btnH))) {
+ if (banner.action == ui::NodeBannerAction::RestartNode) restartDaemon();
+ else if (banner.action == ui::NodeBannerAction::Reconnect) tryConnect();
+ }
+ }
+
+ ImGui::EndChild();
+ ImGui::PopStyleColor();
+}
+
+void App::renderAlertHistoryPanel()
+{
+ namespace m = ui::material;
+ const float dp = ui::Layout::dpiScale();
+ auto& notes = ui::Notifications::instance();
+ const auto& hist = notes.history();
+ const float innerW = ImGui::GetContentRegionAvail().x;
+ const float padX = 8.0f * dp;
+ const float padY = 8.0f * dp;
+
+ ImFont* icoF = m::Type().iconSmall();
+ ImFont* txtF = m::Type().caption();
+
+ ImGui::Dummy(ImVec2(0.0f, padY)); // breathing room above the content
+
+ // Header: "Recent alerts" on the left, a Clear-all icon button on the right.
+ ImGui::SetCursorPosX(padX);
+ ImGui::PushFont(txtF);
+ ImGui::TextDisabled("%s", TR("alerts_recent"));
+ ImGui::PopFont();
+ if (!hist.empty()) {
+ const float clrW = icoF->LegacySize + 8.0f * dp;
+ ImGui::SameLine();
+ ImGui::SetCursorPosX(innerW - clrW);
+ m::IconButtonStyle cst;
+ cst.color = m::OnSurfaceMedium();
+ cst.hoverColor = m::OnSurface();
+ cst.hoverBg = m::StateHover();
+ cst.bgRounding = 4.0f * dp;
+ cst.tooltip = TR("alerts_clear");
+ if (m::IconButton("##ClearAlerts", ICON_MD_CLEAR_ALL, icoF,
+ ImVec2(clrW, icoF->LegacySize + 4.0f * dp), cst)) {
+ notes.clearHistory();
+ alerts_seen_total_ = notes.totalPushed();
+ }
+ }
+ ImGui::Separator();
+
+ if (hist.empty()) {
+ ImGui::SetCursorPosX(padX);
+ ImGui::PushFont(txtF);
+ ImGui::TextDisabled("%s", TR("alerts_none"));
+ ImGui::PopFont();
+ ImGui::Dummy(ImVec2(0.0f, padY)); // breathing room below the content
+ return;
+ }
+
+ // Scrollable list, newest first. Measure the TRUE content height so wrapped (multi-line) messages
+ // and optional action links aren't clipped by an under-estimate; cap so a busy session scrolls
+ // inside the panel instead of blowing past the popup's max height.
+ const float msgWrapW = std::max(40.0f * dp, innerW - 2.0f * padX - icoF->LegacySize - 6.0f * dp);
+ float contentH = 0.0f;
+ for (const auto& a : hist) {
+ const float msgH = txtF->CalcTextSizeA(txtF->LegacySize, FLT_MAX, msgWrapW, a.message.c_str()).y;
+ contentH += std::max(msgH, static_cast(icoF->LegacySize)); // icon + wrapped message
+ contentH += txtF->LegacySize; // relative-age line
+ if (a.onClick && !a.actionHint.empty()) contentH += txtF->LegacySize; // action-link line
+ contentH += 8.0f * dp; // inter-entry spacing
+ }
+ const float listH = std::min(300.0f * dp, contentH);
+ ImGui::BeginChild("##AlertRows", ImVec2(0, listH), false);
+ int idx = 0;
+ for (auto it = hist.rbegin(); it != hist.rend(); ++it, ++idx) {
+ const ui::AlertRecord& a = *it;
+ ImU32 col; const char* icon;
+ switch (a.type) {
+ case ui::NotificationType::Success: col = m::Success(); icon = ICON_MD_CHECK_CIRCLE; break;
+ case ui::NotificationType::Warning: col = m::Warning(); icon = ICON_MD_WARNING; break;
+ case ui::NotificationType::Error: col = m::Error(); icon = ICON_MD_ERROR; break;
+ case ui::NotificationType::Info:
+ default: col = m::Primary(); icon = ICON_MD_INFO; break;
+ }
+ ImGui::PushID(idx);
+ // Icon + message (message wraps in the remaining width).
+ ImGui::SetCursorPosX(padX);
+ ImGui::PushFont(icoF);
+ ImGui::PushStyleColor(ImGuiCol_Text, col);
+ ImGui::TextUnformatted(icon);
+ ImGui::PopStyleColor();
+ ImGui::PopFont();
+ ImGui::SameLine(0.0f, 6.0f * dp);
+ ImGui::PushFont(txtF);
+ ImGui::PushStyleColor(ImGuiCol_Text, m::OnSurface());
+ ImGui::PushTextWrapPos(innerW - padX);
+ ImGui::TextWrapped("%s", a.message.c_str());
+ ImGui::PopTextWrapPos();
+ ImGui::PopStyleColor();
+ // Optional clickable action (accent link), directly under the message so it stays prominent.
+ if (a.onClick && !a.actionHint.empty()) {
+ ImGui::SetCursorPosX(padX + icoF->LegacySize + 6.0f * dp);
+ ImGui::PushStyleColor(ImGuiCol_Text, m::Primary());
+ ImGui::TextUnformatted(a.actionHint.c_str());
+ ImGui::PopStyleColor();
+ if (ImGui::IsItemHovered()) {
+ const ImVec2 lmn = ImGui::GetItemRectMin(), lmx = ImGui::GetItemRectMax();
+ ImGui::GetWindowDrawList()->AddLine(ImVec2(lmn.x, lmx.y), ImVec2(lmx.x, lmx.y), m::Primary());
+ ImGui::SetMouseCursor(ImGuiMouseCursor_Hand);
+ }
+ if (ImGui::IsItemClicked()) { a.onClick(); ImGui::CloseCurrentPopup(); }
+ }
+ // Relative age, dim, indented under the message.
+ ImGui::SetCursorPosX(padX + icoF->LegacySize + 6.0f * dp);
+ ImGui::PushStyleColor(ImGuiCol_Text, m::OnSurfaceDisabled());
+ ImGui::TextUnformatted(util::formatTimeAgoShort(a.epoch).c_str());
+ ImGui::PopStyleColor();
+ ImGui::PopFont();
+ ImGui::PopID();
+ ImGui::Spacing();
+ }
+ ImGui::EndChild();
+ ImGui::Dummy(ImVec2(0.0f, padY)); // breathing room below the content
+}
+
void App::renderStatusBar()
{
// Status bar layout from unified UI schema
const auto& S = ui::schema::UI();
const auto& sbWin = S.window("components.status-bar");
- const float sbHeight = sbWin.height;
- const float sbPadX = sbWin.padding[0];
- const float sbPadY = sbWin.padding[1];
- const float sbIconTextGap = S.drawElement("components.status-bar", "icon-text-gap").size;
- const float sbSectionGap = S.drawElement("components.status-bar", "section-gap").size;
- const float sbSeparatorGap = S.drawElement("components.status-bar", "separator-gap").size;
+ // Schema values are logical px; the fonts/icons drawn inside are DPI-baked, so the box and its
+ // gaps must be scaled by the same factor or they clip the text at HiDPI / font_scale > 1.
+ const float dp = ui::Layout::dpiScale();
+ const float sbHeight = sbWin.height * dp;
+ const float sbPadX = sbWin.padding[0] * dp;
+ const float sbPadY = sbWin.padding[1] * dp;
+ const float sbIconTextGap = S.drawElement("components.status-bar", "icon-text-gap").size * dp;
+ const float sbSectionGap = S.drawElement("components.status-bar", "section-gap").size * dp;
+ const float sbSeparatorGap = S.drawElement("components.status-bar", "separator-gap").size * dp;
ImGuiWindowFlags childFlags = ImGuiWindowFlags_NoScrollbar |
ImGuiWindowFlags_NoScrollWithMouse;
@@ -2360,6 +2773,8 @@ void App::renderStatusBar()
// Compute positions dynamically from actual text widths so they
// never overlap and always stay within the window at any font scale.
{
+ // Where the left-side chain ended, so the right cluster never SameLine()s backward onto it.
+ const float leftEndX = ImGui::GetCursorPosX();
char versionBuf[32];
snprintf(versionBuf, sizeof(versionBuf), "v%s", DRAGONX_VERSION);
float versionW = ImGui::CalcTextSize(versionBuf).x;
@@ -2371,10 +2786,36 @@ void App::renderStatusBar()
float gap = sbSectionGap;
float occupiedX = versionX; // leftmost X used by the version + connection status so far
if (!connection_status_.empty() && connection_status_ != "Connected") {
- float statusW = ImGui::CalcTextSize(connection_status_.c_str()).x;
+ // During a post-repair rescan the raw status is a scary "RPC request failed: Timeout" — show a
+ // calm line instead (the rescan legitimately can't answer RPC yet).
+ const std::string statusBase = post_recovery_rescan_ ? std::string(TR("sb_finishing_repair"))
+ : connection_status_;
+ // Middle-ellipsize to the space between the left chain and the version so a long status
+ // (e.g. "Wallet needs recovery — see the prompt") can't push off-screen or overprint the
+ // left chain; then clamp its start so it never crosses left of where the chain ended.
+ float availW = versionX - leftEndX - gap * 2.0f;
+ if (availW < 24.0f * dp) availW = 24.0f * dp;
+ ImFont* stFont = ImGui::GetFont();
+ std::string statusShown = ui::material::TruncateToWidth(
+ statusBase, stFont, stFont->LegacySize, availW);
+ float statusW = ImGui::CalcTextSize(statusShown.c_str()).x;
float statusX = versionX - statusW - gap;
+ if (statusX < leftEndX + gap) statusX = leftEndX + gap;
ImGui::SameLine(statusX);
- ImGui::TextDisabled("%s", connection_status_.c_str());
+ if (wallet_auto_recovered_ && !post_recovery_rescan_) {
+ // Actionable re-entry: a full-opacity accent chip that reopens the recovery dialog.
+ // Dismiss is non-destructive, so this is the guaranteed way back to the prompt.
+ ImGui::PushStyleColor(ImGuiCol_Text, ui::material::Warning());
+ ImGui::TextUnformatted(statusShown.c_str());
+ ImGui::PopStyleColor();
+ if (ImGui::IsItemHovered()) ImGui::SetMouseCursor(ImGuiMouseCursor_Hand);
+ if (ImGui::IsItemClicked() && recovery_phase_ != RecoveryPhase::Working) {
+ show_wallet_recovered_dialog_ = true; // reopen the (non-destructively dismissed) prompt
+ recovery_phase_ = RecoveryPhase::Offer;
+ }
+ } else {
+ ImGui::TextDisabled("%s", statusShown.c_str());
+ }
occupiedX = statusX;
} else if (!daemon_status_.empty() && daemon_status_.find("Error") != std::string::npos) {
const char* errText = TR("sb_daemon_not_found");
@@ -2394,9 +2835,104 @@ void App::renderStatusBar()
float cbX = occupiedX - cbW - gap;
ImGui::SameLine(cbX);
ImGui::TextUnformatted(cb.c_str());
+ occupiedX = cbX;
}
}
+ // Alert-history bell — leftmost item of the right cluster. Opens a panel of recent alerts,
+ // including ones whose toast already faded; an unread dot marks alerts that arrived since
+ // the panel was last opened.
+ {
+ auto& notes = ui::Notifications::instance();
+ ImFont* bellFont = ui::material::Type().iconSmall();
+ const bool anyHist = notes.hasHistory();
+ const char* bellGlyph = anyHist ? ICON_MD_NOTIFICATIONS : ICON_MD_NOTIFICATIONS_NONE;
+
+ ImGui::PushFont(bellFont);
+ const float glyphW = ImGui::CalcTextSize(bellGlyph).x;
+ ImGui::PopFont();
+ const float bellW = glyphW + 10.0f * dp;
+ const float bellH = bellFont->LegacySize + 4.0f * dp;
+ const float bellX = occupiedX - bellW - gap;
+
+ ImGui::SameLine(bellX);
+ ui::material::IconButtonStyle st;
+ st.color = ui::material::OnSurfaceMedium();
+ st.hoverColor = ui::material::OnSurface();
+ st.hoverBg = ui::material::StateHover();
+ st.bgRounding = 4.0f * dp;
+ st.tooltip = TR("alerts_history_tooltip");
+ const bool clicked = ui::material::IconButton("##AlertBell", bellGlyph, bellFont,
+ ImVec2(bellW, bellH), st);
+ const ImVec2 bellMin = ImGui::GetItemRectMin();
+ const ImVec2 bellMax = ImGui::GetItemRectMax();
+
+ // Unread dot: alerts pushed since the panel was last opened, coloured by the most
+ // severe unseen alert. totalPushed() is monotonic, so this survives capping/clearing.
+ const std::uint64_t unseen = notes.totalPushed() - alerts_seen_total_;
+ if (unseen > 0 && anyHist) {
+ const auto& h = notes.history();
+ size_t scan = (unseen < h.size()) ? static_cast(unseen) : h.size();
+ bool anyErr = false, anyWarn = false;
+ for (size_t i = 0; i < scan; ++i) {
+ auto t = h[h.size() - 1 - i].type;
+ if (t == ui::NotificationType::Error) { anyErr = true; break; }
+ if (t == ui::NotificationType::Warning) anyWarn = true;
+ }
+ ImU32 dotCol = anyErr ? ui::material::Error()
+ : anyWarn ? ui::material::Warning()
+ : ui::material::Primary();
+ const float r = 3.0f * dp;
+ ImGui::GetWindowDrawList()->AddCircleFilled(
+ ImVec2(bellMax.x - r, bellMin.y + r), r, dotCol);
+ }
+
+ if (clicked) {
+ alerts_seen_total_ = notes.totalPushed(); // mark everything currently shown as seen
+ ImGui::OpenPopup("##AlertHistoryPopup");
+ }
+
+ // The bell sits near the window's bottom-right, so anchor the popup's bottom-RIGHT
+ // corner at the bell's right edge (pivot (1,1)) — it then grows LEFT over the canvas and
+ // UP from the status bar. A left pivot would push a 320px panel off the right edge (and
+ // an explicit SetNextWindowPos pivot skips ImGui's on-screen clamp, so it would overflow).
+ ImGui::SetNextWindowPos(ImVec2(bellMax.x, bellMin.y - 4.0f * dp),
+ ImGuiCond_Always, ImVec2(1.0f, 1.0f));
+ const float panelW = 320.0f * dp;
+ ImGui::SetNextWindowSizeConstraints(ImVec2(panelW, 0), ImVec2(panelW, 360.0f * dp));
+ if (ImGui::BeginPopup("##AlertHistoryPopup")) {
+ renderAlertHistoryPanel();
+ ImGui::EndPopup();
+ }
+
+ occupiedX = bellX;
+ }
+
+ // Help (?) — sits just left of the alert bell, on every platform. A plain, subtle question
+ // mark (no circle) that opens the FAQ.
+ {
+ ImFont* helpFont = ui::material::Type().iconSmall();
+ ImGui::PushFont(helpFont);
+ const float helpGlyphW = ImGui::CalcTextSize(ICON_MD_QUESTION_MARK).x;
+ ImGui::PopFont();
+ const float helpW = helpGlyphW + 10.0f * dp;
+ const float helpH = helpFont->LegacySize + 4.0f * dp;
+ const float helpX = occupiedX - helpW - gap;
+
+ ImGui::SameLine(helpX);
+ ui::material::IconButtonStyle hst;
+ hst.color = ui::material::OnSurfaceMedium();
+ hst.hoverColor = ui::material::OnSurface();
+ hst.hoverBg = ui::material::StateHover();
+ hst.bgRounding = 4.0f * dp;
+ hst.tooltip = TR("faq_open_tooltip");
+ if (ui::material::IconButton("##HelpFaq", ICON_MD_QUESTION_MARK, helpFont,
+ ImVec2(helpW, helpH), hst)) {
+ show_faq_ = true;
+ }
+ occupiedX = helpX;
+ }
+
// Version always at far right
ImGui::SameLine(versionX);
ImGui::Text("%s", versionBuf);
@@ -2521,7 +3057,8 @@ void App::renderLiteFirstRunPrompt()
if (ImGui::BeginPopupModal("##LiteFirstRun", nullptr,
ImGuiWindowFlags_AlwaysAutoResize | ImGuiWindowFlags_NoTitleBar)) {
- const float btnW = 170.0f;
+ const float dp = ui::Layout::dpiScale();
+ const float btnW = 170.0f * dp;
if (step == 0) {
// ── Welcome ──────────────────────────────────────────────────────────
@@ -2529,7 +3066,7 @@ void App::renderLiteFirstRunPrompt()
ImGui::TextUnformatted(TR("lite_welcome_title"));
ImGui::PopFont();
ImGui::Spacing();
- ImGui::PushTextWrapPos(ImGui::GetCursorPosX() + 360.0f);
+ ImGui::PushTextWrapPos(ImGui::GetCursorPosX() + 360.0f * dp);
ImGui::TextUnformatted(TR("lite_welcome_msg"));
ImGui::PopTextWrapPos();
ImGui::Spacing(); ImGui::Spacing();
@@ -2537,7 +3074,7 @@ void App::renderLiteFirstRunPrompt()
if (creating) {
// Async create (with server failover) is in flight — driven to completion by
// App::update()'s pumpAsyncOpen(). Poll the controller for the outcome.
- ImGui::TextUnformatted("Creating your wallet\xE2\x80\xA6");
+ ImGui::TextUnformatted(TR("appx_creating_your_wallet"));
if (lite_wallet_->walletOpen()) {
auto s = lite_wallet_->exportSeed(); // read the new seed back (local, fast)
if (s.ok && !s.seedPhrase.empty()) {
@@ -2555,7 +3092,7 @@ void App::renderLiteFirstRunPrompt()
} else if (!lite_wallet_->openInProgress() &&
!lite_wallet_->lastOpenError().empty()) {
ui::Notifications::instance().warning(
- std::string("Create failed: ") + lite_wallet_->lastOpenError());
+ std::string(TR("appx_create_failed_prefix")) + lite_wallet_->lastOpenError());
creating = false; // back to the buttons so the user can retry
}
} else {
@@ -2581,14 +3118,12 @@ void App::renderLiteFirstRunPrompt()
} else if (step == 1) {
// ── Reveal the seed + birthday with backup warnings ─────────────────────
ImGui::PushFont(ui::material::Type().subtitle1());
- ImGui::TextUnformatted("Back up your seed phrase");
+ ImGui::TextUnformatted(TR("appx_back_up_seed_phrase_title"));
ImGui::PopFont();
ImGui::Spacing();
- ImGui::PushTextWrapPos(ImGui::GetCursorPosX() + 380.0f);
+ ImGui::PushTextWrapPos(ImGui::GetCursorPosX() + 380.0f * dp);
ImGui::PushStyleColor(ImGuiCol_Text, ui::material::ReadableError());
- ImGui::TextUnformatted("These 24 words are the ONLY way to restore your wallet. "
- "Write them down in order, store them offline, and never share "
- "them. If you lose them, your funds are gone forever.");
+ ImGui::TextUnformatted(TR("appx_seed_backup_warning"));
ImGui::PopStyleColor();
ImGui::PopTextWrapPos();
ImGui::Spacing();
@@ -2598,17 +3133,17 @@ void App::renderLiteFirstRunPrompt()
char cell[96];
snprintf(cell, sizeof(cell), "%2zu. %s", i + 1, words[i].c_str());
ImGui::TextUnformatted(cell);
- if ((i % 4) != 3 && i + 1 < words.size()) ImGui::SameLine(((i % 4) + 1) * 130.0f);
+ if ((i % 4) != 3 && i + 1 < words.size()) ImGui::SameLine(((i % 4) + 1) * 130.0f * dp);
}
ImGui::Spacing();
char bday[80];
- snprintf(bday, sizeof(bday), "Birthday (block height): %llu — back this up too.", birthday);
+ snprintf(bday, sizeof(bday), TR("appx_birthday_block_height"), birthday);
ImGui::PushStyleColor(ImGuiCol_Text, ui::material::OnSurfaceMedium());
ImGui::TextUnformatted(bday);
ImGui::PopStyleColor();
ImGui::Spacing(); ImGui::Spacing();
- if (ui::material::TactileButton("I've written it down", ImVec2(btnW, 0))) {
+ if (ui::material::TactileButton(TR("appx_ive_written_it_down"), ImVec2(btnW, 0))) {
chips.clear();
for (const auto& w : words) chips.emplace_back(w, false);
std::mt19937 rng{std::random_device{}()};
@@ -2618,9 +3153,9 @@ void App::renderLiteFirstRunPrompt()
step = 2;
}
ImGui::SameLine();
- if (ui::material::TactileButton("Copy", ImVec2(80, 0))) copySecretToClipboard(seed);
+ if (ui::material::TactileButton(TR("appx_copy"), ImVec2(80 * dp, 0))) copySecretToClipboard(seed);
ImGui::SameLine();
- if (ui::material::TactileButton(skipConfirm ? "Skip anyway" : "Skip", ImVec2(120, 0))) {
+ if (ui::material::TactileButton(skipConfirm ? TR("appx_skip_anyway") : TR("appx_skip"), ImVec2(120, 0))) {
if (!skipConfirm) {
skipConfirm = true; // require a second, deliberate click
} else {
@@ -2632,27 +3167,26 @@ void App::renderLiteFirstRunPrompt()
ImGui::Spacing();
ImGui::PushTextWrapPos(ImGui::GetCursorPosX() + 380.0f);
ImGui::PushStyleColor(ImGuiCol_Text, ui::material::ReadableError());
- ImGui::TextUnformatted("You have not backed up your seed — funds could be lost. "
- "Skip anyway?");
+ ImGui::TextUnformatted(TR("appx_seed_not_backed_up_warning"));
ImGui::PopStyleColor();
ImGui::PopTextWrapPos();
}
} else if (step == 2) {
// ── Verify: tap the words in order ──────────────────────────────────────
ImGui::PushFont(ui::material::Type().subtitle1());
- ImGui::TextUnformatted("Confirm your backup");
+ ImGui::TextUnformatted(TR("appx_confirm_your_backup"));
ImGui::PopFont();
ImGui::Spacing();
ImGui::PushTextWrapPos(ImGui::GetCursorPosX() + 380.0f);
- ImGui::TextUnformatted("Tap the words in the correct order to confirm you saved them.");
+ ImGui::TextUnformatted(TR("appx_tap_words_in_order"));
ImGui::PopTextWrapPos();
ImGui::Spacing();
- ImGui::TextDisabled("Progress: %d / %d", progress, (int)words.size());
+ ImGui::TextDisabled(TR("appx_progress_n_of_n"), progress, (int)words.size());
if (ImGui::GetTime() < wrongFlashUntil) {
ImGui::SameLine();
ImGui::PushStyleColor(ImGuiCol_Text, ui::material::ReadableError());
- ImGui::TextUnformatted(" — that's not the next word");
+ ImGui::TextUnformatted(TR("appx_not_next_word"));
ImGui::PopStyleColor();
}
ImGui::Spacing();
@@ -2661,9 +3195,9 @@ void App::renderLiteFirstRunPrompt()
ImGui::PushID((int)i);
if (chips[i].second) {
ImGui::BeginDisabled();
- ui::material::TactileButton(chips[i].first.c_str(), ImVec2(125, 0));
+ ui::material::TactileButton(chips[i].first.c_str(), ImVec2(125 * dp, 0));
ImGui::EndDisabled();
- } else if (ui::material::TactileButton(chips[i].first.c_str(), ImVec2(125, 0))) {
+ } else if (ui::material::TactileButton(chips[i].first.c_str(), ImVec2(125 * dp, 0))) {
if (progress < (int)words.size() && chips[i].first == words[progress]) {
chips[i].second = true; // correct next word
++progress;
@@ -2678,15 +3212,15 @@ void App::renderLiteFirstRunPrompt()
const bool verified = progress == (int)words.size();
if (!verified) ImGui::BeginDisabled();
- if (ui::material::TactileButton("Done", ImVec2(btnW, 0))) {
- ui::Notifications::instance().success("Wallet created and backed up.", 6.0f);
+ if (ui::material::TactileButton(TR("appx_done"), ImVec2(btnW, 0))) {
+ ui::Notifications::instance().success(TR("appx_wallet_created_and_backed_up"), 6.0f);
finish();
}
if (!verified) ImGui::EndDisabled();
ImGui::SameLine();
- if (ui::material::TactileButton("Back", ImVec2(80, 0))) { skipConfirm = false; step = 1; }
+ if (ui::material::TactileButton(TR("appx_back"), ImVec2(80, 0))) { skipConfirm = false; step = 1; }
ImGui::SameLine();
- if (ui::material::TactileButton(skipConfirm ? "Skip anyway" : "Skip", ImVec2(120, 0))) {
+ if (ui::material::TactileButton(skipConfirm ? TR("appx_skip_anyway") : TR("appx_skip"), ImVec2(120, 0))) {
if (!skipConfirm) {
skipConfirm = true; // require a second, deliberate click
} else {
@@ -2698,8 +3232,7 @@ void App::renderLiteFirstRunPrompt()
ImGui::Spacing();
ImGui::PushTextWrapPos(ImGui::GetCursorPosX() + 380.0f);
ImGui::PushStyleColor(ImGuiCol_Text, ui::material::ReadableError());
- ImGui::TextUnformatted("You have not backed up your seed — funds could be lost. "
- "Skip anyway?");
+ ImGui::TextUnformatted(TR("appx_seed_not_backed_up_warning"));
ImGui::PopStyleColor();
ImGui::PopTextWrapPos();
}
@@ -2716,7 +3249,7 @@ void App::renderLiteFirstRunPrompt()
if (restoring) {
// Async restore (with server failover) in flight — driven by pumpAsyncOpen().
- ImGui::TextUnformatted("Restoring your wallet\xE2\x80\xA6");
+ ImGui::TextUnformatted(TR("appx_restoring_your_wallet"));
if (lite_wallet_->walletOpen()) {
ui::Notifications::instance().success(TR("lite_restore_ok"), 6.0f);
finish(); // wipes restoreSeed; the wallet then syncs from the lite server
@@ -2728,10 +3261,10 @@ void App::renderLiteFirstRunPrompt()
} else {
ImGui::TextUnformatted(TR("lite_restore_seed_label"));
ImGui::InputTextMultiline("##LiteRestoreSeed", restoreSeed, sizeof(restoreSeed),
- ImVec2(380.0f, ImGui::GetTextLineHeight() * 3.2f));
+ ImVec2(380.0f * dp, ImGui::GetTextLineHeight() * 3.2f));
ImGui::Spacing();
ImGui::TextUnformatted(TR("lite_restore_birthday_label"));
- ImGui::SetNextItemWidth(160.0f);
+ ImGui::SetNextItemWidth(160.0f * dp);
ImGui::InputInt("##LiteRestoreBirthday", &restoreBirthday);
if (restoreBirthday < 0) restoreBirthday = 0;
@@ -2745,27 +3278,23 @@ void App::renderLiteFirstRunPrompt()
}
ImGui::Spacing(); ImGui::Spacing();
- // Trim surrounding whitespace from the entered seed.
- std::string seedTrim(restoreSeed);
- while (!seedTrim.empty() && std::isspace((unsigned char)seedTrim.front())) seedTrim.erase(seedTrim.begin());
- while (!seedTrim.empty() && std::isspace((unsigned char)seedTrim.back())) seedTrim.pop_back();
+ // Normalize the entered seed (trim, fold exotic Unicode whitespace like NBSP to plain
+ // spaces) so the word count and the phrase we submit agree regardless of paste source.
+ std::string seedTrim = util::normalizeSeedPhrase(restoreSeed);
- // Require a valid BIP39 word count before enabling Restore — otherwise a truncated or
- // garbage phrase (previously any non-empty text passed) is submitted and fails opaquely.
- int seedWords = 0;
- { bool inWord = false;
- for (char c : seedTrim) {
- bool sp = (c == ' ' || c == '\t' || c == '\n' || c == '\r');
- if (!sp && !inWord) { seedWords++; inWord = true; }
- else if (sp) inWord = false;
- } }
- bool seedLenOk = (seedWords == 12 || seedWords == 15 || seedWords == 18 ||
- seedWords == 21 || seedWords == 24);
+ // Require a COMPLETE 24-word phrase before enabling Restore. The SDXL backend only
+ // accepts 24-word / 32-byte-entropy seeds; a shorter valid-BIP39 phrase (12/15/18/21)
+ // panics it uncaught across the restore FFI, so it must be refused here (matches the
+ // Settings restore gate — both go through util::isCompleteRecoveryPhrase).
+ int seedWords = util::seedPhraseWordCount(seedTrim);
+ bool seedLenOk = util::isCompleteRecoveryPhrase(seedWords);
if (!seedTrim.empty() && !seedLenOk) {
ImGui::PushStyleColor(ImGuiCol_Text, ImGui::ColorConvertU32ToFloat4(ui::material::Warning()));
ImGui::PushTextWrapPos(ImGui::GetCursorPosX() + 380.0f);
- ImGui::TextUnformatted(("Recovery phrase should be 24 words — you have " +
- std::to_string(seedWords) + ".").c_str());
+ char recoveryWordsMsg[96];
+ snprintf(recoveryWordsMsg, sizeof(recoveryWordsMsg),
+ TR("appx_recovery_phrase_word_count"), seedWords);
+ ImGui::TextUnformatted(recoveryWordsMsg);
ImGui::PopTextWrapPos();
ImGui::PopStyleColor();
ImGui::Spacing();
@@ -2774,7 +3303,7 @@ void App::renderLiteFirstRunPrompt()
ImGui::BeginDisabled(!seedLenOk);
if (ui::material::TactileButton(TR("lite_restore_btn"), ImVec2(btnW, 0))) {
wallet::LiteWalletRestoreRequest req;
- req.seedPhrase = seedTrim;
+ req.seedPhrase = seedTrim; // normalized: NBSP-glued pastes restore correctly
req.birthday = static_cast(std::max(0, restoreBirthday));
req.overwrite = lite_wallet_->walletExists(); // replace any existing wallet file
if (lite_wallet_->beginRestoreWalletAsync(std::move(req))) {
@@ -2782,13 +3311,13 @@ void App::renderLiteFirstRunPrompt()
restoring = true;
} else {
restoreErr = lite_wallet_->lastOpenError().empty()
- ? std::string("Could not start restore")
+ ? std::string(TR("appx_could_not_start_restore"))
: lite_wallet_->lastOpenError();
}
}
ImGui::EndDisabled();
ImGui::SameLine();
- if (ui::material::TactileButton("Back", ImVec2(80, 0))) {
+ if (ui::material::TactileButton(TR("appx_back"), ImVec2(80, 0))) {
sodium_memzero(restoreSeed, sizeof(restoreSeed));
restoreErr.clear();
step = 0;
@@ -2812,17 +3341,18 @@ void App::renderLiteUnlockPrompt()
ImGuiCond_Appearing, ImVec2(0.5f, 0.5f));
if (ImGui::BeginPopupModal("##LiteUnlock", nullptr,
ImGuiWindowFlags_AlwaysAutoResize | ImGuiWindowFlags_NoTitleBar)) {
+ const float dp = ui::Layout::dpiScale();
ImGui::PushFont(ui::material::Type().subtitle1());
ImGui::TextUnformatted(TR("lite_unlock_title"));
ImGui::PopFont();
ImGui::Spacing();
ImGui::TextUnformatted(TR("lite_unlock_msg"));
ImGui::Spacing();
- ImGui::SetNextItemWidth(280.0f);
+ ImGui::SetNextItemWidth(280.0f * dp);
const bool entered = ImGui::InputText("##LiteUnlockPassModal", pass, sizeof(pass),
ImGuiInputTextFlags_Password | ImGuiInputTextFlags_EnterReturnsTrue);
ImGui::Spacing();
- const float btnW = 130.0f;
+ const float btnW = 130.0f * dp;
bool doUnlock = ui::material::TactileButton(TR("lite_unlock_btn"), ImVec2(btnW, 0)) || entered;
if (doUnlock) {
const bool ok = lite_wallet_->unlockWallet(pass);
@@ -3461,7 +3991,7 @@ void App::maybeOfferDaemonUpdate()
void App::renderDaemonUpdatePrompt()
{
ui::material::OverlayDialogSpec ov;
- ov.title = TR("daemon_update_title"); ov.p_open = &show_daemon_update_prompt_;
+ ov.title = TR("daemon_update_prompt_title"); ov.p_open = &show_daemon_update_prompt_;
ov.style = ui::material::OverlayStyle::BlurFloat;
ov.cardWidth = 520.0f; ov.idSuffix = "daemonupdprompt";
if (!ui::material::BeginOverlayDialog(ov))
@@ -3664,9 +4194,16 @@ void App::renderSeedMigrationDialog()
ImGui::TextWrapped("%s", TR("mig_already_mnemonic"));
ImGui::PopStyleColor();
ImGui::Spacing();
- if (ui::material::TactileButton(TR("mig_backup_instead"), ImVec2(200 * dp, 0))) {
- close();
- showSeedBackupDialog();
+ {
+ // Size to the label — the fixed 200px clipped the FR/PT/ES/DE/RU translations.
+ ImFont* bkFont = ui::material::Type().button();
+ const float bkW = std::max(200.0f * dp,
+ bkFont->CalcTextSizeA(bkFont->LegacySize, FLT_MAX, 0, TR("mig_backup_instead")).x
+ + ImGui::GetStyle().FramePadding.x * 2.0f + 24.0f * dp);
+ if (ui::material::TactileButton(TR("mig_backup_instead"), ImVec2(bkW, 0))) {
+ close();
+ showSeedBackupDialog();
+ }
}
ImGui::SameLine();
if (ui::material::TactileButton(TR("close"), ImVec2(120 * dp, 0))) close();
@@ -3709,7 +4246,12 @@ void App::renderSeedMigrationDialog()
case SeedMigrationStep::ShowSeed: {
ui::material::DialogWarningHeader(TR("mig_seed_warning"));
ImGui::Spacing();
- ui::RenderSeedWordGrid(ui::SplitSeedWords(seed_migration_seed_));
+ // Box the 24-word grid in a glass panel so the wallet's most critical secret reads as a
+ // distinct artifact, not body copy. Purely visual — the words/derivation/logic are untouched.
+ {
+ ui::material::GlassSectionScope seedPanel;
+ ui::RenderSeedWordGrid(ui::SplitSeedWords(seed_migration_seed_));
+ }
ImGui::Spacing();
ImGui::TextColored(kMedium, "%s", TR("mig_receive_addr"));
ImGui::TextWrapped("%s", seed_migration_dest_.c_str());
@@ -3837,8 +4379,12 @@ void App::renderSeedMigrationDialog()
ImGui::Spacing();
char cbuf[64]; snprintf(cbuf, sizeof(cbuf), TR("mig_confs"), seed_migration_sweep_confs_);
ImGui::TextColored(kMedium, "%s", cbuf);
- if (!seed_migration_sweep_txid_.empty())
- ImGui::TextColored(kMedium, "%s%s", TR("mig_txid"), seed_migration_sweep_txid_.c_str());
+ if (!seed_migration_sweep_txid_.empty()) {
+ // A 64-hex txid overflows the fixed 580px card; render it in a wrapping, copyable field
+ // (same widget the import-key sweep result uses) instead of a raw one-line label.
+ ImGui::TextColored(kMedium, "%s", TR("mig_txid"));
+ ui::widgets::AddressCopyField("##migtxid", seed_migration_sweep_txid_);
+ }
if (seed_migration_legacy_remaining_ >= 0.0) {
char rbuf[96]; snprintf(rbuf, sizeof(rbuf), TR("mig_remaining"), seed_migration_legacy_remaining_);
ImGui::TextColored(kMedium, "%s", rbuf);
@@ -3953,6 +4499,485 @@ void App::renderAntivirusHelpDialog()
#endif
}
+// Auto-shown when the node auto-recovered (salvaged) wallet.dat: the real wallet is safe in a
+// wallet..bak, but a possibly-incomplete salvaged copy is now loaded — warn loudly and point
+// the user at the datadir so they can restore the original instead of mistaking it for fund loss.
+void App::renderWalletRecoveredDialog()
+{
+ if (!show_wallet_recovered_dialog_) return;
+
+ ui::material::OverlayDialogSpec ov;
+ ov.title = TR("wallet_recovered_title");
+ // Dismiss (backdrop / [X]) is disabled during Working — files are mid-swap. A null p_open is safe:
+ // BeginOverlayDialog guards both the close button and backdrop-close on it.
+ ov.p_open = (recovery_phase_ == RecoveryPhase::Working) ? nullptr : &show_wallet_recovered_dialog_;
+ ov.style = ui::material::OverlayStyle::BlurFloat;
+ ov.cardWidth = 620.0f; // wide enough for the two side-by-side choice cards
+ ov.idSuffix = "walletrecovered";
+ if (!ui::material::BeginOverlayDialog(ov)) return;
+ const float dp = ui::Layout::dpiScale();
+ // Size each button to its label (with a modest floor) instead of a magic fixed width, so
+ // translations of these keys — added additively to res/lang later — can't silently clip.
+ ImFont* rbf = ui::material::Type().button();
+ auto fitBtnW = [&](const char* label) {
+ return std::max(96.0f * dp,
+ rbf->CalcTextSizeA(rbf->LegacySize, FLT_MAX, 0, label).x
+ + ImGui::GetStyle().FramePadding.x * 2.0f + 28.0f * dp);
+ };
+ // A full-width action row: the button, then a dim wrapped one-line explanation of what it does.
+ auto actionRow = [&](const char* label, const char* sub) -> bool {
+ const bool clicked = ui::material::TactileButton(label, ImVec2(fitBtnW(label), 0));
+ if (sub && sub[0]) {
+ ImGui::PushFont(ui::material::Type().caption());
+ ImGui::PushTextWrapPos(0.0f);
+ ImGui::TextDisabled("%s", sub);
+ ImGui::PopTextWrapPos();
+ ImGui::PopFont();
+ }
+ return clicked;
+ };
+ // A disclosure that recedes: no filled bar, dim label — so the primary action stays dominant.
+ auto quietHeader = [&](const char* label) -> bool {
+ ImGui::PushStyleColor(ImGuiCol_Header, ImVec4(0, 0, 0, 0));
+ ImGui::PushStyleColor(ImGuiCol_HeaderHovered, ui::material::WithAlpha(ui::material::OnSurface(), 20));
+ ImGui::PushStyleColor(ImGuiCol_HeaderActive, ui::material::WithAlpha(ui::material::OnSurface(), 32));
+ ImGui::PushStyleColor(ImGuiCol_Text, ui::material::OnSurfaceMedium());
+ const bool open = ImGui::CollapsingHeader(label);
+ ImGui::PopStyleColor(4);
+ return open;
+ };
+ // Opt-in daemon log — the same lines that used to be dumped on screen, now behind a quiet disclosure.
+ auto techDetails = [&]() {
+ if (!daemon_controller_) return;
+ ImGui::Dummy(ImVec2(0, ui::Layout::spacingSm()));
+ if (quietHeader(TR("wallet_recovery_details_label"))) {
+ ImGui::PushFont(ui::material::Type().caption());
+ ImGui::PushTextWrapPos(0.0f);
+ for (const auto& ln : daemon_controller_->recentLines(8))
+ ImGui::TextDisabled("%s", ln.c_str());
+ ImGui::PopTextWrapPos();
+ ImGui::PopFont();
+ }
+ };
+ // Funds-safety hero: a shield icon + the reassurance, made the visual focal point of the screen.
+ auto safetyHero = [&]() {
+ ImFont* icoF = ui::material::Type().iconLarge();
+ ImFont* txtF = ui::material::Type().subtitle1();
+ const float rowTop = ImGui::GetCursorPosY();
+ ImGui::PushFont(icoF);
+ ImGui::TextColored(ui::material::SuccessVec4(), ICON_MD_HEALTH_AND_SAFETY);
+ ImGui::PopFont();
+ ImGui::SameLine();
+ 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::TextColored(ui::material::SuccessVec4(), "%s", TR("wallet_recovered_safety"));
+ ImGui::PopFont();
+ };
+ // Accent-tinted primary action so the recommended button clearly dominates the quiet disclosures
+ // (a translucent Primary tint over the glass button — keeps the label readable on any theme).
+ auto primaryAction = [&](const char* label, const char* sub) -> bool {
+ ImGui::PushStyleColor(ImGuiCol_Button, ui::material::WithAlpha(ui::material::Primary(), 60));
+ ImGui::PushStyleColor(ImGuiCol_ButtonHovered, ui::material::WithAlpha(ui::material::Primary(), 90));
+ ImGui::PushStyleColor(ImGuiCol_ButtonActive, ui::material::WithAlpha(ui::material::Primary(), 115));
+ const bool clicked = actionRow(label, sub);
+ ImGui::PopStyleColor(3);
+ return clicked;
+ };
+ // A choice card: icon + title (+ optional "recommended" chip) + wrapped blurb + a full-width button
+ // pinned to the card bottom. The recommended card gets a gold-tinted fill + border so the choice
+ // reads at a glance. cardH is precomputed by the caller so side-by-side cards stay equal height.
+ auto renderCard = [&](const char* id, const char* glyph, const char* title, const char* desc,
+ const char* btn, bool recommended, float cardW, float cardH) -> bool {
+ const float pad = ui::Layout::spacingMd();
+ ImGui::PushStyleColor(ImGuiCol_ChildBg,
+ recommended ? ui::material::WithAlpha(ui::material::Primary(), 22)
+ : ui::material::WithAlpha(ui::material::OnSurface(), 8));
+ ImGui::PushStyleColor(ImGuiCol_Border,
+ recommended ? ui::material::WithAlpha(ui::material::Primary(), 140)
+ : ui::material::WithAlpha(ui::material::OnSurface(), 28));
+ ImGui::PushStyleVar(ImGuiStyleVar_ChildRounding, 6.0f * dp);
+ ImGui::PushStyleVar(ImGuiStyleVar_ChildBorderSize, 1.0f);
+ ImGui::PushStyleVar(ImGuiStyleVar_WindowPadding, ImVec2(pad, pad));
+ ImGui::BeginChild(id, ImVec2(cardW, cardH), true,
+ ImGuiWindowFlags_NoScrollbar | ImGuiWindowFlags_NoScrollWithMouse);
+ // Zero implicit item spacing so the only vertical gaps are the explicit Dummy()s below — that
+ // keeps the caller's measured cardH exact, so the bottom-pinned button never clips.
+ ImGui::PushStyleVar(ImGuiStyleVar_ItemSpacing, ImVec2(0.0f, 0.0f));
+
+ ImGui::PushFont(ui::material::Type().iconMed());
+ ImGui::TextColored(recommended ? ui::material::PrimaryVec4()
+ : ImGui::ColorConvertU32ToFloat4(ui::material::OnSurfaceMedium()),
+ "%s", glyph);
+ ImGui::PopFont();
+ ImGui::Dummy(ImVec2(0, ui::Layout::spacingXs()));
+
+ ImGui::PushFont(ui::material::Type().subtitle2());
+ ImGui::TextUnformatted(title);
+ ImGui::PopFont();
+ if (recommended) {
+ ImGui::PushFont(ui::material::Type().caption());
+ ImGui::TextColored(ui::material::PrimaryVec4(), "%s", TR("wallet_recovery_recommended"));
+ ImGui::PopFont();
+ }
+ ImGui::Dummy(ImVec2(0, ui::Layout::spacingXs()));
+
+ ImGui::PushFont(ui::material::Type().caption());
+ ImGui::PushStyleColor(ImGuiCol_Text, ui::material::OnSurfaceMedium());
+ ImGui::PushTextWrapPos(0.0f);
+ ImGui::TextWrapped("%s", desc);
+ ImGui::PopTextWrapPos();
+ ImGui::PopStyleColor();
+ ImGui::PopFont();
+
+ // Pin the button to the card bottom so both cards' buttons line up.
+ const float btnH = ImGui::GetFrameHeight() + 6.0f * dp;
+ const float remaining = ImGui::GetContentRegionAvail().y - btnH;
+ if (remaining > 0.0f) ImGui::Dummy(ImVec2(0, remaining));
+ bool clicked;
+ if (recommended) {
+ 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));
+ clicked = ui::material::TactileButton(btn, ImVec2(-FLT_MIN, btnH));
+ ImGui::PopStyleColor(3);
+ } else {
+ clicked = ui::material::TactileButton(btn, ImVec2(-FLT_MIN, btnH));
+ }
+ ImGui::PopStyleVar(); // ItemSpacing
+ ImGui::EndChild();
+ ImGui::PopStyleVar(3);
+ ImGui::PopStyleColor(2);
+ return clicked;
+ };
+ // A quiet clickable text link for the footer (Show me the files / Not now).
+ 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;
+ };
+
+ switch (recovery_phase_) {
+ case RecoveryPhase::Offer: {
+ safetyHero();
+ ImGui::Dummy(ImVec2(0, ui::Layout::spacingSm()));
+ ImGui::TextWrapped("%s", TR("wallet_recovered_warn"));
+ ImGui::Dummy(ImVec2(0, ui::Layout::spacingMd()));
+
+ // The two real actions as side-by-side CHOICE CARDS — the recommended one gold-tinted, so the
+ // decision reads at a glance instead of hiding in a menu. Rebuild is the more complete fix; when
+ // its helper is missing, Restore becomes the single recommended card (never a dead end).
+ const bool canRebuild = walletRebuildAvailable();
+ const float cardGap = ui::Layout::spacingMd();
+ const float pad = cardGap;
+ const float contentW = ImGui::GetContentRegionAvail().x;
+ const float cardW = canRebuild ? (contentW - cardGap) * 0.5f
+ : std::min(contentW, 320.0f * dp);
+ // Fix a shared card height off the taller card body so the two cards line up. Measure title AND
+ // desc wraps (titles/descs can be multi-line, esp. after translation), with a little width slack.
+ ImFont* descF = ui::material::Type().caption();
+ ImFont* titleF = ui::material::Type().subtitle2();
+ const float innerW = std::max(1.0f, cardW - 2.0f * pad - 6.0f * dp);
+ auto measureH = [&](ImFont* f, const char* s) {
+ return f->CalcTextSizeA(f->LegacySize, FLT_MAX, innerW, s).y;
+ };
+ const float titleH = std::max(measureH(titleF, TR("wallet_recovery_rebuild_card")),
+ measureH(titleF, TR("wallet_recovery_restore_card")));
+ const float descH = std::max(measureH(descF, TR("wallet_recovery_rebuild_card_desc")),
+ measureH(descF, TR("wallet_recovery_restore_card_desc")));
+ const float cardH = 2.0f * pad
+ + ui::material::Type().iconMed()->LegacySize + ui::Layout::spacingXs() // icon + gap
+ + titleH + descF->LegacySize // title + RECOMMENDED chip
+ + ui::Layout::spacingXs() // gap before desc
+ + descH + ui::Layout::spacingSm() // desc + gap before button
+ + ImGui::GetFrameHeight() + 6.0f * dp // button
+ + ui::Layout::spacingXs(); // small buffer
+
+ if (canRebuild) {
+ const bool r = renderCard("##rcRepair", ICON_MD_AUTO_FIX_HIGH, TR("wallet_recovery_rebuild_card"),
+ TR("wallet_recovery_rebuild_card_desc"), TR("wallet_recovery_repair_go"),
+ true, cardW, cardH);
+ ImGui::SameLine(0, cardGap);
+ const bool s = renderCard("##rcRestore", ICON_MD_SETTINGS_BACKUP_RESTORE, TR("wallet_recovery_restore_card"),
+ TR("wallet_recovery_restore_card_desc"), TR("wallet_recovery_restore_go"),
+ false, cardW, cardH);
+ if (r) rebuildWalletDatabase();
+ if (s) restoreOriginalWallet();
+ } else {
+ const float indent = (contentW - cardW) * 0.5f;
+ if (indent > 0.0f) ImGui::Indent(indent);
+ const bool s = renderCard("##rcRestoreOnly", ICON_MD_SETTINGS_BACKUP_RESTORE, TR("wallet_recovery_restore_card"),
+ TR("wallet_recovery_restore_card_desc"), TR("wallet_recovery_restore_go"),
+ true, cardW, cardH);
+ if (indent > 0.0f) ImGui::Unindent(indent);
+ if (s) restoreOriginalWallet();
+ }
+
+ // Quiet footer: inspect-files link + a clearly-labelled, reversible dismiss (with a tooltip that
+ // spells out the consequence — plain "Not now" was ambiguous).
+ 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("wallet_recovery_decide_later"))) {
+ show_wallet_recovered_dialog_ = false; // non-destructive; reopen from the status bar
+ recovery_phase_ = RecoveryPhase::Offer;
+ }
+ if (ImGui::IsItemHovered()) {
+ ImGui::BeginTooltip();
+ ImGui::PushTextWrapPos(ImGui::GetFontSize() * 22.0f);
+ ImGui::TextUnformatted(TR("wallet_recovery_decide_later_tip"));
+ ImGui::PopTextWrapPos();
+ ImGui::EndTooltip();
+ }
+ // Plain-language "what happens to my files" — replaces the unhelpful raw daemon log on this screen
+ // (the log is still available in the Console tab for troubleshooting).
+ ImGui::Dummy(ImVec2(0, ui::Layout::spacingSm()));
+ if (quietHeader(TR("wallet_recovery_files_label"))) {
+ ImGui::PushFont(ui::material::Type().caption());
+ ImGui::PushStyleColor(ImGuiCol_Text, ui::material::OnSurfaceMedium());
+ ImGui::PushTextWrapPos(0.0f);
+ ImGui::TextWrapped("%s", TR("wallet_recovery_files_detail"));
+ ImGui::PopTextWrapPos();
+ ImGui::PopStyleColor();
+ ImGui::PopFont();
+ }
+ break;
+ }
+ case RecoveryPhase::Working: {
+ ImGui::TextWrapped("%s", recovery_last_action_rebuild_ ? TR("wallet_rebuild_started")
+ : TR("wallet_restore_started"));
+ ImGui::Dummy(ImVec2(0, ui::Layout::spacingMd()));
+ ImGui::Text("%s%s", TR("wallet_recovery_working_label"), ui::material::LoadingDots());
+ break;
+ }
+ case RecoveryPhase::Done: {
+ safetyHero();
+ ImGui::Dummy(ImVec2(0, ui::Layout::spacingSm()));
+ ImGui::TextWrapped("%s", recovery_last_action_rebuild_ ? TR("wallet_recovery_success_body")
+ : TR("wallet_recovery_success_restore"));
+ // A sev-1 warning (e.g. restored but the node didn't relaunch) carries a specific message.
+ if (recovery_outcome_sev_ == 1 && !recovery_outcome_msg_.empty()) {
+ ImGui::Dummy(ImVec2(0, ui::Layout::spacingXs()));
+ ImGui::PushStyleColor(ImGuiCol_Text, ui::material::Warning());
+ ImGui::TextWrapped("%s", recovery_outcome_msg_.c_str());
+ ImGui::PopStyleColor();
+ }
+ ImGui::Dummy(ImVec2(0, ui::Layout::spacingMd()));
+ if (ui::material::TactileButton(TR("wallet_recovery_done"), ImVec2(fitBtnW(TR("wallet_recovery_done")), 0))) {
+ show_wallet_recovered_dialog_ = false;
+ recovery_phase_ = RecoveryPhase::Offer;
+ // Clean success — clear the session flag so a later unrelated disconnect doesn't re-raise the
+ // "repair available" chip/dialog for a wallet that's already fixed. (Warnings keep it set.)
+ if (recovery_outcome_sev_ == 0) wallet_auto_recovered_ = false;
+ }
+ techDetails();
+ break;
+ }
+ case RecoveryPhase::Failed: {
+ ImGui::PushFont(ui::material::Type().subtitle1());
+ ImGui::PushStyleColor(ImGuiCol_Text, ui::material::ReadableError());
+ ImGui::TextWrapped("%s", TR("wallet_recovery_failure_title"));
+ ImGui::PopStyleColor();
+ ImGui::PopFont();
+ ImGui::Dummy(ImVec2(0, ui::Layout::spacingSm()));
+ ImGui::TextWrapped("%s", TR("wallet_recovery_failure_body"));
+ if (!recovery_outcome_msg_.empty()) {
+ ImGui::Dummy(ImVec2(0, ui::Layout::spacingXs()));
+ ImGui::PushStyleColor(ImGuiCol_Text, ui::material::ReadableError());
+ ImGui::TextWrapped("%s", recovery_outcome_msg_.c_str());
+ ImGui::PopStyleColor();
+ }
+ ImGui::Dummy(ImVec2(0, ui::Layout::spacingMd()));
+ // Offer the UNtried option (Restore needs no helper; Rebuild only if its helper exists).
+ if (recovery_last_action_rebuild_) {
+ if (actionRow(TR("wallet_recovery_try_other"), TR("wallet_recovered_restore_sub")))
+ restoreOriginalWallet();
+ } else if (walletRebuildAvailable()) {
+ if (actionRow(TR("wallet_recovery_try_other"), TR("wallet_recovered_rebuild_sub")))
+ rebuildWalletDatabase();
+ }
+ ImGui::Dummy(ImVec2(0, ui::Layout::spacingSm()));
+ if (actionRow(TR("wallet_recovered_open_folder"), TR("wallet_recovered_open_folder_sub")))
+ util::Platform::openFolder(util::Platform::getDragonXDataDir());
+ ImGui::Dummy(ImVec2(0, ui::Layout::spacingSm()));
+ if (ui::material::TactileButton(TR("close"), ImVec2(fitBtnW(TR("close")), 0))) {
+ show_wallet_recovered_dialog_ = false;
+ recovery_phase_ = RecoveryPhase::Offer;
+ }
+ techDetails();
+ break;
+ }
+ }
+
+ ui::material::EndOverlayDialog();
+}
+
+// Auto-shown when the active wallet loaded EMPTY but a sibling wallet file in the datadir still holds keys
+// (see maybeWarnEmptyWalletWithFundedSiblings). Funds are not lost — they're in another file, most likely a
+// wallet..bak left by an earlier BDB salvage. This routes the user to the wallet manager to switch, and
+// remembers a per-file dismissal so it never nags again for this wallet.
+void App::renderEmptyWalletWarningDialog()
+{
+ if (!show_empty_wallet_warning_) return;
+
+ const bool salvage = empty_wallet_has_salvage_bak_; // salvage .bak → offer Restore; else → switch wallet
+
+ ui::material::OverlayDialogSpec ov;
+ ov.title = TR(salvage ? "empty_wallet_salvage_title" : "empty_wallet_warning_title");
+ ov.p_open = &show_empty_wallet_warning_;
+ ov.style = ui::material::OverlayStyle::BlurFloat;
+ ov.cardWidth = 560.0f;
+ ov.idSuffix = "emptywalletwarn";
+ if (!ui::material::BeginOverlayDialog(ov)) return;
+ const float dp = ui::Layout::dpiScale();
+
+ // Header: wallet icon in a warning tint + a calm "your coins are likely in another file" framing.
+ {
+ ImFont* icoF = ui::material::Type().iconLarge();
+ const float rowTop = ImGui::GetCursorPosY();
+ ImGui::PushFont(icoF);
+ ImGui::PushStyleColor(ImGuiCol_Text, ui::material::Warning());
+ ImGui::TextUnformatted(ICON_MD_ACCOUNT_BALANCE_WALLET);
+ ImGui::PopStyleColor();
+ ImGui::PopFont();
+ ImGui::SameLine();
+ ImFont* txtF = ui::material::Type().subtitle1();
+ const float iconH = icoF->LegacySize;
+ const float textH = txtF ? txtF->LegacySize : ImGui::GetFontSize();
+ if (iconH > textH) ImGui::SetCursorPosY(rowTop + (iconH - textH) * 0.5f);
+ ImGui::PushFont(txtF);
+ ImGui::TextWrapped("%s", TR(salvage ? "empty_wallet_salvage_headline" : "empty_wallet_warning_headline"));
+ ImGui::PopFont();
+ }
+ ImGui::Dummy(ImVec2(0, ui::Layout::spacingSm()));
+
+ ImGui::PushTextWrapPos(0.0f);
+ ImGui::TextWrapped("%s", TR(salvage ? "empty_wallet_salvage_body" : "empty_wallet_warning_body"));
+ ImGui::PopTextWrapPos();
+
+ // For the "wrong wallet" case, name the other wallet file(s) that hold keys, with a compact key count —
+ // concrete evidence the coins are recoverable from them. The count is built with std::to_string so no
+ // printf format lives in a translatable string (translations are additive and could otherwise drop a %d).
+ // (The salvage case has no sibling list — restoreOriginalWallet() finds the backup itself.)
+ if (!empty_wallet_funded_siblings_.empty()) {
+ ImGui::Dummy(ImVec2(0, ui::Layout::spacingSm()));
+ for (const auto& s : empty_wallet_funded_siblings_) {
+ ImGui::Bullet();
+ ImGui::SameLine();
+ ImGui::TextUnformatted(s.fileName.c_str());
+ ImGui::PushFont(ui::material::Type().caption());
+ ImGui::PushStyleColor(ImGuiCol_Text, ui::material::OnSurfaceMedium());
+ const std::string keys = " " + std::to_string(s.transparentKeys + s.shieldedKeys)
+ + " " + TR("empty_wallet_keys_suffix");
+ ImGui::SameLine();
+ ImGui::TextUnformatted(keys.c_str());
+ ImGui::PopStyleColor();
+ ImGui::PopFont();
+ }
+ }
+
+ // Primary action: route the user to the wallet manager to switch files (accent-tinted so it dominates).
+ ImGui::Dummy(ImVec2(0, ui::Layout::spacingMd()));
+ ImFont* rbf = ui::material::Type().button();
+ auto fitBtnW = [&](const char* label) {
+ return std::max(120.0f * dp,
+ rbf->CalcTextSizeA(rbf->LegacySize, FLT_MAX, 0, label).x
+ + ImGui::GetStyle().FramePadding.x * 2.0f + 28.0f * dp);
+ };
+ const char* primaryLabel = salvage ? TR("empty_wallet_restore") : TR("empty_wallet_open_manager");
+ ImGui::PushStyleColor(ImGuiCol_Button, ui::material::WithAlpha(ui::material::Primary(), 65));
+ ImGui::PushStyleColor(ImGuiCol_ButtonHovered, ui::material::WithAlpha(ui::material::Primary(), 100));
+ ImGui::PushStyleColor(ImGuiCol_ButtonActive, ui::material::WithAlpha(ui::material::Primary(), 125));
+ if (ui::material::TactileButton(primaryLabel, ImVec2(fitBtnW(primaryLabel), 0))) {
+ show_empty_wallet_warning_ = false;
+ if (salvage)
+ restoreOriginalWallet(); // self-contained: swaps the .bak back + drives the recovery dialog's progress
+ else
+ ui::WalletsDialog::show(this);
+ }
+ ImGui::PopStyleColor(3);
+
+ // Quiet footer: open the data folder, or dismiss permanently for THIS wallet file.
+ auto linkText = [&](const char* label) -> bool {
+ ImGui::PushStyleColor(ImGuiCol_Text, ui::material::OnSurfaceMedium());
+ ImGui::TextUnformatted(label);
+ ImGui::PopStyleColor();
+ const bool clicked = ImGui::IsItemClicked();
+ if (ImGui::IsItemHovered()) {
+ ImGui::SetMouseCursor(ImGuiMouseCursor_Hand);
+ const ImVec2 mn = ImGui::GetItemRectMin(), mx = ImGui::GetItemRectMax();
+ ImGui::GetWindowDrawList()->AddLine(ImVec2(mn.x, mx.y), ImVec2(mx.x, mx.y),
+ ui::material::OnSurface());
+ }
+ return clicked;
+ };
+ ImGui::Dummy(ImVec2(0, ui::Layout::spacingMd()));
+ if (linkText(TR("wallet_recovered_open_folder")))
+ util::Platform::openFolder(util::Platform::getDragonXDataDir());
+ ImGui::SameLine(0, ui::Layout::spacingSm());
+ ImGui::TextDisabled("\xC2\xB7"); // middle dot separator
+ ImGui::SameLine(0, ui::Layout::spacingSm());
+ if (linkText(TR("empty_wallet_warning_dismiss"))) {
+ if (settings_) {
+ settings_->ackEmptyWalletWarn(settings_->getActiveWalletFile());
+ settings_->save();
+ }
+ show_empty_wallet_warning_ = false;
+ }
+ if (ImGui::IsItemHovered()) {
+ ImGui::BeginTooltip();
+ ImGui::PushTextWrapPos(ImGui::GetFontSize() * 22.0f);
+ ImGui::TextUnformatted(TR("empty_wallet_warning_dismiss_tip"));
+ ImGui::PopTextWrapPos();
+ ImGui::EndTooltip();
+ }
+
+ ui::material::EndOverlayDialog();
+}
+
+// Auto-shown when the embedded node aborts on an unreadable block database — offers the one-click
+// -reindex rebuild instead of leaving the wallet stuck on a silent zero balance.
+void App::renderBlockDbReindexDialog()
+{
+ if (!show_block_db_reindex_confirm_) return;
+
+ ui::material::OverlayDialogSpec ov;
+ ov.title = TR("block_db_reindex_title");
+ ov.p_open = &show_block_db_reindex_confirm_; // X / backdrop dismisses (offer remains; loop stays held)
+ ov.style = ui::material::OverlayStyle::BlurFloat;
+ ov.cardWidth = 540.0f;
+ ov.idSuffix = "blockdbreindex";
+ if (!ui::material::BeginOverlayDialog(ov)) return;
+ const float dp = ui::Layout::dpiScale();
+
+ ui::material::DialogWarningHeader(TR("block_db_reindex_warn"));
+ ImGui::Dummy(ImVec2(0, ui::Layout::spacingSm()));
+ ImGui::TextWrapped("%s", TR("block_db_reindex_body"));
+ ImGui::Dummy(ImVec2(0, ui::Layout::spacingMd()));
+ if (ui::material::TactileButton(TR("block_db_reindex_confirm"), ImVec2(260.0f * dp, 0))) {
+ reindexBlockDatabase(); // clears show_block_db_reindex_confirm_ + block_db_reindex_available_
+ }
+ ImGui::SameLine();
+ if (ui::material::TactileButton(TR("cancel"), ImVec2(110.0f * dp, 0))) {
+ show_block_db_reindex_confirm_ = false;
+ // Leave block_db_reindex_available_ set: the connect loop keeps HOLDING (no crash-restart storm)
+ // rather than looping into the same abort; the user can rebuild later from Settings.
+ }
+ ui::material::EndOverlayDialog();
+}
+
void App::renderSwitchStopDaemonDialog()
{
const bool confirm = show_switch_stop_daemon_confirm_;
@@ -4082,12 +5107,24 @@ void App::refreshNow()
invalidateShieldedHistoryScanProgress(true);
}
+void App::skipDaemonOutputBacklog()
+{
+ // While minimized, App::update() is paused, so daemon_output_offset_ is never advanced and a large
+ // backlog of daemon output piles up. Parsing it all at once on restore would replay a background
+ // witness rebuild's progress + completion in a single batch and fire a spurious "Blockchain rescan
+ // complete" toast. Advance the offset to the current end so only NEW (post-restore) output is parsed.
+ // A genuine user-initiated rescan still surfaces completion via the getrescaninfo monitor.
+ if (daemon_controller_ && daemon_controller_->isRunning()) {
+ (void)daemon_controller_->outputSince(daemon_output_offset_); // advances daemon_output_offset_ to the end
+ }
+}
+
void App::handlePaymentURI(const std::string& uri)
{
auto payment = util::parsePaymentURI(uri);
if (!payment.valid) {
- ui::Notifications::instance().error("Invalid payment URI: " + payment.error);
+ ui::Notifications::instance().error(std::string(TR("appx_invalid_payment_uri_prefix")) + payment.error);
return;
}
@@ -4102,7 +5139,7 @@ void App::handlePaymentURI(const std::string& uri)
setCurrentPage(ui::NavPage::Send);
// Notify user
- std::string msg = "Payment request loaded";
+ std::string msg = TR("appx_payment_request_loaded");
if (payment.amount > 0) {
char buf[64];
snprintf(buf, sizeof(buf), " for %.8f DRGX", payment.amount);
@@ -4149,7 +5186,11 @@ bool App::startEmbeddedDaemon()
if (resources::hasEmbeddedResources()) {
DEBUG_LOGF("Extracting embedded Sapling params...\n");
daemon_status_ = TR("sb_extracting_sapling");
- resources::extractEmbeddedResources();
+ if (!resources::extractEmbeddedResources()) {
+ daemon_status_ = TR("sb_daemon_extract_failed");
+ DEBUG_LOGF("[ERROR] extractEmbeddedResources() failed — disk full or permission denied?\n");
+ return false;
+ }
// Check again after extraction
if (!rpc::Connection::verifySaplingParams()) {
@@ -4168,8 +5209,13 @@ bool App::startEmbeddedDaemon()
const char* paramFiles[] = { "sapling-spend.params", "sapling-output.params", "asmap.dat" };
bool copied = false;
if (!exe_dir.empty()) {
+ std::string dirErr;
+ if (!util::Platform::ensureDirectory(daemon_dir, &dirErr)) {
+ daemon_status_ = dirErr;
+ DEBUG_LOGF("[ERROR] %s\n", dirErr.c_str());
+ return false;
+ }
std::error_code ec;
- fs::create_directories(daemon_dir, ec);
// On macOS .app bundles, params are in Contents/Resources/
// while the executable is in Contents/MacOS/
@@ -4214,8 +5260,13 @@ bool App::startEmbeddedDaemon()
std::string exe_dir = util::Platform::getExecutableDirectory();
std::string daemon_dir = resources::getDaemonDirectory();
if (!exe_dir.empty()) {
+ std::string dirErr;
+ if (!util::Platform::ensureDirectory(daemon_dir, &dirErr)) {
+ daemon_status_ = dirErr;
+ DEBUG_LOGF("[ERROR] %s\n", dirErr.c_str());
+ return false;
+ }
std::error_code ec;
- fs::create_directories(daemon_dir, ec);
std::vector searchDirs = { exe_dir };
#ifdef __APPLE__
@@ -4226,18 +5277,31 @@ bool App::startEmbeddedDaemon()
}
#endif
const char* extraFiles[] = { "asmap.dat", "dragonxd", "dragonx-cli", "dragonx-tx" };
+ bool copyFailed = false;
for (const char* name : extraFiles) {
fs::path dst = fs::path(daemon_dir) / name;
if (fs::exists(dst)) continue;
for (const auto& dir : searchDirs) {
fs::path src = fs::path(dir) / name;
- if (fs::exists(src)) {
+ if (fs::exists(src)) { // an absent source is optional; only a real copy error counts
DEBUG_LOGF("Copying bundled %s from %s to %s\n", name, dir.c_str(), daemon_dir.c_str());
fs::copy_file(src, dst, ec);
+ if (ec) {
+ DEBUG_LOGF("[ERROR] Failed to copy %s: %s\n", name, ec.message().c_str());
+ copyFailed = true;
+ ec.clear();
+ }
break;
}
}
}
+ if (copyFailed) {
+ char buf[512];
+ snprintf(buf, sizeof(buf), TR("sb_daemon_files_failed"), daemon_dir.c_str());
+ daemon_status_ = buf;
+ DEBUG_LOGF("[ERROR] One or more daemon files failed to copy to %s\n", daemon_dir.c_str());
+ return false;
+ }
}
}
@@ -4281,21 +5345,27 @@ void App::stopEmbeddedDaemon()
return;
}
+ // Do we hold a live process handle for this node? Capture it BEFORE stop() reaps the pid. If owned,
+ // daemon_controller_->stop() below BLOCKS until the process actually exits. If NOT owned (external /
+ // adopted / direct-connected), stop() returns at once — we can only ask it over RPC and then watch
+ // for it to disappear (handled after the stop() call).
+ const bool owned = daemon_controller_->isRunning();
+
// Send RPC "stop" command — this is the graceful path that lets the
// daemon flush state, save block indexes, close sockets, etc.
bool stop_sent = false;
- // Try the existing RPC connection first
+ // Try the existing RPC connection first. beginShutdown() called rpc_->requestAbort() to unblock any
+ // in-flight request — but that sticky abort (cleared only by connect()) would make THIS "stop" self-
+ // abort mid-flight (CURLE_ABORTED_BY_CALLBACK). The old async rpc_->stop() swallowed that error yet
+ // set stop_sent=true, so the daemon never heard "stop" and the working temp-connection fallback was
+ // skipped — which is why "Stop external daemon" left an external dragonxd running (it only died via
+ // the 20s force-kill). Clear the abort and send SYNCHRONOUSLY so real success is surfaced.
if (rpc_ && rpc_->isConnected()) {
DEBUG_LOGF("Sending stop command via existing RPC connection...\n");
- try {
- rpc_->stop([](const json&) {
- DEBUG_LOGF("Stop command acknowledged by daemon\n");
- });
+ rpc_->clearAbort();
+ if (sendStopCommandSafely(*rpc_, "existing connection stop"))
stop_sent = true;
- } catch (...) {
- DEBUG_LOGF("Failed to send stop via existing connection\n");
- }
}
// If the main connection wasn't established (e.g. daemon was still
@@ -4337,6 +5407,27 @@ void App::stopEmbeddedDaemon()
// 20s grace period for the RPC "stop" to complete (LevelDB flush).
// Only after that does stop() escalate to SIGTERM, then SIGKILL.
daemon_controller_->stop(20000);
+
+ // EXTERNAL / adopted node during app shutdown: we hold no process handle, so the stop() above returned
+ // immediately (it can only wait on a node WE spawned). But the user turned on "Stop external daemon",
+ // so keep the window on the shutdown screen and poll until the node is actually gone — surfacing a live
+ // status so they can SEE it stop — rather than closing while it's still flushing. Bounded ~120s (a
+ // graceful full-node shutdown can flush LevelDB for 60-90s). Scoped to real shutdown; the shutdown
+ // screen's Force Quit stays available and flips shutdown_complete_, which breaks us out at once.
+ if (stop_sent && !owned && shutting_down_) {
+ auto stillUp = []() {
+ return daemon::EmbeddedDaemon::isRpcPortInUse() || daemon::EmbeddedDaemon::isDaemonProcessRunning();
+ };
+ // Set the phase text ONCE, then just poll — the shutdown screen already renders a live "N seconds"
+ // elapsed counter on the UI thread, so the user still sees time passing. (shutdown_status_ is now a
+ // GuardedStatus, so per-iteration writes would be race-safe; the single write is just a UX choice.)
+ shutdown_status_ = "Waiting for the external node to stop...";
+ for (int i = 0; i < 1200 && stillUp() && !shutdown_complete_; ++i)
+ std::this_thread::sleep_for(std::chrono::milliseconds(100));
+ shutdown_status_ = stillUp() ? "External node still stopping — closing anyway..."
+ : "External node stopped";
+ DEBUG_LOGF("stopEmbeddedDaemon: external node %s\n", stillUp() ? "still up (timed out)" : "confirmed stopped");
+ }
}
bool App::isEmbeddedDaemonRunning() const
@@ -4397,7 +5488,7 @@ bool App::stopDaemonForWalletSwitch()
void App::rescanBlockchain()
{
if (!supportsFullNodeLifecycleActions()) {
- ui::Notifications::instance().warning("Full-node lifecycle actions are unavailable in lite build");
+ ui::Notifications::instance().warning(TR("appx_fullnode_lifecycle_unavailable_lite"));
return;
}
@@ -4412,23 +5503,24 @@ void App::rescanBlockchain()
// Re-entrancy guard: a rescan/repair (both drive state_.sync.rescanning) or this exact task already
// running would stomp each other — a second confirm must not launch a duplicate operation.
if (state_.sync.rescanning || async_tasks_.isRunning(decision.taskName)) {
- ui::Notifications::instance().warning("A blockchain maintenance operation is already in progress.");
+ ui::Notifications::instance().warning(TR("appx_blockchain_maintenance_in_progress"));
return;
}
// Don't race a wallet switch / seed-adopt / encryption restart, which drive their own daemon stop/start.
if (daemon_restarting_) {
- ui::Notifications::instance().warning("The node is busy restarting — try again in a moment.");
+ ui::Notifications::instance().warning(TR("appx_node_busy_restarting"));
return;
}
DEBUG_LOGF("[App] Starting blockchain rescan - stopping daemon first\n");
- ui::Notifications::instance().info("Restarting daemon with -rescan flag...");
+ ui::Notifications::instance().info(TR("appx_restarting_daemon_rescan_flag"));
// Initialize rescan state for status bar display. rescan_confirmed_active_ stays false until we
// actually observe the restarted daemon rescanning — so the first poll (which may still reach the
// pre-restart daemon and see rescanning=false) can't be misread as instant completion.
state_.sync.rescanning = true;
rescan_confirmed_active_ = false;
+ user_initiated_rescan_ = true; // user-triggered rescan → its completion should toast
state_.sync.rescan_progress = 0.0f;
state_.sync.rescan_status = decision.status;
transactions_dirty_ = true;
@@ -4451,7 +5543,7 @@ void App::rescanBlockchain()
void App::repairWallet()
{
if (!supportsFullNodeLifecycleActions()) {
- ui::Notifications::instance().warning("Full-node lifecycle actions are unavailable in lite build");
+ ui::Notifications::instance().warning(TR("appx_fullnode_lifecycle_unavailable_lite"));
return;
}
@@ -4464,22 +5556,23 @@ void App::repairWallet()
}
if (state_.sync.rescanning || async_tasks_.isRunning(decision.taskName)) {
- ui::Notifications::instance().warning("A blockchain maintenance operation is already in progress.");
+ ui::Notifications::instance().warning(TR("appx_blockchain_maintenance_in_progress"));
return;
}
if (daemon_restarting_) {
- ui::Notifications::instance().warning("The node is busy restarting — try again in a moment.");
+ ui::Notifications::instance().warning(TR("appx_node_busy_restarting"));
return;
}
DEBUG_LOGF("[App] Starting wallet repair (-zapwallettxes=2) - stopping daemon first\n");
- ui::Notifications::instance().info("Restarting daemon with -zapwallettxes=2 (wallet repair)...");
+ ui::Notifications::instance().info(TR("appx_restarting_daemon_zapwallettxes"));
// -zapwallettxes=2 deletes and rebuilds every wallet tx/note record, then rescans the whole
// chain — so reuse the rescan status UI (status bar + warmup-end completion detection). Same
// confirmed-active gating as rescan: the first poll may still reach the pre-restart daemon.
state_.sync.rescanning = true;
rescan_confirmed_active_ = false;
+ user_initiated_rescan_ = true; // user-triggered repair (implies rescan) → completion should toast
state_.sync.rescan_progress = 0.0f;
state_.sync.rescan_status = decision.status;
transactions_dirty_ = true;
@@ -4501,11 +5594,11 @@ void App::repairWallet()
void App::reinstallBundledDaemon()
{
if (!supportsFullNodeLifecycleActions()) {
- ui::Notifications::instance().warning("Full-node lifecycle actions are unavailable in lite build");
+ ui::Notifications::instance().warning(TR("appx_fullnode_lifecycle_unavailable_lite"));
return;
}
if (!resources::getBundledDaemonInfo().available) {
- ui::Notifications::instance().warning("This build has no bundled daemon to install");
+ ui::Notifications::instance().warning(TR("appx_no_bundled_daemon_to_install"));
return;
}
// Require embedded-daemon *support*, but NOT an active daemon_controller_. When the wallet is
@@ -4513,17 +5606,17 @@ void App::reinstallBundledDaemon()
// we can still RPC-stop that node, overwrite the binaries, and bring up our own managed daemon —
// which is exactly the state that previously blocked "Install bundled" with a cryptic warning.
if (!supportsEmbeddedDaemon()) {
- ui::Notifications::instance().warning("This build has no embedded daemon to install");
+ ui::Notifications::instance().warning(TR("appx_no_embedded_daemon_to_install"));
return;
}
if (async_tasks_.isRunning("reinstall-daemon")) {
- ui::Notifications::instance().warning("The daemon reinstall is already in progress.");
+ ui::Notifications::instance().warning(TR("appx_daemon_reinstall_in_progress"));
return;
}
DEBUG_LOGF("[App] Reinstalling bundled daemon binary — stopping daemon first\n");
- ui::Notifications::instance().info("Installing bundled daemon — the node will stop, update, and restart...");
+ ui::Notifications::instance().info(TR("appx_installing_bundled_daemon"));
async_tasks_.submit("reinstall-daemon", [this](const util::AsyncTaskManager::Token& token) {
AppDaemonLifecycleRuntime runtime(*this);
@@ -4555,7 +5648,7 @@ void App::reinstallBundledDaemon()
void App::deleteBlockchainData()
{
if (!supportsFullNodeLifecycleActions()) {
- ui::Notifications::instance().warning("Full-node lifecycle actions are unavailable in lite build");
+ ui::Notifications::instance().warning(TR("appx_fullnode_lifecycle_unavailable_lite"));
return;
}
@@ -4568,12 +5661,12 @@ void App::deleteBlockchainData()
}
if (state_.sync.rescanning || async_tasks_.isRunning(decision.taskName)) {
- ui::Notifications::instance().warning("A blockchain maintenance operation is already in progress.");
+ ui::Notifications::instance().warning(TR("appx_blockchain_maintenance_in_progress"));
return;
}
DEBUG_LOGF("[App] Deleting blockchain data - stopping daemon first\n");
- ui::Notifications::instance().info("Stopping daemon and deleting blockchain data...");
+ ui::Notifications::instance().info(TR("appx_stopping_daemon_deleting_blockchain"));
daemon_controller_->prepareLifecycleOperation(decision, settings_.get());
async_tasks_.submit(decision.taskName, [this, decision](const util::AsyncTaskManager::Token& token) {
@@ -4608,17 +5701,26 @@ bool App::stopDaemonForBootstrap()
double App::getDaemonMemoryUsageMB() const
{
+ // The Mining tab reads this every frame, but the probe is expensive (Linux embedded/external paths
+ // scan /proc; macOS forks a shell). Throttle to ~1.5s so a 60Hz caller doesn't hammer the OS.
+ const double now = ImGui::GetTime();
+ if (daemon_mem_probe_at_ > 0.0 && now - daemon_mem_probe_at_ < 1.5)
+ return daemon_mem_cached_mb_;
+ daemon_mem_probe_at_ = now;
+
+ double result = 0.0;
// If we have an embedded daemon with a tracked process handle, use it
// directly — more reliable than a process scan since we own the handle.
if (daemon_controller_ && daemon_controller_->isRunning()) {
double mb = daemon_controller_->memoryUsageMB();
daemon_mem_diag_ = "embedded";
- if (mb > 0.0) return mb;
+ result = (mb > 0.0) ? mb : util::Platform::getDaemonMemoryUsageMB();
} else {
daemon_mem_diag_ = "process scan";
+ result = util::Platform::getDaemonMemoryUsageMB(); // external daemon
}
- // Fall back to platform-level process scan (external daemon)
- return util::Platform::getDaemonMemoryUsageMB();
+ daemon_mem_cached_mb_ = result;
+ return result;
}
// ============================================================================
@@ -4634,6 +5736,16 @@ void App::beginShutdown()
{
// Only start shutdown once
if (shutting_down_) return;
+
+ // Guard: don't silently discard an in-progress witness-cache rebuild. If we're about to stop the
+ // daemon while it's rebuilding (stopping now forces a multi-minute rebuild on the next launch),
+ // defer shutdown and let render() show the confirm modal. The user's choice re-enters beginShutdown()
+ // with shutdown_confirmed_ set (and, for "keep node running", shutdown_keep_daemon_override_).
+ if (!shutdown_confirmed_ && shouldConfirmDaemonStop()) {
+ pending_shutdown_confirm_ = true;
+ return; // NOT shutting down yet — the normal UI + modal keep rendering
+ }
+
shutting_down_ = true;
quit_requested_ = true;
shutdown_timer_ = 0.0f;
@@ -4662,9 +5774,13 @@ void App::beginShutdown()
fast_worker_->requestStop();
}
+ // Drain + join the mining-control thread FIRST so no async start/stop job runs while we tear the miner
+ // down here (avoids two threads driving xmrig_manager_ during shutdown). (M-03 cluster)
+ stopMiningControlThread();
+
// Stop xmrig pool miner before stopping the daemon
if (xmrig_manager_ && xmrig_manager_->isRunning()) {
- shutdown_status_ = "Stopping pool miner...";
+ shutdown_status_ = TR("appx_stopping_pool_miner");
xmrig_manager_->stop(3000);
}
@@ -4676,7 +5792,7 @@ void App::beginShutdown()
// Worker join + RPC disconnect happen in shutdown().
if (!daemon_controller_) {
DEBUG_LOGF("beginShutdown: no embedded daemon, disconnecting only\n");
- shutdown_status_ = "Disconnecting...";
+ shutdown_status_ = TR("appx_disconnecting");
if (settings_) {
settings_->save();
}
@@ -4690,7 +5806,7 @@ void App::beginShutdown()
}
auto shutdownDecision = daemon_controller_->shutdownDecision(
- settings_ && settings_->getKeepDaemonRunning(),
+ (settings_ && settings_->getKeepDaemonRunning()) || shutdown_keep_daemon_override_,
settings_ && settings_->getStopExternalDaemon());
if (shutdownDecision.action == daemon::DaemonController::ShutdownAction::DisconnectOnly) {
DEBUG_LOGF("beginShutdown: %s, skipping daemon stop\n", shutdownDecision.logReason);
@@ -4707,22 +5823,173 @@ void App::beginShutdown()
// modal "Please wait" dialog).
shutdown_thread_ = std::thread([this]() {
DEBUG_LOGF("shutdown thread: calling stopEmbeddedDaemon()\n");
- shutdown_status_ = "Sending stop command to daemon...";
+ shutdown_status_ = TR("appx_sending_stop_command_to_daemon");
// Send RPC stop command
stopEmbeddedDaemon();
DEBUG_LOGF("shutdown thread: daemon stopped, disconnecting RPC\n");
- shutdown_status_ = "Cleaning up...";
+ shutdown_status_ = TR("appx_cleaning_up");
DEBUG_LOGF("shutdown thread: complete\n");
- shutdown_status_ = "Shutdown complete";
+ shutdown_status_ = TR("appx_shutdown_complete");
shutdown_complete_ = true;
});
}
+std::vector App::tailDaemonDebugLog(int maxLines) const
+{
+ std::vector out;
+ if (maxLines <= 0) return out;
+ const std::string path = util::Platform::getDataDir() + "debug.log";
+ std::error_code ec;
+ const auto sz = std::filesystem::file_size(path, ec);
+ if (ec || sz == 0) return out;
+ std::ifstream f(path, std::ios::binary);
+ if (!f) return out;
+
+ // Read only the last ~16 KB — plenty for a handful of lines, cheap even for a multi-GB log.
+ const std::uintmax_t kTailBytes = 16 * 1024;
+ const std::uintmax_t start = sz > kTailBytes ? sz - kTailBytes : 0;
+ f.seekg(static_cast(start), std::ios::beg);
+ std::string chunk(static_cast(sz - start), '\0');
+ f.read(&chunk[0], static_cast(chunk.size()));
+ chunk.resize(static_cast(f.gcount()));
+
+ std::vector lines;
+ std::string cur;
+ for (char c : chunk) {
+ if (c == '\n') { if (!cur.empty()) lines.push_back(cur); cur.clear(); }
+ else if (c != '\r') cur.push_back(c);
+ }
+ if (!cur.empty()) lines.push_back(cur);
+ // When we seeked into the middle of the file the first line is a fragment — drop it.
+ if (start > 0 && !lines.empty()) lines.erase(lines.begin());
+ if (static_cast(lines.size()) > maxLines)
+ lines.erase(lines.begin(), lines.end() - static_cast(maxLines));
+ return lines;
+}
+
+// Parse a "YYYY-MM-DD HH:MM:SS ..." debug.log line prefix to time_t. Interpreted as local time, but it
+// is only ever used for DELTAS between two lines of the SAME log, so the timezone cancels. Returns 0 if
+// the line has no such timestamp prefix.
+static std::time_t parseDaemonLogTimestamp(const std::string& line)
+{
+ int y = 0, mo = 0, d = 0, h = 0, mi = 0, s = 0;
+ if (std::sscanf(line.c_str(), "%d-%d-%d %d:%d:%d", &y, &mo, &d, &h, &mi, &s) != 6) return 0;
+ std::tm tm{};
+ tm.tm_year = y - 1900; tm.tm_mon = mo - 1; tm.tm_mday = d;
+ tm.tm_hour = h; tm.tm_min = mi; tm.tm_sec = s; tm.tm_isdst = -1;
+ return std::mktime(&tm);
+}
+
+bool App::daemonWitnessRebuildActive() const
+{
+ // Scan the debug.log tail for the daemon's witness-rebuild markers (wallet.cpp): "Cleared witness
+ // data from" (start), "Reading blocks for witness rebuild" / "Setting Initial Sapling Witness"
+ // (progress), vs. "rebuilt N note witness cache(s)" / "aborting…" (finished).
+ //
+ // A genuine ongoing rebuild logs progress CONTINUOUSLY. The routine per-tx witness set the daemon
+ // does as each new wallet tx lands during normal sync is SPARSE (minutes apart) and must NOT trip
+ // this — that was firing the "node is rebuilding" prompt on wallets that just receive frequently.
+ // So require the last progress marker to be (a) later than any completion AND (b) part of the CURRENT
+ // activity — within a few seconds of the newest log line (same-log timestamp delta → timezone-free).
+ const auto lines = tailDaemonDebugLog(120);
+ std::time_t newest = 0, lastProgress = 0, lastDone = 0;
+ for (const auto& l : lines) {
+ const std::time_t ts = parseDaemonLogTimestamp(l);
+ if (ts > newest) newest = ts;
+ if (l.find("note witness cache(s) to height") != std::string::npos ||
+ l.find("aborting witness rebuild") != std::string::npos ||
+ l.find("aborted during witness rebuild") != std::string::npos) {
+ if (ts > lastDone) lastDone = ts;
+ } else if (l.find("Reading blocks for witness rebuild") != std::string::npos ||
+ l.find("Setting Initial Sapling Witness") != std::string::npos ||
+ l.find("Cleared witness data from") != std::string::npos) {
+ if (ts > lastProgress) lastProgress = ts;
+ }
+ }
+ if (lastProgress == 0 || lastDone >= lastProgress || newest == 0) return false;
+ return (newest - lastProgress) <= 15; // progress is part of the current activity → ongoing rebuild
+}
+
+bool App::shouldConfirmDaemonStop() const
+{
+ if (!daemon_controller_) return false;
+ // Only relevant when this shutdown would actually STOP the daemon (embedded, or external with
+ // stop-on-exit) — a DisconnectOnly shutdown leaves it running and loses nothing.
+ const auto decision = daemon_controller_->shutdownDecision(
+ settings_ && settings_->getKeepDaemonRunning(),
+ settings_ && settings_->getStopExternalDaemon());
+ if (decision.action != daemon::DaemonController::ShutdownAction::StopDaemon) return false;
+ // v1.3.0+ checkpoints witness-rescan progress, so stopping mid-rebuild resumes on the next start
+ // instead of redoing it from scratch — the warning's premise no longer holds, so don't prompt.
+ // (daemon_version encodes major*1e6 + minor*1e4 + rev*100 + build; v1.3.0 == 1030000.)
+ if (state_.daemon_version >= 1030000) return false;
+ return daemonWitnessRebuildActive();
+}
+
+void App::renderDaemonStopConfirm()
+{
+ using namespace ui::material;
+ if (pending_shutdown_confirm_) {
+ ImGui::OpenPopup("##DaemonStopConfirm");
+ pending_shutdown_confirm_ = false;
+ daemon_stop_confirm_open_ = true;
+ }
+ if (!daemon_stop_confirm_open_) return;
+
+ ImVec2 center = ImGui::GetMainViewport()->GetCenter();
+ ImGui::SetNextWindowPos(center, ImGuiCond_Appearing, ImVec2(0.5f, 0.5f));
+ bool proceed = false;
+ if (ImGui::BeginPopupModal("##DaemonStopConfirm", nullptr,
+ ImGuiWindowFlags_AlwaysAutoResize | ImGuiWindowFlags_NoTitleBar)) {
+ if (Type().subtitle1()) ImGui::PushFont(Type().subtitle1());
+ ImGui::TextUnformatted(TR("appx_node_rebuilding_witness_cache"));
+ if (Type().subtitle1()) ImGui::PopFont();
+ ImGui::Spacing();
+ ImGui::PushTextWrapPos(ImGui::GetFontSize() * 26.0f);
+ ImGui::TextUnformatted(TR("appx_stopping_node_discards_rebuild"));
+ ImGui::PopTextWrapPos();
+ ImGui::Spacing();
+ ImGui::Spacing();
+
+ if (TactileButton(TR("appx_keep_node_running_and_quit"), ImVec2(0, 0))) {
+ shutdown_keep_daemon_override_ = true;
+ shutdown_confirmed_ = true;
+ daemon_stop_confirm_open_ = false;
+ proceed = true;
+ ImGui::CloseCurrentPopup();
+ }
+ ImGui::SameLine();
+ ImGui::PushStyleColor(ImGuiCol_Button, ImGui::ColorConvertU32ToFloat4(WithAlpha(Error(), 210)));
+ ImGui::PushStyleColor(ImGuiCol_ButtonHovered, ImGui::ColorConvertU32ToFloat4(Error()));
+ ImGui::PushStyleColor(ImGuiCol_ButtonActive, ImGui::ColorConvertU32ToFloat4(WithAlpha(Error(), 160)));
+ const bool stopAnyway = TactileButton(TR("appx_stop_anyway_and_quit"), ImVec2(0, 0));
+ ImGui::PopStyleColor(3);
+ if (stopAnyway) {
+ shutdown_confirmed_ = true;
+ daemon_stop_confirm_open_ = false;
+ proceed = true;
+ ImGui::CloseCurrentPopup();
+ }
+ ImGui::SameLine();
+ if (TactileButton(TR("appx_cancel"), ImVec2(0, 0))) {
+ daemon_stop_confirm_open_ = false; // abort the quit; stay open
+ ImGui::CloseCurrentPopup();
+ }
+ ImGui::EndPopup();
+ } else {
+ daemon_stop_confirm_open_ = false; // dismissed via Esc / click-away = Cancel
+ }
+
+ // Re-enter shutdown outside the popup scope now that the user has chosen (shutdown_confirmed_ set).
+ if (proceed) beginShutdown();
+}
+
void App::renderShutdownScreen()
{
using namespace ui::material;
+ const float dp = ui::Layout::dpiScale();
auto shutElem = [](const char* key, float fb) {
float v = ui::schema::UI().drawElement("components.shutdown", key).size;
return v >= 0 ? v : fb;
@@ -4735,7 +6002,8 @@ void App::renderShutdownScreen()
// it's never impossible to escape.
static std::string s_lastShutStatus;
static float s_shutStallTimer = 0.0f;
- if (shutdown_status_ != s_lastShutStatus) { s_lastShutStatus = shutdown_status_; s_shutStallTimer = 0.0f; }
+ const std::string curShut = shutdown_status_.get(); // one consistent snapshot per frame (M-05)
+ if (curShut != s_lastShutStatus) { s_lastShutStatus = curShut; s_shutStallTimer = 0.0f; }
else s_shutStallTimer += ImGui::GetIO().DeltaTime;
const bool shutdownStalled = s_shutStallTimer >= 8.0f;
const bool allowForceQuit = shutdownStalled || shutdown_timer_ >= 20.0f;
@@ -4775,7 +6043,7 @@ void App::renderShutdownScreen()
// -------------------------------------------------------------------
float lineH = ImGui::GetTextLineHeightWithSpacing();
float titleH = Type().h5() ? Type().h5()->LegacySize : lineH * 1.5f;
- float spinnerD = shutElem("spinner-radius", 20.0f) * 2.0f + 12.0f;
+ float spinnerD = shutElem("spinner-radius", 20.0f) * dp * 2.0f + 12.0f * dp;
float statusH = lineH * 2.0f;
float sepH = lineH;
float panelH = shutElem("panel-max-height", 160.0f);
@@ -4806,10 +6074,10 @@ void App::renderShutdownScreen()
// 2. Animated arc spinner
// -------------------------------------------------------------------
{
- float r = shutElem("spinner-radius", 20.0f);
- float thick = shutElem("spinner-thickness", 3.0f);
+ float r = shutElem("spinner-radius", 20.0f) * dp;
+ float thick = shutElem("spinner-thickness", 3.0f) * dp;
// Screen-space centre for draw list
- ImVec2 sc(wp.x + cx, wp.y + ImGui::GetCursorPosY() + r + 2.0f);
+ ImVec2 sc(wp.x + cx, wp.y + ImGui::GetCursorPosY() + r + 2.0f * dp);
// Background ring (dim)
dl->PathArcTo(sc, r, 0.0f, kPi * 2.0f, 48);
@@ -4823,7 +6091,7 @@ void App::renderShutdownScreen()
dl->PathStroke(ui::schema::UI().resolveColor("var(--spinner-active)", IM_COL32(255, 218, 0, 200)), 0, thick);
// Advance cursor past the spinner
- ImGui::Dummy(ImVec2(0, r * 2.0f + 8.0f));
+ ImGui::Dummy(ImVec2(0, r * 2.0f + 8.0f * dp));
}
ImGui::Spacing();
@@ -4831,11 +6099,11 @@ void App::renderShutdownScreen()
// -------------------------------------------------------------------
// 3. Phase status (what the shutdown thread is doing)
// -------------------------------------------------------------------
- if (!shutdown_status_.empty()) {
- ImVec2 ts = ImGui::CalcTextSize(shutdown_status_.c_str());
+ if (!curShut.empty()) {
+ ImVec2 ts = ImGui::CalcTextSize(curShut.c_str());
ImGui::SetCursorPosX(cx - ts.x * 0.5f);
ImGui::PushStyleColor(ImGuiCol_Text, ImVec4(0.75f, 0.75f, 0.75f, 1.0f));
- ImGui::TextUnformatted(shutdown_status_.c_str());
+ ImGui::TextUnformatted(curShut.c_str());
ImGui::PopStyleColor();
}
@@ -4848,9 +6116,9 @@ void App::renderShutdownScreen()
char elapsed[64];
int secs = (int)shutdown_timer_;
if (secs < 60)
- snprintf(elapsed, sizeof(elapsed), "%d seconds", secs);
+ snprintf(elapsed, sizeof(elapsed), TR("appx_n_seconds"), secs);
else
- snprintf(elapsed, sizeof(elapsed), "%d min %d sec", secs / 60, secs % 60);
+ snprintf(elapsed, sizeof(elapsed), TR("appx_n_min_n_sec"), secs / 60, secs % 60);
ImGui::PushFont(Type().caption());
ImVec2 ts = ImGui::CalcTextSize(elapsed);
@@ -4869,8 +6137,9 @@ void App::renderShutdownScreen()
ImGui::Spacing();
// State-aware caution: while the status is a daemon flush/exit step, force-quitting risks the
// chainstate; say so instead of a bare button.
- if (shutdownStalled && !shutdown_status_.empty()) {
- std::string stalledMsg = "Still \"" + shutdown_status_ + "\" — force quitting now may corrupt chain data.";
+ if (shutdownStalled && !curShut.empty()) {
+ std::string stalledMsg = std::string(TR("appx_still_status_prefix")) + curShut +
+ TR("appx_still_status_suffix");
ImVec2 ms = ImGui::CalcTextSize(stalledMsg.c_str());
ImGui::SetCursorPosX(cx - ms.x * 0.5f);
ImGui::PushStyleColor(ImGuiCol_Text, ImGui::ColorConvertU32ToFloat4(Warning()));
@@ -4879,7 +6148,7 @@ void App::renderShutdownScreen()
ImGui::Spacing();
}
const char* forceLabel = TR("force_quit");
- ImVec2 btnSize(ImGui::CalcTextSize(forceLabel).x + 32.0f, 0);
+ ImVec2 btnSize(ImGui::CalcTextSize(forceLabel).x + 32.0f * dp, 0);
ImGui::SetCursorPosX(cx - btnSize.x * 0.5f);
ImGui::PushStyleColor(ImGuiCol_Button, ImVec4(0.6f, 0.15f, 0.15f, 0.9f));
ImGui::PushStyleColor(ImGuiCol_ButtonHovered, ImVec4(0.75f, 0.2f, 0.2f, 1.0f));
@@ -4909,7 +6178,7 @@ void App::renderShutdownScreen()
ImGui::Spacing();
ImGui::Spacing();
- float btnW = 120.0f;
+ float btnW = 120.0f * dp;
float totalW = btnW * 2 + ImGui::GetStyle().ItemSpacing.x;
ImGui::SetCursorPosX((ImGui::GetWindowWidth() - totalW) * 0.5f);
@@ -4941,7 +6210,7 @@ void App::renderShutdownScreen()
ImVec2 p0(wp.x + pad, wp.y + ImGui::GetCursorPosY());
ImVec2 p1(wp.x + vp_size.x - pad, p0.y);
dl->AddLine(p0, p1, ui::schema::UI().resolveColor("var(--status-divider)", IM_COL32(255, 255, 255, 30)), 1.0f);
- ImGui::Dummy(ImVec2(0, 4.0f));
+ ImGui::Dummy(ImVec2(0, 4.0f * dp));
}
ImGui::Spacing();
@@ -4951,6 +6220,10 @@ void App::renderShutdownScreen()
// -------------------------------------------------------------------
if (daemon_controller_) {
auto lines = daemon_controller_->recentLines(8);
+ // External daemon (attached, not spawned) has no captured stdout — tail its debug.log directly
+ // so the user can still watch the node flush the block index and exit.
+ if (lines.empty())
+ lines = tailDaemonDebugLog(8);
if (!lines.empty()) {
float panelW = vp_size.x * shutElem("panel-width-fraction", 0.70f);
float panelX = cx - panelW * 0.5f;
@@ -4970,7 +6243,7 @@ void App::renderShutdownScreen()
ImGui::GetCursorPosY() + panelPad));
ImGui::PushFont(Type().caption());
ImGui::PushStyleColor(ImGuiCol_Text, ImVec4(0.45f, 0.45f, 0.50f, 1.0f));
- ImGui::TextUnformatted("dragonxd output");
+ ImGui::TextUnformatted(TR("appx_dragonxd_output"));
ImGui::PopStyleColor();
ImGui::PopFont();
@@ -5010,7 +6283,7 @@ void App::renderShutdownScreen()
// Advance cursor past the panel
float panelBottom = panelMax.y - wp.y;
- ImGui::SetCursorPosY(panelBottom + 4.0f);
+ ImGui::SetCursorPosY(panelBottom + 4.0f * dp);
ImGui::Dummy(ImVec2(0, 0));
}
}
@@ -5029,6 +6302,10 @@ void App::renderLoadingOverlay(float contentH)
using namespace ui::material;
constexpr float kPi = 3.14159265f;
+ // The wallet-recovery dialog owns the screen while a salvage is pending — suppress the loading
+ // spinner underneath it so "still loading" and "make a decision" are never the same screen.
+ if (show_wallet_recovered_dialog_) return;
+
auto loadElem = [](const char* key, float fb) {
float v = ui::schema::UI().drawElement("screens.loading", key).size;
return v >= 0 ? v : fb;
@@ -5040,12 +6317,17 @@ void App::renderLoadingOverlay(float contentH)
ImVec2 wp = ImGui::GetWindowPos();
ImVec2 ws = ImGui::GetWindowSize();
+ // Hand-drawn geometry here is in logical px; the fonts drawn between the elements are DPI-baked,
+ // so every spinner/bar/gap constant must be multiplied by dpiScale or it renders native-tiny and
+ // the spacing/centering drifts at HiDPI / font_scale > 1. (Font metrics are already scaled.)
+ const float dpi = ui::Layout::dpiScale();
+
// Layout constants
float lineH = ImGui::GetTextLineHeightWithSpacing();
- float spinnerR = loadElem("spinner-radius", 18.0f);
- float gap = loadElem("vertical-gap", 8.0f);
- float barH = loadElem("progress-bar", 6.0f);
- float barW = loadElem("progress-width", 260.0f);
+ float spinnerR = loadElem("spinner-radius", 18.0f) * dpi;
+ float gap = loadElem("vertical-gap", 8.0f) * dpi;
+ float barH = loadElem("progress-bar", 6.0f) * dpi;
+ float barW = loadElem("progress-width", 260.0f) * dpi;
float cx = ws.x * 0.5f; // centre X (local coords)
// Estimate total block height for vertical centering
@@ -5061,8 +6343,8 @@ void App::renderLoadingOverlay(float contentH)
// -------------------------------------------------------------------
{
float r = spinnerR;
- float thick = loadElem("spinner-thickness", 2.5f);
- ImVec2 sc(wp.x + cx, curY + r + 2.0f);
+ float thick = loadElem("spinner-thickness", 2.5f) * dpi;
+ ImVec2 sc(wp.x + cx, curY + r + 2.0f * dpi);
// Background ring (dim)
dl->PathArcTo(sc, r, 0.0f, kPi * 2.0f, 48);
@@ -5075,7 +6357,63 @@ void App::renderLoadingOverlay(float contentH)
dl->PathStroke(ui::schema::UI().resolveColor("var(--spinner-active)",
IM_COL32(255, 218, 0, 200)), 0, thick);
- curY += r * 2.0f + gap + 4.0f;
+ curY += r * 2.0f + gap + 4.0f * dpi;
+ }
+
+ // -------------------------------------------------------------------
+ // Post-repair rescan — a calm, recovery-aware screen (not the generic "daemon stuck / RPC timeout /
+ // restart daemon" text). The daemon was intentionally restarted with a full rescan, which
+ // legitimately takes minutes and won't answer RPC yet; reassure + show elapsed and the growing size.
+ // -------------------------------------------------------------------
+ // Only while the daemon is actually alive-and-rescanning. If it EXITED (crashed for a reason distinct
+ // from the wallet file), fall through to the normal error/stall UI — tryConnect() clears the flag, but
+ // gate here too so a lingering frame never shows "everything's fine" over a dead daemon.
+ if (post_recovery_rescan_ &&
+ !(daemon_controller_ && daemon_controller_->state() == daemon::EmbeddedDaemon::State::Error)) {
+ if (post_recovery_rescan_since_ <= 0.0) post_recovery_rescan_since_ = ImGui::GetTime();
+ ImFont* titleF = Type().subtitle1(); if (!titleF) titleF = ImGui::GetFont();
+ ImFont* capF = Type().caption(); if (!capF) capF = ImGui::GetFont();
+
+ auto centeredLine = [&](ImFont* f, ImU32 col, const char* s, float wrapW) {
+ ImVec2 ts = f->CalcTextSizeA(f->LegacySize, FLT_MAX, wrapW, s);
+ float x = (wrapW > 0.0f) ? (wp.x + cx - wrapW * 0.5f) : (wp.x + cx - ts.x * 0.5f);
+ dl->AddText(f, f->LegacySize, ImVec2(x, curY), col, s, nullptr, wrapW);
+ curY += ts.y + gap;
+ };
+
+ centeredLine(titleF, IM_COL32(230, 210, 90, 235), TR("wallet_recovery_rescan_title"), 0.0f);
+ float wrapW = ws.x * 0.8f; if (wrapW > 620.0f * dpi) wrapW = 620.0f * dpi;
+ centeredLine(capF, IM_COL32(200, 200, 200, 220), TR("wallet_recovery_rescan_body"), wrapW);
+
+ // Elapsed + the growing wallet size — concrete "it's working" feedback while RPC is silent. Recompute
+ // at most once a second (the display granularity) to avoid a stat()+format on every frame.
+ int secs = (int)(ImGui::GetTime() - post_recovery_rescan_since_); if (secs < 0) secs = 0;
+ static int s_lastSec = -1;
+ static std::string s_elapsed, s_size;
+ if (secs != s_lastSec) {
+ s_lastSec = secs;
+ char clock[16]; snprintf(clock, sizeof(clock), "%d:%02d", secs / 60, secs % 60);
+ char ebuf[96]; snprintf(ebuf, sizeof(ebuf), TR("wallet_recovery_rescan_elapsed"), clock);
+ s_elapsed = ebuf;
+ const std::string walletPath = util::Platform::getDragonXDataDir() + "/" +
+ ((settings_ && !settings_->getActiveWalletFile().empty())
+ ? settings_->getActiveWalletFile() : std::string("wallet.dat"));
+ uint64_t wsz = util::Platform::getFileSize(walletPath);
+ if (wsz > 0) { char sbuf[96]; snprintf(sbuf, sizeof(sbuf), TR("wallet_recovery_rescan_size"),
+ util::Platform::formatFileSize(wsz).c_str()); s_size = sbuf; }
+ else s_size.clear();
+ }
+ curY += gap * 0.5f;
+ centeredLine(capF, IM_COL32(150, 150, 150, 220), s_elapsed.c_str(), 0.0f);
+ if (!s_size.empty()) centeredLine(capF, IM_COL32(130, 130, 130, 210), s_size.c_str(), 0.0f);
+
+ // Backstop for a genuinely slow (but still running) rescan: after several minutes add a gentle
+ // "it's safe to leave running / watch the Console" line — never the scary "restart the node".
+ if (secs > 600) {
+ curY += gap * 0.5f;
+ centeredLine(capF, IM_COL32(150, 150, 150, 200), TR("wallet_recovery_rescan_slow"), wrapW);
+ }
+ return; // skip the generic status / stall / error / daemon-log sections
}
// -------------------------------------------------------------------
@@ -5085,11 +6423,23 @@ void App::renderLoadingOverlay(float contentH)
const char* statusText = connection_status_.c_str();
ImFont* font = Type().subtitle1();
if (!font) font = ImGui::GetFont();
- ImVec2 ts = font->CalcTextSizeA(font->LegacySize, FLT_MAX, 0.0f, statusText);
- dl->AddText(font, font->LegacySize,
- ImVec2(wp.x + cx - ts.x * 0.5f, curY),
- IM_COL32(220, 220, 220, 255), statusText);
- curY += ts.y + gap;
+ // Short statuses (the common case: "Connected", "Starting daemon") stay centered; long ones
+ // (a full dir_error path, a libcurl connect error) wrap to a clamped box instead of running
+ // off both edges of the overlay — the same clamp the daemon-error blocks below use.
+ float wrapW = ws.x * 0.8f; if (wrapW > 640.0f * dpi) wrapW = 640.0f * dpi;
+ ImVec2 full = font->CalcTextSizeA(font->LegacySize, FLT_MAX, 0.0f, statusText);
+ if (full.x <= wrapW) {
+ dl->AddText(font, font->LegacySize,
+ ImVec2(wp.x + cx - full.x * 0.5f, curY),
+ IM_COL32(220, 220, 220, 255), statusText);
+ curY += full.y + gap;
+ } else {
+ ImVec2 ts = font->CalcTextSizeA(font->LegacySize, FLT_MAX, wrapW, statusText);
+ dl->AddText(font, font->LegacySize,
+ ImVec2(wp.x + cx - wrapW * 0.5f, curY),
+ IM_COL32(220, 220, 220, 255), statusText, nullptr, wrapW);
+ curY += ts.y + gap;
+ }
}
// -------------------------------------------------------------------
@@ -5113,7 +6463,7 @@ void App::renderLoadingOverlay(float contentH)
float progress = state_.sync.witness_progress;
if (progress < 0.0f) progress = 0.0f;
if (progress > 1.0f) progress = 1.0f;
- float barRadius = loadElem("progress-bar", 3.0f);
+ float barRadius = loadElem("progress-bar", 3.0f) * dpi;
float barX = wp.x + cx - barW * 0.5f;
ImVec2 barMin(barX, curY);
ImVec2 barMax(barX + barW, curY + barH);
@@ -5128,14 +6478,14 @@ void App::renderLoadingOverlay(float contentH)
char wbuf[128];
if (state_.sync.witness_phase == 2 && progress > 0.01f && state_.sync.witness_remaining > 0)
- snprintf(wbuf, sizeof(wbuf), "Rebuilding witness cache %.0f%% — %d blocks left",
+ snprintf(wbuf, sizeof(wbuf), TR("appx_rebuilding_witness_cache_blocks_left"),
progress * 100.0f, state_.sync.witness_remaining);
else if (state_.sync.witness_phase == 2 && progress > 0.01f)
- snprintf(wbuf, sizeof(wbuf), "Rebuilding witness cache %.0f%%", progress * 100.0f);
+ snprintf(wbuf, sizeof(wbuf), TR("appx_rebuilding_witness_cache_pct"), progress * 100.0f);
else if (progress > 0.01f)
- snprintf(wbuf, sizeof(wbuf), "Setting initial Sapling witnesses %.0f%%", progress * 100.0f);
+ snprintf(wbuf, sizeof(wbuf), TR("appx_setting_initial_sapling_witnesses"), progress * 100.0f);
else
- snprintf(wbuf, sizeof(wbuf), "Rebuilding Sapling note witnesses…");
+ snprintf(wbuf, sizeof(wbuf), "%s", TR("appx_rebuilding_sapling_note_witnesses"));
ImFont* capFont = Type().caption();
if (!capFont) capFont = ImGui::GetFont();
ImVec2 ts = capFont->CalcTextSizeA(capFont->LegacySize, FLT_MAX, 0.0f, wbuf);
@@ -5150,7 +6500,7 @@ void App::renderLoadingOverlay(float contentH)
// -------------------------------------------------------------------
if (state_.connected && state_.sync.syncing) {
float progress = static_cast(state_.sync.verification_progress);
- float barRadius = loadElem("progress-bar", 3.0f);
+ float barRadius = loadElem("progress-bar", 3.0f) * dpi;
float barX = wp.x + cx - barW * 0.5f;
ImVec2 barMin(barX, curY);
@@ -5173,7 +6523,7 @@ void App::renderLoadingOverlay(float contentH)
// Progress text — "Syncing 45.2% — Block 123456 / 234567"
char syncBuf[128];
- snprintf(syncBuf, sizeof(syncBuf), "Syncing %.1f%% — Block %d / %d",
+ snprintf(syncBuf, sizeof(syncBuf), TR("appx_syncing_pct_block_n_of_n"),
progress * 100.0f, state_.sync.blocks, state_.sync.headers);
ImFont* capFont = Type().caption();
if (!capFont) capFont = ImGui::GetFont();
@@ -5185,7 +6535,7 @@ void App::renderLoadingOverlay(float contentH)
} else if (!state_.connected && state_.sync.blocks > 0) {
// Show last known block height while reconnecting
char blockBuf[64];
- snprintf(blockBuf, sizeof(blockBuf), "Last block: %d", state_.sync.blocks);
+ snprintf(blockBuf, sizeof(blockBuf), TR("appx_last_block_n"), state_.sync.blocks);
ImFont* capFont = Type().caption();
if (!capFont) capFont = ImGui::GetFont();
ImVec2 ts = capFont->CalcTextSizeA(capFont->LegacySize, FLT_MAX, 0.0f, blockBuf);
@@ -5204,8 +6554,8 @@ void App::renderLoadingOverlay(float contentH)
if (!capFont) capFont = ImGui::GetFont();
const char* encLabel = encrypt_in_progress_
- ? "Encrypting wallet..."
- : "Waiting for daemon to encrypt wallet...";
+ ? TR("appx_encrypting_wallet")
+ : TR("appx_waiting_for_daemon_to_encrypt_wallet");
ImVec2 ts = capFont->CalcTextSizeA(capFont->LegacySize, FLT_MAX, 0.0f, encLabel);
ImU32 encCol = IM_COL32(255, 218, 0, 200);
dl->AddText(capFont, capFont->LegacySize,
@@ -5214,7 +6564,7 @@ void App::renderLoadingOverlay(float contentH)
// Indeterminate progress bar
float encBarW = barW * 0.6f;
- float encBarH = 4.0f;
+ float encBarH = 4.0f * dpi;
float encBarX = wp.x + cx - encBarW * 0.5f;
dl->AddRectFilled(ImVec2(encBarX, curY), ImVec2(encBarX + encBarW, curY + encBarH),
IM_COL32(255, 255, 255, 20), 2.0f);
@@ -5231,7 +6581,8 @@ void App::renderLoadingOverlay(float contentH)
// 3c. Daemon crash error message
// -------------------------------------------------------------------
if (daemon_controller_ &&
- daemon_controller_->state() == daemon::EmbeddedDaemon::State::Error) {
+ daemon_controller_->state() == daemon::EmbeddedDaemon::State::Error &&
+ !wallet_auto_recovered_) { // a salvage is NOT a crash — it has its own recovery dialog
curY += gap;
ImFont* capFont = Type().caption();
if (!capFont) capFont = ImGui::GetFont();
@@ -5239,28 +6590,27 @@ void App::renderLoadingOverlay(float contentH)
if (!bodyFont2) bodyFont2 = ImGui::GetFont();
// Error title
- const char* errTitle = "Daemon Error";
+ const char* errTitle = TR("appx_daemon_error");
ImVec2 ts = bodyFont2->CalcTextSizeA(bodyFont2->LegacySize, FLT_MAX, 0.0f, errTitle);
dl->AddText(bodyFont2, bodyFont2->LegacySize,
ImVec2(wp.x + cx - ts.x * 0.5f, curY),
IM_COL32(255, 90, 90, 255), errTitle);
curY += ts.y + gap * 0.5f;
- // Error details (wrapped) — show full diagnostic info
+ // Error details (wrapped) — full diagnostic info for a genuine (non-recovery) crash.
const std::string& errDetail = daemon_controller_->lastError();
if (!errDetail.empty()) {
float wrapW = ws.x * 0.8f;
- if (wrapW > 700.0f) wrapW = 700.0f;
+ if (wrapW > 700.0f * dpi) wrapW = 700.0f * dpi;
ImVec2 es = capFont->CalcTextSizeA(capFont->LegacySize, FLT_MAX, wrapW, errDetail.c_str());
dl->AddText(capFont, capFont->LegacySize,
ImVec2(wp.x + cx - wrapW * 0.5f, curY),
IM_COL32(255, 180, 180, 220), errDetail.c_str(), nullptr, wrapW);
curY += es.y + gap;
}
-
// Crash count hint
if (daemon_controller_->crashCount() >= 3) {
- const char* hint = "Use Settings > Restart Daemon to try again";
+ const char* hint = TR("appx_use_settings_restart_daemon_hint");
ImVec2 hs2 = capFont->CalcTextSizeA(capFont->LegacySize, FLT_MAX, 0.0f, hint);
dl->AddText(capFont, capFont->LegacySize,
ImVec2(wp.x + cx - hs2.x * 0.5f, curY),
@@ -5269,22 +6619,27 @@ void App::renderLoadingOverlay(float contentH)
}
}
+ // 3d. The "Taking longer than expected" stall notice was intentionally removed — it added
+ // clutter to the startup screen. The live daemon-output panel below is the real signal that
+ // the node is making progress. (connect_stall_since_ is still maintained in app_network.cpp
+ // for connection bookkeeping; it just no longer drives any on-screen text.)
+
// -------------------------------------------------------------------
// 4. Daemon output snippet (last few lines, if embedded)
// -------------------------------------------------------------------
- if (daemon_controller_) {
+ if (daemon_controller_ && !wallet_auto_recovered_) { // recovery moves the log behind the dialog's disclosure
auto lines = daemon_controller_->recentLines(8);
if (!lines.empty()) {
curY += gap;
float panelW = ws.x * 0.85f;
- if (panelW > 900.0f) panelW = 900.0f;
+ if (panelW > 900.0f * dpi) panelW = 900.0f * dpi;
float panelX = wp.x + cx - panelW * 0.5f;
- float panelPad = 8.0f;
+ float panelPad = 8.0f * dpi;
ImFont* capFont = Type().caption();
if (!capFont) capFont = ImGui::GetFont();
- float panelLineH = capFont->LegacySize + 4.0f;
+ float panelLineH = capFont->LegacySize + 4.0f * dpi;
float panelContentH = panelPad * 2.0f + panelLineH * (float)lines.size();
ImVec2 panelMin(panelX, curY);
@@ -5320,6 +6675,10 @@ void App::renderLoadingOverlay(float contentH)
void App::shutdown()
{
+ // Ensure the mining-control thread is stopped + joined (idempotent; beginShutdown already did it on the
+ // normal quit path, but shutdown() can also run without it). Must precede xmrig_manager_ teardown.
+ stopMiningControlThread();
+
// Wipe any copied secret from the OS clipboard before we exit — the 45s auto-clear timer
// never fires if the user quits sooner, which would otherwise leave a key/seed resident.
// (ImGui context is still alive here; App::shutdown() runs before ImGui::DestroyContext().)
@@ -5524,7 +6883,7 @@ void App::copySecretToClipboard(const std::string& secret)
clipboard_secret_hash_ = secret.empty() ? 0 : h;
clipboard_clear_deadline_ = secret.empty() ? 0.0 : (ImGui::GetTime() + 45.0);
if (!secret.empty())
- ui::Notifications::instance().info("Copied — clipboard auto-clears in 45s", 4.0f);
+ ui::Notifications::instance().info(TR("appx_copied_clipboard_autoclears"), 4.0f);
}
void App::clearSecretClipboardIfArmed()
@@ -5559,10 +6918,66 @@ void App::maybeFinishTransactionSendProgress()
if (addresses_dirty_ || network_refresh_.jobInProgress(Job::Addresses)) return;
send_progress_active_ = false;
}
+std::string App::buildDiagnosticsReport()
+{
+ std::ostringstream os;
+ os << "=== ObsidianDragon diagnostics ===\n";
+ os << "version: " << DRAGONX_VERSION << "\n";
+#if DRAGONX_LITE_BUILD
+ os << "variant: Lite\n";
+#else
+ os << "variant: Full-node\n";
+#endif
+#if defined(_WIN32)
+ os << "platform: windows\n";
+#elif defined(__APPLE__)
+ os << "platform: macos\n";
+#else
+ os << "platform: linux\n";
+#endif
+ os << "connected: " << (state_.connected ? "yes" : "no") << "\n";
+ os << "status: " << connection_status_ << "\n";
+
+ const std::string activeWallet = settings_ ? settings_->getActiveWalletFile() : std::string("(none)");
+ os << "active wallet: " << activeWallet << "\n";
+ {
+ std::error_code ec;
+ const std::string wp = util::Platform::getDragonXDataDir() + "/" + activeWallet;
+ const bool present = std::filesystem::exists(wp, ec);
+ os << " path: " << wp << (present ? " [present" : " [MISSING");
+ if (present) { auto sz = std::filesystem::file_size(wp, ec); if (!ec) os << ", " << sz << " bytes"; }
+ os << "]\n";
+ }
+ os << "encryption: "
+ << (state_.encryption_state_known
+ ? (state_.encrypted ? (state_.locked ? "encrypted, locked" : "encrypted, unlocked") : "unencrypted")
+ : "unknown")
+ << "\n";
+ os << "sync: block " << state_.sync.blocks << " / " << state_.sync.headers
+ << (state_.sync.syncing ? " (syncing)" : "")
+ << (state_.warming_up ? " (warming up)" : "") << "\n";
+
+#if !DRAGONX_LITE_BUILD
+ os << "daemon status: " << daemon_status_ << "\n";
+ if (daemon_controller_) {
+ os << "daemon running: " << (daemon_controller_->isRunning() ? "yes" : "no")
+ << ", crashes: " << daemon_controller_->crashCount() << "\n";
+ const std::string derr = daemon_controller_->lastError();
+ if (!derr.empty()) os << "daemon lastError: " << derr << "\n";
+ }
+#endif
+
+ const std::string cfg = util::Platform::getObsidianDragonDir();
+ os << "log folder: " << cfg << "\n";
+ os << " " << cfg << "/dragonx-debug.log\n";
+ os << " " << cfg << "/dragonx-crash.log\n";
+ return os.str();
+}
+
void App::restartDaemon()
{
if (!supportsFullNodeLifecycleActions()) {
- ui::Notifications::instance().warning("Full-node lifecycle actions are unavailable in lite build");
+ ui::Notifications::instance().warning(TR("appx_fullnode_lifecycle_unavailable_lite"));
return;
}
@@ -5595,4 +7010,25 @@ void App::restartDaemon()
});
}
+// One-click recovery for an unreadable block database: arm the one-shot -reindex flag, then un-gate the
+// connect loop (which detected the abort and stopped restarting). Its next attempt calls
+// startEmbeddedDaemon(), which consumes the flag → the node rebuilds its block index + chainstate from
+// the raw blocks. The daemon is not running here (it aborted), so no explicit stop/restart is needed.
+void App::reindexBlockDatabase()
+{
+ if (!supportsFullNodeLifecycleActions()) {
+ ui::Notifications::instance().warning(TR("appx_fullnode_lifecycle_unavailable_lite"));
+ return;
+ }
+ if (!daemon_controller_) return;
+ daemon_controller_->setReindexOnNextStart(true);
+ user_initiated_rescan_ = true; // reindex implies a rescan → its completion should toast
+ daemon_controller_->resetCrashCount(); // the abort no longer counts against the restart budget
+ show_block_db_reindex_confirm_ = false;
+ block_db_reindex_available_ = false; // un-gate → the connect loop restarts the node with -reindex
+ connection_status_ = TR("sb_starting_daemon");
+ ui::Notifications::instance().info(TR("block_db_reindex_started"), 12.0f);
+ DEBUG_LOGF("[App] Block-database reindex requested — restarting node with -reindex\n");
+}
+
} // namespace dragonx
diff --git a/src/app.h b/src/app.h
index 9dea83e..dbb6794 100644
--- a/src/app.h
+++ b/src/app.h
@@ -14,6 +14,7 @@
#include
#include
#include
+#include
#include
#include "data/transaction_history_cache.h"
#include "data/address_book.h"
@@ -71,14 +72,28 @@ enum class EncryptDialogPhase {
Done // Finished — close dialog
};
+// A status string written by a background/worker thread and read every frame by the UI thread. Its
+// operator= locks, so all the plain `x = "..."` assignment sites stay unchanged; readers call get()
+// for a consistent per-frame snapshot instead of racing a non-atomic std::string. (M-05, L-06)
+class GuardedStatus {
+public:
+ GuardedStatus() = default;
+ GuardedStatus& operator=(std::string v) { std::lock_guard lk(m_); v_ = std::move(v); return *this; }
+ std::string get() const { std::lock_guard lk(m_); return v_; }
+private:
+ mutable std::mutex m_;
+ std::string v_;
+};
+
/**
* @brief Main application class
- *
+ *
* Manages application state, RPC connection, and coordinates UI rendering.
*/
class App {
public:
App();
+ void wipeSecrets(); // scrub all resident secret buffers; called from ~App() AND the forced-exit path (L-05)
~App();
// Non-copyable
@@ -147,6 +162,18 @@ public:
bool isLiteBuild() const { return wallet::isLiteBuild(walletCapabilities()); }
bool supportsEmbeddedDaemon() const { return wallet::supportsEmbeddedDaemon(walletCapabilities()); }
bool supportsFullNodeLifecycleActions() const { return wallet::supportsFullNodeLifecycleActions(walletCapabilities()); }
+
+ // Daemon (v1.3.0+) coinbase auto-shield status, from z_autoshieldstatus. "Not probed" / all-false on
+ // pre-1.3.0 daemons (no such RPC) — callers treat that as "the wallet handles auto-shield itself".
+ bool daemonAutoShieldProbed() const { return daemon_autoshield_probed_; }
+ bool daemonAutoShieldActive() const { return daemon_autoshield_active_; }
+ const std::string& daemonAutoShieldAddress() const { return daemon_autoshield_address_; }
+ const std::string& daemonAutoShieldDisabledReason() const { return daemon_autoshield_disabled_reason_; }
+ bool daemonAutoShieldSeedRecoverable() const { return daemon_autoshield_seed_recoverable_; }
+
+ // W7 QoL: a plaintext support-diagnostics snapshot (version, variant, daemon/RPC/wallet/log state)
+ // for the "Copy diagnostics" action. Contains no secrets.
+ std::string buildDiagnosticsReport();
bool supportsSoloMining() const { return wallet::supportsSoloMining(walletCapabilities()); }
bool supportsPoolMining() const { return wallet::supportsPoolMining(walletCapabilities()); }
bool supportsLiteBackend() const { return wallet::supportsLiteBackend(walletCapabilities()); }
@@ -156,6 +183,21 @@ public:
*/
void renderShutdownScreen();
+ /**
+ * @brief Tail the last N lines of the daemon's debug.log (best-effort, reads only the file tail).
+ * Fallback for the shutdown screen when we have no captured stdout — e.g. an external daemon we
+ * attached to rather than spawned — so the user can still see the node flushing/exiting.
+ */
+ std::vector tailDaemonDebugLog(int maxLines) const;
+
+ // True when the daemon's debug.log shows an in-progress Sapling witness-cache rebuild (best-effort
+ // heuristic). Stopping the daemon during one discards it and forces a multi-minute redo next launch.
+ bool daemonWitnessRebuildActive() const;
+ // Whether beginShutdown() should pause and confirm before stopping the daemon (rebuild in progress).
+ bool shouldConfirmDaemonStop() const;
+ // The "node is rebuilding — stop anyway / keep running / cancel" modal, rendered from render().
+ void renderDaemonStopConfirm();
+
/**
* @brief Render loading overlay in content area while daemon is starting/syncing
* @param contentH Height of the content area child window
@@ -352,6 +394,10 @@ public:
// Force refresh
void refreshNow();
+ // Called on window restore: drop the daemon-output backlog that accumulated while minimized (the
+ // per-frame update loop was paused), so a background witness rebuild that started+finished during
+ // the minimize isn't parsed in one batch and mistaken for a completed rescan (spurious toast).
+ void skipDaemonOutputBacklog();
void refreshMiningInfo();
void refreshPeerInfo();
void refreshMarketData();
@@ -389,6 +435,9 @@ public:
// each under every skin. Output: /screenshots-full//.png + an index.
void startFullUiSweep();
std::string screenshotFullDir() const;
+ // Debug option: restrict either sweep to just the currently-active theme instead of cycling all.
+ bool sweepCurrentThemeOnly() const { return sweep_current_theme_only_; }
+ void setSweepCurrentThemeOnly(bool v) { sweep_current_theme_only_ = v; }
bool isScreenshotSweeping() const { return screenshot_sweep_active_; }
bool wantsScreenshotThisFrame() const { return sweep_capture_this_frame_; }
const std::string& screenshotSweepPath() const { return sweep_current_path_; }
@@ -414,6 +463,7 @@ public:
return 0;
}
void showAboutDialog() { show_about_ = true; }
+ void showFaqDialog() { show_faq_ = true; }
// Legacy tab compat — maps int to NavPage
void setCurrentTab(int tab);
@@ -711,6 +761,22 @@ private:
// wallets. In-memory only (resets on app restart).
std::map chat_seen_watermark_;
+ // ── Per-frame render caches (avoid O(N) recompute every frame; see the respective call sites) ──
+ // Chat nav-badge unread count — recomputed only when the store revision changes or after a short
+ // interval (mute/hide/seen changes don't bump the store). See App::chatUnreadCount().
+ mutable std::uint64_t chat_unread_rev_ = ~0ull; // ~0 forces the first compute
+ mutable int chat_unread_cached_ = 0;
+ mutable double chat_unread_computed_at_ = 0.0;
+ // Sidebar unconfirmed-tx badge — recomputed only when the tx list changes (keyed on last_tx_update +
+ // size), not every frame. See App::render().
+ std::int64_t sb_unconf_key_ts_ = -1;
+ std::size_t sb_unconf_key_n_ = 0;
+ int sb_unconf_count_ = 0;
+ // Daemon-memory probe is expensive (/proc scan on Linux, popen on macOS); throttle it to ~1.5s so the
+ // Mining tab's per-frame read doesn't hammer the OS. See App::getDaemonMemoryUsageMB().
+ mutable double daemon_mem_cached_mb_ = 0.0;
+ mutable double daemon_mem_probe_at_ = 0.0;
+
// ── Chat note buffer (BOTH variants) ────────────────────────────────────────────────────────
// Each chat message is a shielded tx that spends a note; its change needs a few confirmations before
// it's spendable again (lite: backend ANCHOR_OFFSET+1 = 5; full node: z_sendmany minconf = 1), so
@@ -752,6 +818,7 @@ private:
// balance poll discards per-note data). Mirrors chat_fast_scan_in_flight_.
bool chat_note_scan_in_flight_ = false;
double chat_note_scan_last_ = 0.0; // ImGui time of the last note scan (rate limit)
+ double chat_note_scan_ms_ = 0.0; // measured cost of the last note scan (adaptive back-off)
// Most-recent chain tip, cached across refreshes: a lite refresh model that carries spendableOutputs
// may NOT carry sync status that same cycle (tolerated partial refresh), so verifiedSelfNoteCount reads
// this cache rather than requiring the current model to have both — else the budget flickers to 0.
@@ -761,6 +828,9 @@ private:
// Coordinator helpers (both variants unless noted; see app_network.cpp).
void refreshChatNoteBudget(const wallet::LiteWalletAppRefreshModel& model); // lite: recompute caches on a fresh model
void refreshChatNoteBudgetNode(); // full node: rate-limited z_listunspent worker scan
+ // Feeds the SAME chat send-budget from an already-collected z_listunspent (the address refresh),
+ // so the dedicated scan above is skipped while the address refresh is active (dedup — see #3).
+ void updateChatNoteBudgetFromUnspent(const std::vector& unspentNotes);
void pumpChatNoteBuffer(); // per-frame: drain the queue / build the buffer
int verifiedSelfNoteCount(const wallet::LiteWalletAppRefreshModel& model); // lite
int pipelineSelfNoteCount(const wallet::LiteWalletAppRefreshModel& model); // lite
@@ -782,6 +852,9 @@ public:
int chatUnreadCount() const;
// Mark a conversation read up to latestTs (called by the Chat tab while a thread is displayed).
void markChatConversationSeen(const std::string& cid, std::int64_t latestTs);
+ // Drop the seen-watermark for a conversation (used on revive-delete so a re-imported message — even one
+ // whose stamped time predates the deleted thread's last message — still badges as unread).
+ void forgetChatConversationSeen(const std::string& cid);
private:
// Provision the chat identity once the wallet seed is reachable+unlocked (per-tick, both
// variants); derives via deriveChatIdentityFromSecret and wipes the secret. No-op when the
@@ -794,12 +867,21 @@ private:
// One-time nudge: on a full-node wallet that has a mnemonic, remind the user (once per
// install) to back up their seed phrase. Cheap early-outs keep it idle until it can act.
void maybeRemindSeedBackup();
+ void maybeWarnEmptyWalletWithFundedSiblings(); // full-node: empty active wallet + a funded sibling → warn once
+ void maybeWarnLargeWallet(); // full-node: wallet.dat past bloat threshold → one-time toast + clickable alert
+ void scanFundedSiblingsAsync(); // off-UI-thread probe of sibling wallet files
+ static void scrubAndRemoveExport(const std::string& path); // zero + delete a plaintext key export (H-02)
+ void sweepStaleDecryptExports(); // startup net: purge stale obsidiandecryptexport* files (H-02)
// Seed-wallet migration (Phase 1: create a new mnemonic wallet in isolation, no funds moved).
void beginCreateSeedWallet(); // starts the isolated create on a background thread
void pumpSeedMigration(); // main thread: pick up background progress/result each frame
// Phase 2: sweep all legacy funds into the new wallet, then adopt it as the primary wallet.
void refreshSeedMigrationBalance(); // query the legacy total (shown on the Sweep step)
+ // W3-3: the terminal callback for the sweep opid, shared by the initial submit and a resume
+ // re-track. `resumed` selects the failure behaviour: a fresh sweep that fails -> Error; a resumed
+ // opid the daemon no longer knows (stale) -> back to the dismissable Sweep gate (re-check balance).
+ std::function makeSweepCompletionCallback(bool resumed);
void beginSweepToSeedWallet(); // z_mergetoaddress ["ANY_TADDR","ANY_ZADDR"] -> dest
void pollSweepStatus(); // Confirming step: poll sweep confirmations + legacy balance
void beginAdoptSeedWallet(); // stop daemon -> swap wallet.dat -> restart with -rescan
@@ -820,6 +902,8 @@ private:
void fastScanChatMemos();
bool chat_fast_scan_in_flight_ = false; // guard against overlapping fast-scan RPCs
float chat_fast_scan_accum_ = 0.0f; // seconds since the last fast-scan (dedicated ~2.5s poll)
+ double chat_fast_scan_last_ = 0.0; // ImGui time of the last 0-conf fast scan (adaptive back-off)
+ double chat_fast_scan_ms_ = 0.0; // measured cost of the last fast scan (z_listreceivedbyaddress)
bool font_rebuild_requested_ = false; // set by requestFontRebuild(); consumed in preFrame()
// Lite first-run welcome prompt: dismissed for the session once the user picks an action.
bool lite_firstrun_dismissed_ = false;
@@ -829,6 +913,16 @@ private:
bool lite_startup_lock_checked_ = false;
std::unique_ptr daemon_controller_;
std::unique_ptr xmrig_manager_;
+ // Serialized async mining-control queue: xmrig start/stop (SIGTERM->SIGKILL->join, up to ~3s) run on
+ // this dedicated FIFO thread instead of the render thread, so the UI never blocks and stop/start
+ // ordering is preserved across the ~13 call sites. (M-03/L-06/L-08/L-09/L-13)
+ std::thread mining_ctl_thread_;
+ std::mutex mining_ctl_mutex_;
+ std::condition_variable mining_ctl_cv_;
+ std::deque> mining_ctl_queue_;
+ bool mining_ctl_stop_ = false;
+ void postMiningControl(std::function job); // enqueue a blocking xmrig op onto the FIFO thread
+ void stopMiningControlThread(); // signal + join the control thread (shutdown)
// Auto-balance runtime state (pool mining, full-node only). The service fetches
// pool hashrates off-thread; the RNG drives the weighted-random pick.
util::PoolStatsService pool_stats_service_;
@@ -861,8 +955,13 @@ private:
std::atomic shutting_down_{false};
std::atomic shutdown_complete_{false};
bool address_list_dirty_ = false; // P8: dedup rebuildAddressList
- std::string shutdown_status_;
+ GuardedStatus shutdown_status_; // thread-safe: written by the shutdown thread, read by the UI (M-05)
std::thread shutdown_thread_;
+ // Confirm-before-stopping-daemon-mid-witness-rebuild guard (see beginShutdown / renderDaemonStopConfirm)
+ bool pending_shutdown_confirm_ = false; // a quit is deferred, waiting to open the confirm modal
+ bool daemon_stop_confirm_open_ = false; // the confirm modal is currently showing
+ bool shutdown_confirmed_ = false; // user chose to proceed — bypass the guard on re-entry
+ bool shutdown_keep_daemon_override_ = false; // user chose "keep node running" for this shutdown only
float shutdown_timer_ = 0.0f;
bool force_quit_confirm_ = false;
std::chrono::steady_clock::time_point shutdown_start_time_;
@@ -887,6 +986,49 @@ private:
// sets these and defers to renderSwitchStopDaemonDialog; confirming re-calls switchToWallet(w, true).
bool show_switch_stop_daemon_confirm_ = false;
std::string pending_switch_wallet_file_;
+ // Block-database recovery: set when the embedded node aborts because its block DB is unreadable
+ // (a daemon-vs-chaindata format mismatch after an update, or a corrupt index). While set, the
+ // connect loop STOPS crash-restarting into the same abort and offers a one-click reindex instead.
+ bool block_db_reindex_available_ = false; // node needs its block DB rebuilt (gates restart loop)
+ bool show_block_db_reindex_confirm_ = false; // auto-shown offer dialog
+ // Wallet auto-recovery: the daemon moved wallet.dat to wallet..bak and loaded a salvaged copy
+ // (BDB-verify failure — often a false positive from stale/cross-platform env state). We warn loudly
+ // so a possibly-incomplete salvaged wallet isn't mistaken for fund loss. Warned once per 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_degraded_ = false; // v1.3.0+ opened the wallet in DEGRADED mode (no new HD keys)
+ bool wallet_degraded_warned_ = false; // guard: surface the degraded-mode notice once per session
+ bool show_wallet_recovered_dialog_ = false; // auto-shown warning dialog
+ // Complementary on-disk safety net for a salvage we DIDN'T witness this launch (happened on a prior
+ // run, or under an external daemon whose startup output we never captured): if the active wallet loads
+ // empty while a sibling wallet file in the datadir still holds keys, warn once so the user's funds
+ // (likely in a wallet..bak) aren't mistaken for loss. See maybeWarnEmptyWalletWithFundedSiblings().
+ struct FundedSibling { std::string fileName; int transparentKeys = 0; int shieldedKeys = 0; };
+ bool show_empty_wallet_warning_ = false; // auto-shown warning modal
+ bool empty_wallet_warn_checked_ = false; // evaluated this wallet-open already (reset in onConnected)
+ bool empty_wallet_scan_in_flight_ = false; // a sibling scan is running (main-thread only)
+ bool empty_wallet_has_salvage_bak_ = false; // modal variant: a funded salvage .bak → offer Restore
+ std::vector empty_wallet_funded_siblings_; // scan result (main-thread only)
+ // The recovery dialog is the ONE authoritative surface: it stays open through the async rebuild/
+ // restore, driven Offer → Working → Done/Failed (pumpWalletRestore sets the outcome). Presentation
+ // only — the fund-safety file ops in rebuildWalletDatabase()/restoreOriginalWallet() are unchanged.
+ enum class RecoveryPhase { Offer, Working, Done, Failed };
+ RecoveryPhase recovery_phase_ = RecoveryPhase::Offer;
+ int recovery_outcome_sev_ = 0; // 0 ok / 1 warn / 2 error, set at Done/Failed
+ std::string recovery_outcome_msg_; // honest result string for the Done/Failed body
+ bool recovery_last_action_rebuild_ = false; // which handler ran (for "try the other option")
+ // After a successful repair the daemon restarts with a full rescan — minutes long, and it won't
+ // answer RPC yet. This makes the loading overlay show a calm "finishing your wallet repair" screen
+ // (instead of the generic "daemon stuck / RPC timeout / restart daemon" text) and suppresses the
+ // daemon-crash toast. Set on repair success; cleared on connect (onConnected).
+ bool post_recovery_rescan_ = false;
+ double post_recovery_rescan_since_ = 0.0; // stamped on first overlay frame (ImGui::GetTime)
+ // "Restore original wallet" background op: worker sets these under the mutex, pumpWalletRestore()
+ // (main thread) shows the result. 0 = success, 1 = warning, 2 = error.
+ std::mutex wallet_restore_mutex_;
+ bool wallet_restore_done_ = false;
+ int wallet_restore_severity_ = 0;
+ std::string wallet_restore_msg_;
// Live progress for the switch modal: it stays open from confirm through stop → wait-for-exit → start →
// reconnect and auto-closes when the new node connects (onConnected). Phase is worker-updated;
// dialog_open_ gates rendering — both atomic since onConnected/the worker may run off the main thread.
@@ -913,6 +1055,7 @@ private:
bool show_demo_window_ = false;
bool show_settings_ = false;
bool show_about_ = false;
+ bool show_faq_ = false;
bool show_import_key_ = false;
bool show_export_key_ = false;
bool show_backup_ = false;
@@ -926,6 +1069,7 @@ private:
bool seed_backup_loading_ = false;
bool seed_backup_no_mnemonic_ = false;
bool seed_backup_reminder_in_flight_ = false; // guards the one-time backup nudge probe
+ bool large_wallet_checked_ = false; // gate: stat wallet.dat for the bloat nudge once per launch
// Cached mnemonic status of the current wallet, driving the Migrate-to-seed button glow. Probed
// once per connect (probeWalletSeedStatus, via exportSeedPhrase); NoMnemonic = a legacy wallet a
@@ -1019,10 +1163,30 @@ private:
bool daemon_start_error_shown_ = false;
int daemon_last_seen_crashes_ = 0; // surface each new embedded-daemon crash reason once
bool refresh_policy_syncing_ = false; // whether the sync-throttle refresh profile is active
+ // Sync-settle hysteresis + adaptive balance-poll throttle. Balance polling (z_gettotalbalance) is
+ // O(mapWallet) and holds the daemon's cs_main, which starves block connection on a large shielded
+ // wallet — so we keep the low-impact profile briefly after catching up, and back the balance poll
+ // off in proportion to its own measured cost. See effectivelySyncing() / balanceRefreshDue().
+ bool was_core_syncing_ = false; // previous Core-refresh sync state, to detect the caught-up edge
+ std::time_t sync_settle_until_ = 0; // hold the sync-throttle until this wall-clock time (0 = not settling)
+ double last_balance_scan_ms_ = 0.0; // measured cost of the last z_gettotalbalance scan
+ bool force_balance_refresh_ = false; // a wallet mutation forces the next balance poll through the throttle
+ // Same adaptive back-off applied to the other two O(mapWallet) scans that hold the daemon's cs_main:
+ // the address scan (z_listunspent) and the history scan (z_listreceivedbyaddress). Without this, a
+ // large shielded wallet re-scans them every tab cadence (~seconds each), saturating cs_main and
+ // starving block connection near the tip (where effectivelySyncing() reads false). See
+ // addressRefreshDue() / txRefreshDue(); only the routine periodic poll is throttled — explicit
+ // refreshes (tab switch, dirty set, in-flight send) call the refresh directly and bypass this.
+ double last_address_scan_ms_ = 0.0; // measured cost of the last address scan
+ double last_tx_scan_ms_ = 0.0; // measured cost of the last history scan
// Auto-clear for secrets copied to the clipboard. Only a hash of the copied secret is kept.
std::uint64_t clipboard_secret_hash_ = 0;
double clipboard_clear_deadline_ = 0.0;
float loading_timer_ = 0.0f; // spinner animation for loading overlay
+ double connect_stall_since_ = 0.0; // ImGui::GetTime() when the daemon first went "reachable but not ready"; 0 = not stalling (see util/connect_stall.h)
+ bool encryption_incomplete_warned_ = false; // W2-2: once-per-session guard for the "encryption didn't complete" warning
+ bool lock_failure_warned_ = false; // W2-4: guard so a repeatedly-failing auto-lock warns once, not every retry
+ std::uint64_t alerts_seen_total_ = 0; // Notifications::totalPushed() at last alert-panel open; drives the bell's unread dot
// Current page (sidebar navigation)
ui::NavPage current_page_ = ui::NavPage::Overview;
@@ -1032,6 +1196,7 @@ private:
// Debug screenshot sweep state.
bool screenshot_sweep_active_ = false;
+ bool sweep_current_theme_only_ = false; // Debug Options: sweep only the active theme
bool sweep_capture_this_frame_ = false;
int sweep_skin_idx_ = 0;
int sweep_settle_frames_ = 0; // frames to let a new skin/surface settle before capture
@@ -1125,6 +1290,16 @@ private:
// Auto-shield guard (prevents concurrent auto-shield operations)
std::atomic auto_shield_pending_{false};
+ // v1.3.0+ daemons auto-shield coinbase themselves; probe z_autoshieldstatus once per connection and
+ // defer the wallet's own client-side auto-shield when the daemon is doing it (otherwise both race for
+ // the same coinbase UTXOs and split funds across different z-addresses). Fail-closed: a pre-1.3.0
+ // daemon lacks the RPC → active stays false → the wallet keeps shielding client-side (no regression).
+ bool daemon_autoshield_probed_ = false;
+ bool daemon_autoshield_active_ = false;
+ std::atomic daemon_autoshield_probe_inflight_{false};
+ std::string daemon_autoshield_address_; // z_autoshieldstatus fields (O1); empty on old daemons
+ std::string daemon_autoshield_disabled_reason_; // daemon's reason auto-shield is off (e.g. seed not recoverable)
+ bool daemon_autoshield_seed_recoverable_ = false;
// P4: Incremental transaction cache
int last_tx_block_height_ = -1; // block height at last full tx fetch
@@ -1163,6 +1338,11 @@ private:
// the per-second mining/rescan-status pollers are suppressed (the daemon holds cs_main for
// the whole scan and would block them); completion is signalled by the rescan RPC callback.
bool runtime_rescan_active_ = false;
+ // True only for a rescan the WALLET/USER initiated (the Rescan button, a -rescan/salvage/zap/reindex
+ // restart, key import, seed migration) — not an autonomous background witness rebuild the daemon does
+ // on its own. Gates the "Blockchain rescan complete" toast so background rebuilds don't fire it;
+ // cleared when the toast is shown.
+ std::atomic user_initiated_rescan_{false}; // atomic: some rescan triggers run on worker threads
// Set when a bootstrap completes; consumed once the daemon is connected to auto-run a rescan
// that reconciles the preserved wallet.dat against the freshly-imported chain.
bool post_bootstrap_rescan_pending_ = false;
@@ -1221,8 +1401,8 @@ private:
services::WalletSecurityWorkflow wallet_security_workflow_;
// Wizard: stopping an external daemon before bootstrap
- bool wizard_stopping_external_ = false;
- std::string wizard_stop_status_;
+ std::atomic wizard_stopping_external_{false}; // written by the stop worker, read by the UI (L-06)
+ GuardedStatus wizard_stop_status_; // thread-safe: written by the stop worker, read by the UI (L-06)
// PIN vault
std::unique_ptr vault_;
@@ -1288,6 +1468,13 @@ private:
// Private methods - rendering
void renderStatusBar();
+ // Persistent node/RPC error strip at the top of the content column when the wallet can't
+ // reach its node (or the embedded daemon gave up crashing). Decision logic is the pure
+ // evaluateNodeStatusBanner() in ui/node_status_banner.h; this draws it and wires the action.
+ void renderNodeStatusBanner();
+ // Body of the status-bar alert-history popup: recent alerts (incl. ones whose toast faded),
+ // newest first, with severity icon + relative age + a Clear action. See src/ui/notifications.h.
+ void renderAlertHistoryPanel();
void renderLiteFirstRunPrompt(); // lite-only welcome modal when no wallet exists yet
void renderLiteUnlockPrompt(); // lite-only send-time unlock modal
void renderImportKeyDialog();
@@ -1305,6 +1492,16 @@ private:
void renderPinDialogs();
void renderAntivirusHelpDialog();
void renderSwitchStopDaemonDialog(); // confirm before stopping an adopted node to switch wallets
+ void renderBlockDbReindexDialog(); // offer to rebuild an unreadable block database (-reindex)
+ void reindexBlockDatabase(); // restart the daemon with -reindex to rebuild the block DB
+ void renderWalletRecoveredDialog(); // warn that the node auto-recovered/salvaged wallet.dat
+ void renderEmptyWalletWarningDialog();// warn that the active wallet is empty while a sibling holds funds
+ void detectWalletAutoRecovery(); // scan daemon output for a salvage; fire the warning once/session
+ void detectWalletDegraded(); // scan daemon output for a DEGRADED-mode open; warn once/session
+ void restoreOriginalWallet(); // swap the wallet..bak back over the salvaged copy + restart
+ void pumpWalletRestore(); // main-thread: surface the restore/rebuild op's result
+ void rebuildWalletDatabase(); // rebuild a BDB-inconsistent wallet into a loadable one (helper)
+ bool walletRebuildAvailable() const; // the dragonx-wallet-rebuild helper is present
void processDeferredEncryption();
// Private methods - connection
@@ -1335,6 +1532,13 @@ private:
void refreshPrice();
void refreshWalletEncryptionState();
void applyRefreshPolicy(ui::NavPage page);
+ bool effectivelySyncing() const; // syncing, or within the post-sync settle window (hysteresis)
+ bool balanceRefreshDue() const; // adaptive: enough time elapsed given the last balance-scan cost?
+ bool addressRefreshDue() const; // same adaptive back-off for the address scan (z_listunspent)
+ bool txRefreshDue() const; // same adaptive back-off for the history scan (z_listreceivedbyaddress)
+ // Shared duty-cycle rule: a scan may resume only once (lastScanMs / kScanDutyCycle) has elapsed since
+ // lastUpdate, so any single O(mapWallet) scan can occupy at most ~kScanDutyCycle of wall-clock.
+ bool scanRefreshDue(std::int64_t lastUpdate, double lastScanMs) const;
bool currentPageNeedsWalletDataRefresh() const;
bool shouldRunWalletTransactionRefresh() const;
bool shouldRefreshTransactions() const;
diff --git a/src/app_network.cpp b/src/app_network.cpp
index d331f35..a987c54 100644
--- a/src/app_network.cpp
+++ b/src/app_network.cpp
@@ -35,6 +35,8 @@
#include "rpc/connection.h"
#include "chat/chat_identity.h" // deriveChatIdentityFromSecret for HushChat identity provisioning
#include "ui/windows/chat_tab.h" // ui::ResetChatTab — wipe chat UI plaintext on a wallet switch
+#include "ui/windows/shield_dialog.h" // ui::ShieldDialog — Merge to Address shortcut from the bloat nudge
+#include "ui/windows/mining_pool_panel.h" // ui::resolveMiningUserAddress
#include // sodium_memzero for wiping the fetched mnemonic
#include
#include "config/settings.h"
@@ -43,6 +45,7 @@
#include "wallet/lite_diagnostics.h" // liteLog — chat note-buffer coordinator diagnostics
#include "config/version.h"
#include "daemon/daemon_controller.h"
+#include "daemon/daemon_startup_diagnosis.h"
#include "daemon/embedded_daemon.h"
#include "daemon/seed_wallet_creator.h"
#include "daemon/xmrig_manager.h"
@@ -54,9 +57,14 @@
#include "util/http_download.h"
#include "data/exchange_info.h"
#include "data/exchange_candles.h"
+#include "data/seed_migration_resume.h"
#include "util/platform.h"
+#include "util/wallet_file_probe.h" // verify a salvage-backup is a real BDB before restoring it
+#include "resources/embedded_resources.h" // getDaemonDirectory() — locate the wallet-rebuild helper
+#include // popen the rebuild helper
#include "util/perf_log.h"
#include "util/i18n.h"
+#include "util/address_validation.h" // isValidRecipientAddress — payout validation at every start path (M-01)
#include "util/secure_vault.h"
#include
@@ -196,10 +204,14 @@ static WarmupText translateWarmup(const std::string& raw)
// Used to offer a -salvagewallet repair when a switch fails because the target wallet is corrupt.
static bool walletOutputLooksCorrupt(const std::string& out)
{
+ // W1-2: the generic "Error loading wallet" fallback is ALSO printed for DB_TOO_NEW
+ // ("...requires ... newer version..."), which -salvagewallet cannot fix — so don't misclassify a
+ // version mismatch as salvageable corruption and offer a repair that can't help.
+ const bool versionMismatch = out.find("newer version") != std::string::npos;
return out.find("Failed to rename") != std::string::npos
|| out.find("salvage failed") != std::string::npos
|| out.find("wallet.dat corrupt") != std::string::npos
- || out.find("Error loading wallet") != std::string::npos;
+ || (out.find("Error loading wallet") != std::string::npos && !versionMismatch);
}
// Phrases dragonxd prints to its console while initializing, in the order translateWarmup()
@@ -219,6 +231,39 @@ static constexpr int kDaemonWaitWarnAttempts = 4;
// Connection Management
// ============================================================================
+// dragonxd moves wallet.dat to wallet..bak and loads a salvaged copy whenever BDB verify fails —
+// no flag, and often a false positive (stale/cross-platform env) or an inconsistent-but-readable file.
+// The salvage prints to the node's captured output at STARTUP, but the node may then fail to connect
+// (block-index abort, long sync, crash) so we must NOT wait for onConnected — scan the output on every
+// tryConnect tick, early enough that the line hasn't been trimmed from the rolling buffer. Fires once
+// per session; the dialog offers Rebuild (fix the DB) / Restore (swap the .bak back).
+void App::detectWalletAutoRecovery()
+{
+ if (wallet_auto_recovered_warned_) return;
+ if (!isUsingEmbeddedDaemon() || !daemon_controller_ || !daemon_controller_->daemon()) return;
+ if (!daemon::walletAutoRecovered(daemon_controller_->daemon()->getOutput())) return;
+ wallet_auto_recovered_ = true;
+ wallet_auto_recovered_warned_ = true;
+ show_wallet_recovered_dialog_ = true;
+ ui::Notifications::instance().info(TR("wallet_recovered_notify"), 30.0f); // calm, not red — coins are safe
+ VERBOSE_LOGF("[recovery] Daemon auto-recovered/salvaged wallet.dat — surfacing the recovery dialog\n");
+}
+
+// v1.3.0+ opens a wallet that lost its hdchain in DEGRADED mode (funds spendable) instead of aborting,
+// but can't derive NEW HD keys — z_getnewaddress / z_shieldcoinbase / t->z z_sendmany fail with "HD seed
+// not found". Only a startup log line signals it, so scan the captured output and warn once. Pre-1.3.0
+// daemons never emit it, so this is a no-op there (backwards compatible).
+void App::detectWalletDegraded()
+{
+ if (wallet_degraded_warned_) return;
+ if (!isUsingEmbeddedDaemon() || !daemon_controller_ || !daemon_controller_->daemon()) return;
+ if (!daemon::walletOpenedDegraded(daemon_controller_->daemon()->getOutput())) return;
+ wallet_degraded_ = true;
+ wallet_degraded_warned_ = true;
+ ui::Notifications::instance().warning(TR("wallet_degraded_notify"), 30.0f);
+ VERBOSE_LOGF("[recovery] Daemon opened wallet in DEGRADED mode — new-key derivation disabled\n");
+}
+
void App::tryConnect()
{
// Lite builds have no full node / RPC daemon, so never run the RPC connection state machine
@@ -226,6 +271,11 @@ void App::tryConnect()
// derived from it each frame in App::update(), which also gates the wallet UI (isConnected()).
if (isLiteBuild()) return;
+ // Catch a startup wallet salvage as soon as it appears in the node's output — independent of whether
+ // the node ever finishes starting or connects (skip only while an orchestrated swap is mid-flight).
+ if (!daemon_restarting_) detectWalletAutoRecovery();
+ if (!daemon_restarting_) detectWalletDegraded();
+
if (connection_in_progress_) return;
// Don't fight an in-progress restart/adopt orchestration: while it stops the daemon, swaps
@@ -240,6 +290,16 @@ void App::tryConnect()
// Auto-detect configuration (file I/O — fast, safe on main thread)
auto config = rpc::Connection::autoDetectConfig();
+
+ if (!config.dir_error.empty()) {
+ // The data directory could not be created (read-only home, permission denied,
+ // disk full). Retrying won't fix it, so surface it in the status line instead of
+ // mislabelling it as "waiting for config" below.
+ connection_in_progress_ = false;
+ connection_status_ = config.dir_error;
+ VERBOSE_LOGF("[connect #%d] data dir error: %s\n", connect_attempt, config.dir_error.c_str());
+ return;
+ }
if (config.rpcuser.empty() || config.rpcpassword.empty()) {
connection_in_progress_ = false;
@@ -310,11 +370,21 @@ void App::tryConnect()
VERBOSE_LOGF("[connect #%d] Connecting to %s:%s (user=%s)\n",
connect_attempt, config.host.c_str(), config.port.c_str(), config.rpcuser.c_str());
- if (rpc::Connection::usesPlaintextRemote(config) && !remote_rpc_plaintext_warning_shown_) {
- remote_rpc_plaintext_warning_shown_ = true;
- ui::Notifications::instance().warning(
- "Remote RPC is using plaintext HTTP. Add rpctls=1 to DRAGONX.conf if your daemon supports TLS.",
- 10.0f);
+ if (rpc::Connection::usesPlaintextRemote(config) &&
+ !rpc::Connection::allowsPlaintextRemote(config)) {
+ // Refuse to send Basic-auth credentials in cleartext to a remote host — a local-network
+ // MITM would otherwise capture rpcuser:rpcpassword. This is a deliberate behaviour change
+ // from the old warn-and-proceed: opt in explicitly with rpcallowplaintext=1 in
+ // DRAGONX.conf (or enable TLS with rpctls=1) if the plaintext link is intended.
+ connection_in_progress_ = false;
+ connection_status_ = TR("sb_plaintext_remote_blocked");
+ if (!remote_rpc_plaintext_warning_shown_) {
+ remote_rpc_plaintext_warning_shown_ = true;
+ ui::Notifications::instance().warning(TR("sb_plaintext_remote_blocked"), 20.0f);
+ }
+ VERBOSE_LOGF("[connect #%d] refusing plaintext-remote RPC to %s:%s (set rpcallowplaintext=1 to override)\n",
+ connect_attempt, config.host.c_str(), config.port.c_str());
+ return;
}
// Run the blocking rpc_->connect() on the worker thread so the UI
@@ -356,9 +426,12 @@ void App::tryConnect()
// "stuck connecting" while the node silently died-and-respawned. Surface each new crash once.
const int crashes = daemon_controller_->crashCount();
if (crashes > daemon_last_seen_crashes_) {
- daemon_last_seen_crashes_ = crashes;
+ daemon_last_seen_crashes_ = crashes; // consume it either way, so it can't toast later
const std::string detail = daemon_controller_->lastError();
- if (!detail.empty()) {
+ // Suppress the scary "dragonxd exited unexpectedly" toast during recovery: the stop after a
+ // salvage, and the intentional restart-with-rescan after a repair, are both EXPECTED here and
+ // owned by the recovery UI (dialog / calm rescan overlay).
+ if (!detail.empty() && !wallet_auto_recovered_ && !post_recovery_rescan_) {
connection_status_ = TR("sb_daemon_start_failed");
ui::Notifications::instance().error(detail, 30.0f);
}
@@ -385,6 +458,7 @@ void App::tryConnect()
// fail until warmup completes. Set the warmup state so
// the UI shows status instead of a blocking overlay.
state_.warming_up = true;
+ if (connect_stall_since_ <= 0.0) connect_stall_since_ = ImGui::GetTime(); // start the "taking too long" clock
auto wt = translateWarmup(warmupStatus);
state_.warmup_status = wt.title;
state_.warmup_description = wt.description;
@@ -474,8 +548,34 @@ void App::tryConnect()
VERBOSE_LOGF("[connect #%d] RPC connection failed — no daemon starting, no external detected\n", attempt);
if (isUsingEmbeddedDaemon() && !isEmbeddedDaemonRunning()) {
+ // A repair completed and we restarted with a full rescan, but the fresh daemon has
+ // now EXITED — a fault distinct from the wallet file (corrupt block index, disk full,
+ // OOM). Drop out of the calm "finishing repair" state so this surfaces as a normal
+ // daemon failure (reindex offer / crash toast / restart) instead of silently freezing
+ // the reconnect loop on a reassuring "don't restart" screen with no way forward.
+ if (post_recovery_rescan_) {
+ post_recovery_rescan_ = false;
+ wallet_auto_recovered_ = false; // repair done; the original-salvage hold is over
+ }
+ // If the node aborted because its BLOCK DATABASE is unreadable (a daemon-vs-chaindata
+ // format mismatch after an update, or a corrupt index), crash-restarting just repeats
+ // the same abort — and each attempt reloads the whole index (wasteful). Detect it once
+ // and offer a one-click reindex instead of silently looping into a zero-balance node.
+ if (!block_db_reindex_available_ && daemon_controller_ && daemon_controller_->daemon() &&
+ daemon::blockDbOutputLooksBroken(daemon_controller_->daemon()->getOutput())) {
+ block_db_reindex_available_ = true;
+ show_block_db_reindex_confirm_ = true;
+ ui::Notifications::instance().error(TR("block_db_reindex_notify"), 20.0f);
+ VERBOSE_LOGF("[connect #%d] Block database unreadable — offering a one-click reindex\n", attempt);
+ }
// Prevent infinite crash-restart loop
- if (daemon_controller_ && daemon_controller_->crashCount() >= 3) {
+ if (block_db_reindex_available_) {
+ connection_status_ = TR("sb_block_db_unreadable"); // hold; awaiting the rebuild choice
+ } else if (wallet_auto_recovered_) {
+ // A salvage is happening — DON'T restart into another one (each round can shrink
+ // the wallet further). Hold while the recovery dialog (Rebuild/Restore) is up.
+ connection_status_ = TR("sb_wallet_needs_recovery");
+ } else if (daemon_controller_ && daemon_controller_->crashCount() >= 3) {
if (wallet_switch_pending_confirm_.load()) {
// The just-switched-to wallet's daemon keeps crashing (e.g. a wallet that
// fails LATE in init, past the fast start grace) — revert to the previous
@@ -526,10 +626,25 @@ void App::onConnected()
}
state_.daemon_initializing = false; // RPC is answering now; clear the "initializing" overlay
daemon_wait_attempts_ = 0; // re-arm the port-busy / start-failure notifications
+ connect_stall_since_ = 0.0; // connected — clear the "taking too long" clock
+ // A repair's rescan finished and connected — retire the recovery session so its crash-toast/error-card
+ // suppression and status-chip hold can't persist forever. (Only when we were mid-post-repair-rescan;
+ // a pre-repair salvaged-daemon connect keeps the flag so the recovery dialog/chip stay available.)
+ if (post_recovery_rescan_) wallet_auto_recovered_ = false;
+ post_recovery_rescan_ = false; // repair's post-restart rescan is past the RPC-less phase now
daemon_start_error_shown_ = false;
daemon_last_seen_crashes_ = 0; // (onConnected resets the daemon's crash count too)
connection_status_ = TR("connected");
+ detectWalletAutoRecovery(); // also runs every tryConnect tick — catches a salvage even if we never connect
+
+ // Re-arm the empty-wallet-with-funded-sibling check for this (possibly switched) wallet: re-evaluate the
+ // on-disk state once it finishes loading + syncing. Deliberately DON'T touch empty_wallet_scan_in_flight_
+ // here — a scan from a prior connect self-clears it when it posts back, and resetting it while that scan
+ // is still running would let the next submit() block the UI thread on join() (async_tasks_ is only ever
+ // cancelled at shutdown, so the flag can't wedge during normal runtime).
+ empty_wallet_warn_checked_ = false;
+
// Stamp the active wallet as opened in the index (last-opened + size + synced-here). Balance +
// address count fill in on the first address refresh (addresses aren't loaded yet here).
updateWalletIndexForActiveWallet(/*markOpened=*/true);
@@ -606,6 +721,7 @@ void App::onDisconnected(const std::string& reason)
state_.connected = false;
state_.warming_up = false;
state_.warmup_status.clear();
+ connect_stall_since_ = 0.0; // reset the "taking too long" clock (App member, untouched by state_.clear())
state_.clear();
connection_status_ = reason;
@@ -614,6 +730,11 @@ void App::onDisconnected(const std::string& reason)
wallet_seed_status_ = WalletSeedStatus::Unknown;
wallet_seed_status_attempts_ = 0;
+ // Re-probe whether the daemon auto-shields coinbase on the next connect — it may have been
+ // upgraded/swapped (e.g. v1.0.3 which has no z_autoshieldstatus -> v1.3.0 which auto-shields).
+ daemon_autoshield_probed_ = false;
+ daemon_autoshield_active_ = false;
+
// Clear RPC result caches
viewtx_cache_.clear();
confirmed_tx_cache_.clear();
@@ -660,6 +781,7 @@ void App::onDisconnected(const std::string& reason)
std::string App::applyDaemonInitStatus(bool reachableButBusy)
{
state_.daemon_initializing = true;
+ if (connect_stall_since_ <= 0.0) connect_stall_since_ = ImGui::GetTime(); // start the "taking too long" clock
// Find the most recent console line that names an init phase, so we can tell the user exactly
// what the node is doing (loading the block index, verifying, activating best chain, …).
@@ -736,13 +858,58 @@ void App::applyRefreshPolicy(ui::NavPage page)
// While the daemon is syncing, override the per-tab cadence with the low-impact sync profile so
// the wallet stops contending for the daemon's cs_main lock (frequent getpeerinfo / per-block
// transaction scans / balance polls slow block connection). This makes every tab sync as fast
- // as the Console tab does today. Reverts to the per-tab profile once sync finishes.
- refresh_policy_syncing_ = state_.sync.syncing;
+ // as the Console tab does today. effectivelySyncing() keeps this profile on briefly after catching
+ // up (hysteresis) so a large-wallet scan can't immediately re-starve connection and bounce the
+ // node back into "syncing". Reverts to the per-tab profile once the settle window passes.
+ refresh_policy_syncing_ = effectivelySyncing();
network_refresh_.setIntervals(refresh_policy_syncing_
? services::RefreshScheduler::kSyncProfile
: getIntervalsForPage(page));
}
+// True while the node is behind, and for a short settle window after it first catches up. The settle
+// window is armed only on the syncing→caught-up edge (see the Core refresh callback), so a wallet that
+// was synced from the start is never throttled at connect — only a node that just finished catching up.
+bool App::effectivelySyncing() const
+{
+ if (state_.sync.syncing) return true;
+ if (sync_settle_until_ == 0) return false; // no pending settle → genuinely caught up
+ return std::time(nullptr) < sync_settle_until_;
+}
+
+// Adaptive throttle: the next balance poll must wait at least (lastScanCost / kBalanceDutyCycle) since
+// the last one, so balance scanning can never occupy more than ~kBalanceDutyCycle of wall-clock. A
+// cheap wallet (sub-cadence cost) is unaffected — the tab's Core timer stays the real cadence; a ~20s
+// scan on a large wallet backs off to roughly every ~200s instead of every 2s, freeing cs_main for
+// block connection. A wallet mutation bypasses this via force_balance_refresh_.
+bool App::scanRefreshDue(std::int64_t lastUpdate, double lastScanMs) const
+{
+ constexpr double kScanDutyCycle = 0.10;
+ if (lastUpdate == 0) return true; // never fetched
+ if (lastScanMs <= 0.0) return true; // no cost measured yet
+ const double minInterval = (lastScanMs / 1000.0) / kScanDutyCycle;
+ return std::difftime(std::time(nullptr), static_cast(lastUpdate)) >= minInterval;
+}
+
+bool App::balanceRefreshDue() const
+{
+ return scanRefreshDue(state_.last_balance_update, last_balance_scan_ms_);
+}
+
+// The address scan (z_listunspent) and history scan (z_listreceivedbyaddress) are just as O(mapWallet)
+// and cs_main-bound as the balance scan, so they get the same adaptive back-off. This is what keeps a
+// large synced wallet from re-scanning them every tab cadence — the near-tip starvation the balance-only
+// throttle missed (only the periodic poll is gated; explicit refreshes call the refresh directly).
+bool App::addressRefreshDue() const
+{
+ return scanRefreshDue(state_.last_address_update, last_address_scan_ms_);
+}
+
+bool App::txRefreshDue() const
+{
+ return scanRefreshDue(state_.last_tx_update, last_tx_scan_ms_);
+}
+
bool App::currentPageNeedsWalletDataRefresh() const
{
using NP = ui::NavPage;
@@ -920,10 +1087,13 @@ void App::applyPendingSendDelta(const std::string& fromAddress, double signedAmo
// For a debit (signedAmount < 0) this clamps at >=0 exactly as the debit sites did. For a
// restore (signedAmount > 0) the clamp is a no-op — max(0, bal+amt) == bal+amt when bal,amt>=0 —
// so the single clamped form reproduces the original restore's unclamped bal += amt.
+ // Debit/restore the SPENDABLE view ONLY. The DISPLAY balances now come from an honest minconf=0 RPC
+ // that already reflects the outgoing spend AND the incoming 0-conf change, so debiting them here too
+ // would re-crater the display. This delta only lowers what can be re-spent before the change confirms.
auto applyToAddress = [&](std::vector& addresses) {
for (auto& address : addresses) {
if (address.address == fromAddress) {
- address.balance = std::max(0.0, address.balance + signedAmount);
+ address.spendableBalance = std::max(0.0, address.spendableBalance + signedAmount);
return true;
}
}
@@ -932,11 +1102,12 @@ void App::applyPendingSendDelta(const std::string& fromAddress, double signedAmo
if (!applyToAddress(state_.z_addresses)) applyToAddress(state_.t_addresses);
if (includeAggregates) {
if (!fromAddress.empty() && fromAddress[0] == 'z') {
- state_.privateBalance = std::max(0.0, state_.privateBalance + signedAmount);
+ state_.spendablePrivateBalance = std::max(0.0, state_.spendablePrivateBalance + signedAmount);
} else {
- state_.transparentBalance = std::max(0.0, state_.transparentBalance + signedAmount);
+ state_.spendableTransparentBalance = std::max(0.0, state_.spendableTransparentBalance + signedAmount);
}
- state_.totalBalance = std::max(0.0, state_.totalBalance + signedAmount);
+ state_.spendableTotalBalance = std::max(0.0, state_.spendableTotalBalance + signedAmount);
+ state_.unconfirmedBalance = std::max(0.0, state_.totalBalance - state_.spendableTotalBalance);
}
}
@@ -1067,9 +1238,16 @@ void App::updateWalletIndexForActiveWallet(bool markOpened)
if (!ec) e.sizeBytesAtLastOpen = static_cast(sz);
}
+ // W1-3: record "synced here" only once the wallet's identity is actually verified (its addresses are
+ // known -> idHash non-empty). Stamping it on the bare connect (before any address readback) would let
+ // a freshly-restored wallet skip its needed rescan. It's idempotent, so the post-refresh update
+ // (updateWalletIndexForActiveWallet after addresses load) sets it once; lastOpenedEpoch is still
+ // recorded at open time here.
+ if (!idHash.empty()) {
+ e.syncedHere = true; // loaded + identity-verified in this datadir -> catch-up (no full rescan)
+ }
if (markOpened) {
e.lastOpenedEpoch = static_cast(std::time(nullptr));
- e.syncedHere = true; // we've loaded it in this datadir -> catch-up (no full rescan) on switch
}
if (wallet_index_.upsert(e)) wallet_index_.save();
@@ -1096,7 +1274,9 @@ void App::switchToWallet(const std::string& walletFile, bool stopDaemonConfirmed
ui::Notifications::instance().warning("A rescan or repair is in progress — try again once it finishes.");
return;
}
- if (show_seed_migration_) {
+ // W3-4: block switching while a migration is PENDING, not only while its dialog is open — closing
+ // the dialog via "Later" mid-migration leaves the pending state but previously dropped this guard.
+ if (show_seed_migration_ || (settings_ && settings_->getSeedMigrationPending())) {
ui::Notifications::instance().warning("Finish or cancel the seed migration before switching wallets.");
return;
}
@@ -1108,6 +1288,19 @@ void App::switchToWallet(const std::string& walletFile, bool stopDaemonConfirmed
ui::Notifications::instance().warning("Finish or cancel the pending send before switching wallets.");
return;
}
+ // W1-1: verify the target wallet file actually exists before switching. dragonxd auto-CREATES a
+ // fresh empty wallet for a missing -wallet=, so without this a moved/deleted wallet file would
+ // silently "open" as a brand-new empty wallet with a zero balance — looking exactly like fund loss.
+ // (Also closes the W1-4 stale-switcher-row race: the check runs no matter how switchToWallet is called.)
+ {
+ std::error_code existEc;
+ const std::string walletPath = util::Platform::getDragonXDataDir() + "/" + walletFile;
+ if (!std::filesystem::exists(walletPath, existEc)) {
+ ui::Notifications::instance().warning(
+ "Wallet file not found (moved or deleted?): " + walletFile + " — it was not opened.", 15.0f);
+ return;
+ }
+ }
// If we're connected to a node this session did NOT spawn (no live process handle — it was left
// running by "keep node running", started by the user, or we just direct-connected to a config-provided
// one), confirm before stopping it: switching must stop+restart it on the new wallet, but the user may
@@ -1191,6 +1384,7 @@ void App::switchToWallet(const std::string& walletFile, bool stopDaemonConfirmed
if (daemon_controller_) daemon_controller_->clearExternalDaemonDetected();
if (salvage && daemon_controller_) daemon_controller_->setSalvageOnNextStart(true); // repair a corrupt wallet
else if (needRescan && daemon_controller_) daemon_controller_->setRescanOnNextStart(true); // (salvage implies rescan)
+ if (salvage || needRescan) user_initiated_rescan_ = true; // wallet-triggered repair/rescan → completion should toast
// Start ONCE. Do NOT retry-spawn: a second start while the first is still shutting down leaves
// two dragonxd holding wallet.dat against each other (BDB "Failed to rename … Error"). The
// stopDaemonForWalletSwitch() wait already ensured the old node's process is gone, so a valid
@@ -1264,18 +1458,26 @@ void App::processWalletSwitchRevert()
int App::chatUnreadCount() const
{
if (!chat::hushChatFeatureEnabledAtBuild() || !chat_service_.hasIdentity()) return 0;
- int unread = 0;
+ // This is called every frame from render() to size the Chat nav badge. Recompute only when the store
+ // actually changed (new/removed messages bump revision()) or after a short interval (to pick up
+ // mute/hide/seen changes, which don't bump the store). Previously it ran a full O(conversations x
+ // messages) scan that COPIED and stable_sorted every conversation's messages, every frame.
const auto& store = chat_service_.store();
- for (const auto& cid : store.conversationIds()) {
- if (settings_ && settings_->isChatMuted(cid)) continue; // muted conversations don't badge (Q10)
- if (settings_ && settings_->isChatHidden(cid)) continue; // hidden conversations don't badge
- std::int64_t seen = 0;
- const auto it = chat_seen_watermark_.find(cid);
- if (it != chat_seen_watermark_.end()) seen = it->second;
- for (const auto& m : store.conversation(cid))
- if (m.direction == chat::ChatDirection::Incoming && m.timestamp > seen) ++unread;
+ const std::uint64_t rev = store.revision();
+ const double now = ImGui::GetTime();
+ if (rev != chat_unread_rev_ || now - chat_unread_computed_at_ > 0.25) {
+ chat_unread_cached_ = store.countUnread(
+ [this](const std::string& cid) { // excluded from the badge
+ return settings_ && (settings_->isChatMuted(cid) || settings_->isChatHidden(cid));
+ },
+ [this](const std::string& cid) -> std::int64_t { // seen watermark
+ const auto it = chat_seen_watermark_.find(cid);
+ return it != chat_seen_watermark_.end() ? it->second : 0;
+ });
+ chat_unread_rev_ = rev;
+ chat_unread_computed_at_ = now;
}
- return unread;
+ return chat_unread_cached_;
}
void App::markChatConversationSeen(const std::string& cid, std::int64_t latestTs)
@@ -1283,6 +1485,11 @@ void App::markChatConversationSeen(const std::string& cid, std::int64_t latestTs
if (latestTs > 0) chat_seen_watermark_[cid] = latestTs;
}
+void App::forgetChatConversationSeen(const std::string& cid)
+{
+ chat_seen_watermark_.erase(cid);
+}
+
void App::wipePendingTransactionHistoryCachePassphrase()
{
if (!pending_transaction_history_cache_passphrase_.empty()) {
@@ -1488,6 +1695,7 @@ void App::refreshCoreData()
state_.warming_up = false;
state_.warmup_status.clear();
state_.warmup_description.clear();
+ connect_stall_since_ = 0.0; // warmup finished — clear the "taking too long" clock
connection_status_ = TR("connected");
VERBOSE_LOGF("[warmup] Daemon ready, warmup complete\n");
@@ -1505,7 +1713,10 @@ void App::refreshCoreData()
transactions_dirty_ = true;
last_tx_block_height_ = -1;
invalidateShieldedHistoryScanProgress(true);
- ui::Notifications::instance().success("Blockchain rescan complete");
+ if (user_initiated_rescan_) {
+ ui::Notifications::instance().success("Blockchain rescan complete");
+ user_initiated_rescan_ = false; // surfaced once; not for background rebuilds
+ }
}
NetworkRefreshService::applyConnectionInfoResult(state_, result.info);
@@ -1538,9 +1749,15 @@ void App::refreshCoreData()
? fast_rpc_.get() : rpc_.get();
if (!w || !rpc) return;
ui::NavPage tracePage = current_page_;
- // Skip the balance call while syncing (it's incomplete anyway and takes the wallet lock +
- // cs_main). Captured on the main thread to avoid reading state_ off the worker thread.
- const bool includeBalance = !state_.sync.syncing;
+ // Decide whether to include the balance call (z_gettotalbalance — O(mapWallet), holds cs_main).
+ // Suppress it (a) while syncing or within the post-sync settle window, so it can't starve block
+ // connection, and (b) unless enough time has elapsed given the LAST scan's measured cost, so a
+ // large shielded wallet backs off automatically instead of re-scanning every couple of seconds.
+ // A wallet mutation (send/shield) forces the next poll through so the user's own action updates the
+ // balance immediately. Captured on the main thread to avoid reading state_ off the worker thread.
+ const bool includeBalance = !effectivelySyncing() &&
+ (force_balance_refresh_ || balanceRefreshDue());
+ if (includeBalance) force_balance_refresh_ = false;
auto enqueued = network_refresh_.enqueue(services::NetworkRefreshService::Job::Core, *w, [this, rpc, tracePage, includeBalance]() -> rpc::RPCWorker::MainCb {
AppRefreshRpcGateway refreshRpc(*rpc, traceSource(tracePage, "Core refresh"));
@@ -1550,6 +1767,27 @@ void App::refreshCoreData()
NetworkRefreshService::applyCoreRefreshResult(state_, result, std::time(nullptr));
applyPendingSendBalanceDeltas(true);
+ // Feed the adaptive balance throttle + sync-settle hysteresis. Record the last scan's
+ // cost (0 when balance was skipped), and arm the settle window only on the
+ // syncing→caught-up edge so a wallet synced from the start is never throttled at connect.
+ if (result.balanceScanMs > 0.0) last_balance_scan_ms_ = result.balanceScanMs;
+ // Sticky-behind hysteresis: hold the low-impact profile for kSyncSettleSeconds after the
+ // LAST time we read "behind" — not only on the caught-up edge. While actually syncing we
+ // re-arm every Core tick, so the profile stays engaged continuously; once we truly reach
+ // the tip (within the 2-block tolerance in applyCoreRefreshResult) we stop re-arming and it
+ // lapses after the window. This is what breaks the near-tip feedback loop: a single stale or
+ // optimistic "caught up" reading can no longer immediately unleash the heavy O(mapWallet)
+ // scans and re-starve the tail (which dropped us behind again → oscillation). With the
+ // scans suppressed, kSyncProfile keeps sync detection itself cheap, so we keep getting fresh
+ // "behind" readings and stay latched until genuinely caught up. Window must exceed the
+ // worst-case Core-refresh interval so it can't lapse between two behind readings mid-catchup.
+ const bool nowSyncing = state_.sync.syncing;
+ constexpr double kSyncSettleSeconds = 30.0;
+ if (nowSyncing || was_core_syncing_) {
+ sync_settle_until_ = std::time(nullptr) + static_cast(kSyncSettleSeconds);
+ }
+ was_core_syncing_ = nowSyncing;
+
// Mid-session connection-loss detection. During normal operation, both core
// RPCs failing together means the daemon connection is dead (a busy daemon
// fails them individually, not both at once). Warmup is excluded — both fail
@@ -1568,9 +1806,40 @@ void App::refreshCoreData()
}
}
- // Auto-shield transparent funds if enabled
- if (result.balanceOk && settings_ && settings_->getAutoShield() &&
- state_.transparent_balance > 0.0001 && !state_.sync.syncing &&
+ // Probe the daemon's auto-shield status once per connection — independent of our own
+ // toggle/balance — so both the defer-gate below and the Settings UI (O1) can read it.
+ // Only when synced (daemon past warmup). Fail-closed: a pre-1.3.0 daemon lacks the RPC,
+ // so the probe leaves active=false and the wallet keeps shielding client-side.
+ if (result.balanceOk && !state_.sync.syncing && !daemon_autoshield_probed_ && worker_ &&
+ !daemon_autoshield_probe_inflight_.exchange(true)) {
+ worker_->post([this]() -> rpc::RPCWorker::MainCb {
+ bool active = false, seedRecoverable = false;
+ std::string addr, reason;
+ try {
+ auto st = rpc_->call("z_autoshieldstatus", json::array());
+ if (st.is_object()) {
+ if (st.contains("autoshield")) active = st["autoshield"].get();
+ if (st.contains("autoshieldaddress")) addr = st["autoshieldaddress"].get();
+ if (st.contains("disabled_reason")) reason = st["disabled_reason"].get();
+ if (st.contains("seed_recoverable")) seedRecoverable = st["seed_recoverable"].get();
+ }
+ } catch (...) {} // pre-1.3.0 daemon: no such method — leave defaults (inactive)
+ return [this, active, addr, reason, seedRecoverable]() {
+ daemon_autoshield_active_ = active;
+ daemon_autoshield_address_ = addr;
+ daemon_autoshield_disabled_reason_ = reason;
+ daemon_autoshield_seed_recoverable_ = seedRecoverable;
+ daemon_autoshield_probed_ = true;
+ daemon_autoshield_probe_inflight_ = false;
+ };
+ });
+ }
+
+ // Auto-shield transparent funds — but defer to the daemon's own coinbase auto-shielder
+ // (v1.3.0+) when it's active, so we don't double-shield and split funds across z-addrs.
+ const bool autoShieldEligible = result.balanceOk && settings_ && settings_->getAutoShield() &&
+ state_.spendableTransparentBalance > 0.0001 && !state_.sync.syncing;
+ if (autoShieldEligible && daemon_autoshield_probed_ && !daemon_autoshield_active_ &&
!auto_shield_pending_.exchange(true)) {
std::string targetZAddr;
for (const auto& addr : state_.addresses) {
@@ -1581,7 +1850,7 @@ void App::refreshCoreData()
}
if (!targetZAddr.empty() && worker_) {
DEBUG_LOGF("[AutoShield] Shielding %.8f DRGX to %s\n",
- state_.transparent_balance, targetZAddr.c_str());
+ state_.spendableTransparentBalance, targetZAddr.c_str());
// Use the user-configured fee, formatted fixed-decimal so the daemon's
// ParseFixedPoint accepts it (a small double would serialize to "5e-05").
const std::string feeStr =
@@ -1642,7 +1911,18 @@ void App::refreshAddressData()
auto result = NetworkRefreshService::collectAddressRefreshResult(refreshRpc, addressSnapshot);
return [this, previousAddressCount, previousWalletIdentity, result = std::move(result)]() mutable {
+ const bool addrListOk = result.addressListOk; // capture before the move
+ const double scanMs = result.scanMs; // feed the adaptive throttle (addressRefreshDue)
+ if (scanMs > 0.0) last_address_scan_ms_ = scanMs;
+ // #3: this scan already listed the wallet's unspent notes — feed the chat send-budget from it
+ // (dedups the dedicated chat z_listunspent). Grab it before `result` is moved below.
+ auto unspentNotes = std::move(result.unspentNotes);
NetworkRefreshService::applyAddressRefreshResult(state_, std::move(result));
+ updateChatNoteBudgetFromUnspent(unspentNotes);
+ // Mark the address list as loaded ONLY if enumeration actually succeeded — a swallowed
+ // z_listaddresses/getaddressesbyaccount failure returns a falsely-short list, and stamping it
+ // would let the empty-wallet warning trust a spurious 0 count (see maybeWarnEmptyWallet…).
+ if (addrListOk) state_.last_address_update = std::time(nullptr);
applyPendingSendBalanceDeltas(false);
address_validation_cache_dirty_ = false;
address_list_dirty_ = true;
@@ -1697,6 +1977,13 @@ void App::refreshTransactionData()
transactionSnapshot.maxShieldedReceiveScans = shieldedReceiveScanBudget(current_page_);
transactionSnapshot.shieldedScanTipTolerance =
shieldedScanTipTolerance(transactionSnapshot.shieldedAddresses.size());
+ // When fully synced, widen the re-scan tolerance so the O(mapWallet) per-address history scan runs
+ // every several blocks instead of ~every 2 — the heaviest residual synced-state cs_main cost. The
+ // balance poll still surfaces incoming funds on its own cadence; only the detailed history list lags a
+ // few minutes. (During catch-up the tx refresh is suppressed by kSyncProfile, so this is synced-only.)
+ if (!effectivelySyncing())
+ transactionSnapshot.shieldedScanTipTolerance =
+ std::max(transactionSnapshot.shieldedScanTipTolerance, 8);
ui::NavPage tracePage = current_page_;
auto enqueued = network_refresh_.enqueue(services::NetworkRefreshService::Job::Transactions, *worker_, [this, currentBlocks,
@@ -1707,6 +1994,7 @@ void App::refreshTransactionData()
return [this, result = std::move(result)]() mutable {
bool shieldedScanComplete = result.shieldedScanComplete;
+ if (result.scanMs > 0.0) last_tx_scan_ms_ = result.scanMs; // feed the adaptive throttle (txRefreshDue)
std::size_t nextShieldedScanStartIndex = result.nextShieldedScanStartIndex;
auto shieldedScanHeights = std::move(result.shieldedScanHeights);
NetworkRefreshService::TransactionCacheUpdate cacheUpdate{
@@ -1772,6 +2060,9 @@ void App::refreshRecentTransactionData()
transactionSnapshot.maxShieldedReceiveScans = 1;
transactionSnapshot.shieldedScanTipTolerance =
shieldedScanTipTolerance(transactionSnapshot.shieldedAddresses.size());
+ if (!effectivelySyncing()) // synced: widen the re-scan tolerance (see refreshTransactionData)
+ transactionSnapshot.shieldedScanTipTolerance =
+ std::max(transactionSnapshot.shieldedScanTipTolerance, 8);
ui::NavPage tracePage = current_page_;
auto enqueued = network_refresh_.enqueue(services::NetworkRefreshService::Job::Transactions, *worker_, [this, currentBlocks,
@@ -2207,6 +2498,10 @@ void App::startMining(int threads)
return;
}
if (!state_.connected || !rpc_ || !worker_) return;
+ // Clamp the requested thread count to [1, logical cores] before setgenerate — an unclamped value
+ // (from a settings field or idle-scaling) would ask the daemon to spawn arbitrarily many threads. (M-08)
+ const int maxThreads = std::max(1, (int)std::thread::hardware_concurrency());
+ threads = std::clamp(threads, 1, maxThreads);
if (mining_toggle_in_progress_.exchange(true)) return; // already in progress
worker_->post([this, threads]() -> rpc::RPCWorker::MainCb {
@@ -2241,19 +2536,25 @@ void App::stopMining()
worker_->post([this]() -> rpc::RPCWorker::MainCb {
bool ok = false;
+ std::string errMsg;
try {
rpc::RPCClient::TraceScope trace("Mining tab / Stop mining");
rpc_->call("setgenerate", {false, 0});
ok = true;
} catch (const std::exception& e) {
- DEBUG_LOGF("Failed to stop mining: %s\n", e.what());
+ errMsg = e.what();
+ DEBUG_LOGF("Failed to stop mining: %s\n", errMsg.c_str());
}
- return [this, ok]() {
+ return [this, ok, errMsg]() {
mining_toggle_in_progress_.store(false);
if (ok) {
state_.mining.generate = false;
state_.mining.localHashrate = 0.0;
DEBUG_LOGF("Mining stopped\n");
+ } else {
+ // Don't silently leave generate=true as if it worked: tell the user and let the next
+ // getmininginfo refresh reconcile the true daemon state. (M-07)
+ ui::Notifications::instance().error("Failed to stop mining: " + errMsg);
}
};
});
@@ -2266,21 +2567,28 @@ void App::startPoolMining(int threads)
ui::Notifications::instance().warning("Pool mining is unavailable in this build");
return;
}
+ // Clamp to [1, logical cores] before the count reaches xmrig (M-06/M-08 pool path).
+ threads = std::clamp(threads, 1, std::max(1, (int)std::thread::hardware_concurrency()));
if (!xmrig_manager_)
xmrig_manager_ = std::make_unique();
- // If already running, stop first (e.g. thread count change)
- if (xmrig_manager_->isRunning()) {
- xmrig_manager_->stop();
- }
-
- // Stop solo mining first if active
+ // Stop solo mining first if active (async via the RPC worker).
if (state_.mining.generate) stopMining();
+ // (the "stop the already-running miner first" step is done inside the control job below, in FIFO order)
daemon::XmrigManager::Config cfg;
cfg.pool_url = settings_->getPoolUrl();
cfg.worker_name = settings_->getPoolWorker();
+ // Validate the payout address at EVERY start entry point (manual Start button, idle auto-start, thread
+ // scaling) — not just the UI gate — since a stale/hand-edited/wrong-chain address here silently loses
+ // mining rewards. (M-01) worker_name IS the pool login the rewards are credited to (see below).
+ if (!cfg.worker_name.empty() && cfg.worker_name != "x" &&
+ !util::isValidRecipientAddress(cfg.worker_name)) {
+ ui::Notifications::instance().error(
+ "Pool payout address is not a valid DragonX address — mining not started.");
+ return;
+ }
// The algo follows the pool: official pools use their own algo (pool.dragonx.cc
// needs rx/dragonx, pool.dragonx.is rx/hush); custom hosts keep the setting.
cfg.algo = util::resolvePoolAlgo(cfg.pool_url, settings_->getPoolAlgo());
@@ -2288,27 +2596,23 @@ void App::startPoolMining(int threads)
cfg.tls = settings_->getPoolTls();
cfg.hugepages = settings_->getPoolHugepages();
- // Use first shielded address as the mining wallet address, fall back to transparent
+ // xmrig "user" is the pool login the block rewards are credited to. The user's
+ // "Payout Address" field (cfg.worker_name = getPoolWorker) is exactly that, so it
+ // takes priority — otherwise a payout address that differs from the wallet's own
+ // first z-address is silently ignored and rewards go to the wrong address. Only when
+ // no payout address is set do we fall back to the wallet's own first shielded, then
+ // transparent, address (available even before the daemon is connected/synced).
+ std::string firstShielded, firstTransparent;
for (const auto& addr : state_.z_addresses) {
- if (!addr.address.empty()) {
- cfg.wallet_address = addr.address;
+ if (!addr.address.empty()) { firstShielded = addr.address; break; }
+ }
+ for (const auto& addr : state_.addresses) {
+ if (addr.type == "transparent" && !addr.address.empty()) {
+ firstTransparent = addr.address;
break;
}
}
- if (cfg.wallet_address.empty()) {
- for (const auto& addr : state_.addresses) {
- if (addr.type == "transparent" && !addr.address.empty()) {
- cfg.wallet_address = addr.address;
- break;
- }
- }
- }
-
- // Fallback: use pool worker address from settings (available even before
- // the daemon is connected or the blockchain is synced).
- if (cfg.wallet_address.empty() && !cfg.worker_name.empty()) {
- cfg.wallet_address = cfg.worker_name;
- }
+ cfg.wallet_address = ui::resolveMiningUserAddress(cfg.worker_name, firstShielded, firstTransparent);
if (cfg.wallet_address.empty()) {
DEBUG_LOGF("[ERROR] Pool mining: No wallet address available\n");
@@ -2316,34 +2620,46 @@ void App::startPoolMining(int threads)
return;
}
- if (!xmrig_manager_->start(cfg)) {
- std::string err = xmrig_manager_->getLastError();
- DEBUG_LOGF("[ERROR] Pool mining: %s\n", err.c_str());
-
- // Check for Windows Defender blocking (error 225 = ERROR_VIRUS_INFECTED)
- if (err.find("error 225") != std::string::npos ||
- err.find("virus") != std::string::npos) {
- ui::Notifications::instance().error(
- "Windows Defender blocked xmrig. Add exclusion for %APPDATA%\\ObsidianDragon");
+ // Run the blocking stop(if running)+start on the serialized mining-control thread so the render thread
+ // never blocks on stop()'s SIGTERM->SIGKILL->join; marshal the spawn result back to the UI. (M-03/L-06/
+ // L-08/L-09/L-13). cfg was fully built above on this (main) thread.
+ daemon::XmrigManager::Config cfgCopy = cfg;
+ postMiningControl([this, cfgCopy]() {
+ if (xmrig_manager_->isRunning()) xmrig_manager_->stop(3000);
+ const bool ok = xmrig_manager_->start(cfgCopy);
+ const std::string err = ok ? std::string() : xmrig_manager_->getLastError();
+ if (!worker_) return;
+ worker_->post([this, ok, err]() -> rpc::RPCWorker::MainCb {
+ return [this, ok, err]() {
+ if (ok) {
+ // Miner spawned — it still needs a few seconds to connect to the pool and start hashing.
+ pool_starting_.store(true, std::memory_order_relaxed);
+ ui::Notifications::instance().info("Starting pool miner — connecting to the pool…");
+ } else {
+ DEBUG_LOGF("[ERROR] Pool mining: %s\n", err.c_str());
+ // Windows Defender blocking (error 225 = ERROR_VIRUS_INFECTED)
+ if (err.find("error 225") != std::string::npos || err.find("virus") != std::string::npos) {
+ ui::Notifications::instance().error(
+ "Windows Defender blocked xmrig. Add exclusion for %APPDATA%\\ObsidianDragon");
#ifdef _WIN32
- // Offer to open Windows Security settings
- pending_antivirus_dialog_ = true;
+ pending_antivirus_dialog_ = true;
#endif
- } else {
- ui::Notifications::instance().error("Failed to start pool miner: " + err);
- }
- } else {
- // Miner spawned — it still needs a few seconds to connect to the pool and start hashing.
- pool_starting_.store(true, std::memory_order_relaxed);
- ui::Notifications::instance().info("Starting pool miner — connecting to the pool…");
- }
+ } else {
+ ui::Notifications::instance().error("Failed to start pool miner: " + err);
+ }
+ }
+ };
+ });
+ });
}
void App::stopPoolMining()
{
- if (xmrig_manager_ && xmrig_manager_->isRunning()) {
- xmrig_manager_->stop(3000);
- }
+ if (!xmrig_manager_) return;
+ // Off the render thread — stop()'s SIGTERM->SIGKILL->join can block up to ~3s. (M-03/L-06/L-08/L-09)
+ postMiningControl([this]() {
+ if (xmrig_manager_->isRunning()) xmrig_manager_->stop(3000);
+ });
}
// ============================================================================
@@ -2738,6 +3054,10 @@ void App::provisionChatIdentityFromSecret(std::string secret)
// Persistence: unlock the seed-derived chat DB with the SAME secret and rehydrate the store
// with prior messages (decrypted at rest under a key only this seed can derive).
chat_service_.setPersistence(&chat_db_);
+ // A blocked conversation's messages are dropped at ingest (old + future) until it is unblocked.
+ chat_service_.setBlockedPredicate([this](const std::string& cid) {
+ return settings_ && settings_->isChatBlocked(cid);
+ });
if (chat_db_.unlockWithSecret(trimmed)) {
chat_service_.loadFromDatabase();
// Baseline unread: treat everything already in the store at load as read, so only messages
@@ -2935,14 +3255,14 @@ std::string App::chatPayFromZaddr(double fee) const
std::string reply;
if (settings_) reply = settings_->getChatReplyZaddr();
for (const auto& a : state_.z_addresses)
- if (a.address == reply && a.has_spending_key && a.balance >= fee) return reply;
+ if (a.address == reply && a.has_spending_key && a.spendableBalance >= fee) return reply;
std::string best;
double bestBal = -1.0;
for (const auto& a : state_.z_addresses)
- if (a.has_spending_key && !a.address.empty() && a.balance >= fee && a.balance > bestBal) {
+ if (a.has_spending_key && !a.address.empty() && a.spendableBalance >= fee && a.spendableBalance > bestBal) {
best = a.address;
- bestBal = a.balance;
+ bestBal = a.spendableBalance;
}
return best; // empty → no z-address can cover the fee
}
@@ -3276,9 +3596,25 @@ void App::refreshChatNoteBudget(const wallet::LiteWalletAppRefreshModel& model)
void App::refreshChatNoteBudgetNode() {
if (lite_wallet_ || !chat::hushChatFeatureEnabledAtBuild() || !chat_service_.hasIdentity()) return;
if (!state_.connected || !rpc_ || !worker_ || state_.isLocked()) return;
+ // Never contend the daemon's cs_main while the node is catching up. z_listunspent is O(mapWallet)
+ // and holds cs_main for its whole duration — seconds on a large wallet — so an ungated per-few-second
+ // chat scan here starves block connection and keeps the node from ever pinning to the tip. Chat
+ // send-readiness can wait until we're synced; this budget is only advisory (the send path re-checks).
+ if (effectivelySyncing()) return;
+ // Only pre-warm the send-budget while the user is actually in chat. Off the Chat tab this heavy
+ // z_listunspent is unnecessary — and on a wallet-data tab, refreshAddressData's own z_listunspent
+ // already feeds the budget for free (updateChatNoteBudgetFromUnspent), so this dedicated scan is a
+ // fallback for the Chat tab (where the address refresh doesn't run).
+ if (current_page_ != ui::NavPage::Chat) return;
if (chat_note_scan_in_flight_) return; // one scan at a time
const double now = ImGui::GetTime();
- if (now - chat_note_scan_last_ < 4.0) return; // rate limit; self-throttles via the in-flight flag too
+ // Adaptive back-off keyed on the last scan's own cost: on a large wallet where the scan takes many
+ // seconds, this stretches the interval so the scan can't occupy more than ~10% of wall-clock (same
+ // duty-cycle discipline as the balance/address/tx polls). A cheap wallet stays at the 4s floor.
+ double minInterval = 4.0;
+ if (chat_note_scan_ms_ > 0.0)
+ minInterval = std::max(4.0, (chat_note_scan_ms_ / 1000.0) / 0.10);
+ if (now - chat_note_scan_last_ < minInterval) return;
chat_note_scan_last_ = now;
chat_note_scan_in_flight_ = true;
@@ -3289,6 +3625,7 @@ void App::refreshChatNoteBudgetNode() {
int verified = 0, pipeline = 0;
std::uint64_t verifiedZat = 0;
bool ok = false;
+ const auto scanStart = std::chrono::steady_clock::now();
try {
rpc::RPCClient::TraceScope trace("HushChat / note-buffer scan");
nlohmann::json notes = rpc_->call("z_listunspent", nlohmann::json::array({0})); // 0 = include maturing
@@ -3308,9 +3645,12 @@ void App::refreshChatNoteBudgetNode() {
}
}
} catch (const std::exception&) {}
- return [this, scanGen, ok, verified, pipeline, verifiedZat]() {
+ const double scanMs = std::chrono::duration(
+ std::chrono::steady_clock::now() - scanStart).count();
+ return [this, scanGen, ok, verified, pipeline, verifiedZat, scanMs]() {
if (scanGen != chat_session_generation_) return; // wallet switched — drop (reset cleared the flag)
chat_note_scan_in_flight_ = false;
+ if (scanMs > 0.0) chat_note_scan_ms_ = scanMs; // feed the adaptive back-off
if (!ok) return; // z_listunspent unavailable — leave caches as-is
chat_note_model_seen_ = true;
chat_verified_note_budget_ = verified;
@@ -3325,6 +3665,36 @@ void App::refreshChatNoteBudgetNode() {
});
}
+// #3: derive the chat send-budget from an already-collected z_listunspent (refreshAddressData's scan),
+// so we don't issue a duplicate z_listunspent. Runs on the main thread; the notes are pre-parsed to the
+// fields we need (amount / locked / TRUE confirmations), so there's no RPC and no worker hop. Mirrors the
+// apply in refreshChatNoteBudgetNode and stamps chat_note_scan_last_ so the dedicated scan backs off.
+void App::updateChatNoteBudgetFromUnspent(
+ const std::vector& unspentNotes) {
+ if (lite_wallet_ || !chat::hushChatFeatureEnabledAtBuild() || !chat_service_.hasIdentity()) return;
+ const int confsNeeded = chatConfsRequired();
+ const std::uint64_t minVal = chatDrgxToZat(kChatMinFeeDrgx);
+ int verified = 0, pipeline = 0;
+ std::uint64_t verifiedZat = 0;
+ for (const auto& nz : unspentNotes) {
+ if (nz.locked) continue; // tied up by an in-flight send
+ const std::uint64_t amtZat = chatDrgxToZat(nz.amount);
+ if (amtZat < minVal) continue; // skip dust below a fee
+ ++pipeline; // unspent self-note (verified or maturing)
+ if (nz.confirmations >= confsNeeded) { ++verified; verifiedZat += amtZat; }
+ }
+ chat_note_model_seen_ = true;
+ chat_verified_note_budget_ = verified;
+ chat_pipeline_note_count_ = pipeline;
+ chat_verified_shielded_zat_ = verifiedZat;
+ chat_note_scan_last_ = ImGui::GetTime(); // counts as a fresh scan → dedicated scan backs off
+ if (chat_split_outstanding_ &&
+ (chat_pipeline_note_count_ >= kChatRefillTrigger ||
+ ImGui::GetTime() - chat_split_submitted_at_ > kChatSplitWatchdogSecs)) {
+ chat_split_outstanding_ = false;
+ }
+}
+
void App::enqueueChatSend(LiteOpKind kind, const chat::OutgoingChatMemos& memos, const std::string& echoLocalId) {
QueuedChatOp op;
op.kind = kind;
@@ -3566,15 +3936,28 @@ void App::fastScanChatMemos()
if (!chat::hushChatFeatureEnabledAtBuild() || !chat_service_.hasIdentity()) return;
if (!state_.connected || !rpc_ || !worker_) return;
if (chat_fast_scan_in_flight_) return; // don't stack RPCs if a previous scan is still running
+ // z_listreceivedbyaddress is O(mapWallet) (the daemon iterates every wtx even when filtered to one
+ // address) — seconds on a large wallet — so this 0-conf poll must NOT run while the node is catching
+ // up, and even when synced it backs off to ~10% wall-clock keyed on its own cost. The block-tip
+ // harvest still ingests every message on confirmation; this only trades away mempool-speed delivery.
+ if (effectivelySyncing()) return;
+ // Mempool-speed 0-conf delivery is only worth its heavy cost while the user is watching chat. Off the
+ // Chat tab, incoming messages still arrive via the confirmed block-tip harvest (+ toast) within ~1 block.
+ if (current_page_ != ui::NavPage::Chat) return;
+ const double nowT = ImGui::GetTime();
+ if (chat_fast_scan_ms_ > 0.0 &&
+ nowT - chat_fast_scan_last_ < (chat_fast_scan_ms_ / 1000.0) / 0.10) return;
const std::string addr = chatReplyZaddr();
if (addr.empty()) return;
+ chat_fast_scan_last_ = nowT;
chat_fast_scan_in_flight_ = true;
const int scanGen = chat_session_generation_; // guard: drop the result if the wallet switches/locks
worker_->post([this, addr, scanGen]() -> rpc::RPCWorker::MainCb {
std::vector metadata;
int rawMemoCount = 0; // received notes carrying a memo at the reply addr (0-conf visibility signal)
std::string scanError;
+ const auto scanStart = std::chrono::steady_clock::now();
try {
rpc::RPCClient::TraceScope trace("HushChat / 0-conf fast scan");
nlohmann::json received = rpc_->call("z_listreceivedbyaddress", {addr, 0}); // 0 = include mempool
@@ -3605,12 +3988,15 @@ void App::fastScanChatMemos()
}
} catch (const std::exception& e) { scanError = e.what(); }
const int parsedCount = static_cast(metadata.size());
- return [this, scanGen, metadata = std::move(metadata), rawMemoCount, parsedCount, scanError]() mutable {
+ const double scanMs = std::chrono::duration(
+ std::chrono::steady_clock::now() - scanStart).count();
+ return [this, scanGen, metadata = std::move(metadata), rawMemoCount, parsedCount, scanError, scanMs]() mutable {
// The wallet was switched/locked between post and now — this metadata belongs to the previous
// session. resetChatSession already reset the in-flight flag, so just drop; clearing it here
// would clobber a new session's own in-flight scan (mirrors the broadcast/identity guards).
if (scanGen != chat_session_generation_) return;
chat_fast_scan_in_flight_ = false;
+ if (scanMs > 0.0) chat_fast_scan_ms_ = scanMs; // feed the adaptive back-off
// Diagnostic (console App/chat channel): log when the memo-note count at the reply address
// CHANGES, so a stable mempool doesn't spam every 2.5s. Reveals whether inbound 0-conf messages
// are reaching us at all, and how many parse as chat vs are unrelated memos.
@@ -3761,6 +4147,8 @@ void App::exportAllKeys(std::function callba
(*pending)--;
if (*pending == 0 && callback) {
callback(*keys_result, *exported, *total);
+ // Scrub the concatenated all-keys buffer once the consumer (backup writer) has used it.
+ if (!keys_result->empty()) sodium_memzero(&(*keys_result)[0], keys_result->size());
}
});
}
@@ -3783,7 +4171,9 @@ void App::importPrivateKey(const std::string& rawKey, int startHeight,
// Reject anything that isn't a recognized Z/T private key or shielded viewing key before handing
// it to the daemon (the dialog's indicator and this guard share isRecognizedImportKey).
if (!services::WalletSecurityController::isRecognizedImportKey(key)) {
- if (callback) callback(false, "Unrecognized key format.", "");
+ if (callback) callback(false,
+ "Not a recognized DragonX private key or viewing key. Check for missing or "
+ "mistyped characters, and that this is a DragonX key (not another coin).", "");
return;
}
@@ -3792,7 +4182,7 @@ void App::importPrivateKey(const std::string& rawKey, int startHeight,
== services::WalletSecurityController::KeyKind::Shielded;
// Run on the worker thread — import requests a full rescan (rescan=true), so the
// synchronous curl call can take many seconds; never block the UI thread on it.
- worker_->post([this, key, viewing, shielded, startHeight, callback]() -> rpc::RPCWorker::MainCb {
+ worker_->post([this, key, viewing, shielded, startHeight, callback]() mutable -> rpc::RPCWorker::MainCb {
std::string err, addr;
try {
rpc::RPCClient::TraceScope trace("Settings / Import key");
@@ -3804,6 +4194,11 @@ void App::importPrivateKey(const std::string& rawKey, int startHeight,
// A start height (shielded RPCs only) rescans from that block instead of genesis.
if (startHeight > 0 && (viewing || shielded)) params.push_back(startHeight);
nlohmann::json r = rpc_->call(method, params);
+ // Scrub the key out of the request params (the json holds its own copy of it).
+ if (params.is_array() && !params.empty() && params[0].is_string()) {
+ std::string& pk = params[0].get_ref();
+ if (!pk.empty()) sodium_memzero(&pk[0], pk.size());
+ }
// z_import* return {type,address}; importprivkey returns the t-address string.
if (r.is_object() && r.contains("address") && r["address"].is_string())
addr = r["address"].get();
@@ -3816,6 +4211,16 @@ void App::importPrivateKey(const std::string& rawKey, int startHeight,
// below would never run, leaving a stuck "Importing…" spinner.
err = "Import failed (unknown error)";
}
+ // Scrub the worker's copy of the key now that the request has been sent (all paths).
+ if (!key.empty()) sodium_memzero(&key[0], key.size());
+ // A checksum-valid key the daemon still rejects is almost always the right *format* but the
+ // wrong network/coin (Komodo-family chains share version bytes) or a corrupted paste — say so,
+ // since the bare "Invalid …" text reads like a wallet bug (F5).
+ if (!err.empty() && err.find("Invalid") != std::string::npos &&
+ err.find("DragonX") == std::string::npos) {
+ err += " — check the key is for DragonX (not another coin or network) and has no missing "
+ "or altered characters.";
+ }
return [this, err, addr, callback]() {
if (!err.empty()) {
if (callback) callback(false, err, "");
@@ -3826,6 +4231,7 @@ void App::importPrivateKey(const std::string& rawKey, int startHeight,
if (callback) callback(true, "", addr);
};
});
+ if (!key.empty()) sodium_memzero(&key[0], key.size()); // scrub the calling-frame copy
}
// Sweep a spending key: import it (a full rescan populates its UTXOs/notes — the stock node has no
@@ -3863,7 +4269,7 @@ void App::sweepPrivateKey(const std::string& rawKey, int startHeight, int destMo
const bool shielded = services::WalletSecurityController::classifyPrivateKey(key)
== services::WalletSecurityController::KeyKind::Shielded;
const double fee = DRAGONX_DEFAULT_FEE;
- worker_->post([this, key, startHeight, destMode, destExisting, shielded, fee]() -> rpc::RPCWorker::MainCb {
+ worker_->post([this, key, startHeight, destMode, destExisting, shielded, fee]() mutable -> rpc::RPCWorker::MainCb {
std::string err, dest, sourceAddr, amountStr;
double amount = 0.0;
try {
@@ -3887,6 +4293,11 @@ void App::sweepPrivateKey(const std::string& rawKey, int startHeight, int destMo
else { method = "importprivkey"; params = {key, "", true}; }
if (startHeight > 0 && shielded) params.push_back(startHeight);
nlohmann::json r = rpc_->call(method, params);
+ // Scrub the key out of the request params (the json holds its own copy of it).
+ if (params.is_array() && !params.empty() && params[0].is_string()) {
+ std::string& pk = params[0].get_ref();
+ if (!pk.empty()) sodium_memzero(&pk[0], pk.size());
+ }
// 2. Determine the swept address. importprivkey returns the t-address string; z_importkey
// returns null, so diff the z-address list to find the one the key just added.
@@ -3945,6 +4356,8 @@ void App::sweepPrivateKey(const std::string& rawKey, int startHeight, int destMo
} catch (...) {
err = "Sweep failed (unknown error)";
}
+ // Scrub the worker's copy of the spending key now that the request has been sent (all paths).
+ if (!key.empty()) sodium_memzero(&key[0], key.size());
return [this, err, sourceAddr, dest, amount, amountStr, fee]() {
invalidateAddressValidationCache();
refreshAddresses();
@@ -3981,6 +4394,7 @@ void App::sweepPrivateKey(const std::string& rawKey, int startHeight, int destMo
});
};
});
+ if (!key.empty()) sodium_memzero(&key[0], key.size()); // scrub the calling-frame copy
}
void App::exportSeedPhrase(std::function callback)
@@ -4047,6 +4461,141 @@ void App::maybeRemindSeedBackup()
});
}
+// One-time nudge (full-node) when the BDB wallet.dat has bloated past the threshold. Berkeley DB never
+// shrinks in place and shielded-note witness data accumulates, so a mining/shielded wallet can grow
+// unbounded. Fires ONCE (persisted flag) a warning toast + a clickable "Consolidate notes…" entry in the
+// bell/alert panel that opens Merge to Address; re-arms if the file later drops back under the threshold.
+void App::maybeWarnLargeWallet()
+{
+ if (capture_mode_ || lite_wallet_) return; // no live nags during a UI sweep; lite has no wallet.dat
+ if (!supportsFullNodeLifecycleActions() || !settings_) return;
+ if (!state_.connected || !state_.encryption_state_known) return;
+ if (state_.warming_up || state_.daemon_initializing || !state_.sync.isSynced()) return;
+ if (large_wallet_checked_) return; // stat wallet.dat at most once per launch
+ large_wallet_checked_ = true;
+
+ static constexpr uint64_t kWalletBloatWarnBytes = 500ull * 1024 * 1024; // 500 MB (matches the Settings banner)
+ const std::string walletPath = util::Platform::getDragonXDataDir() + "/wallet.dat";
+ const uint64_t sz = util::Platform::getFileSize(walletPath);
+ if (sz <= kWalletBloatWarnBytes) {
+ // Re-arm the one-time warning if the file shrank back under the threshold (e.g. after a fresh seed wallet).
+ if (settings_->getLargeWalletWarned()) { settings_->setLargeWalletWarned(false); settings_->save(); }
+ return;
+ }
+ if (settings_->getLargeWalletWarned()) return; // already warned once for this bloat episode
+ settings_->setLargeWalletWarned(true);
+ settings_->save();
+ ui::Notifications::instance().action(
+ TR("wallet_size_warn"), ui::NotificationType::Warning,
+ []() { ui::ShieldDialog::showConsolidate(); },
+ TR("wallet_size_consolidate"), 12.0f);
+}
+
+// Complementary on-disk safety net (full-node) for a wallet salvage we did NOT witness this launch — it
+// happened on a prior run, or under an external daemon whose startup output we never captured, so
+// detectWalletAutoRecovery() never fired. If the active wallet loads EMPTY while a sibling wallet file in
+// the datadir still holds keys, warn once: the user's funds were likely moved into a wallet..bak by a
+// prior BDB salvage and are not lost, just in another file. Fires at most once per wallet-open and once per
+// unacknowledged wallet filename; the probe runs off the UI thread (scanFundedSiblingsAsync).
+void App::maybeWarnEmptyWalletWithFundedSiblings()
+{
+ if (capture_mode_) return; // no live ops during a UI sweep
+ if (lite_wallet_ || !supportsFullNodeLifecycleActions()) return; // full-node only (lite = single-file dir)
+ if (!settings_) return;
+ if (empty_wallet_warn_checked_ || empty_wallet_scan_in_flight_) return; // at most once per wallet-open
+ if (show_empty_wallet_warning_) return; // already surfaced
+ // The console-driven recovery flow owns the salvage-this-launch case — don't double-warn.
+ if (wallet_auto_recovered_ || show_wallet_recovered_dialog_) return;
+ // Only meaningful once the wallet is truly loaded AND fully synced: a mid-sync wallet reads empty.
+ if (!state_.connected || !state_.encryption_state_known) return;
+ if (state_.warming_up || state_.daemon_initializing || !state_.sync.isSynced()) return;
+ // Wait for the first Core refresh to land. The ConnectionInit prefetch sets sync.blocks but NOT headers,
+ // so isSynced() (blocks >= headers-2) is spuriously true in the window before the Core refresh — during
+ // which balance/addresses also read 0. last_balance_update flips non-zero only when the Core refresh
+ // applies (network_refresh_service.cpp), by which point balance & headers are real.
+ if (state_.last_balance_update == 0) return;
+ // And wait for the ADDRESS list to have loaded at least once — otherwise getAddressCount()==0 is
+ // ambiguous ("no keys" vs "not fetched yet"), which would false-fire on a spent-down wallet (0 balance
+ // but has addresses) whose address refresh lands a beat after the balance refresh.
+ if (state_.last_address_update == 0) return;
+ // "Empty" = no addresses and no funds. A salvage-created fresh wallet has no keys; a legitimately
+ // spent-down wallet keeps its addresses, so requiring zero addresses avoids nagging the latter.
+ if (state_.getAddressCount() != 0) return;
+ if (state_.totalBalance > 0.0 || state_.spendableTotalBalance > 0.0) return;
+ if (settings_->isEmptyWalletWarnAcked(settings_->getActiveWalletFile())) return; // dismissed for this file
+
+ empty_wallet_warn_checked_ = true; // evaluate the on-disk state once for this wallet-open
+ scanFundedSiblingsAsync();
+}
+
+// Off-UI-thread: enumerate the datadir's OTHER wallet files (incl. salvage wallet..bak backups) and
+// offline-probe each for key material. Read-only; never opens a file in the daemon. The probe reads up to a
+// bounded prefix per file, so it runs on its own task thread (not the RPC worker) and the result is
+// marshaled back to the main thread before touching any UI state. Routing, applied on the main thread:
+// • a funded salvage .bak exists → the coins were moved aside by an unwitnessed salvage; hand off to the
+// existing recovery dialog, whose Restore action swaps the .bak back (the correct, tested fix).
+// • otherwise a funded sibling .dat exists → the user simply opened the wrong (empty) wallet; show the
+// lightweight warning modal that routes to the wallet manager to switch.
+void App::scanFundedSiblingsAsync()
+{
+ if (empty_wallet_scan_in_flight_) return;
+ empty_wallet_scan_in_flight_ = true;
+ const std::string datadir = util::Platform::getDragonXDataDir();
+ const std::string activeFile = settings_ ? settings_->getActiveWalletFile() : std::string("wallet.dat");
+ async_tasks_.submit("Empty-wallet sibling scan",
+ [this, datadir, activeFile](const util::AsyncTaskManager::Token& tok) {
+ std::vector funded; // funded plain-.dat wallets → the "switch wallet" modal
+ bool hasSalvageBak = false; // a funded wallet..bak → route to the recovery/restore dialog
+ for (const auto& path : util::enumerateDatadirWalletFiles(datadir, activeFile, /*includeSalvageBaks=*/true)) {
+ if (tok.cancelled()) return;
+ const auto bt = util::parseWalletBtree(path);
+ if (!(bt.parsed && bt.addresses() > 0)) continue; // ignore junk / empty siblings
+ const std::string name = std::filesystem::path(path).filename().string();
+ const bool isBak = name.size() > 4 && name.compare(name.size() - 4, 4, ".bak") == 0;
+ if (isBak) {
+ // Only a salvage-pattern wallet..bak has a defined restore path; other .bak files are
+ // ignored (the wallet manager lists only .dat, so routing them there would be a dead end).
+ if (daemon::parseWalletSalvageBakTs(name) >= 0) hasSalvageBak = true;
+ continue;
+ }
+ FundedSibling s;
+ s.fileName = name;
+ s.transparentKeys = bt.transparentKeys;
+ s.shieldedKeys = bt.shieldedKeys;
+ funded.push_back(std::move(s));
+ }
+ // On teardown/cancel, skip posting (shutdown only; the in-flight flag is irrelevant then).
+ if (tok.cancelled() || !worker_) return;
+ // Apply UI state on the main thread only (the render loop reads these members).
+ worker_->post([this, funded, hasSalvageBak]() -> rpc::RPCWorker::MainCb {
+ return [this, funded, hasSalvageBak]() {
+ empty_wallet_scan_in_flight_ = false;
+ if (wallet_auto_recovered_ || show_wallet_recovered_dialog_) return; // recovery already owns it
+ // Re-validate emptiness on the main thread: balance/address refreshes may have landed while
+ // the scan ran (it takes long enough to read+parse sibling files), so a warm-reconnect or a
+ // spent-down wallet that momentarily read empty is now correctly excluded.
+ if (!state_.connected || state_.getAddressCount() != 0 ||
+ state_.totalBalance > 0.0 || state_.spendableTotalBalance > 0.0) return;
+ // Both cases surface OUR modal (renderEmptyWalletWarningDialog), keyed by has_salvage_bak.
+ // We deliberately DON'T set the wallet_auto_recovered_ latch or auto-open the recovery dialog:
+ // the daemon is healthy, and that latch gates the crash-restart loop (app_network.cpp:556) +
+ // crash-toast suppression, so it would wedge the wallet offline on any later unrelated crash.
+ // For the salvage case the modal's "Restore" button calls restoreOriginalWallet() directly
+ // (self-contained: it drives the recovery dialog into its Working phase itself).
+ if (hasSalvageBak) {
+ empty_wallet_has_salvage_bak_ = true;
+ empty_wallet_funded_siblings_.clear();
+ show_empty_wallet_warning_ = true;
+ } else if (!funded.empty()) {
+ empty_wallet_has_salvage_bak_ = false;
+ empty_wallet_funded_siblings_ = funded;
+ show_empty_wallet_warning_ = true;
+ }
+ };
+ });
+ });
+}
+
// One-shot (per connect) probe of the current wallet's mnemonic status, so the Settings
// Migrate-to-seed button can glow for a legacy wallet without opening the migration dialog. Same
// classification as the migration Intro pre-flight, but proactive and cached. Reads no secret past
@@ -4111,27 +4660,56 @@ void App::showSeedMigrationDialog()
// Resume a pending migration. If a sweep was already submitted (txid persisted), resume at the
// confirm/adopt stage — re-derived from the chain — rather than sweeping again; otherwise start
// at the Sweep step. With no pending migration, start fresh at the intro.
- if (settings_ && settings_->getSeedMigrationPending() && !settings_->getSeedMigrationDest().empty()) {
- seed_migration_dest_ = settings_->getSeedMigrationDest();
+ const bool pending = settings_ && settings_->getSeedMigrationPending();
+ const bool haveDest = settings_ && !settings_->getSeedMigrationDest().empty();
+ const std::string sweepTxid = settings_ ? settings_->getSeedMigrationSweepTxid() : std::string();
+ const std::string sweepOpid = settings_ ? settings_->getSeedMigrationSweepOpid() : std::string();
+ const bool connected = state_.connected && rpc_ && worker_;
+
+ switch (decideSeedMigrationResume(pending, haveDest, sweepTxid, sweepOpid, connected)) {
+ case MigrationResume::Confirming:
+ seed_migration_dest_ = settings_->getSeedMigrationDest();
+ seed_migration_temp_dir_ = settings_->getSeedMigrationTempDir();
+ seed_migration_sweep_txid_ = sweepTxid;
+ seed_migration_sweep_confs_ = 0;
+ seed_migration_legacy_remaining_ = -1.0;
+ seed_migration_poll_timer_ = 0.0f; // poll immediately
+ seed_migration_step_ = SeedMigrationStep::Confirming;
+ break;
+ case MigrationResume::RetrackOpid:
+ // W3-3: a sweep opid was submitted but its txid was never persisted (app closed mid-Sweeping).
+ // Re-track it to recover the txid. If the daemon forgot it (restart), the opid poller flags it
+ // stale and makeSweepCompletionCallback(resumed) falls back to the Sweep gate — never a hang.
+ // Only reached when connected (decideSeedMigrationResume), so the poller can actually run and
+ // the buttonless "Sweeping" spinner is guaranteed an exit.
+ seed_migration_dest_ = settings_->getSeedMigrationDest();
+ seed_migration_temp_dir_ = settings_->getSeedMigrationTempDir();
+ seed_migration_sweep_txid_.clear();
+ pending_send_callbacks_[sweepOpid] = makeSweepCompletionCallback(/*resumed=*/true);
+ trackOperation(sweepOpid);
+ seed_migration_step_ = SeedMigrationStep::Sweeping;
+ seed_migration_status_ = "Checking on the previous sweep…";
+ break;
+ case MigrationResume::SweepGate:
+ // No txid, and either no opid or not connected to re-track it (a persisted opid is left in
+ // place so a later reconnect+reopen can re-track it). The Sweep step is dismissable and
+ // reloads the balance, so the user is never trapped while offline.
+ seed_migration_dest_ = settings_->getSeedMigrationDest();
seed_migration_temp_dir_ = settings_->getSeedMigrationTempDir();
- seed_migration_sweep_txid_ = settings_->getSeedMigrationSweepTxid();
- if (!seed_migration_sweep_txid_.empty()) {
- seed_migration_sweep_confs_ = 0;
- seed_migration_legacy_remaining_ = -1.0;
- seed_migration_poll_timer_ = 0.0f; // poll immediately
- seed_migration_step_ = SeedMigrationStep::Confirming;
- } else {
- seed_migration_step_ = SeedMigrationStep::Sweep;
- seed_migration_balance_loaded_ = false;
- seed_migration_nofunds_confirmed_ = false;
- refreshSeedMigrationBalance();
- }
- } else {
+ seed_migration_sweep_txid_.clear();
+ seed_migration_step_ = SeedMigrationStep::Sweep;
+ seed_migration_balance_loaded_ = false;
+ seed_migration_nofunds_confirmed_ = false;
+ refreshSeedMigrationBalance();
+ break;
+ case MigrationResume::Intro:
+ default:
seed_migration_step_ = SeedMigrationStep::Intro;
// Fresh start: the Intro step will pre-flight the wallet (legacy vs already-seeded vs old
// daemon) before offering to create anything.
seed_migration_precheck_ = SeedMigrationPrecheck::Pending;
seed_migration_precheck_started_ = false;
+ break;
}
}
@@ -4206,29 +4784,71 @@ void App::beginSweepToSeedWallet()
seed_migration_step_ = SeedMigrationStep::Error;
return;
}
- pending_send_callbacks_[opid] = [this](bool ok, const std::string& result) {
- if (ok) {
- seed_migration_sweep_txid_ = result;
- // Persist the txid so a restart resumes at the confirm/adopt stage and never
- // re-sweeps from scratch. The Confirming step gates adopt on this tx being mined
- // (>= 1 confirmation) AND the legacy balance dropping to ~0.
- if (settings_) { settings_->setSeedMigrationSweepTxid(result); settings_->save(); }
- seed_migration_sweep_confs_ = 0;
- seed_migration_legacy_remaining_ = -1.0;
- seed_migration_poll_timer_ = 0.0f;
- seed_migration_status_.clear();
- seed_migration_step_ = SeedMigrationStep::Confirming;
- } else {
- seed_migration_status_ = result.empty() ? "The sweep transaction failed." : result;
- seed_migration_step_ = SeedMigrationStep::Error;
- }
- };
+ // W3-3: adopt this new opid atomically — persist it AND clear any prior sweep txid in the
+ // SAME settings write. Persisting the opid lets an app-close during Sweeping (opid
+ // submitted, not yet resolved to a txid) re-poll it on resume instead of dropping it. Doing
+ // the swap HERE — only once the new submit has succeeded — rather than speculatively at
+ // function entry means a FAILED "Sweep remaining" remainder re-sweep leaves the
+ // already-mined first sweep's txid intact and resumable to Confirming; and the txid and
+ // opid are never both authoritative at once (torn-write safe; resume checks txid first).
+ seed_migration_sweep_txid_.clear();
+ if (settings_) {
+ settings_->setSeedMigrationSweepTxid("");
+ settings_->setSeedMigrationSweepOpid(opid);
+ settings_->save();
+ }
+ pending_send_callbacks_[opid] = makeSweepCompletionCallback(/*resumed=*/false);
trackOperation(opid);
seed_migration_status_ = "Waiting for the sweep transaction to be accepted…";
};
});
}
+// W3-3: terminal handling for the sweep operation, shared by the initial submit (resumed=false) and
+// a resume re-track (resumed=true). On success it persists the txid and clears the opid in the SAME
+// settings write, so the txid always outranks the opid on a later resume (torn-write safe).
+std::function App::makeSweepCompletionCallback(bool resumed)
+{
+ return [this, resumed](bool ok, const std::string& result) {
+ if (ok) {
+ seed_migration_sweep_txid_ = result;
+ // Persist the txid (and drop the now-redundant opid) so a restart resumes at the
+ // confirm/adopt stage and never re-sweeps from scratch. The Confirming step gates adopt on
+ // this tx being mined (>= 1 confirmation) AND the legacy balance dropping to ~0.
+ if (settings_) {
+ settings_->setSeedMigrationSweepTxid(result);
+ settings_->setSeedMigrationSweepOpid("");
+ settings_->save();
+ }
+ seed_migration_sweep_confs_ = 0;
+ seed_migration_legacy_remaining_ = -1.0;
+ seed_migration_poll_timer_ = 0.0f;
+ seed_migration_status_.clear();
+ seed_migration_step_ = SeedMigrationStep::Confirming;
+ } else if (resumed) {
+ // A resumed opid the daemon no longer knows (it restarted — the op queue is in-memory
+ // only). "Stale" can't be told apart from "failed", and the earlier sweep may in fact have
+ // already broadcast/mined, so DON'T dead-end at Error: drop the stale opid and return to
+ // the Sweep gate, re-fetching the legacy balance. If that sweep did complete, the balance
+ // reads ~0 and the Sweep step short-circuits to adopt; otherwise the user can sweep again.
+ if (settings_) { settings_->setSeedMigrationSweepOpid(""); settings_->save(); }
+ seed_migration_sweep_txid_.clear();
+ seed_migration_balance_loaded_ = false;
+ seed_migration_nofunds_confirmed_ = false;
+ seed_migration_status_ =
+ "Couldn't confirm the earlier sweep — it may have already completed. "
+ "Check your balance below before sweeping again.";
+ seed_migration_step_ = SeedMigrationStep::Sweep;
+ refreshSeedMigrationBalance(); // else the Sweep step sits on a permanent "Checking balance…"
+ } else {
+ // A fresh sweep that genuinely failed. Clear the persisted opid so it can't mis-resume.
+ if (settings_) { settings_->setSeedMigrationSweepOpid(""); settings_->save(); }
+ seed_migration_status_ = result.empty() ? "The sweep transaction failed." : result;
+ seed_migration_step_ = SeedMigrationStep::Error;
+ }
+ };
+}
+
// Confirming step: poll the sweep tx's confirmations + the legacy wallet's remaining balance. The
// adopt step is gated on the tx being mined (confs >= 1) AND the legacy balance being ~0, so we
// never swap wallet.dat while the funds could still bounce back (dropped/reorged tx) or while a
@@ -4282,7 +4902,13 @@ void App::beginAdoptSeedWallet()
// has its own passphrase; the user can re-enable PIN quick-unlock for it).
if (vault_) vault_->removeVault();
const std::string base = seed_migration_temp_dir_;
- async_tasks_.submit("Adopt seed wallet", [this, base](const util::AsyncTaskManager::Token&) {
+ // W3-1: adopt must swap the ACTIVE wallet file (multi-wallet), not a hardcoded "wallet.dat" —
+ // otherwise a migration run while e.g. wallet-2.dat is active would install the swept seed wallet
+ // into an unloaded wallet.dat and leave the daemon loading the (now-emptied) legacy wallet.
+ // Captured on the main thread; wallet switching is blocked during migration so this can't race.
+ const std::string activeWalletName = (settings_ && !settings_->getActiveWalletFile().empty())
+ ? settings_->getActiveWalletFile() : std::string("wallet.dat");
+ async_tasks_.submit("Adopt seed wallet", [this, base, activeWalletName](const util::AsyncTaskManager::Token&) {
namespace fs = std::filesystem;
std::string err; // fatal (swap did not happen; migration incomplete)
std::string warn; // non-fatal (swap done but the daemon did not restart)
@@ -4302,7 +4928,7 @@ void App::beginAdoptSeedWallet()
// 2. Swap wallet.dat. Move the legacy one aside to a timestamped backup (NEVER
// delete), then copy the new seed wallet in. On any failure, restore the legacy.
const std::string datadir = util::Platform::getDragonXDataDir();
- const std::string legacy = datadir + "/wallet.dat";
+ const std::string legacy = datadir + "/" + activeWalletName;
const std::string newWallet = base + "/DRAGONX/wallet.dat";
std::time_t t = std::time(nullptr);
std::tm tmv{}; // thread-safe local time (the UI thread also uses localtime)
@@ -4336,7 +4962,7 @@ void App::beginAdoptSeedWallet()
// 3. Rescan on next start (only if the swap happened) and bring the daemon back up —
// unless we're quitting, in which case don't resurrect it.
- if (swapDone && daemon_controller_) daemon_controller_->setRescanOnNextStart(true);
+ if (swapDone && daemon_controller_) { daemon_controller_->setRescanOnNextStart(true); user_initiated_rescan_ = true; }
if (!shutting_down_) {
// We stopped the daemon ourselves (port_free) — clear the adopted-external latch so the
// relaunched process is treated as owned (stop/isRunning/exit behave normally afterward).
@@ -4363,6 +4989,301 @@ void App::beginAdoptSeedWallet()
});
}
+// Undo a daemon wallet auto-recovery: swap the untouched original (wallet..bak) back over the
+// salvaged copy and clear the stale BDB env that triggered the false recovery, then restart. Modeled on
+// beginAdoptSeedWallet — stop daemon → file ops (copy/rename only, NEVER delete user data) → restart.
+void App::restoreOriginalWallet()
+{
+ if (!supportsFullNodeLifecycleActions()) {
+ ui::Notifications::instance().warning("Full-node lifecycle actions are unavailable in lite build");
+ return;
+ }
+ if (daemon_restarting_) {
+ ui::Notifications::instance().warning(TR("wallet_restore_busy"));
+ return;
+ }
+ // Keep the recovery dialog OPEN and drive it into the Working phase — it shows progress and the
+ // honest outcome in place (pumpWalletRestore flips it to Done/Failed). Presentation only; every
+ // file-safety step below is unchanged.
+ show_wallet_recovered_dialog_ = true;
+ recovery_phase_ = RecoveryPhase::Working;
+ recovery_last_action_rebuild_ = false;
+ { std::lock_guard lk(wallet_restore_mutex_); wallet_restore_done_ = false; }
+ daemon_restarting_ = true; // gate the reconnect loop while we swap files
+ connection_status_ = TR("sb_restarting_daemon");
+ if (rpc_ && rpc_->isConnected()) rpc_->disconnect();
+ onDisconnected("Restoring original wallet");
+ ui::Notifications::instance().info(TR("wallet_restore_started"), 12.0f);
+
+ const std::string activeWalletName = (settings_ && !settings_->getActiveWalletFile().empty())
+ ? settings_->getActiveWalletFile() : std::string("wallet.dat");
+
+ async_tasks_.submit("Restore original wallet", [this, activeWalletName](const util::AsyncTaskManager::Token&) {
+ namespace fs = std::filesystem;
+ std::string err, warn;
+ try {
+ const std::string datadir = util::Platform::getDragonXDataDir();
+ // 1. Find the LARGEST salvage backup (offline — no daemon needed). Largest = least-salvaged =
+ // the original: a salvage cascade shrinks the wallet each round, so the newest .bak can be
+ // empty ("Salvage found no records") while the original is untouched and huge.
+ std::vector> files;
+ {
+ std::error_code lec;
+ for (const auto& e : fs::directory_iterator(datadir, lec)) {
+ if (lec) break;
+ std::error_code se;
+ const auto sz = fs::is_regular_file(e, se) ? fs::file_size(e, se) : 0;
+ files.emplace_back(e.path().filename().string(),
+ se ? 0ull : static_cast(sz));
+ }
+ }
+ const std::string bak = daemon::largestWalletSalvageBak(files);
+ if (bak.empty()) {
+ err = TR("wallet_restore_no_backup");
+ } else if (!util::probeWalletFile(datadir + "/" + bak).isBerkeleyDB) {
+ err = TR("wallet_restore_bad_backup"); // don't overwrite a working wallet with a bad .bak
+ } else if (!stopDaemonForWalletSwitch()) { // 2. Release wallet.dat + the RPC port first.
+ err = TR("wallet_restore_stop_failed");
+ } else {
+ std::error_code ec;
+ std::time_t t = std::time(nullptr);
+ std::tm tmv{};
+#ifdef _WIN32
+ localtime_s(&tmv, &t);
+#else
+ localtime_r(&t, &tmv);
+#endif
+ char ts[32]; std::strftime(ts, sizeof(ts), "%Y%m%d-%H%M%S", &tmv);
+ const std::string active = datadir + "/" + activeWalletName;
+ const std::string salvagedAside = active + ".salvaged-" + ts + ".dat";
+ // 3. Move the salvaged copy aside (NEVER delete), then copy the original .bak into place
+ // (copy, so the .bak itself stays as a backup). Roll back the move if the copy fails.
+ bool movedSalvaged = false;
+ if (fs::exists(active)) {
+ fs::rename(active, salvagedAside, ec);
+ if (ec) err = TR("wallet_restore_move_failed");
+ else movedSalvaged = true;
+ }
+ if (err.empty()) {
+ fs::copy_file(datadir + "/" + bak, active, fs::copy_options::overwrite_existing, ec);
+ if (ec) {
+ if (movedSalvaged) { std::error_code e2; fs::rename(salvagedAside, active, e2); }
+ err = TR("wallet_restore_copy_failed");
+ }
+ }
+ // 4. Clear the stale BDB environment that triggered the false recovery — otherwise the
+ // daemon would just re-salvage the restored wallet on the next start. Move database/
+ // aside (keeps its logs) and drop the transient __db.* region files.
+ if (err.empty()) {
+ std::error_code e2;
+ if (fs::exists(datadir + "/database"))
+ fs::rename(datadir + "/database", datadir + "/database.pre-restore-" + ts + ".bak", e2);
+ for (const auto& e : fs::directory_iterator(datadir, e2)) {
+ if (e.path().filename().string().rfind("__db.", 0) == 0) {
+ std::error_code e3; fs::remove(e.path(), e3);
+ }
+ }
+ }
+ }
+
+ // 5. Bring the daemon back up (unless quitting). Even on a restore failure we relaunch so the
+ // node isn't left down; the connect loop reconnects and onConnected clears the gate.
+ if (!shutting_down_) {
+ if (daemon_controller_) daemon_controller_->clearExternalDaemonDetected();
+ if (!startEmbeddedDaemon() && err.empty())
+ warn = TR("wallet_restore_no_restart");
+ }
+ } catch (const std::exception& e) {
+ err = std::string("Restore failed: ") + e.what();
+ } catch (...) {
+ err = "Restore failed due to an unexpected error.";
+ }
+ daemon_restarting_ = false; // ALWAYS re-arm the reconnect gate
+
+ std::lock_guard lk(wallet_restore_mutex_);
+ wallet_restore_severity_ = !err.empty() ? 2 : (!warn.empty() ? 1 : 0);
+ wallet_restore_msg_ = !err.empty() ? err : warn;
+ wallet_restore_done_ = true;
+ });
+}
+
+void App::pumpWalletRestore()
+{
+ if (capture_mode_) return;
+ bool done = false; int sev = 0; std::string msg;
+ {
+ std::lock_guard lk(wallet_restore_mutex_);
+ if (wallet_restore_done_) { done = true; sev = wallet_restore_severity_; msg = wallet_restore_msg_; wallet_restore_done_ = false; }
+ }
+ if (!done) return;
+ // Primary outcome channel: if the recovery dialog is still up (Working), flip it to Done/Failed in
+ // place with the real result. The toast below stays as the secondary echo for a dismissed/alt-tabbed
+ // user. sev 2 = failed (op discarded, nothing changed); sev 0/1 = done (1 carries a warning message).
+ if (show_wallet_recovered_dialog_ && recovery_phase_ == RecoveryPhase::Working) {
+ recovery_outcome_sev_ = sev;
+ recovery_outcome_msg_ = msg;
+ recovery_phase_ = (sev == 2) ? RecoveryPhase::Failed : RecoveryPhase::Done;
+ // Clean success (sev 0) → the daemon is now restarting with a full rescan. Flag it so the loading
+ // overlay shows a calm "finishing repair" screen (not the scary generic stall) until it connects.
+ // NOT for sev 1 (a warning like "node didn't restart") — nothing is rescanning then, and the Done
+ // dialog already shows that message. Timestamp is stamped on the first overlay frame.
+ if (sev == 0) { post_recovery_rescan_ = true; post_recovery_rescan_since_ = 0.0; }
+ }
+ if (sev == 2) ui::Notifications::instance().error(msg, 25.0f);
+ else if (sev == 1) ui::Notifications::instance().warning(msg, 20.0f);
+ else ui::Notifications::instance().success(msg.empty() ? TR("wallet_restore_ok") : msg, 12.0f);
+}
+
+// Locate the bundled dragonx-wallet-rebuild helper (exe dir → daemon dir). "" if not present.
+static std::string findWalletRebuildHelper()
+{
+ namespace fs = std::filesystem;
+#ifdef _WIN32
+ const char* exe = "dragonx-wallet-rebuild.exe";
+#else
+ const char* exe = "dragonx-wallet-rebuild";
+#endif
+ for (const std::string& d : { util::Platform::getExecutableDirectory(),
+ dragonx::resources::getDaemonDirectory() }) {
+ if (d.empty()) continue;
+ std::error_code ec;
+ const std::string p = d + "/" + exe;
+ if (fs::exists(p, ec)) return p;
+ }
+ // Not sitting next to the app/daemon — but a self-contained exe carries it embedded. Extract it on
+ // demand (first-run param extraction is gated on needsParamsExtraction(), so it may never have run
+ // on a machine that already had the Sapling params). Returns "" on non-embedded builds.
+ return dragonx::resources::ensureWalletRebuildHelperExtracted();
+}
+
+bool App::walletRebuildAvailable() const { return !findWalletRebuildHelper().empty(); }
+
+// Rebuild a BDB-inconsistent wallet into a fresh, daemon-loadable one via the offline helper (see
+// tools/wallet_rebuild). This is the real fix for the salvage cascade: plain "Restore original" just
+// hands the same broken file back and the daemon re-salvages it. Modeled on restoreOriginalWallet:
+// stop daemon → run helper → verify → safe swap (copy/rename only, never delete) → rescan → restart.
+void App::rebuildWalletDatabase()
+{
+ if (!supportsFullNodeLifecycleActions()) {
+ ui::Notifications::instance().warning("Full-node lifecycle actions are unavailable in lite build");
+ return;
+ }
+ if (daemon_restarting_) { ui::Notifications::instance().warning(TR("wallet_restore_busy")); return; }
+ const std::string helper = findWalletRebuildHelper();
+ if (helper.empty()) { ui::Notifications::instance().error(TR("wallet_rebuild_no_helper"), 15.0f); return; }
+
+ // Keep the recovery dialog OPEN through the rebuild (Working → Done/Failed in place). Presentation
+ // only; the run-helper → verify-before-swap → copy/rename-never-delete steps below are unchanged.
+ show_wallet_recovered_dialog_ = true;
+ recovery_phase_ = RecoveryPhase::Working;
+ recovery_last_action_rebuild_ = true;
+ { std::lock_guard lk(wallet_restore_mutex_); wallet_restore_done_ = false; }
+ daemon_restarting_ = true;
+ connection_status_ = TR("sb_restarting_daemon");
+ if (rpc_ && rpc_->isConnected()) rpc_->disconnect();
+ onDisconnected("Rebuilding wallet database");
+ ui::Notifications::instance().info(TR("wallet_rebuild_started"), 15.0f);
+
+ const std::string activeWalletName = (settings_ && !settings_->getActiveWalletFile().empty())
+ ? settings_->getActiveWalletFile() : std::string("wallet.dat");
+
+ async_tasks_.submit("Rebuild wallet database", [this, helper, activeWalletName](const util::AsyncTaskManager::Token&) {
+ namespace fs = std::filesystem;
+ std::string err, warn;
+ try {
+ const std::string datadir = util::Platform::getDragonXDataDir();
+ const std::string active = datadir + "/" + activeWalletName;
+ // 1. Rebuild SOURCE = the largest readable wallet file (the active wallet or any salvage .bak).
+ // Largest = most records = the original / least-salvaged (a salvaged copy is tiny).
+ std::string src; unsigned long long best = 0;
+ {
+ std::error_code ec;
+ for (const auto& e : fs::directory_iterator(datadir, ec)) {
+ if (ec) break;
+ const std::string n = e.path().filename().string();
+ if (n != activeWalletName && daemon::parseWalletSalvageBakTs(n) < 0) continue;
+ std::error_code se;
+ const auto sz = fs::is_regular_file(e, se) ? fs::file_size(e, se) : 0;
+ const auto usz = se ? 0ull : static_cast(sz);
+ if (usz > best && util::probeWalletFile(e.path().string()).isBerkeleyDB) {
+ best = usz; src = e.path().string();
+ }
+ }
+ }
+ if (src.empty()) {
+ err = TR("wallet_rebuild_no_source");
+ } else if (!stopDaemonForWalletSwitch()) { // 2. release wallet.dat + the port
+ err = TR("wallet_restore_stop_failed");
+ } else {
+ std::time_t t = std::time(nullptr);
+ std::tm tmv{};
+#ifdef _WIN32
+ localtime_s(&tmv, &t);
+#else
+ localtime_r(&t, &tmv);
+#endif
+ char ts[32]; std::strftime(ts, sizeof(ts), "%Y%m%d-%H%M%S", &tmv);
+ const std::string tmpOut = datadir + "/wallet.rebuilt-" + std::string(ts) + ".tmp";
+ { std::error_code ec; fs::remove(tmpOut, ec); } // helper uses DB_EXCL — path must be fresh
+
+ // 3. Run the helper (src -> tmpOut). Quote both paths; capture its JSON line. Windowless
+ // (runHiddenCapture) so a wallet rebuild never flashes a cmd.exe console; it runs the
+ // helper via CreateProcess directly on Windows, so no cmd.exe outer-quote wrap is needed.
+ const std::string cmd = "\"" + helper + "\" \"" + src + "\" \"" + tmpOut + "\"";
+ int rc = -1;
+ const std::string jout = util::Platform::runHiddenCapture(cmd, /*mergeStderr=*/false, &rc);
+ DEBUG_LOGF("[App] wallet-rebuild helper rc=%d out=%s\n", rc, jout.c_str());
+
+ // 4. Verify-before-swap: the output must be a readable BDB with the fund-critical keys.
+ const auto probe = util::parseWalletBtree(tmpOut);
+ if (rc != 0 || !probe.parsed || probe.addresses() == 0) {
+ std::error_code ec; fs::remove(tmpOut, ec);
+ err = TR("wallet_rebuild_failed");
+ } else {
+ // 5. Swap: set the current wallet aside (kept), install the rebuilt one, clear stale env.
+ std::error_code ec;
+ const std::string aside = active + ".prerebuild-" + std::string(ts) + ".dat";
+ bool moved = false;
+ if (fs::exists(active)) {
+ fs::rename(active, aside, ec);
+ if (ec) err = TR("wallet_restore_move_failed"); else moved = true;
+ }
+ if (err.empty()) {
+ fs::rename(tmpOut, active, ec);
+ if (ec) {
+ if (moved) { std::error_code e2; fs::rename(aside, active, e2); }
+ err = TR("wallet_rebuild_install_failed");
+ }
+ }
+ if (err.empty()) {
+ std::error_code e2;
+ if (fs::exists(datadir + "/database"))
+ fs::rename(datadir + "/database", datadir + "/database.prerebuild-" + std::string(ts) + ".bak", e2);
+ for (const auto& e : fs::directory_iterator(datadir, e2))
+ if (e.path().filename().string().rfind("__db.", 0) == 0) { std::error_code e3; fs::remove(e.path(), e3); }
+ if (daemon_controller_) { daemon_controller_->setRescanOnNextStart(true); user_initiated_rescan_ = true; }
+ }
+ }
+ }
+
+ if (!shutting_down_) {
+ if (daemon_controller_) daemon_controller_->clearExternalDaemonDetected();
+ if (!startEmbeddedDaemon() && err.empty()) warn = TR("wallet_restore_no_restart");
+ }
+ } catch (const std::exception& e) {
+ err = std::string("Rebuild failed: ") + e.what();
+ } catch (...) {
+ err = "Rebuild failed due to an unexpected error.";
+ }
+ daemon_restarting_ = false;
+
+ std::lock_guard lk(wallet_restore_mutex_);
+ wallet_restore_severity_ = !err.empty() ? 2 : (!warn.empty() ? 1 : 0);
+ wallet_restore_msg_ = !err.empty() ? err : (!warn.empty() ? warn : std::string(TR("wallet_rebuild_ok")));
+ wallet_restore_done_ = true;
+ });
+}
+
void App::pumpSeedMigration()
{
if (capture_mode_) return; // no live ops during a UI sweep (steps are set directly)
@@ -4386,6 +5307,7 @@ void App::pumpSeedMigration()
settings_->setSeedMigrationDest("");
settings_->setSeedMigrationTempDir("");
settings_->setSeedMigrationSweepTxid("");
+ settings_->setSeedMigrationSweepOpid(""); // W3-3
settings_->save();
}
seed_migration_status_ = err; // a non-empty warning here (e.g. restart hiccup) is shown on Done
@@ -4428,6 +5350,11 @@ void App::pumpSeedMigration()
settings_->setSeedMigrationPending(true);
settings_->setSeedMigrationDest(seed_migration_dest_);
settings_->setSeedMigrationTempDir(seed_migration_temp_dir_);
+ // W3-3: a brand-new migration has done no sweep yet — clear any sweep artifacts left over
+ // from a prior aborted run so reopening this fresh migration can't mis-resume on a stale
+ // txid/opid (the resume block reads these whenever the migration is pending).
+ settings_->setSeedMigrationSweepTxid("");
+ settings_->setSeedMigrationSweepOpid("");
settings_->save();
}
} else {
@@ -4456,13 +5383,12 @@ void App::backupWallet(const std::string& destination, std::function
#include
#include
+#include
+#include
+#include
+#include
#include
#include
#include
@@ -234,6 +238,41 @@ private:
// daemon off the main thread (to avoid stalling the UI), or ask the user to
// restart an external daemon. Shared by encryptWalletWithPassphrase() and
// processDeferredEncryption(); must be called on the main thread.
+// Zero (overwrite) then delete a plaintext key export so a full cleartext dump of every private key is
+// never left readable on disk. Idempotent + error-tolerant (safe on a missing/locked file). (H-02)
+void App::scrubAndRemoveExport(const std::string& path)
+{
+ if (path.empty()) return;
+ std::error_code ec;
+ const auto sz = std::filesystem::file_size(path, ec);
+ if (!ec && sz > 0) {
+ std::fstream scrub(path, std::ios::binary | std::ios::in | std::ios::out);
+ if (scrub) {
+ const std::vector zeros(static_cast(sz), 0);
+ scrub.write(zeros.data(), static_cast(sz));
+ scrub.flush();
+ }
+ }
+ std::filesystem::remove(path, ec);
+}
+
+// Startup net for H-02: a crash/kill/early-return between exporting the cleartext keys and scrubbing them
+// could leave an obsidiandecryptexport* file behind. Purge any found in the data dir on launch.
+void App::sweepStaleDecryptExports()
+{
+ std::error_code ec;
+ const std::string dir = util::Platform::getDragonXDataDir();
+ std::filesystem::directory_iterator it(dir, ec), end;
+ for (; it != end; it.increment(ec)) {
+ if (ec) break;
+ const std::string name = it->path().filename().string();
+ if (name.rfind("obsidiandecryptexport", 0) == 0) {
+ scrubAndRemoveExport(it->path().string());
+ DEBUG_LOGF("[decrypt] swept stale plaintext key export: %s\n", name.c_str());
+ }
+ }
+}
+
void App::restartDaemonAfterEncryption(const char* taskName, bool announceRestartStatus) {
if (isUsingEmbeddedDaemon()) {
if (announceRestartStatus) {
@@ -261,14 +300,14 @@ void App::restartDaemonAfterEncryption(const char* taskName, bool announceRestar
});
} else {
ui::Notifications::instance().warning(
- "Please restart your daemon for encryption to take effect.");
+ TR("sec_restart_daemon_for_encryption"));
}
}
void App::encryptWalletWithPassphrase(const std::string& passphrase) {
if (!rpc_ || !rpc_->isConnected()) return;
encrypt_in_progress_ = true;
- encrypt_status_ = "Encrypting wallet...";
+ encrypt_status_ = TR("sec_encrypting_wallet");
if (worker_) {
worker_->post([this, passphrase]() mutable -> rpc::RPCWorker::MainCb {
@@ -278,7 +317,7 @@ void App::encryptWalletWithPassphrase(const std::string& passphrase) {
if (result.encrypted) {
return [this]() {
encrypt_in_progress_ = false;
- encrypt_status_ = "Wallet encrypted. Restarting daemon...";
+ encrypt_status_ = TR("sec_wallet_encrypted_restarting_daemon");
DEBUG_LOGF("[App] Wallet encrypted — restarting daemon\n");
// Immediately update local encryption state so the
@@ -297,7 +336,7 @@ void App::encryptWalletWithPassphrase(const std::string& passphrase) {
}
ui::Notifications::instance().info(
- "Wallet encrypted successfully", 5.0f);
+ TR("sec_wallet_encrypted_successfully"), 5.0f);
// The daemon shuts itself down after encryptwallet.
// Update connection_status_ so the loading overlay
@@ -309,11 +348,11 @@ void App::encryptWalletWithPassphrase(const std::string& passphrase) {
std::string err = result.error;
return [this, err]() {
encrypt_in_progress_ = false;
- encrypt_status_ = "Encryption failed: " + err;
+ encrypt_status_ = std::string(TR("sec_encryption_failed_prefix")) + err;
DEBUG_LOGF("[App] encryptwallet failed: %s\n", err.c_str());
ui::Notifications::instance().error(
- "Encryption failed: " + err);
+ std::string(TR("sec_encryption_failed_prefix")) + err);
// Return to passphrase entry on failure
if (show_encrypt_dialog_ &&
@@ -354,7 +393,7 @@ void App::processDeferredEncryption() {
std::string pin = std::move(deferredEncryption.pin);
encrypt_in_progress_ = true;
- encrypt_status_ = "Encrypting wallet...";
+ encrypt_status_ = TR("sec_encrypting_wallet");
if (worker_) {
worker_->post([this, request = services::WalletSecurityController::DeferredEncryptionSnapshot{std::move(passphrase), std::move(pin)}]() mutable -> rpc::RPCWorker::MainCb {
@@ -374,13 +413,13 @@ void App::processDeferredEncryption() {
if (result.pinStored) {
settings_->setPinEnabled(true);
settings_->save();
- ui::Notifications::instance().info("Wallet encrypted & PIN set", 5.0f);
+ ui::Notifications::instance().info(TR("sec_wallet_encrypted_and_pin_set"), 5.0f);
} else {
ui::Notifications::instance().warning(
- "Wallet encrypted but PIN vault failed");
+ TR("sec_wallet_encrypted_but_pin_vault_failed"));
}
} else {
- ui::Notifications::instance().info("Wallet encrypted successfully", 5.0f);
+ ui::Notifications::instance().info(TR("sec_wallet_encrypted_successfully"), 5.0f);
}
wallet_security_.clearDeferredEncryption();
@@ -393,9 +432,9 @@ void App::processDeferredEncryption() {
std::string err = result.error;
return [this, err]() {
encrypt_in_progress_ = false;
- encrypt_status_ = "Encryption failed: " + err;
+ encrypt_status_ = std::string(TR("sec_encryption_failed_prefix")) + err;
DEBUG_LOGF("[App] Deferred encryptwallet failed: %s\n", err.c_str());
- ui::Notifications::instance().error("Encryption failed: " + err);
+ ui::Notifications::instance().error(std::string(TR("sec_encryption_failed_prefix")) + err);
wallet_security_.clearDeferredEncryption();
};
}
@@ -483,7 +522,17 @@ void App::lockWallet() {
state_.locked = true;
state_.unlocked_until = 0;
resetTransactionHistoryCacheSession();
+ lock_failure_warned_ = false;
DEBUG_LOGF("[App] Wallet locked\n");
+ } else {
+ // The walletlock RPC failed — the wallet is still UNLOCKED. Surface it (once) rather
+ // than silently leaving an auto-lock unfulfilled and the wallet exposed (W2-4).
+ DEBUG_LOGF("[App] walletlock failed — wallet remains unlocked\n");
+ if (!lock_failure_warned_) {
+ lock_failure_warned_ = true;
+ ui::Notifications::instance().warning(
+ TR("sec_couldnt_lock_wallet"), 12.0f);
+ }
}
};
});
@@ -492,7 +541,7 @@ void App::lockWallet() {
void App::changePassphrase(const std::string& oldPass, const std::string& newPass) {
if (!rpc_ || !rpc_->isConnected() || !worker_) return;
encrypt_in_progress_ = true;
- encrypt_status_ = "Changing passphrase...";
+ encrypt_status_ = TR("sec_changing_passphrase");
auto* w = (fast_worker_ && fast_worker_->isRunning()) ? fast_worker_.get() : worker_.get();
auto* r = (fast_rpc_ && fast_rpc_->isConnected()) ? fast_rpc_.get() : rpc_.get();
@@ -525,9 +574,9 @@ void App::changePassphrase(const std::string& oldPass, const std::string& newPas
memset(change_confirm_buf_, 0, sizeof(change_confirm_buf_));
unlockTransactionHistoryCacheWithPassphrase(newPass);
storeTransactionHistoryCacheIfAvailable();
- ui::Notifications::instance().info("Passphrase changed successfully");
+ ui::Notifications::instance().info(TR("sec_passphrase_changed_successfully"));
} else {
- encrypt_status_ = "Failed: " + err_msg;
+ encrypt_status_ = std::string(TR("sec_failed_prefix")) + err_msg;
}
util::SecureVault::secureZero(newPass.data(), newPass.size());
};
@@ -560,6 +609,12 @@ void App::refreshWalletEncryptionState() {
state_.unlocked_until = until;
state_.locked = (until == 0);
state_.encryption_state_known = true;
+ // Wallet is encrypted — any pending deferred-encryption request has now been
+ // satisfied (however it completed). Clear the persisted flag (W2-2).
+ if (settings_ && settings_->getEncryptionPending()) {
+ settings_->setEncryptionPending(false);
+ settings_->save();
+ }
if (state_.locked) {
resetTransactionHistoryCacheSession();
} else if (state_.transactions.empty()) {
@@ -572,6 +627,18 @@ void App::refreshWalletEncryptionState() {
state_.locked = false;
state_.unlocked_until = 0;
state_.encryption_state_known = true;
+ // W2-2: encryption was requested (persisted flag) but the wallet is NOT encrypted,
+ // and no deferred encryption is pending/in-flight — it was lost to a quit/crash or a
+ // failed connect before it applied. Warn (once/session) instead of silently leaving
+ // an unencrypted wallet the user believes is protected. The flag stays set until the
+ // wallet is actually encrypted, so the warning recurs each launch until resolved.
+ if (settings_ && settings_->getEncryptionPending() &&
+ !wallet_security_.hasDeferredEncryption() && !encrypt_in_progress_ &&
+ !encryption_incomplete_warned_) {
+ encryption_incomplete_warned_ = true;
+ ui::Notifications::instance().warning(
+ TR("sec_encryption_did_not_complete"), 30.0f);
+ }
if (state_.transactions.empty()) {
loadTransactionHistoryCacheIfAvailable();
} else {
@@ -691,6 +758,10 @@ void App::checkIdleMining() {
// Resolve auto values: active defaults to half, idle defaults to all
if (activeThreads <= 0) activeThreads = std::max(1, maxThreads / 2);
if (idleThreads <= 0) idleThreads = maxThreads;
+ // Clamp to [1, logical cores] before these reach setgenerate / startPoolMining — a settings field
+ // could otherwise carry an arbitrary count straight past every bound. (M-06)
+ activeThreads = std::clamp(activeThreads, 1, maxThreads);
+ idleThreads = std::clamp(idleThreads, 1, maxThreads);
if (systemIdle) {
// System is idle — scale up to idle thread count
@@ -879,7 +950,7 @@ void App::renderLockScreen() {
ImU32 textCol = ui::material::OnSurface();
{
- const char* title = "Wallet Locked";
+ const char* title = TR("sec_wallet_locked_title");
ImVec2 ts = titleFont->CalcTextSizeA(titleFont->LegacySize, FLT_MAX, 0, title);
dl->AddText(titleFont, titleFont->LegacySize,
ImVec2(cardX + (cardW - ts.x) * 0.5f, cy), textCol, title);
@@ -892,7 +963,7 @@ void App::renderLockScreen() {
if (lock_lockout_timer_ < 0) lock_lockout_timer_ = 0;
char msg[128];
- snprintf(msg, sizeof(msg), "Too many attempts. Wait %.0f seconds...", lock_lockout_timer_);
+ snprintf(msg, sizeof(msg), TR("sec_too_many_attempts_wait"), lock_lockout_timer_);
ImVec2 ms = captionFont->CalcTextSizeA(captionFont->LegacySize, FLT_MAX, 0, msg);
dl->AddText(captionFont, captionFont->LegacySize,
ImVec2(cardX + (cardW - ms.x) * 0.5f, cy), ui::material::Warning(), msg);
@@ -905,10 +976,10 @@ void App::renderLockScreen() {
// Mode toggle (PIN / Passphrase) — only show if PIN vault exists
if (hasPinVault) {
const char* modeIcon = lock_use_pin_ ? ICON_MD_DIALPAD : ICON_MD_PASSWORD;
- const char* modeText = lock_use_pin_ ? " PIN" : " Passphrase";
+ const char* modeText = lock_use_pin_ ? " PIN" : TR("sec_mode_passphrase");
const char* switchLabel = lock_use_pin_
- ? "Use passphrase instead"
- : "Use PIN instead";
+ ? TR("sec_use_passphrase_instead")
+ : TR("sec_use_pin_instead");
// Current mode indicator — icon with icon font, text with caption font
ImFont* iconFont = ui::material::Type().iconSmall();
@@ -1010,7 +1081,7 @@ void App::renderLockScreen() {
if (lock_unlock_in_progress_) {
// Animated spinner dots
char msg[64];
- snprintf(msg, sizeof(msg), "Unlocking%s", ui::material::LoadingDots());
+ snprintf(msg, sizeof(msg), TR("sec_unlocking_fmt"), ui::material::LoadingDots());
ImVec2 ms = captionFont->CalcTextSizeA(captionFont->LegacySize, FLT_MAX, 0, msg);
dl->AddText(captionFont, captionFont->LegacySize,
ImVec2(cardX + (cardW - ms.x) * 0.5f, cy),
@@ -1034,7 +1105,7 @@ void App::renderLockScreen() {
ImGui::PushStyleColor(ImGuiCol_Text, ImGui::ColorConvertU32ToFloat4(ui::material::OnPrimary()));
ImGui::PushStyleVar(ImGuiStyleVar_FrameRounding, 8.0f * dp);
ImGui::BeginDisabled(!canSubmit);
- bool btnClicked = ui::material::TactileButton("Unlock", ImVec2(unlockW, unlockH));
+ bool btnClicked = ui::material::TactileButton(TR("sec_unlock_button"), ImVec2(unlockW, unlockH));
ImGui::EndDisabled();
ImGui::PopStyleVar();
ImGui::PopStyleColor(3);
@@ -1088,7 +1159,7 @@ void App::renderLockScreen() {
r->call("walletpassphrase", {passphrase, timeout});
rpcOk = true;
} else {
- rpcErr = "Not connected to daemon";
+ rpcErr = TR("sec_not_connected_to_daemon");
}
} catch (const std::exception& e) {
rpcErr = e.what();
@@ -1104,7 +1175,7 @@ void App::renderLockScreen() {
// so route through applyUnlockFailure so the lockout curve applies
// (this path previously bumped the counter but skipped the lockout math).
return [this, rpcErr, passphrase = std::move(passphrase)]() mutable {
- applyUnlockFailure("Unlock failed: " + rpcErr);
+ applyUnlockFailure(std::string(TR("sec_unlock_failed_prefix")) + rpcErr);
util::SecureVault::secureZero(passphrase.data(), passphrase.size());
};
}
@@ -1193,7 +1264,7 @@ void App::renderEncryptWalletDialog() {
else if (tier == 1) { strengthLabel = TR("wiz_strength_fair"); strengthCol = ImVec4(1,0.7f,0.3f,1); strengthPct = 0.5f; }
float barW = ImGui::GetContentRegionAvail().x;
- float barH = 4.0f;
+ float barH = 4.0f * ui::Layout::dpiScale();
ImVec2 p = ImGui::GetCursorScreenPos();
ImDrawList* dl = ImGui::GetWindowDrawList();
dl->AddRectFilled(p, ImVec2(p.x + barW, p.y + barH),
@@ -1243,7 +1314,7 @@ void App::renderEncryptWalletDialog() {
// Indeterminate progress bar
{
float barW = ImGui::GetContentRegionAvail().x;
- float barH = 6.0f;
+ float barH = 6.0f * ui::Layout::dpiScale();
ImVec2 p = ImGui::GetCursorScreenPos();
ImDrawList* dl = ImGui::GetWindowDrawList();
dl->AddRectFilled(p, ImVec2(p.x + barW, p.y + barH),
@@ -1311,9 +1382,13 @@ void App::renderEncryptWalletDialog() {
enc_dlg_pin_status_.clear();
std::string savedPass = enc_dlg_saved_passphrase_;
if (worker_ && vault_) {
- worker_->post([this, pinStr, savedPass]() -> rpc::RPCWorker::MainCb {
+ worker_->post([this, pinStr, savedPass]() mutable -> rpc::RPCWorker::MainCb {
// Argon2id runs here (worker thread)
bool ok = vault_->store(pinStr, savedPass);
+ // Scrub the captured PIN + passphrase copies (they live in the worker's task
+ // queue until this runs); the source member is scrubbed in the MainCb. (L-03)
+ if (!savedPass.empty()) util::SecureVault::secureZero(&savedPass[0], savedPass.size());
+ if (!pinStr.empty()) util::SecureVault::secureZero(&pinStr[0], pinStr.size());
return [this, ok]() {
if (ok) {
settings_->setPinEnabled(true);
@@ -1374,6 +1449,11 @@ void App::renderEncryptWalletDialog() {
ov.cardWidth = 460.0f; ov.idSuffix = "changepass";
if (BeginOverlayDialog(ov)) {
+ // Same fund-loss consequence as Encrypt/Remove Encryption if the new
+ // passphrase is lost — reuse their warning string/header for consistency.
+ DialogWarningHeader(TR("wiz_encrypt_warning"));
+ ImGui::Spacing();
+
ImGui::TextUnformatted(TR("change_pass_current"));
ImGui::PushItemWidth(-1);
ImGui::InputText("##chg_old", change_old_pass_buf_, sizeof(change_old_pass_buf_),
@@ -1400,12 +1480,22 @@ void App::renderEncryptWalletDialog() {
bool valid = strlen(change_old_pass_buf_) > 0 &&
strlen(change_new_pass_buf_) >= 8 &&
strcmp(change_new_pass_buf_, change_confirm_buf_) == 0;
+
+ // Two-button footer (primary + Cancel) to match the encrypt/decrypt siblings.
+ float btnW = (ImGui::GetContentRegionAvail().x - ImGui::GetStyle().ItemSpacing.x) * 0.5f;
ImGui::BeginDisabled(!valid || encrypt_in_progress_);
- if (ui::material::TactileButton(TR("change_pass_title"), ImVec2(-1, 40))) {
+ if (ui::material::TactileButton(TR("change_pass_title"), ImVec2(btnW, 40))) {
changePassphrase(std::string(change_old_pass_buf_),
std::string(change_new_pass_buf_));
}
ImGui::EndDisabled();
+
+ ImGui::SameLine();
+ // Cancel does what Esc/close does — dismiss without applying. Buffers are wiped by
+ // the !show_change_passphrase_ cleanup block below.
+ if (ui::material::TactileButton(TR("cancel"), ImVec2(btnW, 40))) {
+ show_change_passphrase_ = false;
+ }
EndOverlayDialog();
}
@@ -1478,15 +1568,17 @@ void App::renderDecryptWalletDialog() {
// Run entire decrypt flow on worker thread
if (worker_) {
- worker_->post([this, passphrase]() -> rpc::RPCWorker::MainCb {
+ worker_->post([this, passphrase = std::move(passphrase)]() mutable -> rpc::RPCWorker::MainCb {
WalletSecurityDecryptRpcAdapter decryptRpc(rpc_.get(),
[this](rpc::RPCClient& client, const char* context) {
return sendStopCommandSafely(client, context);
});
auto unlock = services::WalletSecurityWorkflowExecutor::unlockWallet(passphrase, decryptRpc);
+ // Scrub the passphrase — unlock is its only use in this flow.
+ if (!passphrase.empty()) sodium_memzero(&passphrase[0], passphrase.size());
if (!unlock.ok) {
return [this]() {
- wallet_security_workflow_.failEntry("Incorrect passphrase");
+ wallet_security_workflow_.failEntry(TR("sec_incorrect_passphrase_decrypt"));
};
}
@@ -1553,6 +1645,11 @@ void App::renderDecryptWalletDialog() {
std::chrono::steady_clock::now());
auto restartAndImport = [this, exportPath](const util::AsyncTaskManager::Token& token) {
+ // Scrub + delete the plaintext key export (obsidiandecryptexport…) on EVERY exit path —
+ // success, a restart-failure early return, or an exception. A full cleartext dump of all
+ // private keys must never outlive this step. The startup sweep is a further net for a
+ // crash/kill mid-flight. (H-02)
+ struct ExportScrub { std::string p; ~ExportScrub() { App::scrubAndRemoveExport(p); } } exportScrub{exportPath};
WalletSecurityDaemonAdapter daemonAdapter(*this, token);
WalletSecurityDecryptRpcAdapter decryptRpc(rpc_.get(),
[this](rpc::RPCClient& client, const char* context) {
@@ -1597,7 +1694,7 @@ void App::renderDecryptWalletDialog() {
});
ui::Notifications::instance().info(
- "Importing keys & rescanning blockchain — wallet is usable while this runs",
+ TR("sec_importing_keys_rescanning"),
8.0f);
};
});
@@ -1606,6 +1703,8 @@ void App::renderDecryptWalletDialog() {
WalletSecurityImportRpcAdapter importAdapter(rpc_.get(), saved_config_);
auto importResult = services::WalletSecurityWorkflowExecutor::importWallet(
importAdapter, exportPath);
+ // (exportScrub scrubs + deletes the plaintext key export on scope exit — H-02)
+
if (!importResult.ok) {
std::string err = importResult.error;
if (worker_) {
@@ -1614,7 +1713,7 @@ void App::renderDecryptWalletDialog() {
wallet_security_workflow_.finishImport();
ui::Notifications::instance().error(
err +
- "\nEncrypted backup: wallet.dat.encrypted.bak",
+ TR("sec_encrypted_backup_suffix"),
12.0f);
};
});
@@ -1640,7 +1739,7 @@ void App::renderDecryptWalletDialog() {
refreshPeerInfo();
ui::Notifications::instance().success(
- "Wallet decrypted successfully! All keys imported.",
+ TR("sec_wallet_decrypted_all_keys_imported"),
8.0f);
DEBUG_LOGF("[App] Wallet decrypted successfully\n");
};
@@ -1730,7 +1829,7 @@ void App::renderDecryptWalletDialog() {
// Indeterminate progress bar
{
float barW = ImGui::GetContentRegionAvail().x;
- float barH = 6.0f;
+ float barH = 6.0f * ui::Layout::dpiScale();
ImVec2 p = ImGui::GetCursorScreenPos();
ImDrawList* dl = ImGui::GetWindowDrawList();
dl->AddRectFilled(p, ImVec2(p.x + barW, p.y + barH),
@@ -1762,7 +1861,7 @@ void App::renderDecryptWalletDialog() {
int tMins = (int)(totalElapsed / 60);
int tSecs = (int)(totalElapsed % 60);
ImGui::Spacing();
- ImGui::TextDisabled("Total elapsed: %dm %02ds", tMins, tSecs);
+ ImGui::TextDisabled(TR("sec_total_elapsed_fmt"), tMins, tSecs);
}
// ---- Phase 2: Success ----
@@ -1860,10 +1959,12 @@ void App::renderPinDialogs() {
util::SecureVault::isValidPin(pinStr) &&
strcmp(pin_buf_, pin_confirm_buf_) == 0;
+ // Two-button footer (primary + Cancel) to match the encrypt/decrypt siblings.
+ float btnW = (ImGui::GetContentRegionAvail().x - ImGui::GetStyle().ItemSpacing.x) * 0.5f;
ImGui::BeginDisabled(!valid || pin_in_progress_);
- if (ui::material::TactileButton(TR("settings_set_pin"), ImVec2(-1, 40))) {
+ if (ui::material::TactileButton(TR("settings_set_pin"), ImVec2(btnW, 40))) {
pin_in_progress_ = true;
- pin_status_ = "Verifying passphrase...";
+ pin_status_ = TR("sec_verifying_passphrase");
// Verify passphrase + store vault on worker thread to avoid
// blocking the UI with Argon2id key derivation.
@@ -1874,20 +1975,25 @@ void App::renderPinDialogs() {
memset(pin_confirm_buf_, 0, sizeof(pin_confirm_buf_));
if (rpc_ && rpc_->isConnected() && worker_) {
- worker_->post([this, passphrase, pin]() -> rpc::RPCWorker::MainCb {
+ worker_->post([this, passphrase, pin]() mutable -> rpc::RPCWorker::MainCb {
// Verify passphrase via RPC (worker thread)
try {
rpc::RPCClient::TraceScope trace("Security / PIN setup");
rpc_->call("walletpassphrase", {passphrase, 5});
} catch (const std::exception& e) {
+ if (!passphrase.empty()) util::SecureVault::secureZero(&passphrase[0], passphrase.size());
+ if (!pin.empty()) util::SecureVault::secureZero(&pin[0], pin.size());
return [this]() {
- pin_status_ = "Incorrect passphrase";
+ pin_status_ = TR("sec_incorrect_passphrase_pin_setup");
pin_in_progress_ = false;
};
}
// Passphrase correct — store in vault (Argon2id, worker thread)
bool storeOk = vault_ && vault_->store(pin, passphrase);
+ // Captured passphrase + PIN are no longer needed — scrub the worker-queue copies. (M-01)
+ if (!passphrase.empty()) util::SecureVault::secureZero(&passphrase[0], passphrase.size());
+ if (!pin.empty()) util::SecureVault::secureZero(&pin[0], pin.size());
// Lock wallet back
try {
@@ -1902,19 +2008,26 @@ void App::renderPinDialogs() {
pin_status_.clear();
pin_in_progress_ = false;
show_pin_setup_ = false;
- ui::Notifications::instance().info("PIN set successfully");
+ ui::Notifications::instance().info(TR("sec_pin_set_successfully"));
} else {
- pin_status_ = "Failed to create vault";
+ pin_status_ = TR("sec_failed_to_create_vault");
pin_in_progress_ = false;
}
};
});
} else {
- pin_status_ = "Not connected to daemon";
+ pin_status_ = TR("sec_not_connected_to_daemon_pin");
pin_in_progress_ = false;
}
}
ImGui::EndDisabled();
+
+ ImGui::SameLine();
+ // Cancel does what Esc/close does — dismiss without applying. Buffers are wiped by
+ // the !show_pin_setup_ cleanup block below.
+ if (ui::material::TactileButton(TR("cancel"), ImVec2(btnW, 40))) {
+ show_pin_setup_ = false;
+ }
EndOverlayDialog();
}
// Wipe the passphrase/PIN buffers if the dialog was dismissed (X / Esc /
@@ -1968,7 +2081,7 @@ void App::renderPinDialogs() {
ImGui::BeginDisabled(!valid || pin_in_progress_);
if (ui::material::TactileButton(TR("settings_change_pin"), ImVec2(-1, 40))) {
pin_in_progress_ = true;
- pin_status_ = "Changing PIN...";
+ pin_status_ = TR("sec_changing_pin");
std::string oldPin(pin_old_buf_);
std::string newPinCopy = newPin;
memset(pin_old_buf_, 0, sizeof(pin_old_buf_));
@@ -1984,15 +2097,15 @@ void App::renderPinDialogs() {
pin_status_.clear();
pin_in_progress_ = false;
show_pin_change_ = false;
- ui::Notifications::instance().info("PIN changed successfully");
+ ui::Notifications::instance().info(TR("sec_pin_changed_successfully"));
} else {
- pin_status_ = "Incorrect current PIN";
+ pin_status_ = TR("sec_incorrect_current_pin");
pin_in_progress_ = false;
}
};
});
} else {
- pin_status_ = "Internal error";
+ pin_status_ = TR("sec_internal_error_change_pin");
pin_in_progress_ = false;
}
}
@@ -2033,7 +2146,7 @@ void App::renderPinDialogs() {
ImGui::BeginDisabled(!valid || pin_in_progress_);
if (ui::material::TactileButton(TR("settings_remove_pin"), ImVec2(-1, 40))) {
pin_in_progress_ = true;
- pin_status_ = "Verifying PIN...";
+ pin_status_ = TR("sec_verifying_pin");
std::string oldPin(pin_old_buf_);
memset(pin_old_buf_, 0, sizeof(pin_old_buf_));
@@ -2053,15 +2166,15 @@ void App::renderPinDialogs() {
pin_status_.clear();
pin_in_progress_ = false;
show_pin_remove_ = false;
- ui::Notifications::instance().info("PIN removed");
+ ui::Notifications::instance().info(TR("sec_pin_removed"));
} else {
- pin_status_ = "Incorrect PIN";
+ pin_status_ = TR("sec_incorrect_pin_remove");
pin_in_progress_ = false;
}
};
});
} else {
- pin_status_ = "Internal error";
+ pin_status_ = TR("sec_internal_error_remove_pin");
pin_in_progress_ = false;
}
}
diff --git a/src/app_sweep.cpp b/src/app_sweep.cpp
index cb6407a..b7309f3 100644
--- a/src/app_sweep.cpp
+++ b/src/app_sweep.cpp
@@ -170,10 +170,10 @@ void App::installDemoWalletData()
}
auto zaddr = [](const char* a, double bal, const char* label) {
- AddressInfo i; i.address = a; i.balance = bal; i.type = "shielded"; i.label = label; return i;
+ AddressInfo i; i.address = a; i.balance = bal; i.spendableBalance = bal; i.type = "shielded"; i.label = label; return i;
};
auto taddr = [](const char* a, double bal, const char* label) {
- AddressInfo i; i.address = a; i.balance = bal; i.type = "transparent"; i.label = label; return i;
+ AddressInfo i; i.address = a; i.balance = bal; i.spendableBalance = bal; i.type = "transparent"; i.label = label; return i;
};
state_.z_addresses = {
zaddr("zs1demoprimaryshieldedaddressforuisweep000000000000000000000000000", 12.0, "Savings"),
@@ -310,6 +310,8 @@ void App::buildSweepCatalog()
[](App& a) { ui::BootstrapDownloadDialog::show(&a); }, [](App&) { ui::BootstrapDownloadDialog::hide(); });
add("modal-backup", ui::NavPage::Overview,
[](App& a) { a.show_backup_ = true; }, [](App& a) { a.show_backup_ = false; a.backup_status_.clear(); });
+ add("modal-faq", ui::NavPage::Overview,
+ [](App& a) { a.show_faq_ = true; }, [](App& a) { a.show_faq_ = false; });
// Encrypt-wallet dialog — the redesigned passphrase-entry phase (never fires the async encrypt).
add("modal-encrypt", ui::NavPage::Settings,
[](App& a) { a.encrypt_dialog_phase_ = EncryptDialogPhase::PassphraseEntry; a.show_encrypt_dialog_ = true; },
@@ -704,9 +706,26 @@ void App::startSweepImpl(bool full)
if (sk.valid) sweep_skins_.push_back(sk.id);
if (sweep_skins_.empty()) return;
+ // Debug Options "Current theme only": sweep just the active skin instead of cycling every theme.
+ if (sweep_current_theme_only_)
+ sweep_skins_.assign(1, ui::schema::SkinManager::instance().activeSkinId());
+
+ // DEV/TEST hook (dormant unless the env is set): DRAGONX_SWEEP_ONLY="send,receive" restricts the
+ // sweep to the named surfaces and the dark skin, so a slow large-window run captures just the tab
+ // under review instead of all surfaces x every skin.
+ const char* sweepOnly = std::getenv("DRAGONX_SWEEP_ONLY");
+ std::string sweepOnlyStr = sweepOnly ? sweepOnly : "";
+ if (!sweepOnlyStr.empty()) sweep_skins_.assign(1, std::string("dark"));
+
sweep_full_ = full;
if (full) { capture_mode_ = true; installDemoWalletData(); }
buildSweepCatalog();
+ if (!sweepOnlyStr.empty()) {
+ std::vector keep;
+ for (const auto& t : sweep_targets_)
+ if (sweepOnlyStr.find(t.name) != std::string::npos) keep.push_back(t);
+ sweep_targets_.swap(keep);
+ }
if (sweep_targets_.empty()) { if (full) { clearDemoWalletData(); capture_mode_ = false; sweep_full_ = false; } return; }
sweep_dir_ = full ? screenshotFullDir() : screenshotDir();
diff --git a/src/app_wizard.cpp b/src/app_wizard.cpp
index d36c08c..2b30807 100644
--- a/src/app_wizard.cpp
+++ b/src/app_wizard.cpp
@@ -177,8 +177,36 @@ void App::renderFirstRunWizard() {
// DPI scale factor — multiply all pixel constants by dp
const float dp = ui::Layout::dpiScale();
+ // Vertical scroll: the wizard cards are hand-drawn at absolute Y offsets and grow ~1.5x with the
+ // font-scale setting, so at high scale the focused card's primary button (Continue / Encrypt & Continue
+ // / Skip) can fall below the fixed window. Offset the whole layout by a wheel-driven scroll, clamped to
+ // last frame's measured content height, so every control stays reachable. The window keeps
+ // NoScrollWithMouse, so ImGui doesn't consume the wheel — we read the raw delta and apply our own offset.
+ static float s_wizScroll = 0.0f, s_wizContentH = 0.0f;
+ if (ImGui::IsWindowAppearing()) s_wizScroll = 0.0f;
+ const float wizMaxScroll = std::max(0.0f, s_wizContentH - winSize.y);
+ // Don't steal the wheel from an open combo popup (e.g. the 9-item Language dropdown, which is a
+ // scrollable popup): NoPopupHierarchy stops the popup counting as hovering the wizard, and the
+ // IsPopupOpen guard ensures no wheel is consumed for the whole wizard while any popup is showing.
+ const bool wizPopupOpen = ImGui::IsPopupOpen("", ImGuiPopupFlags_AnyPopupId | ImGuiPopupFlags_AnyPopupLevel);
+ if (wizMaxScroll > 0.0f && !wizPopupOpen &&
+ ImGui::IsWindowHovered(ImGuiHoveredFlags_ChildWindows | ImGuiHoveredFlags_NoPopupHierarchy)) {
+ float wheel = ImGui::GetIO().MouseWheel;
+ if (wheel != 0.0f) s_wizScroll -= wheel * 60.0f * dp;
+ }
+ s_wizScroll = std::max(0.0f, std::min(s_wizScroll, wizMaxScroll));
+ const float scrollY = s_wizScroll;
+
// --- Header: Logo + Welcome ---
- float headerCy = winPos.y + 20.0f * dp;
+ // Vertically center the content when it fits (mirrors the horizontal centering below): on a tall
+ // monitor top-anchoring leaves a large void under the cards. Using last frame's measured block
+ // height, when the content fits inside the window (and we're NOT overflowing, so this doesn't
+ // fight the scroll), push everything down by half the leftover space. No-op once content
+ // fills/exceeds the window (s_wizContentH >= winSize.y ⇒ wizMaxScroll > 0 ⇒ vCenter skipped).
+ float vCenter = 0.0f;
+ if (wizMaxScroll == 0.0f && s_wizContentH > 0.0f && s_wizContentH < winSize.y)
+ vCenter = std::max(0.0f, (winSize.y - s_wizContentH) * 0.5f);
+ float headerCy = winPos.y - scrollY + 20.0f * dp + vCenter;
float logoSize = S.drawElement("screens.first-run", "logo").sizeOr(56.0f);
if (logo_tex_ != 0) {
float aspect = (logo_h_ > 0) ? (float)logo_w_ / (float)logo_h_ : 1.0f;
@@ -266,29 +294,43 @@ void App::renderFirstRunWizard() {
{
int state = cardState(0);
bool isFocused = (state == 1);
+ bool isCollapsed = (state == 2); // Completed: minimize to a compact pill (mirrors Card 1)
float cx = leftX + cardPad;
float cy = card0Top + cardPad;
float contentW = colW - 2 * cardPad;
- // Step indicator
- {
+ // Step indicator + title (inline when collapsed)
+ if (isCollapsed) {
+ // Compact single-line: check icon + "Step 1" + "Appearance"
float iconW = iconFont->CalcTextSizeA(iconFont->LegacySize, FLT_MAX, 0, stepIcon(state)).x;
dl->AddText(iconFont, iconFont->LegacySize, ImVec2(cx, cy), dimCol, stepIcon(state));
- dl->AddText(captionFont, captionFont->LegacySize, ImVec2(cx + iconW + 4.0f * dp, cy), dimCol, TR("wiz_step1"));
- cy += captionFont->LegacySize + 6.0f * dp;
- }
+ float labelX = cx + iconW + 4.0f * dp;
+ dl->AddText(captionFont, captionFont->LegacySize, ImVec2(labelX, cy), dimCol, TR("wiz_step1"));
+ float step1W = captionFont->CalcTextSizeA(captionFont->LegacySize, FLT_MAX, 0, TR("wiz_step1")).x;
+ float titleX = labelX + step1W + 12.0f * dp;
+ dl->AddText(bodyFont, bodyFont->LegacySize, ImVec2(titleX, cy), dimCol, TR("wiz_appearance"));
+ cy += captionFont->LegacySize + 4.0f * dp;
+ } else {
+ // Step indicator
+ {
+ float iconW = iconFont->CalcTextSizeA(iconFont->LegacySize, FLT_MAX, 0, stepIcon(state)).x;
+ dl->AddText(iconFont, iconFont->LegacySize, ImVec2(cx, cy), dimCol, stepIcon(state));
+ dl->AddText(captionFont, captionFont->LegacySize, ImVec2(cx + iconW + 4.0f * dp, cy), dimCol, TR("wiz_step1"));
+ cy += captionFont->LegacySize + 6.0f * dp;
+ }
- // Title
- {
- const char* t = TR("wiz_appearance");
- dl->AddText(titleFont, titleFont->LegacySize, ImVec2(cx, cy), textCol, t);
- cy += titleFont->LegacySize + 10.0f * dp;
- }
+ // Title
+ {
+ const char* t = TR("wiz_appearance");
+ dl->AddText(titleFont, titleFont->LegacySize, ImVec2(cx, cy), textCol, t);
+ cy += titleFont->LegacySize + 10.0f * dp;
+ }
- // Separator
- dl->AddLine(ImVec2(cx, cy), ImVec2(cx + contentW, cy),
- (textCol & 0x00FFFFFF) | IM_COL32(0,0,0,40), 1.0f * dp);
- cy += 14.0f * dp;
+ // Separator
+ dl->AddLine(ImVec2(cx, cy), ImVec2(cx + contentW, cy),
+ (textCol & 0x00FFFFFF) | IM_COL32(0,0,0,40), 1.0f * dp);
+ cy += 14.0f * dp;
+ }
float& wiz_blur_amount = wizardUi.blur_amount;
bool& wiz_theme_effects = wizardUi.theme_effects;
@@ -324,6 +366,9 @@ void App::renderFirstRunWizard() {
wiz_appearance_init = true;
}
+ // Controls: rendered for the focused and upcoming states so content is visible under
+ // the dim overlay; skipped entirely once completed so the card shrinks to a compact pill.
+ if (!isCollapsed) {
// Render controls always so content is visible under the dim
// overlay when not focused; disable interaction when not active.
ImGui::BeginDisabled(!isFocused);
@@ -640,13 +685,21 @@ void App::renderFirstRunWizard() {
cy += btnH;
}
- cy += cardPad;
- // Lock card height to the tallest content ever seen
- float& card0MaxH = wizardUi.card0_max_h;
- card0MaxH = std::max(card0MaxH, cy - card0Top);
- card0Bot = card0Top + card0MaxH;
+ } // if (!isCollapsed)
- // Card 0 finalization deferred until after cards 1+2 are sized
+ cy += cardPad;
+ // Lock card height to the tallest content ever seen (but not when collapsed)
+ float& card0MaxH = wizardUi.card0_max_h;
+ if (isCollapsed) {
+ // Completed: finalize immediately as a compact pill (do not stretch to the
+ // right column height, and skip the deferred stretch below).
+ card0Bot = card0Top + (cy - card0Top);
+ finalizeCard(leftX, colW, card0Top, card0Bot, state);
+ } else {
+ card0MaxH = std::max(card0MaxH, cy - card0Top);
+ card0Bot = card0Top + card0MaxH;
+ // Card 0 finalization deferred until after cards 1+2 are sized
+ }
}
@@ -889,8 +942,9 @@ void App::renderFirstRunWizard() {
}
if (wizard_stopping_external_) {
+ const std::string ws = wizard_stop_status_.get();
dl->AddText(captionFont, captionFont->LegacySize, ImVec2(cx, cy), dimCol,
- wizard_stop_status_.c_str());
+ ws.c_str());
cy += captionFont->LegacySize + 8.0f * dp;
} else {
float stopW = 150.0f * dp;
@@ -1338,6 +1392,10 @@ void App::renderFirstRunWizard() {
wallet_security_.beginDeferredEncryption(
std::string(encrypt_pass_buf_),
(pinEntered && pinOk) ? pinStr : std::string());
+ // Persist that encryption was requested (never the passphrase) so a quit/crash or
+ // failed daemon connect before it applies isn't silent — reconciled on the next
+ // connect in refreshWalletEncryptionState (W2-2). Saved with the wizard state below.
+ settings_->setEncryptionPending(true);
// Clear sensitive buffers
memset(encrypt_pass_buf_, 0, sizeof(encrypt_pass_buf_));
@@ -1372,6 +1430,13 @@ void App::renderFirstRunWizard() {
encrypt_status_ = TR("wiz_skip_confirm");
} else {
s_skipEncConfirm = false;
+ // Skipping leaves the wallet UNENCRYPTED — wipe the passphrase/PIN the user may have
+ // typed so it doesn't linger in these process-lifetime buffers (only the Encrypt
+ // path cleared them before). (L-07)
+ memset(encrypt_pass_buf_, 0, sizeof(encrypt_pass_buf_));
+ memset(encrypt_confirm_buf_, 0, sizeof(encrypt_confirm_buf_));
+ memset(wizard_pin_buf_, 0, sizeof(wizard_pin_buf_));
+ memset(wizard_pin_confirm_buf_, 0, sizeof(wizard_pin_confirm_buf_));
wizard_phase_ = WizardPhase::Done;
settings_->setWizardCompleted(true);
settings_->save();
@@ -1407,7 +1472,9 @@ void App::renderFirstRunWizard() {
}
// --- Deferred Card 0 finalization: match right column total height ---
- {
+ // Only for the focused/upcoming Appearance card; a completed one was already finalized
+ // above as a compact pill and must not be re-stretched.
+ if (cardState(0) != 2) {
float rightColBot = card2Bot;
if (rightColBot > card0Bot) card0Bot = rightColBot;
finalizeCard(leftX, colW, card0Top, card0Bot, cardState(0));
@@ -1416,6 +1483,24 @@ void App::renderFirstRunWizard() {
// Merge channels: backgrounds → content → overlays
dl->ChannelsMerge();
+ // Measure this frame's content height (feeds next frame's scroll clamp) and, when it overflows the
+ // window, draw a slim scroll indicator so the off-screen content is discoverable.
+ {
+ float contentBottom = std::max(card0Bot, std::max(card1Bot, card2Bot));
+ // Subtract vCenter back out: everything below the header was shifted down by it, so the raw
+ // span includes it. We want s_wizContentH to be the true (un-centered) content height, or the
+ // vertical-centering above would feed on itself and oscillate frame-to-frame.
+ s_wizContentH = (contentBottom - winPos.y + scrollY - vCenter) + 24.0f * dp;
+ if (wizMaxScroll > 0.0f && s_wizContentH > 0.0f) {
+ float trackH = winSize.y - 8.0f * dp;
+ float thumbH = std::min(trackH, std::max(32.0f * dp, trackH * (winSize.y / s_wizContentH)));
+ float thumbY = winPos.y + 4.0f * dp + (trackH - thumbH) * (scrollY / wizMaxScroll);
+ float barX = winPos.x + winSize.x - 6.0f * dp;
+ dl->AddRectFilled(ImVec2(barX, thumbY), ImVec2(barX + 3.0f * dp, thumbY + thumbH),
+ ui::material::WithAlpha(ui::material::OnSurface(), 55), 1.5f * dp);
+ }
+ }
+
ImGui::End();
}
diff --git a/src/chat/chat_database.cpp b/src/chat/chat_database.cpp
index 13d697f..f533819 100644
--- a/src/chat/chat_database.cpp
+++ b/src/chat/chat_database.cpp
@@ -89,6 +89,7 @@ bool ChatDatabase::unlockWithSecret(const std::string& secret)
lock();
return false;
}
+ loadTombstones();
return true;
}
@@ -97,11 +98,13 @@ void ChatDatabase::lock()
sodium_memzero(key_.data(), key_.size());
key_ready_ = false;
wallet_tag_.clear();
+ tombstones_.clear();
}
bool ChatDatabase::append(const ChatMessage& message)
{
if (!key_ready_ || !ensureOpen()) return false;
+ if (isTombstoned(message)) return false; // locally deleted — don't re-persist on a chain re-scan
std::vector nonce;
std::vector cipher;
@@ -193,13 +196,79 @@ std::vector ChatDatabase::load()
void ChatDatabase::clearWallet()
{
if (wallet_tag_.empty() || !ensureOpen()) return;
+ for (const char* sql : {"DELETE FROM chat_messages WHERE wallet_tag = ?",
+ "DELETE FROM chat_deleted WHERE wallet_tag = ?"}) {
+ sqlite3_stmt* stmt = nullptr;
+ if (sqlite3_prepare_v2(db_, sql, -1, &stmt, nullptr) != SQLITE_OK) continue;
+ sqlite3_bind_text(stmt, 1, wallet_tag_.c_str(), -1, SQLITE_TRANSIENT);
+ sqlite3_step(stmt);
+ sqlite3_finalize(stmt);
+ }
+ tombstones_.clear();
+}
+
+bool ChatDatabase::deleteMessages(const std::vector& messages, bool tombstone)
+{
+ if (!key_ready_ || !ensureOpen()) return false;
+ if (messages.empty()) return true;
+
+ if (!exec("BEGIN")) return false;
+ bool ok = true;
+ for (const auto& m : messages) {
+ const std::string dedup = dedupHash(m.txid, m.payload_position);
+
+ sqlite3_stmt* del = nullptr;
+ if (sqlite3_prepare_v2(db_, "DELETE FROM chat_messages WHERE wallet_tag = ? AND dedup_hash = ?",
+ -1, &del, nullptr) == SQLITE_OK) {
+ sqlite3_bind_text(del, 1, wallet_tag_.c_str(), -1, SQLITE_TRANSIENT);
+ sqlite3_bind_text(del, 2, dedup.c_str(), -1, SQLITE_TRANSIENT);
+ if (sqlite3_step(del) != SQLITE_DONE) ok = false;
+ sqlite3_finalize(del);
+ } else {
+ ok = false;
+ }
+
+ if (tombstone) {
+ sqlite3_stmt* ins = nullptr;
+ if (sqlite3_prepare_v2(db_,
+ "INSERT OR IGNORE INTO chat_deleted (wallet_tag, dedup_hash) VALUES (?, ?)",
+ -1, &ins, nullptr) == SQLITE_OK) {
+ sqlite3_bind_text(ins, 1, wallet_tag_.c_str(), -1, SQLITE_TRANSIENT);
+ sqlite3_bind_text(ins, 2, dedup.c_str(), -1, SQLITE_TRANSIENT);
+ if (sqlite3_step(ins) != SQLITE_DONE) ok = false;
+ sqlite3_finalize(ins);
+ } else {
+ ok = false;
+ }
+ }
+ }
+ if (!exec(ok ? "COMMIT" : "ROLLBACK")) ok = false;
+ // Only reflect the tombstones in the in-memory cache once they are durably committed.
+ if (ok && tombstone)
+ for (const auto& m : messages) tombstones_.insert(dedupHash(m.txid, m.payload_position));
+ return ok;
+}
+
+bool ChatDatabase::isTombstoned(const ChatMessage& message) const
+{
+ if (!key_ready_ || tombstones_.empty()) return false;
+ return tombstones_.count(dedupHash(message.txid, message.payload_position)) > 0;
+}
+
+void ChatDatabase::loadTombstones()
+{
+ tombstones_.clear();
+ if (!key_ready_ || !ensureOpen()) return;
sqlite3_stmt* stmt = nullptr;
- if (sqlite3_prepare_v2(db_, "DELETE FROM chat_messages WHERE wallet_tag = ?", -1, &stmt, nullptr)
- != SQLITE_OK) {
+ if (sqlite3_prepare_v2(db_, "SELECT dedup_hash FROM chat_deleted WHERE wallet_tag = ?",
+ -1, &stmt, nullptr) != SQLITE_OK) {
return;
}
sqlite3_bind_text(stmt, 1, wallet_tag_.c_str(), -1, SQLITE_TRANSIENT);
- sqlite3_step(stmt);
+ while (sqlite3_step(stmt) == SQLITE_ROW) {
+ const auto* h = reinterpret_cast(sqlite3_column_text(stmt, 0));
+ if (h) tombstones_.insert(h);
+ }
sqlite3_finalize(stmt);
}
@@ -260,11 +329,18 @@ bool ChatDatabase::exec(const char* sql)
bool ChatDatabase::createSchema()
{
- return exec("CREATE TABLE IF NOT EXISTS chat_messages ("
+ if (!exec("CREATE TABLE IF NOT EXISTS chat_messages ("
+ "wallet_tag TEXT NOT NULL, "
+ "dedup_hash TEXT NOT NULL, "
+ "nonce BLOB NOT NULL, "
+ "payload BLOB NOT NULL, "
+ "PRIMARY KEY (wallet_tag, dedup_hash))"))
+ return false;
+ // Tombstones for locally-deleted messages (dedup_hash only — the same keyed, non-revealing hash the
+ // message rows use). A chain re-scan checks this so a deleted message never re-imports.
+ return exec("CREATE TABLE IF NOT EXISTS chat_deleted ("
"wallet_tag TEXT NOT NULL, "
"dedup_hash TEXT NOT NULL, "
- "nonce BLOB NOT NULL, "
- "payload BLOB NOT NULL, "
"PRIMARY KEY (wallet_tag, dedup_hash))");
}
diff --git a/src/chat/chat_database.h b/src/chat/chat_database.h
index 1156a60..550c0ae 100644
--- a/src/chat/chat_database.h
+++ b/src/chat/chat_database.h
@@ -16,6 +16,7 @@
#include
#include
#include
+#include
#include
struct sqlite3;
@@ -54,8 +55,20 @@ public:
void clearWallet(); // delete the unlocked wallet's rows
+ // Per-conversation local delete. Removes the given messages' rows; when `tombstone` is true it also
+ // records their (txid,position) dedup keys so a chain re-scan never re-imports them — this backs the
+ // "delete, but a NEW message revives the thread" path. With `tombstone` false the rows are simply
+ // removed (used by "delete & block", where a settings-level cid block suppresses re-import until the
+ // user unblocks, at which point the history re-imports from chain). Atomic; no-op while locked.
+ bool deleteMessages(const std::vector& messages, bool tombstone);
+
+ // True if this message's (txid,position) was locally deleted with a tombstone. Checked against an
+ // in-memory cache loaded on unlock — O(1), no SQL. False while locked.
+ bool isTombstoned(const ChatMessage& message) const;
+
private:
bool ensureOpen();
+ void loadTombstones(); // populate tombstones_ from chat_deleted for the unlocked wallet
bool exec(const char* sql);
bool createSchema();
std::string dedupHash(const std::string& txid, std::size_t position) const;
@@ -74,6 +87,7 @@ private:
std::array key_{}; // AEAD storage key (seed-derived)
std::string wallet_tag_; // seed-derived row partition (a keyed hash, hex)
bool key_ready_ = false;
+ std::unordered_set tombstones_; // dedup_hash cache of locally-deleted messages
};
} // namespace dragonx::chat
diff --git a/src/chat/chat_service.cpp b/src/chat/chat_service.cpp
index df97bff..e817a10 100644
--- a/src/chat/chat_service.cpp
+++ b/src/chat/chat_service.cpp
@@ -28,6 +28,10 @@ int ChatService::ingest(const std::vector& metadata
std::int64_t fallbackTimestamp,
std::vector* newIncomingCids) {
if (!has_identity_) return 0;
+ // Persistence is attached but not unlocked (e.g. the DB failed to open with the seed): we can't
+ // consult tombstones, so ingesting now would resurface locally-deleted messages into the store.
+ // Skip until the DB is usable — chat is degraded anyway without its store.
+ if (db_ && !db_->hasKey()) return 0;
const std::string myPubKey = chatIdentityPublicKeyHex(identity_);
@@ -62,6 +66,13 @@ int ChatService::ingest(const std::vector& metadata
}
message.payload_position = meta.payload_position;
+ // Suppress locally-removed conversations before the (relatively costly) decrypt. A blocked cid
+ // is dropped outright (old + future messages) until unblocked; a tombstoned (txid,position) was
+ // deleted with "revive on new message", so only that exact message is skipped — a new message
+ // in the same conversation has a different txid and flows through normally.
+ if (blocked_pred_ && blocked_pred_(message.conversation_id)) continue;
+ if (db_ && db_->isTombstoned(message)) continue;
+
if (meta.type == HushChatHeaderType::ContactRequest) {
message.kind = ChatMessageKind::ContactRequest;
message.body = meta.payload_memo; // plaintext request text
@@ -92,10 +103,28 @@ int ChatService::ingest(const std::vector& metadata
void ChatService::loadFromDatabase() {
if (!db_) return;
for (const auto& message : db_->load()) {
+ // Never surface a blocked conversation, even if a prior "delete & block" failed to remove its
+ // rows (defense-in-depth): the ingest guard already drops live scans, this covers the reload path.
+ if (blocked_pred_ && blocked_pred_(message.conversation_id)) continue;
store_.append(message);
}
}
+bool ChatService::deleteConversation(const std::string& conversationId, bool block) {
+ // Delete the persisted rows FIRST and only mutate the in-memory view if that succeeds. Doing it the
+ // other way round means a failed DB write (disk full / locked) would empty the store while the rows
+ // survive — and on the next reload the conversation silently reappears with no tombstone.
+ // Revive-on-new-message => tombstone the removed rows so a re-scan won't re-import them.
+ // Block => remove the rows without a tombstone; the caller's blocked predicate suppresses re-import
+ // until unblocked, at which point the conversation re-imports from chain.
+ if (db_) {
+ std::vector msgs = store_.conversation(conversationId); // snapshot (copy)
+ if (!db_->deleteMessages(msgs, /*tombstone=*/!block)) return false;
+ }
+ store_.eraseConversation(conversationId);
+ return true;
+}
+
std::string ChatService::identityPublicKeyHex() const {
if (!has_identity_) return {};
return chatIdentityPublicKeyHex(identity_);
diff --git a/src/chat/chat_service.h b/src/chat/chat_service.h
index af9d5d9..b570560 100644
--- a/src/chat/chat_service.h
+++ b/src/chat/chat_service.h
@@ -11,6 +11,7 @@
#include "chat_store.h"
#include
+#include
#include
#include
#include
@@ -55,6 +56,19 @@ public:
// the seed-derived key) into the in-memory store. No-op without an unlocked database.
void loadFromDatabase();
+ // Set a predicate returning true for a BLOCKED conversation id. ingest() drops those messages
+ // entirely (they are never stored or persisted) until the predicate stops returning true — this is
+ // how "delete & block" suppresses old and future messages. Typically wired to Settings::isChatBlocked.
+ void setBlockedPredicate(std::function pred) { blocked_pred_ = std::move(pred); }
+
+ // Locally delete a conversation: remove its messages from the store and the database. When `block`
+ // is false, the removed messages are tombstoned so a chain re-scan won't re-import them, but a
+ // genuinely NEW message (new txid) revives the thread. When `block` is true, the rows are removed
+ // WITHOUT a tombstone (the caller also records the cid as blocked via the predicate above); unblocking
+ // later lets the conversation re-import from chain. Returns false (leaving the store untouched) if the
+ // persisted rows couldn't be removed, so the caller can avoid a store/DB divergence.
+ bool deleteConversation(const std::string& conversationId, bool block);
+
// --- Outgoing (compose) ---
// My chat public key (hex), or "" without an identity — goes in an outgoing header's "p".
std::string identityPublicKeyHex() const;
@@ -90,6 +104,7 @@ private:
bool has_identity_ = false;
ChatStore store_;
ChatDatabase* db_ = nullptr; // optional; not owned
+ std::function blocked_pred_; // true => cid is blocked (drop its messages)
};
} // namespace dragonx::chat
diff --git a/src/chat/chat_store.cpp b/src/chat/chat_store.cpp
index 82cf76b..b493f90 100644
--- a/src/chat/chat_store.cpp
+++ b/src/chat/chat_store.cpp
@@ -13,6 +13,7 @@ std::string ChatStore::dedupKey(const ChatMessage& message) {
bool ChatStore::append(const ChatMessage& message) {
if (!seen_.insert(dedupKey(message)).second) return false;
messages_.push_back(message);
+ ++revision_;
return true;
}
@@ -38,12 +39,24 @@ const ChatMessage* ChatStore::updateDelivery(const std::string& txid, ChatDelive
for (auto& message : messages_) {
if (message.txid == txid) {
message.delivery = delivery;
+ ++revision_;
return &message;
}
}
return nullptr;
}
+int ChatStore::countUnread(const std::function& excluded,
+ const std::function& seenFor) const {
+ int unread = 0;
+ for (const auto& m : messages_) {
+ if (m.direction != ChatDirection::Incoming) continue;
+ if (excluded && excluded(m.conversation_id)) continue;
+ if (m.timestamp > seenFor(m.conversation_id)) ++unread;
+ }
+ return unread;
+}
+
std::vector ChatStore::conversationIds() const {
std::vector ids;
std::unordered_set seenIds;
@@ -53,9 +66,27 @@ std::vector ChatStore::conversationIds() const {
return ids;
}
+std::vector ChatStore::eraseConversation(const std::string& conversationId) {
+ std::vector removed;
+ std::vector kept;
+ kept.reserve(messages_.size());
+ for (auto& m : messages_) {
+ if (m.conversation_id == conversationId) {
+ seen_.erase(dedupKey(m));
+ removed.push_back(std::move(m));
+ } else {
+ kept.push_back(std::move(m));
+ }
+ }
+ messages_.swap(kept);
+ if (!removed.empty()) ++revision_;
+ return removed;
+}
+
void ChatStore::clear() {
messages_.clear();
seen_.clear();
+ ++revision_;
}
} // namespace dragonx::chat
diff --git a/src/chat/chat_store.h b/src/chat/chat_store.h
index 3c923e8..4ab6bf2 100644
--- a/src/chat/chat_store.h
+++ b/src/chat/chat_store.h
@@ -5,6 +5,8 @@
#include "chat_message.h"
+#include
+#include
#include
#include
#include
@@ -38,15 +40,32 @@ public:
return out;
}
+ // Remove every message in a conversation from the in-memory view and return the removed messages
+ // (so the caller can delete/tombstone their persisted rows). Re-appends are prevented by the ingest
+ // guard (blocked-cid predicate / DB tombstone), not here.
+ std::vector eraseConversation(const std::string& conversationId);
+
std::size_t size() const { return messages_.size(); }
bool empty() const { return messages_.empty(); }
void clear();
+ // Monotonic counter bumped on every mutation (append / updateDelivery change / eraseConversation /
+ // clear). Callers memoize expensive per-frame reads (conversation-list build, unread count) against it
+ // so they only rebuild when the store actually changed.
+ std::uint64_t revision() const { return revision_; }
+
+ // Count unread incoming messages in a SINGLE pass over the store: an incoming message counts when its
+ // cid is not `excluded` and its timestamp is newer than `seenFor(cid)`. Avoids the per-conversation
+ // copy+sort that conversation() does — the count doesn't need ordering.
+ int countUnread(const std::function& excluded,
+ const std::function& seenFor) const;
+
private:
static std::string dedupKey(const ChatMessage& message);
std::vector messages_;
std::unordered_set seen_;
+ std::uint64_t revision_ = 0;
};
} // namespace dragonx::chat
diff --git a/src/config/settings.cpp b/src/config/settings.cpp
index 852c4c5..2274f28 100644
--- a/src/config/settings.cpp
+++ b/src/config/settings.cpp
@@ -157,6 +157,13 @@ bool Settings::load(const std::string& path)
for (const auto& c : j["hidden_chat_cids"])
if (c.is_string()) hidden_chat_cids_.push_back(c.get());
}
+ if (j.contains("blocked_chat_convs") && j["blocked_chat_convs"].is_array()) {
+ blocked_chat_convs_.clear();
+ for (const auto& c : j["blocked_chat_convs"])
+ if (c.is_object() && c.contains("cid") && c["cid"].is_string())
+ blocked_chat_convs_.push_back({c["cid"].get(),
+ c.value("name", std::string())});
+ }
// Chat-tab customization (re-clamped through the setters so hand-edited JSON stays in range).
loadScalar(j, "chat_emoji_color", chat_emoji_color_);
loadScalar(j, "chat_poll_rate_sec", chat_poll_rate_sec_); setChatPollRateSec(chat_poll_rate_sec_);
@@ -204,6 +211,7 @@ bool Settings::load(const std::string& path)
loadScalar(j, "console_text_color", console_text_color_);
loadScalar(j, "console_zoom", console_zoom_);
if (!(console_zoom_ >= 0.25f && console_zoom_ <= 4.0f)) console_zoom_ = 1.0f; // guard bad/NaN
+ loadScalar(j, "console_auto_focus", console_auto_focus_);
if (j.contains("hidden_addresses") && j["hidden_addresses"].is_array()) {
hidden_addresses_.clear();
for (const auto& a : j["hidden_addresses"])
@@ -231,18 +239,28 @@ bool Settings::load(const std::string& path)
}
loadScalar(j, "wizard_completed", wizard_completed_);
loadScalar(j, "seed_backup_reminded", seed_backup_reminded_);
+ loadScalar(j, "large_wallet_warned", large_wallet_warned_);
+ if (j.contains("empty_wallet_warning_acked") && j["empty_wallet_warning_acked"].is_array()) {
+ empty_wallet_warning_acked_.clear();
+ for (const auto& w : j["empty_wallet_warning_acked"])
+ if (w.is_string()) empty_wallet_warning_acked_.insert(w.get());
+ }
+ loadScalar(j, "encryption_pending", encryption_pending_);
loadScalar(j, "daemon_update_prompted_size", daemon_update_prompted_size_);
loadScalar(j, "active_wallet_file", active_wallet_file_);
loadScalar(j, "seed_migration_pending", seed_migration_pending_);
loadScalar(j, "seed_migration_dest", seed_migration_dest_);
loadScalar(j, "seed_migration_temp_dir", seed_migration_temp_dir_);
loadScalar(j, "seed_migration_sweep_txid", seed_migration_sweep_txid_);
+ loadScalar(j, "seed_migration_sweep_opid", seed_migration_sweep_opid_);
loadScalar(j, "auto_lock_timeout", auto_lock_timeout_);
loadScalar(j, "unlock_duration", unlock_duration_);
loadScalar(j, "pin_enabled", pin_enabled_);
loadScalar(j, "keep_daemon_running", keep_daemon_running_);
loadScalar(j, "stop_external_daemon", stop_external_daemon_);
loadScalar(j, "max_connections", max_connections_);
+ loadScalar(j, "stratum_host_enabled", stratum_host_enabled_);
+ loadScalar(j, "stratum_allowip", stratum_allowip_);
if (j.contains("lite_wallet") && j["lite_wallet"].is_object()) {
const auto& lite = j["lite_wallet"];
if (lite.contains("server_selection_mode")) {
@@ -450,6 +468,13 @@ bool Settings::save(const std::string& path)
j["hidden_chat_cids"] = json::array();
for (const auto& c : hidden_chat_cids_)
j["hidden_chat_cids"].push_back(c);
+ j["blocked_chat_convs"] = json::array();
+ for (const auto& b : blocked_chat_convs_) {
+ json o;
+ o["cid"] = b.cid;
+ o["name"] = b.name;
+ j["blocked_chat_convs"].push_back(o);
+ }
j["chat_emoji_color"] = chat_emoji_color_;
j["chat_poll_rate_sec"] = chat_poll_rate_sec_;
j["chat_bubble_style"] = chat_bubble_style_;
@@ -476,6 +501,7 @@ bool Settings::save(const std::string& path)
j["console_line_accents"] = console_line_accents_;
j["console_text_color"] = console_text_color_;
j["console_zoom"] = console_zoom_;
+ j["console_auto_focus"] = console_auto_focus_;
j["hidden_addresses"] = json::array();
for (const auto& addr : hidden_addresses_)
j["hidden_addresses"].push_back(addr);
@@ -497,18 +523,26 @@ bool Settings::save(const std::string& path)
}
j["wizard_completed"] = wizard_completed_;
j["seed_backup_reminded"] = seed_backup_reminded_;
+ j["large_wallet_warned"] = large_wallet_warned_;
+ j["empty_wallet_warning_acked"] = json::array();
+ for (const auto& w : empty_wallet_warning_acked_)
+ j["empty_wallet_warning_acked"].push_back(w);
+ j["encryption_pending"] = encryption_pending_;
j["daemon_update_prompted_size"] = daemon_update_prompted_size_;
j["active_wallet_file"] = active_wallet_file_;
j["seed_migration_pending"] = seed_migration_pending_;
j["seed_migration_dest"] = seed_migration_dest_;
j["seed_migration_temp_dir"] = seed_migration_temp_dir_;
j["seed_migration_sweep_txid"] = seed_migration_sweep_txid_;
+ j["seed_migration_sweep_opid"] = seed_migration_sweep_opid_;
j["auto_lock_timeout"] = auto_lock_timeout_;
j["unlock_duration"] = unlock_duration_;
j["pin_enabled"] = pin_enabled_;
j["keep_daemon_running"] = keep_daemon_running_;
j["stop_external_daemon"] = stop_external_daemon_;
j["max_connections"] = max_connections_;
+ j["stratum_host_enabled"] = stratum_host_enabled_;
+ j["stratum_allowip"] = stratum_allowip_;
{
json lite = json::object();
lite["server_selection_mode"] = liteServerSelectionPreferenceModeName(lite_server_selection_mode_);
diff --git a/src/config/settings.h b/src/config/settings.h
index 8cdd124..5b2b0a9 100644
--- a/src/config/settings.h
+++ b/src/config/settings.h
@@ -144,6 +144,26 @@ public:
hidden_chat_cids_.end());
}
+ // Blocked chat conversations (by cid). Unlike hide (reversible, keeps messages), block DELETES the
+ // local history AND suppresses every message for the cid — old and future — until you unblock, at
+ // which point the conversation re-imports from the chain. The last-known peer name is kept so the
+ // "Blocked" list can label the entry (its messages are gone from the local store).
+ struct BlockedChatConv { std::string cid; std::string name; };
+ bool isChatBlocked(const std::string& cid) const {
+ for (const auto& b : blocked_chat_convs_) if (b.cid == cid) return true;
+ return false;
+ }
+ void setChatBlocked(const std::string& cid, const std::string& name, bool blocked) {
+ const bool already = isChatBlocked(cid);
+ if (blocked && !already) blocked_chat_convs_.push_back({cid, name});
+ else if (!blocked && already)
+ blocked_chat_convs_.erase(
+ std::remove_if(blocked_chat_convs_.begin(), blocked_chat_convs_.end(),
+ [&](const BlockedChatConv& b) { return b.cid == cid; }),
+ blocked_chat_convs_.end());
+ }
+ const std::vector& blockedChatConversations() const { return blocked_chat_convs_; }
+
// ── Chat-tab customization (chat settings modal + Settings → Chat & Contacts) ──────
bool getChatEmojiColor() const { return chat_emoji_color_; }
void setChatEmojiColor(bool v) { chat_emoji_color_ = v; }
@@ -256,6 +276,9 @@ public:
void setConsoleTextColor(bool v) { console_text_color_ = v; }
float getConsoleZoom() const { return console_zoom_; }
void setConsoleZoom(float v) { console_zoom_ = v; }
+ // Auto-place the text cursor in the command box when the Console tab is opened.
+ bool getConsoleAutoFocus() const { return console_auto_focus_; }
+ void setConsoleAutoFocus(bool v) { console_auto_focus_ = v; }
// Hidden addresses (addresses hidden from the UI by the user)
const std::set& getHiddenAddresses() const { return hidden_addresses_; }
@@ -327,6 +350,24 @@ public:
bool getSeedBackupReminded() const { return seed_backup_reminded_; }
void setSeedBackupReminded(bool v) { seed_backup_reminded_ = v; }
+ // One-time nudge when wallet.dat grows past the bloat threshold (re-armed if it shrinks back).
+ bool getLargeWalletWarned() const { return large_wallet_warned_; }
+ void setLargeWalletWarned(bool v) { large_wallet_warned_ = v; }
+
+ // Wallet filenames for which the one-time "this wallet is empty but a sibling holds funds"
+ // warning has been dismissed. Keyed per active wallet file so switching to a different empty
+ // wallet can warn again (see App::maybeWarnEmptyWalletWithFundedSiblings).
+ bool isEmptyWalletWarnAcked(const std::string& walletFile) const {
+ return empty_wallet_warning_acked_.count(walletFile) > 0;
+ }
+ void ackEmptyWalletWarn(const std::string& walletFile) { empty_wallet_warning_acked_.insert(walletFile); }
+
+ // Persisted the moment deferred (wizard) encryption is requested; cleared only once the wallet is
+ // observed to be actually encrypted. Lets a quit/crash/failed-connect before it applies be detected
+ // and surfaced (W2-2). NEVER stores the passphrase — only the fact that encryption was requested.
+ bool getEncryptionPending() const { return encryption_pending_; }
+ void setEncryptionPending(bool v) { encryption_pending_ = v; }
+
// Bundled-daemon size we last prompted to install (see App::renderDaemonUpdatePrompt). Lets the
// "a newer node is bundled — update?" prompt fire once per wallet version, never re-nagging.
long long getDaemonUpdatePromptedSize() const { return daemon_update_prompted_size_; }
@@ -350,6 +391,11 @@ public:
// migration is past the sweep, so a resume goes to the confirm/adopt stage (not sweep again).
std::string getSeedMigrationSweepTxid() const { return seed_migration_sweep_txid_; }
void setSeedMigrationSweepTxid(const std::string& v) { seed_migration_sweep_txid_ = v; }
+ // W3-3: the async sweep operation id, persisted while the sweep is in flight (before it resolves
+ // to a txid). Lets a resume re-poll a mid-sweep interruption instead of dropping the txid. Cleared
+ // in the same write that persists the txid, so the txid always outranks it (see [[decideSeedMigrationResume]]).
+ std::string getSeedMigrationSweepOpid() const { return seed_migration_sweep_opid_; }
+ void setSeedMigrationSweepOpid(const std::string& v) { seed_migration_sweep_opid_ = v; }
// Security — auto-lock timeout (seconds; 0 = disabled)
int getAutoLockTimeout() const { return auto_lock_timeout_; }
@@ -374,6 +420,11 @@ public:
// Daemon — maximum peer connections (0 = daemon default)
int getMaxConnections() const { return max_connections_; }
void setMaxConnections(int v) { max_connections_ = std::max(0, v); }
+ // Host a RandomX stratum pool from the node (v1.3.0+ daemons). Empty allow-IP = loopback only (safe).
+ bool getStratumHost() const { return stratum_host_enabled_; }
+ void setStratumHost(bool v) { stratum_host_enabled_ = v; }
+ const std::string& getStratumAllowIp() const { return stratum_allowip_; }
+ void setStratumAllowIp(const std::string& v) { stratum_allowip_ = v; }
// Lite wallet server selection
LiteServerSelectionPreferenceMode getLiteServerSelectionMode() const { return lite_server_selection_mode_; }
@@ -528,6 +579,7 @@ private:
std::string chat_reply_zaddr_;
std::vector muted_chat_cids_; // muted chat conversations by cid (Q10)
std::vector hidden_chat_cids_; // hidden chat conversations by cid
+ std::vector blocked_chat_convs_; // blocked chat conversations (cid + last-known name)
// Chat-tab customization (chat settings modal + Settings → Chat & Contacts).
bool chat_emoji_color_ = true; // true = color (needs FreeType; falls back to mono if absent), false = monochrome
float chat_poll_rate_sec_ = 2.5f; // 0-conf chat fast-scan cadence (full node)
@@ -569,23 +621,30 @@ private:
bool console_line_accents_ = true; // left color accent bars in console output
bool console_text_color_ = true; // per-channel text coloring in console output
float console_zoom_ = 1.0f; // console output font zoom factor
+ bool console_auto_focus_ = false; // focus the command input when the Console tab is opened (opt-in)
std::set hidden_addresses_;
std::set favorite_addresses_;
std::map address_meta_;
bool wizard_completed_ = false;
bool seed_backup_reminded_ = false;
+ bool large_wallet_warned_ = false;
+ std::set empty_wallet_warning_acked_; // wallet files whose empty-wallet warning was dismissed
+ bool encryption_pending_ = false;
long long daemon_update_prompted_size_ = 0; // bundled daemon size last offered via the update prompt
std::string active_wallet_file_ = "wallet.dat"; // -wallet= the daemon loads (multi-wallet)
bool seed_migration_pending_ = false;
std::string seed_migration_dest_;
std::string seed_migration_temp_dir_;
std::string seed_migration_sweep_txid_;
+ std::string seed_migration_sweep_opid_;
int auto_lock_timeout_ = 900; // 15 minutes
int unlock_duration_ = 600; // 10 minutes
bool pin_enabled_ = false;
bool keep_daemon_running_ = false;
bool stop_external_daemon_ = false;
int max_connections_ = 0; // 0 = daemon default
+ bool stratum_host_enabled_ = false; // host a RandomX stratum pool from the node (v1.3.0+ daemons)
+ std::string stratum_allowip_; // -stratumallowip filter (empty = daemon default: loopback only)
// Lite wallet server preferences. These are user/server settings only;
// wallet secrets, wallet files, and lifecycle state are never stored here.
diff --git a/src/daemon/daemon_controller.cpp b/src/daemon/daemon_controller.cpp
index 6f27c48..305656d 100644
--- a/src/daemon/daemon_controller.cpp
+++ b/src/daemon/daemon_controller.cpp
@@ -26,6 +26,7 @@ void DaemonController::syncSettings(const config::Settings* settings)
if (!settings) return;
daemon_->setDebugCategories(settings->getDebugCategories());
daemon_->setMaxConnections(settings->getMaxConnections());
+ daemon_->setStratumHosting(settings->getStratumHost(), settings->getStratumAllowIp());
std::string walletFile = settings->getActiveWalletFile();
// The Wallets dialog opens an out-of-datadir wallet by linking it into the datadir under a
@@ -71,9 +72,11 @@ DaemonController::State DaemonController::state() const
return daemon_->getState();
}
-const std::string& DaemonController::lastError() const
+std::string DaemonController::lastError() const
{
- return daemon_->getLastError();
+ // By value — getLastError() now returns a mutex-locked COPY, so forwarding it by reference would
+ // dangle (bind a reference to that temporary). (M-04 follow-through)
+ return daemon_ ? daemon_->getLastError() : std::string();
}
int DaemonController::crashCount() const
@@ -126,6 +129,11 @@ void DaemonController::setSalvageOnNextStart(bool enabled)
daemon_->setSalvageOnNextStart(enabled);
}
+void DaemonController::setReindexOnNextStart(bool enabled)
+{
+ daemon_->setReindexOnNextStart(enabled);
+}
+
bool DaemonController::zapOnNextStart() const
{
return daemon_->zapOnNextStart();
diff --git a/src/daemon/daemon_controller.h b/src/daemon/daemon_controller.h
index 5f2fe25..d55ebfd 100644
--- a/src/daemon/daemon_controller.h
+++ b/src/daemon/daemon_controller.h
@@ -95,7 +95,7 @@ public:
bool externalDaemonDetected() const;
void clearExternalDaemonDetected();
State state() const;
- const std::string& lastError() const;
+ std::string lastError() const; // by value: EmbeddedDaemon::getLastError() returns a locked copy (M-04)
int crashCount() const;
int lastBlockHeight() const;
double memoryUsageMB() const;
@@ -108,6 +108,7 @@ public:
void setZapOnNextStart(bool enabled);
bool zapOnNextStart() const;
void setSalvageOnNextStart(bool enabled);
+ void setReindexOnNextStart(bool enabled); // -reindex: rebuild the block DB from raw blocks on next start
static ShutdownDecision evaluateShutdownPolicy(bool hasDaemon,
bool externalDaemonDetected,
diff --git a/src/daemon/daemon_startup_diagnosis.h b/src/daemon/daemon_startup_diagnosis.h
new file mode 100644
index 0000000..325e2bb
--- /dev/null
+++ b/src/daemon/daemon_startup_diagnosis.h
@@ -0,0 +1,107 @@
+// DragonX Wallet - ImGui Edition
+// Copyright 2024-2026 The Hush Developers
+// Released under the GPLv3
+//
+// daemon_startup_diagnosis.h — pure classifiers over a crashed daemon's captured console output,
+// so the app can offer a targeted one-click fix instead of a bare "daemon crashed" / silent no-funds.
+
+#pragma once
+
+#include
+#include
+#include
+
+namespace dragonx {
+namespace daemon {
+
+// True when dragonxd aborted because its BLOCK DATABASE could not be loaded — either a
+// daemon-vs-chaindata serialization-format mismatch after a daemon update (the deterministic
+// "non-canonical optional discriminant" → "Error loading block database" → "Aborted block database
+// rebuild. Exiting." sequence) or a genuinely corrupt/incomplete block index. In BOTH cases the fix
+// is the same: `-reindex` rebuilds the index + chainstate from the intact raw blocks (blk*.dat).
+// This is what otherwise silently presents as a wallet with zero balance — the node never starts.
+inline bool blockDbOutputLooksBroken(const std::string& out)
+{
+ return out.find("Error loading block database") != std::string::npos
+ || out.find("non-canonical optional discriminant") != std::string::npos
+ || out.find("Aborted block database rebuild") != std::string::npos
+ || out.find("LoadBlockIndex()") != std::string::npos; // "... : failed to read value"
+}
+
+// True when dragonxd AUTO-RECOVERED the wallet on startup: on any BDB-verify failure it moves the
+// original wallet.dat to "wallet.{timestamp}.bak", salvages readable keys into a fresh wallet.dat, and
+// keeps running — no flag required (CWallet::Verify → CDBEnv::Verify(walletFile, CWalletDB::Recover)).
+// The salvage can be incomplete (or a false positive from stale/cross-platform BDB env state), so the
+// node silently comes up on a possibly-empty wallet — which reads as fund loss unless we surface it.
+inline bool walletAutoRecovered(const std::string& out)
+{
+ // Cover BOTH salvage outcomes. A successful salvage prints the "data salvaged"/"saved as wallet..bak"
+ // warning; a FAILED one (e.g. an inconsistent-but-readable file where aggressive salvage finds no
+ // records) prints "salvage failed"/"found no records". In every case CWalletDB::Recover first logs
+ // "Renamed to wallet..bak" and CDBEnv::Salvage logs its own banner — those two fire the
+ // instant a salvage begins, before the daemon may abort, so they're the earliest reliable signal.
+ return out.find("CDBEnv::Salvage") != std::string::npos // salvage is running
+ || out.find("wallet.dat corrupt, data salvaged") != std::string::npos // RECOVER_OK
+ || out.find("Original wallet.dat saved as wallet.") != std::string::npos
+ || out.find("wallet.dat corrupt, salvage failed") != std::string::npos // RECOVER_FAIL
+ || out.find("found no records in wallet") != std::string::npos // aggressive salvage empty
+ || (out.find("Renamed ") != std::string::npos && out.find(" to wallet.") != std::string::npos
+ && out.find(".bak") != std::string::npos); // Recover moved wallet.dat aside
+}
+
+// True when dragonxd (v1.3.0+) opened the wallet in DEGRADED mode: a wallet.dat that lost its hdchain
+// record (e.g. an old `-salvagewallet` output) now OPENS — existing keys stay intact and spendable —
+// instead of aborting, but the daemon can no longer derive NEW HD keys, so z_getnewaddress /
+// z_shieldcoinbase / a t->z z_sendmany fail with "HD seed not found". The only signal is a startup log
+// line; pre-1.3.0 daemons never emit it, so this classifier is naturally a no-op against them.
+inline bool walletOpenedDegraded(const std::string& out)
+{
+ return out.find("Wallet opened in DEGRADED mode") != std::string::npos;
+}
+
+// If `name` is a daemon salvage backup "wallet..bak", return its timestamp; else -1.
+inline long long parseWalletSalvageBakTs(const std::string& name)
+{
+ if (name.rfind("wallet.", 0) != 0) return -1; // must start "wallet."
+ if (name.size() < 12 || name.compare(name.size() - 4, 4, ".bak") != 0) return -1; // ...and end ".bak"
+ const std::string mid = name.substr(7, name.size() - 7 - 4); // digits between the dots
+ if (mid.empty() || mid.size() > 18) return -1;
+ for (char c : mid) if (c < '0' || c > '9') return -1;
+ long long ts = 0;
+ for (char c : mid) ts = ts * 10 + (c - '0');
+ return ts;
+}
+
+// Most RECENT salvage backup (highest timestamp). Pure, testable.
+inline std::string newestWalletSalvageBak(const std::vector& filenames)
+{
+ long long best = -1;
+ std::string bestName;
+ for (const auto& f : filenames) {
+ const long long ts = parseWalletSalvageBakTs(f);
+ if (ts > best) { best = ts; bestName = f; }
+ }
+ return bestName;
+}
+
+// LARGEST salvage backup, from (filename, fileSize) pairs — the least-salvaged one, i.e. the original.
+// This is what "Restore original wallet" should use: a salvage CASCADE shrinks the wallet each round, so
+// the newest .bak is the WORST and the largest is the pristine pre-salvage original (an emptied salvage
+// is tiny; a real wallet is large). Ties break toward the newest timestamp. Returns "" if none present.
+inline std::string largestWalletSalvageBak(const std::vector>& files)
+{
+ std::string bestName;
+ unsigned long long bestSize = 0;
+ long long bestTs = -1;
+ for (const auto& fp : files) {
+ const long long ts = parseWalletSalvageBakTs(fp.first);
+ if (ts < 0) continue;
+ if (fp.second > bestSize || (fp.second == bestSize && ts > bestTs)) {
+ bestSize = fp.second; bestTs = ts; bestName = fp.first;
+ }
+ }
+ return bestName;
+}
+
+} // namespace daemon
+} // namespace dragonx
diff --git a/src/daemon/embedded_daemon.cpp b/src/daemon/embedded_daemon.cpp
index 2e67fd6..c05c4f6 100644
--- a/src/daemon/embedded_daemon.cpp
+++ b/src/daemon/embedded_daemon.cpp
@@ -205,11 +205,13 @@ std::vector EmbeddedDaemon::getChainParams()
"-ac_reward=300000000",
"-ac_blocktime=36",
"-ac_private=1",
- "-addnode=node.dragonx.is",
+ // Seeds: seed.dragonx.is is a round-robin A record over the live seed set (self-updates without
+ // a wallet release), with node1/node5 as static fallbacks — mirrors the daemon's own vSeeds.
+ // Plain -addnode hostname resolution works on EVERY daemon version, and is load-bearing for
+ // pre-1.3.0 daemons whose built-in peer discovery was broken (they rely on these to find peers).
+ "-addnode=seed.dragonx.is",
"-addnode=node1.dragonx.is",
- "-addnode=node2.dragonx.is",
- "-addnode=node3.dragonx.is",
- "-addnode=node4.dragonx.is",
+ "-addnode=node5.dragonx.is",
"-experimentalfeatures",
"-developerencryptwallet",
// Create fresh wallets from a BIP39 mnemonic so their 24-word phrase can be
@@ -224,12 +226,11 @@ std::vector EmbeddedDaemon::getChainParams()
void EmbeddedDaemon::setState(State s, const std::string& message)
{
state_ = s;
- if (!message.empty()) {
- if (s == State::Error) {
- last_error_ = message;
- }
+ if (!message.empty() && s == State::Error) {
+ std::lock_guard lk(error_mutex_); // dedicated mutex — never taken with output_mutex_ held
+ last_error_ = message;
}
-
+
if (state_callback_) {
state_callback_(s, message);
}
@@ -488,6 +489,34 @@ bool EmbeddedDaemon::start(const std::string& binary_path)
return false;
}
external_daemon_detected_ = false;
+
+ // A previous dragonxd can release the RPC port well before it releases the datadir
+ // .lock — a graceful shutdown can take up to ~90s (see isDaemonProcessRunning). Starting
+ // into a still-held lock spawns a process that dies instantly with "Cannot obtain a lock
+ // on data directory"; the crash monitor reports that generically and, three times in
+ // ~12s, that is enough to trip the 3-strike restart cap before the lock's ~90s life
+ // elapses. Gate on the process actually still being alive, with a SHORT bounded wait
+ // (not the full ~90s — start() runs on the UI thread). Isolated starts (migrate-to-seed:
+ // skip_port_check_ / -datadir override) are exempt; they run their own datadir+port.
+ {
+ constexpr int kDatadirLockWaitPollMs = 100;
+ constexpr int kDatadirLockWaitMaxPolls = 3; // ~300ms total, breaks early on exit
+ bool stillRunning = false;
+ if (!skip_port_check_ && override_datadir_.empty()) {
+ stillRunning = isDaemonProcessRunning();
+ for (int i = 0; stillRunning && i < kDatadirLockWaitMaxPolls; ++i) {
+ std::this_thread::sleep_for(std::chrono::milliseconds(kDatadirLockWaitPollMs));
+ stillRunning = isDaemonProcessRunning();
+ }
+ }
+ const StartLockGateDecision gate =
+ evaluateDatadirLockGate(skip_port_check_, !override_datadir_.empty(), stillRunning);
+ if (!gate.proceed) {
+ VERBOSE_LOGF("[INFO] %s\n", gate.errorMessage);
+ setState(State::Error, gate.errorMessage);
+ return false;
+ }
+ }
setState(State::Starting, "Looking for dragonxd binary...");
@@ -516,6 +545,15 @@ bool EmbeddedDaemon::start(const std::string& binary_path)
args.push_back("-maxconnections=" + std::to_string(max_connections_));
}
+ // Host a RandomX stratum pool from this node (-stratum). Only v1.3.0+ daemons implement it; older
+ // ones ignore the unknown flag (no fatal arg check), and the Settings toggle is gated on daemon
+ // version, so this is only enabled against a daemon that supports it. Without -stratumallowip the
+ // daemon serves loopback only (safe default); a subnet opens it to that LAN.
+ if (stratum_enabled_) {
+ args.push_back("-stratum");
+ if (!stratum_allowip_.empty()) args.push_back("-stratumallowip=" + stratum_allowip_);
+ }
+
// Active wallet file (multi-wallet). The daemon loads /. Only pass it for a
// non-default name so the common case's command line is unchanged; skip during an isolated
// start (seed migration manages its own throwaway wallet).
@@ -543,6 +581,14 @@ bool EmbeddedDaemon::start(const std::string& binary_path)
args.push_back("-rescan");
}
+ // -reindex rebuilds the block index + chainstate from the raw blocks (fixes an unreadable/format-
+ // mismatched block DB). It's about the CHAIN, not the wallet, so it's independent of the wallet-repair
+ // chain above (and implies its own wallet rescan). One-shot, consumed here.
+ if (reindex_on_next_start_.exchange(false)) {
+ DEBUG_LOGF("[INFO] Adding -reindex flag to rebuild the block database from raw blocks\n");
+ args.push_back("-reindex");
+ }
+
// One-shot isolated-datadir override (migrate-to-seed flow): run this start against a
// throwaway datadir, plus any extra args (e.g. -connect=0). Consumed here so later starts
// revert to the normal datadir. The datadir's basename MUST be the assetchain name (DRAGONX)
@@ -557,8 +603,14 @@ bool EmbeddedDaemon::start(const std::string& binary_path)
override_extra_args_.clear();
if (!startProcess(daemon_path, args)) {
- DEBUG_LOGF("[ERROR] Failed to start dragonxd process: %s\\n", last_error_.c_str());
- setState(State::Error, "Failed to start dragonxd process");
+ // startProcess() sets a precise last_error_ (e.g. "dragonxd could not be executed:
+ // ... not executable or wrong architecture"). Surface THAT via setState — which also
+ // stores the Error message into last_error_ — instead of clobbering it with a generic
+ // string that would then be all getLastError()/the UI ever sees.
+ std::string detail = last_error_.empty() ? std::string("Failed to start dragonxd process")
+ : last_error_;
+ DEBUG_LOGF("[ERROR] %s\n", detail.c_str());
+ setState(State::Error, detail);
return false;
}
@@ -579,12 +631,28 @@ bool EmbeddedDaemon::start(const std::string& binary_path)
// Forward declaration — defined after startProcess
static DWORD findProcessByName(const char* name);
+// Quote a single argument per the CommandLineToArgvW rules (MSDN) so a value containing a space or a
+// quote is delivered as ONE argv token to the daemon instead of splitting/corrupting argv (L-02).
+static std::string quoteWinArg(const std::string& arg) {
+ if (!arg.empty() && arg.find_first_of(" \t\n\v\"") == std::string::npos) return arg;
+ std::string out = "\"";
+ for (size_t i = 0; ; ++i) {
+ size_t nbs = 0;
+ while (i < arg.size() && arg[i] == '\\') { ++nbs; ++i; }
+ if (i == arg.size()) { out.append(nbs * 2, '\\'); break; }
+ if (arg[i] == '"') { out.append(nbs * 2 + 1, '\\'); out.push_back('"'); }
+ else { out.append(nbs, '\\'); out.push_back(arg[i]); }
+ }
+ out.push_back('"');
+ return out;
+}
+
bool EmbeddedDaemon::startProcess(const std::string& binary_path, const std::vector& args)
{
- // Build command line
+ // Build command line (binary path always quoted; each arg quoted/escaped per Windows rules — L-02)
std::string cmd = "\"" + binary_path + "\"";
for (const auto& arg : args) {
- cmd += " " + arg;
+ cmd += " " + quoteWinArg(arg);
}
DEBUG_LOGF("[INFO] Starting daemon: %s\n", cmd.c_str());
@@ -632,7 +700,10 @@ bool EmbeddedDaemon::startProcess(const std::string& binary_path, const std::vec
debug_log_path_.c_str(), debug_log_offset_);
}
- // Launch daemon with CREATE_NEW_CONSOLE (hidden via SW_HIDE).
+ // Launch daemon windowless. Use CREATE_NO_WINDOW (NOT CREATE_NEW_CONSOLE): CREATE_NEW_CONSOLE
+ // allocates a console window that briefly flashes on screen before SW_HIDE can hide it, which is
+ // visible as a console-window flash on wallet launch. CREATE_NO_WINDOW gives the console child no
+ // window at all (same approach as the xmrig launcher). The daemon logs to debug.log, not a console.
// The daemon binary must NOT be in the data directory (%APPDATA%\Hush\DRAGONX)
// — it must be in /dragonx/ to avoid conflicts with lock files and data.
STARTUPINFOA si;
@@ -642,7 +713,7 @@ bool EmbeddedDaemon::startProcess(const std::string& binary_path, const std::vec
si.dwFlags = STARTF_USESHOWWINDOW;
si.wShowWindow = SW_HIDE;
ZeroMemory(&pi, sizeof(pi));
-
+
char* cmd_line = _strdup(cmd.c_str());
BOOL success = CreateProcessA(
NULL,
@@ -650,7 +721,7 @@ bool EmbeddedDaemon::startProcess(const std::string& binary_path, const std::vec
NULL,
NULL,
FALSE,
- CREATE_NEW_CONSOLE,
+ CREATE_NO_WINDOW,
NULL,
work_dir.c_str(),
&si,
@@ -745,11 +816,21 @@ void EmbeddedDaemon::drainOutput()
}
size_t currentSize = static_cast(fileSize.QuadPart);
+ // Truncation / rotation detection. dragonxd shrinks debug.log on startup (ShrinkDebugFile keeps
+ // only the tail once it has grown large), so the file can become SMALLER than where we left off.
+ // Our offset was set to the PRE-shrink size at spawn, so without this reset it stays stranded ahead
+ // of the freshly-truncated file and we read NOTHING for the whole session — no block height (status
+ // bar shows "Block: 0") and, worse, no witness-rebuild progress, since the "Setting Initial Sapling
+ // Witness …" lines the warmup progress bar parses only exist in debug.log on Windows. On a shrink,
+ // restart from the new beginning; the parser is monotonic and converges as it reaches current output.
+ if (currentSize < debug_log_offset_) {
+ debug_log_offset_ = 0;
+ }
if (currentSize <= debug_log_offset_) {
CloseHandle(hFile);
return; // No new data
}
-
+
// Seek to where we left off
LARGE_INTEGER seekPos;
seekPos.QuadPart = static_cast(debug_log_offset_);
@@ -962,18 +1043,38 @@ bool EmbeddedDaemon::startProcess(const std::string& binary_path, const std::vec
last_error_ = "Failed to create pipe: " + std::string(strerror(errno));
return false;
}
+
+ // Self-pipe used purely as an exec-success/failure handshake, separate from
+ // the stdout pipe above. Both ends are close-on-exec, so a successful execv()
+ // closes the write end for free (parent reads EOF); on execv() failure the
+ // child writes errno here, so the parent learns synchronously instead of
+ // reporting State::Running for a child that never became dragonxd. We use
+ // pipe()+FD_CLOEXEC (not pipe2) because this POSIX branch is shared with
+ // macOS, which has no pipe2().
+ int execpipe[2];
+ if (pipe(execpipe) == -1) {
+ last_error_ = "Failed to create exec-status pipe: " + std::string(strerror(errno));
+ close(pipefd[0]);
+ close(pipefd[1]);
+ return false;
+ }
+ fcntl(execpipe[0], F_SETFD, FD_CLOEXEC);
+ fcntl(execpipe[1], F_SETFD, FD_CLOEXEC);
pid_t pid = fork();
if (pid == -1) {
last_error_ = "Fork failed: " + std::string(strerror(errno));
close(pipefd[0]);
close(pipefd[1]);
+ close(execpipe[0]);
+ close(execpipe[1]);
return false;
}
if (pid == 0) {
// Child process
- close(pipefd[0]); // Close read end
+ close(pipefd[0]); // Close read end of the stdout pipe
+ close(execpipe[0]); // Child only writes the exec-status pipe
// Put child in its own process group so we can kill the entire
// group later (including dragonxd spawned by a wrapper script).
@@ -1040,22 +1141,61 @@ bool EmbeddedDaemon::startProcess(const std::string& binary_path, const std::vec
execv(binary_path.c_str(), argv.data());
}
- // If we get here, exec failed
- fprintf(stderr, "execv failed: %s\n", strerror(errno));
+ // If we get here, execv() failed — the child never became dragonxd.
+ // Capture errno before fprintf/strerror can clobber it, report it to
+ // the parent over the exec-status pipe (EINTR-safe), then exit.
+ int exec_errno = errno;
+ fprintf(stderr, "execv failed: %s\n", strerror(exec_errno));
+ ssize_t w;
+ do {
+ w = write(execpipe[1], &exec_errno, sizeof(exec_errno));
+ } while (w < 0 && errno == EINTR);
_exit(127);
}
// Parent process
- close(pipefd[1]); // Close write end
+ close(pipefd[1]); // Close our copy of the stdout write end
+ close(execpipe[1]); // Must close our copy, or the read() below never sees EOF
+
+ // Exec-status handshake: EOF => execv() succeeded (its write end was closed
+ // on exec); a full sizeof(int) => execv() failed and the child sent errno.
+ int child_errno = 0;
+ size_t got = 0;
+ char* ep = reinterpret_cast(&child_errno);
+ for (;;) {
+ ssize_t n = read(execpipe[0], ep + got, sizeof(child_errno) - got);
+ if (n == 0) break; // EOF: exec succeeded
+ if (n < 0) { if (errno == EINTR) continue; break; } // other error: assume success
+ got += static_cast(n);
+ if (got >= sizeof(child_errno)) break; // full errno: exec failed
+ }
+ close(execpipe[0]);
+
+ if (got >= sizeof(child_errno)) {
+ // execv() never replaced the child; it fprintf'd and _exit(127)'d. Reap
+ // the already-dead zombie here — monitorProcess() is only started after
+ // this function returns true, so there is no competing reaper.
+ close(pipefd[0]);
+ int status;
+ waitpid(pid, &status, 0);
+ last_error_ = "dragonxd could not be executed: " + std::string(strerror(child_errno)) +
+ " — not executable or wrong architecture";
+ return false;
+ }
+
stdout_fd_ = pipefd[0];
-
- // Also set process group from parent side (race with child's setpgid)
- setpgid(pid, pid);
-
+
+ // Best-effort: the child already calls setpgid(0, 0); this parent-side call
+ // just closes the fork/exec race window. A failure here is not fatal to
+ // startup, so we log rather than abort.
+ if (setpgid(pid, pid) != 0) {
+ DEBUG_LOGF("[WARN] setpgid(%d) from parent failed: %s\n", (int)pid, strerror(errno));
+ }
+
// Set non-blocking
int flags = fcntl(stdout_fd_, F_GETFL, 0);
fcntl(stdout_fd_, F_SETFL, flags | O_NONBLOCK);
-
+
process_pid_ = pid;
return true;
}
@@ -1135,17 +1275,21 @@ double EmbeddedDaemon::getMemoryUsageMB() const
bool EmbeddedDaemon::isRunning() const
{
+ // Read the atomic state_ instead of calling waitpid() here. monitorProcess()
+ // is the sole thread allowed to waitpid() process_pid_ during normal operation.
+ // Calling waitpid() from this method too (as it used to, and this is invoked
+ // from the UI thread nearly every frame) meant whichever thread reaped the
+ // child's exit first consumed the status; if isRunning() won that race,
+ // monitorProcess() never saw the exit, so crash_count_ / the decoded exit
+ // code / the State::Error transition were all silently lost. Mirrors the
+ // fix already in XmrigManager::isRunning().
if (process_pid_ <= 0) return false;
-
- int status;
- pid_t result = waitpid(process_pid_, &status, WNOHANG);
-
- if (result == 0) {
- // Still running
- return true;
- }
-
- return false;
+
+ const State s = state_.load(std::memory_order_relaxed);
+ // State::Stopping is included: stop()'s graceful/SIGTERM wait loops poll
+ // isRunning() while state_ == Stopping — before the process has actually
+ // terminated — and must keep seeing "alive" to wait/escalate correctly.
+ return (s == State::Running || s == State::Stopping);
}
void EmbeddedDaemon::drainOutput()
diff --git a/src/daemon/embedded_daemon.h b/src/daemon/embedded_daemon.h
index 4021cab..b2b8576 100644
--- a/src/daemon/embedded_daemon.h
+++ b/src/daemon/embedded_daemon.h
@@ -79,7 +79,9 @@ public:
/**
* @brief Get last error message
*/
- const std::string& getLastError() const { return last_error_; }
+ // Copy under lock: last_error_ is written from the monitor thread (setState on an unexpected exit)
+ // while the UI thread reads it — a reference would be a torn-read / use-after-free race (M-04).
+ std::string getLastError() const { std::lock_guard lk(error_mutex_); return last_error_; }
/**
* @brief Get dragonxd process output (thread-safe copy)
@@ -180,6 +182,7 @@ public:
* @brief Set maximum peer connections (0 = use daemon default)
*/
void setMaxConnections(int v) { max_connections_ = v; }
+ void setStratumHosting(bool enabled, const std::string& allowIp) { stratum_enabled_ = enabled; stratum_allowip_ = allowIp; }
/**
* @brief Request a blockchain rescan on the next daemon start
@@ -206,6 +209,13 @@ public:
void setSalvageOnNextStart(bool v) { salvage_on_next_start_ = v; }
bool salvageOnNextStart() const { return salvage_on_next_start_.load(); }
+ // -reindex: rebuild the block index + chainstate from the raw blocks (blk*.dat) on startup. One-shot,
+ // consumed on the next start. Offered when the node aborts on an unreadable block database (a
+ // daemon-vs-chaindata format mismatch after an update, or a corrupt index). It implies a wallet
+ // rescan, so it's the block-DB analogue of -salvagewallet and coexists with the wallet-repair flags.
+ void setReindexOnNextStart(bool v) { reindex_on_next_start_ = v; }
+ bool reindexOnNextStart() const { return reindex_on_next_start_.load(); }
+
/**
* @brief One-shot isolated-datadir override for the NEXT start(): run the daemon against a
* different datadir (with its own DRAGONX.conf) plus the given extra args. Used by the
@@ -235,6 +245,32 @@ public:
*/
static bool isDaemonProcessRunning();
+ /** Decision returned by evaluateDatadirLockGate(): whether start() may spawn now. */
+ struct StartLockGateDecision {
+ bool proceed = true; // false => bail before spawning
+ const char* errorMessage = ""; // set (a string literal) when proceed == false
+ };
+
+ /**
+ * @brief Pure decision for start(): bail because a previous dragonxd still holds the
+ * shared datadir lock? Isolated instances (skip_port_check_ / an active -datadir
+ * override) are exempt — they run their own throwaway datadir+port and can coexist
+ * with the main daemon. Does no process/fs I/O itself (the caller does the probing),
+ * so it is directly unit-testable; defined inline so tests need only this header.
+ */
+ static StartLockGateDecision evaluateDatadirLockGate(bool skipPortCheck,
+ bool isolatedOverride,
+ bool stillRunningAfterWait)
+ {
+ if (skipPortCheck || isolatedOverride) return {true, ""};
+ if (stillRunningAfterWait) {
+ return {false,
+ "A previous dragonxd is still shutting down and holding the data "
+ "directory lock. Retrying shortly…"};
+ }
+ return {true, ""};
+ }
+
/** @brief Is an arbitrary TCP port currently in use on localhost? (used to pick a free port) */
static bool tcpPortInUse(int port);
@@ -253,6 +289,7 @@ private:
std::atomic state_{State::Stopped};
std::atomic external_daemon_detected_{false};
std::string last_error_;
+ mutable std::mutex error_mutex_; // protects last_error_ (written by main + monitor threads)
mutable std::mutex output_mutex_; // protects process_output_
std::string process_output_;
StateCallback state_callback_;
@@ -275,11 +312,14 @@ private:
std::atomic should_stop_{false};
std::set debug_categories_;
int max_connections_ = 0; // 0 = daemon default
+ bool stratum_enabled_ = false; // -stratum: host a RandomX pool (v1.3.0+; older daemons ignore it)
+ std::string stratum_allowip_; // -stratumallowip subnet (empty = daemon default: loopback only)
std::string wallet_file_; // -wallet= for the active wallet; empty/"wallet.dat" = default
std::atomic crash_count_{0}; // consecutive crash counter
std::atomic rescan_on_next_start_{false}; // -rescan flag for next start
std::atomic zap_on_next_start_{false}; // -zapwallettxes=2 flag for next start
std::atomic salvage_on_next_start_{false}; // -salvagewallet flag for next start
+ std::atomic reindex_on_next_start_{false}; // -reindex flag for next start (rebuild block DB)
std::string override_datadir_; // one-shot: -datadir for the next start
std::vector override_extra_args_; // one-shot: extra args for the next start
bool skip_port_check_ = false; // isolated instance on a non-default port
diff --git a/src/daemon/seed_wallet_creator.cpp b/src/daemon/seed_wallet_creator.cpp
index 8d3349b..504fbb9 100644
--- a/src/daemon/seed_wallet_creator.cpp
+++ b/src/daemon/seed_wallet_creator.cpp
@@ -54,6 +54,16 @@ SeedWalletResult SeedWalletCreator::create(bool keepDatadir,
// RPC port. So the wallet lives in /DRAGONX; `base` is the migration root we clean up.
const std::string base = util::Platform::getConfigDir() + "/seed-migrate";
const std::string dataDir = base + "/DRAGONX";
+ // W3-2: never blindly wipe a pre-existing temp seed wallet. A prior migration that swept funds into
+ // it but was abandoned or crashed before adopting would otherwise have its (fund-bearing) wallet
+ // destroyed here. A completed migration removes this dir on adopt, so a leftover means an unfinished
+ // one — refuse and point the user at it rather than silently destroying it.
+ if (fs::exists(dataDir + "/wallet.dat")) {
+ r.error = "A previous seed migration looks unfinished — its temporary wallet is still at\n" + base +
+ "\nResume or cancel it first. If you are certain its funds are already in your main "
+ "wallet, delete that folder and try again.";
+ return r;
+ }
fs::remove_all(base, ec);
fs::create_directories(dataDir, ec);
if (ec) { r.error = "Could not create the temporary wallet directory."; return r; }
diff --git a/src/daemon/xmrig_manager.cpp b/src/daemon/xmrig_manager.cpp
index 696b6d3..776896b 100644
--- a/src/daemon/xmrig_manager.cpp
+++ b/src/daemon/xmrig_manager.cpp
@@ -23,6 +23,7 @@
#include
#include "../util/logger.h"
+#include "../util/platform.h"
#include "../util/pool_registry.h"
#ifdef _WIN32
@@ -89,8 +90,32 @@ static std::string getConfigDir() {
// libcurl write callback
static size_t curlWriteCb(void* ptr, size_t sz, size_t n, void* userdata) {
auto* s = static_cast(userdata);
- s->append(static_cast(ptr), sz * n);
- return sz * n;
+ const size_t add = sz * n;
+ // Stats JSON (local xmrig HTTP API + pool API) is tiny; refuse an unbounded body from a hostile or
+ // MITM'd endpoint so it can't grow this string until OOM. Returning < add aborts the transfer. (L-02)
+ constexpr size_t kMaxStatsBytes = 1u << 20; // 1 MiB
+ if (s->size() + add > kMaxStatsBytes) return 0;
+ s->append(static_cast(ptr), add);
+ return add;
+}
+
+// True if `host` (already stripped of scheme+port) is a loopback/private/link-local/single-label target
+// that a public mining pool would never be — used to refuse a background stats GET to it (M-09).
+static bool hostLooksInternal(const std::string& host) {
+ if (host.empty() || host == "localhost") return true;
+ if (host.rfind("127.", 0) == 0 || host.rfind("10.", 0) == 0 ||
+ host.rfind("192.168.", 0) == 0 || host.rfind("169.254.", 0) == 0) return true;
+ if (host.rfind("172.", 0) == 0) { // 172.16.0.0 - 172.31.255.255
+ const int second = std::atoi(host.c_str() + 4);
+ if (second >= 16 && second <= 31) return true;
+ }
+ if (host.find(':') != std::string::npos) { // IPv6 literal: loopback / ULA / link-local
+ if (host == "::1" || host.rfind("fc", 0) == 0 || host.rfind("fd", 0) == 0 ||
+ host.rfind("fe80", 0) == 0) return true;
+ }
+ if (host.size() >= 6 && host.compare(host.size() - 6, 6, ".local") == 0) return true;
+ if (host.find('.') == std::string::npos) return true; // bare single-label name = LAN/hosts, not a pool
+ return false;
}
// ============================================================================
@@ -100,9 +125,14 @@ static size_t curlWriteCb(void* ptr, size_t sz, size_t n, void* userdata) {
XmrigManager::XmrigManager() = default;
XmrigManager::~XmrigManager() {
+ should_stop_ = true;
if (isRunning()) {
stop(3000);
}
+ // Join a monitor thread left joinable by an unexpected xmrig exit (State::Error, so isRunning() is
+ // false and stop() above was skipped) — std::thread's destructor would otherwise std::terminate(). (M-04)
+ if (monitor_thread_.joinable())
+ monitor_thread_.join();
}
// ============================================================================
@@ -116,32 +146,18 @@ std::string XmrigManager::findXmrigBinary() {
return path;
}
- // Fallback: system PATH
+ // Fallback: system PATH — windowless so it never flashes a console.
#ifdef _WIN32
- FILE* f = _popen("where xmrig.exe 2>nul", "r");
+ std::string out = util::Platform::runHiddenCapture("where xmrig.exe");
#else
- FILE* f = popen("which xmrig 2>/dev/null", "r");
-#endif
- if (f) {
- char line[512];
- if (fgets(line, sizeof(line), f)) {
- std::string s(line);
- while (!s.empty() && (s.back() == '\n' || s.back() == '\r'))
- s.pop_back();
- if (!s.empty() && fs::exists(s)) {
-#ifdef _WIN32
- _pclose(f);
-#else
- pclose(f);
-#endif
- return s;
- }
- }
-#ifdef _WIN32
- _pclose(f);
-#else
- pclose(f);
+ std::string out = util::Platform::runHiddenCapture("which xmrig");
#endif
+ {
+ std::string s = out;
+ const auto nl = s.find_first_of("\r\n"); // first line only
+ if (nl != std::string::npos) s.erase(nl);
+ while (!s.empty() && (s.back() == ' ' || s.back() == '\t')) s.pop_back();
+ if (!s.empty() && fs::exists(s)) return s;
}
return {};
@@ -208,23 +224,43 @@ bool XmrigManager::generateConfig(const Config& cfg, const std::string& outPath)
try {
fs::create_directories(fs::path(outPath).parent_path());
- std::ofstream ofs(outPath, std::ios::trunc);
- if (!ofs.is_open()) {
- last_error_ = "Cannot write xmrig config: " + outPath;
+ const std::string dumped = j.dump(4);
+#ifndef _WIN32
+ // Create the config 0600 AT CREATION (open with mode) so the API token + wallet address are never
+ // in a world/group-readable file — even for a local attacker who opened it in the old
+ // create-then-chmod window and held the fd open across the chmod. (L-01)
+ int fd = ::open(outPath.c_str(), O_WRONLY | O_CREAT | O_TRUNC, 0600);
+ if (fd < 0) {
+ setLastError("Cannot write xmrig config: " + outPath);
DEBUG_LOGF("[ERROR] XmrigManager: %s\n", last_error_.c_str());
return false;
}
-#ifndef _WIN32
- // Restrict to owner (0600) BEFORE writing any secret material (API token, wallet
- // address, worker name). The file is still empty here, so the config is never
- // world-readable — closing the window between creation and the previous post-write chmod.
- chmod(outPath.c_str(), 0600);
-#endif
- ofs << j.dump(4);
+ size_t off = 0;
+ bool wrote = true;
+ while (off < dumped.size()) {
+ ssize_t nw = ::write(fd, dumped.data() + off, dumped.size() - off);
+ if (nw <= 0) { wrote = false; break; }
+ off += static_cast(nw);
+ }
+ ::close(fd);
+ if (!wrote) {
+ setLastError("Cannot write xmrig config: " + outPath);
+ return false;
+ }
+ return true;
+#else
+ std::ofstream ofs(outPath, std::ios::trunc);
+ if (!ofs.is_open()) {
+ setLastError("Cannot write xmrig config: " + outPath);
+ DEBUG_LOGF("[ERROR] XmrigManager: %s\n", last_error_.c_str());
+ return false;
+ }
+ ofs << dumped;
ofs.close();
return true;
+#endif
} catch (const std::exception& e) {
- last_error_ = std::string("Config write error: ") + e.what();
+ setLastError(std::string("Config write error: ") + e.what());
DEBUG_LOGF("[ERROR] XmrigManager: %s\n", last_error_.c_str());
return false;
}
@@ -236,19 +272,22 @@ bool XmrigManager::generateConfig(const Config& cfg, const std::string& outPath)
bool XmrigManager::start(const Config& cfg) {
if (state_ == State::Running || state_ == State::Starting) {
- last_error_ = "Already running";
+ setLastError("Already running");
DEBUG_LOGF("[WARN] XmrigManager: %s\n", last_error_.c_str());
return false;
}
state_ = State::Starting;
should_stop_ = false;
- last_error_.clear();
+ setLastError(std::string());
{
std::lock_guard lk(output_mutex_);
process_output_.clear();
}
- stats_ = PoolStats{};
+ {
+ std::lock_guard lk(stats_mutex_);
+ stats_ = PoolStats{};
+ }
// Extract pool hostname for stats API queries
{
@@ -265,7 +304,7 @@ bool XmrigManager::start(const Config& cfg) {
// Find binary
std::string binary = findXmrigBinary();
if (binary.empty()) {
- last_error_ = "xmrig binary not found";
+ setLastError("xmrig binary not found");
state_ = State::Error;
DEBUG_LOGF("[ERROR] XmrigManager: xmrig binary not found\n");
return false;
@@ -293,7 +332,11 @@ bool XmrigManager::start(const Config& cfg) {
return false;
}
- // Start monitor thread
+ // Join a prior monitor thread before move-assigning: if xmrig exited unexpectedly, monitorProcess set
+ // State::Error and returned, leaving monitor_thread_ joinable — move-assigning over a joinable
+ // std::thread calls std::terminate() and aborts the whole wallet. (M-04)
+ if (monitor_thread_.joinable())
+ monitor_thread_.join();
monitor_thread_ = std::thread(&XmrigManager::monitorProcess, this);
state_ = State::Running;
DEBUG_LOGF("[INFO] XmrigManager: started\n");
@@ -368,7 +411,7 @@ bool XmrigManager::startProcess(const std::string& xmrigPath, const std::string&
HANDLE hRead = nullptr, hWrite = nullptr;
if (!CreatePipe(&hRead, &hWrite, &sa, 0)) {
- last_error_ = "CreatePipe failed";
+ setLastError("CreatePipe failed");
DEBUG_LOGF("[ERROR] XmrigManager: %s\n", last_error_.c_str());
return false;
}
@@ -400,7 +443,7 @@ bool XmrigManager::startProcess(const std::string& xmrigPath, const std::string&
char errBuf[256];
FormatMessageA(FORMAT_MESSAGE_FROM_SYSTEM | FORMAT_MESSAGE_IGNORE_INSERTS,
NULL, err, 0, errBuf, sizeof(errBuf), NULL);
- last_error_ = "CreateProcess failed for xmrig (error " + std::to_string(err) + "): " + errBuf;
+ setLastError("CreateProcess failed for xmrig (error " + std::to_string(err) + "): " + errBuf);
DEBUG_LOGF("[ERROR] XmrigManager: %s\nCommand: %s\n", last_error_.c_str(), cmdLine.c_str());
return false;
}
@@ -451,14 +494,14 @@ void XmrigManager::drainOutput() {
bool XmrigManager::startProcess(const std::string& xmrigPath, const std::string& cfgPath, int threads) {
int pipefd[2];
if (pipe(pipefd) != 0) {
- last_error_ = "pipe() failed";
+ setLastError("pipe() failed");
DEBUG_LOGF("[ERROR] XmrigManager: %s\n", last_error_.c_str());
return false;
}
pid_t pid = fork();
if (pid < 0) {
- last_error_ = "fork() failed";
+ setLastError("fork() failed");
DEBUG_LOGF("[ERROR] XmrigManager: %s\n", last_error_.c_str());
close(pipefd[0]);
close(pipefd[1]);
@@ -629,7 +672,7 @@ void XmrigManager::monitorProcess() {
if (GetExitCodeProcess(process_handle_, &exitCode) && exitCode != STILL_ACTIVE) {
DEBUG_LOGF("[ERROR] XmrigManager: process exited (code %lu)\n", exitCode);
state_ = State::Error;
- last_error_ = "xmrig process exited unexpectedly";
+ setLastError("xmrig process exited unexpectedly");
break;
}
}
@@ -640,7 +683,7 @@ void XmrigManager::monitorProcess() {
if (ret == process_pid_ || ret < 0) {
DEBUG_LOGF("[ERROR] XmrigManager: process exited (waitpid=%d)\n", ret);
state_ = State::Error;
- last_error_ = "xmrig process exited unexpectedly";
+ setLastError("xmrig process exited unexpectedly");
break;
}
}
@@ -780,6 +823,11 @@ void XmrigManager::fetchPoolApiStats() {
// own API shape (pool.dragonx.is = custom /api/stats; pool.dragonx.cc = Miningcore
// /api/pools); unknown/custom hosts fall back to the .is convention.
const util::KnownPool* known = util::findKnownPoolByUrl(pool_host_);
+ // SSRF guard: for an UNKNOWN (user-typed) pool host, don't let the wallet issue a background GET to a
+ // loopback/private/link-local/single-label target — those aren't public mining pools, and a
+ // paste-a-pool-config lure could otherwise point us at an internal host. Known pools use their trusted
+ // registry statsUrl and are exempt. (M-09)
+ if (!known && hostLooksInternal(pool_host_)) return;
const std::string url = known ? known->statsUrl
: ("https://" + pool_host_ + "/api/stats");
@@ -858,25 +906,18 @@ void XmrigManager::startVersionDetection()
std::thread([]() {
const std::string bin = findXmrigBinary();
std::string ver;
- if (!bin.empty()) {
- const std::string cmd = "\"" + bin + "\" --version 2>&1";
-#ifdef _WIN32
- FILE* fp = _popen(cmd.c_str(), "r");
-#else
- FILE* fp = popen(cmd.c_str(), "r");
-#endif
- if (fp) {
- std::string out;
- char buf[256];
- size_t n;
- while ((n = fread(buf, 1, sizeof(buf), fp)) > 0) out.append(buf, n);
-#ifdef _WIN32
- _pclose(fp);
-#else
- pclose(fp);
-#endif
- ver = parseMinerVersion(out);
- }
+ // Don't hand a path containing shell/cmd metacharacters to popen()'s shell — bin is normally an
+ // app-controlled path, but this closes command injection if it ever isn't. (M-10)
+ // Reject only chars that stay shell-special INSIDE the double-quotes we wrap bin in ("\"" + bin + "\"")
+ // on cmd.exe or /bin/sh. Parens are inert when quoted, so they're excluded — otherwise common Windows
+ // paths like "C:\Program Files (x86)\..." would be rejected and version detection would silently fail. (M-10)
+ const bool binShellSafe =
+ !bin.empty() && bin.find_first_of("\"'`$;&|<>^%\n\r") == std::string::npos;
+ if (binShellSafe) {
+ // Windowless capture (mergeStderr: xmrig may print --version to stderr) — never flashes.
+ const std::string cmd = "\"" + bin + "\" --version";
+ const std::string out = util::Platform::runHiddenCapture(cmd, /*mergeStderr=*/true);
+ if (!out.empty()) ver = parseMinerVersion(out);
}
std::lock_guard lk(g_installed_ver_mutex);
g_installed_ver = ver;
diff --git a/src/daemon/xmrig_manager.h b/src/daemon/xmrig_manager.h
index 3935daf..54d1834 100644
--- a/src/daemon/xmrig_manager.h
+++ b/src/daemon/xmrig_manager.h
@@ -86,8 +86,10 @@ public:
bool isRunning() const;
State getState() const { return state_.load(std::memory_order_relaxed); }
- const PoolStats& getStats() const { return stats_; }
- const std::string& getLastError() const { return last_error_; }
+ // Return COPIES under lock: stats_ and last_error_ are mutated by the monitor thread while the UI
+ // thread reads them, so handing out a reference is a torn-read / use-after-free race (M-03, M-04).
+ PoolStats getStats() const { std::lock_guard lk(stats_mutex_); return stats_; }
+ std::string getLastError() const { std::lock_guard lk(error_mutex_); return last_error_; }
/// Thread count requested at start() — available immediately, unlike
/// PoolStats::threads_active which requires an API response.
@@ -156,11 +158,14 @@ private:
void monitorProcess();
void drainOutput();
void appendOutput(const char* data, size_t len);
+ // Set last_error_ under error_mutex_ (writers run on both the main thread and the monitor thread).
+ void setLastError(std::string e) { std::lock_guard lk(error_mutex_); last_error_ = std::move(e); }
void fetchStatsHttp(); // Blocking HTTP call — runs on monitor thread only
void fetchPoolApiStats(); // Fetch pool-side stats (hashrate) from pool HTTP API
std::atomic state_{State::Stopped};
std::string last_error_;
+ mutable std::mutex error_mutex_; // guards last_error_ (written by main + monitor threads)
mutable std::mutex output_mutex_;
std::string process_output_;
diff --git a/src/data/address_book.cpp b/src/data/address_book.cpp
index ef2d2ea..0c36d4d 100644
--- a/src/data/address_book.cpp
+++ b/src/data/address_book.cpp
@@ -46,25 +46,31 @@ bool AddressBook::load()
entries_.clear();
if (j.contains("entries") && j["entries"].is_array()) {
+ size_t skipped = 0;
for (const auto& entry : j["entries"]) {
- AddressBookEntry e;
- e.label = entry.value("label", "");
- e.address = entry.value("address", "");
- e.notes = entry.value("notes", "");
- // Legacy entries (no "scope") migrate to "global" so nothing disappears when
- // multi-wallet scoping lands — a contact you already had stays visible everywhere.
- e.scope = entry.value("scope", "global");
- e.avatar = entry.value("avatar", "");
-
- if (!e.address.empty()) {
- entries_.push_back(e);
- }
+ // W6-3: skip (and count) a malformed element rather than letting one bad entry throw and
+ // abort the whole load — which would discard EVERY contact (entries_ was already cleared).
+ if (!entry.is_object()) { ++skipped; continue; }
+ try {
+ AddressBookEntry e;
+ e.label = entry.value("label", "");
+ e.address = entry.value("address", "");
+ e.notes = entry.value("notes", "");
+ // Legacy entries (no "scope") migrate to "global" so nothing disappears when
+ // multi-wallet scoping lands — a contact you already had stays visible everywhere.
+ e.scope = entry.value("scope", "global");
+ e.avatar = entry.value("avatar", "");
+ if (!e.address.empty()) entries_.push_back(e);
+ } catch (const std::exception&) { ++skipped; }
}
+ if (skipped > 0)
+ DEBUG_LOGF("Address book: skipped %zu malformed entr%s\n", skipped, skipped == 1 ? "y" : "ies");
}
DEBUG_LOGF("Address book loaded: %zu entries\n", entries_.size());
+ ++revision_;
return true;
-
+
} catch (const std::exception& e) {
DEBUG_LOGF("Error loading address book: %s\n", e.what());
return false;
@@ -116,6 +122,7 @@ bool AddressBook::addEntry(const AddressBookEntry& entry)
}
entries_.push_back(entry);
+ ++revision_;
return save();
}
@@ -131,6 +138,7 @@ bool AddressBook::updateEntry(size_t index, const AddressBookEntry& entry)
}
entries_[index] = entry;
+ ++revision_;
return save();
}
@@ -141,6 +149,7 @@ bool AddressBook::removeEntry(size_t index)
}
entries_.erase(entries_.begin() + index);
+ ++revision_;
return save();
}
@@ -154,7 +163,7 @@ int AddressBook::reattachLegacyScopes(const std::string& scopeId)
e.scope = scopeId;
++rescoped;
}
- if (rescoped > 0) save();
+ if (rescoped > 0) { ++revision_; save(); }
return rescoped;
}
diff --git a/src/data/address_book.h b/src/data/address_book.h
index 1c4ae02..5c2127b 100644
--- a/src/data/address_book.h
+++ b/src/data/address_book.h
@@ -4,6 +4,7 @@
#pragma once
+#include
#include
#include
@@ -121,11 +122,18 @@ public:
*/
size_t size() const { return entries_.size(); }
+ /**
+ * @brief Monotonic counter bumped on every content change (add/update/remove/load/sweep). Consumers
+ * (e.g. the chat conversation-list memo) key their caches off this so an IN-PLACE edit — a rename or
+ * address change that keeps size() constant — still invalidates them.
+ */
+ std::uint64_t revision() const { return revision_; }
+
/**
* @brief UI-sweep ONLY: replace the in-memory entries WITHOUT persisting to disk, so the sweep can
* seed demo contacts and restore the real book without a disk write. Do not use outside the sweep.
*/
- void sweepSetEntries(std::vector e) { entries_ = std::move(e); }
+ void sweepSetEntries(std::vector e) { entries_ = std::move(e); ++revision_; }
/**
* @brief Check if empty
@@ -134,6 +142,7 @@ public:
private:
std::vector entries_;
+ std::uint64_t revision_ = 0;
std::string file_path_;
};
diff --git a/src/data/seed_migration_resume.h b/src/data/seed_migration_resume.h
new file mode 100644
index 0000000..fe138d9
--- /dev/null
+++ b/src/data/seed_migration_resume.h
@@ -0,0 +1,37 @@
+#pragma once
+
+#include
+
+// Pure routing decision for resuming a pending migrate-to-seed flow (finding W3-3). Kept free of
+// App/UI/RPC state so the highest-risk branch — where a reopened migration lands — is unit-testable
+// and reviewable in isolation. App::showSeedMigrationDialog feeds it the persisted state + live
+// connectivity and switches on the result. See src/app_network.cpp.
+namespace dragonx {
+
+enum class MigrationResume {
+ Intro, // no pending migration → start fresh at the intro
+ Confirming, // a sweep txid is persisted → resume at the confirm/adopt gate (re-derived from chain)
+ RetrackOpid, // a sweep opid (but no txid yet) is persisted AND we're connected → re-poll it
+ SweepGate, // otherwise → the dismissable Sweep step (reload balance, offer re-sweep)
+};
+
+// Decide where reopening the migration dialog lands.
+//
+// Invariant: the txid outranks the opid — once a sweep resolves to a txid the opid is cleared in the
+// same settings write, so a persisted txid always means "past the sweep". A persisted opid is only
+// re-tracked when connected, because the buttonless "Sweeping" spinner relies on the opid poller
+// (which needs an RPC connection) to ever exit; disconnected, we fall back to the dismissable Sweep
+// gate (which reloads the balance and, if the earlier sweep already drained it, short-circuits to
+// adopt) — never trapping the user.
+inline MigrationResume decideSeedMigrationResume(bool pending,
+ bool haveDest,
+ const std::string& sweepTxid,
+ const std::string& sweepOpid,
+ bool connected) {
+ if (!pending || !haveDest) return MigrationResume::Intro;
+ if (!sweepTxid.empty()) return MigrationResume::Confirming;
+ if (!sweepOpid.empty() && connected) return MigrationResume::RetrackOpid;
+ return MigrationResume::SweepGate;
+}
+
+} // namespace dragonx
diff --git a/src/data/wallet_state.cpp b/src/data/wallet_state.cpp
index 880ec6b..2767c01 100644
--- a/src/data/wallet_state.cpp
+++ b/src/data/wallet_state.cpp
@@ -19,12 +19,13 @@ std::vector sortedSpendableAddressIndices(const std::vector
for (size_t i = 0; i < addresses.size(); ++i) {
const auto& address = addresses[i];
if (!address.isSpendable()) continue;
- if (requirePositiveBalance && address.balance <= 0.0) continue;
+ // Rank/filter by the CONFIRMED balance — an address holding only 0-conf change can't be sent from.
+ if (requirePositiveBalance && address.spendableBalance <= 0.0) continue;
indices.push_back(i);
}
std::sort(indices.begin(), indices.end(), [&](size_t lhs, size_t rhs) {
- return addresses[lhs].balance > addresses[rhs].balance;
+ return addresses[lhs].spendableBalance > addresses[rhs].spendableBalance;
});
return indices;
}
@@ -34,8 +35,8 @@ int bestSpendableAddressIndex(const std::vector& addresses)
int bestIndex = -1;
double bestBalance = 0.0;
for (size_t i = 0; i < addresses.size(); ++i) {
- if (addresses[i].isSpendable() && addresses[i].balance > bestBalance) {
- bestBalance = addresses[i].balance;
+ if (addresses[i].isSpendable() && addresses[i].spendableBalance > bestBalance) {
+ bestBalance = addresses[i].spendableBalance;
bestIndex = static_cast(i);
}
}
diff --git a/src/data/wallet_state.h b/src/data/wallet_state.h
index 1fefe45..dc0a00f 100644
--- a/src/data/wallet_state.h
+++ b/src/data/wallet_state.h
@@ -21,13 +21,17 @@ namespace dragonx {
*/
struct AddressInfo {
std::string address;
- double balance = 0.0;
+ double balance = 0.0; // DISPLAY total incl. pending 0-conf change (minconf=0)
std::string type; // "shielded" or "transparent"
bool has_spending_key = true; // false for view-only (imported via z_importviewingkey)
// For display
std::string label;
-
+
+ // CONFIRMED balance (minconf>=1) — what z_sendmany can actually spend now. Kept last so positional
+ // brace-init of the leading fields (used in tests) still compiles.
+ double spendableBalance = 0.0;
+
// Derived
bool isZAddr() const { return !address.empty() && address[0] == 'z'; }
bool isShielded() const { return type == "shielded"; }
@@ -252,12 +256,18 @@ struct WalletState {
// Sync status
SyncInfo sync;
- // Balances (named to match UI usage)
- double privateBalance = 0.0; // shielded balance
+ // Balances (named to match UI usage). These are the DISPLAY totals — minconf=0, so they include the
+ // user's own pending change and don't crater during an unconfirmed send.
+ double privateBalance = 0.0; // shielded balance (display, incl. pending change)
double transparentBalance = 0.0;
double totalBalance = 0.0;
- double unconfirmedBalance = 0.0;
-
+ double unconfirmedBalance = 0.0; // = totalBalance - spendableTotalBalance (the pending portion)
+ // CONFIRMED / spendable totals (minconf>=1) — what can actually be sent right now. z_sendmany runs at
+ // minconf=1, so the Send form / Max / spend validation must size off these, never the display totals.
+ double spendablePrivateBalance = 0.0;
+ double spendableTransparentBalance = 0.0;
+ double spendableTotalBalance = 0.0;
+
// Aliases for backward compatibility
double& shielded_balance = privateBalance;
double& transparent_balance = transparentBalance;
@@ -302,6 +312,7 @@ struct WalletState {
// Timestamps for refresh logic
int64_t last_balance_update = 0;
+ int64_t last_address_update = 0; // set when an address-list refresh applies; 0 = never loaded yet
int64_t last_tx_update = 0;
int64_t last_peer_update = 0;
int64_t last_mining_update = 0;
@@ -325,6 +336,7 @@ struct WalletState {
sync = SyncInfo{};
privateBalance = transparentBalance = totalBalance = 0.0;
unconfirmedBalance = 0.0;
+ spendablePrivateBalance = spendableTransparentBalance = spendableTotalBalance = 0.0;
encrypted = false;
locked = false;
unlocked_until = 0;
@@ -335,6 +347,15 @@ struct WalletState {
transactions.clear();
peers.clear();
bannedPeers.clear();
+ // W6-1: reset node-level mining state too — the daemon restarts on a wallet switch (mining
+ // stops), so leaving the previous wallet's hashrate/blocks would show stale mining stats.
+ mining = MiningInfo{};
+ pool_mining = PoolMiningState{};
+ // After a disconnect / wallet switch nothing is freshly known, so drop the "last successful
+ // refresh" stamps. Otherwise the pre-teardown time survives and, on reconnect, the staleness
+ // badge (and any "updated X ago" reader) briefly reports it as current until the first refresh
+ // re-stamps it. All readers treat 0 as "never" (formatTimeAgoShort/timeAgo return "").
+ last_balance_update = last_address_update = last_tx_update = last_peer_update = last_mining_update = 0;
}
// Rebuild combined addresses list from z/t lists
diff --git a/src/main.cpp b/src/main.cpp
index 31a404a..7143d2d 100644
--- a/src/main.cpp
+++ b/src/main.cpp
@@ -721,20 +721,105 @@ static void handleDisplayScaleChange(SDL_Window* window, float newScale,
}
}
+#if !defined(_WIN32)
+#include
+#include
+#include
+#include
+#if defined(__has_include)
+# if __has_include()
+# include
+# define DRAGONX_HAVE_BACKTRACE 1
+# endif
+#endif
+
+// Absolute path to the crash log, filled at install time so the async-signal handler needs no
+// allocation. (POSIX counterpart of the Windows SEH CrashHandler above — W7-3.)
+static char g_crashLogPath[1024] = {0};
+
+// Async-signal-safe crash handler: only open()/write()/backtrace_symbols_fd()/raise() are used —
+// no stdio, std::filesystem or malloc (all unsafe inside a signal handler).
+static void PosixCrashHandler(int sig)
+{
+ int fd = g_crashLogPath[0] ? open(g_crashLogPath, O_WRONLY | O_CREAT | O_APPEND, 0600) : -1;
+ if (fd >= 0) {
+ auto put = [fd](const char* s) { ssize_t n = write(fd, s, std::strlen(s)); (void)n; };
+ put("\n=== CRASH: signal ");
+ char num[16]; int i = 0, v = sig; // signal number -> decimal, no stdio
+ if (v == 0) { num[i++] = '0'; }
+ else { char tmp[16]; int t = 0; while (v > 0) { tmp[t++] = char('0' + v % 10); v /= 10; }
+ while (t > 0) num[i++] = tmp[--t]; }
+ num[i] = '\n';
+ ssize_t nn = write(fd, num, i + 1); (void)nn;
+#ifdef DRAGONX_HAVE_BACKTRACE
+ void* frames[64];
+ int nframes = backtrace(frames, 64);
+ backtrace_symbols_fd(frames, nframes, fd); // async-signal-safe
+#endif
+ put("=== END CRASH ===\n");
+ close(fd);
+ }
+ // Restore the default disposition and re-raise so we still get a core dump / normal termination.
+ signal(sig, SIG_DFL);
+ raise(sig);
+}
+
+static void installPosixCrashHandler(const std::string& crashLogPath)
+{
+ std::snprintf(g_crashLogPath, sizeof(g_crashLogPath), "%s", crashLogPath.c_str());
+ struct sigaction sa;
+ std::memset(&sa, 0, sizeof(sa));
+ sa.sa_handler = PosixCrashHandler;
+ sigemptyset(&sa.sa_mask);
+ sa.sa_flags = 0;
+ for (int sig : {SIGSEGV, SIGABRT, SIGBUS, SIGFPE, SIGILL}) {
+ sigaction(sig, &sa, nullptr);
+ }
+}
+#endif // !_WIN32
+
int main(int argc, char* argv[])
{
// Ensure ObsidianDragon config directory exists early (before any file I/O)
{
std::string odDir = dragonx::util::Platform::getObsidianDragonDir();
- std::error_code ec;
- std::filesystem::create_directories(odDir, ec);
+ std::string odErr;
+ if (!dragonx::util::Platform::ensureDirectory(odDir, &odErr)) {
+ // Pre-App-init: nothing (ini, logs, config) can persist if this fails, and the
+ // Windows log redirect below isn't set up yet — report loudly before any setup.
+ std::fprintf(stderr, "%s\n", odErr.c_str());
+#ifdef _WIN32
+ MessageBoxA(nullptr, odErr.c_str(), DRAGONX_APP_NAME, MB_OK | MB_ICONERROR);
+#endif
+ return 1;
+ }
}
-#ifdef _WIN32
- // Redirect stdout/stderr to a log file so diagnostic output is visible
- // even when built as a GUI app (WIN32_EXECUTABLE hides the console).
+ // W7-2: initialize the app-level Logger's file sink on ALL platforms so LOG/LOGF/VERBOSE_LOGF are
+ // actually persisted to dragonx-debug.log. Previously init() was never called, so on Linux/macOS the
+ // file never existed at all (the Windows-only stdout freopen below is a separate mechanism).
{
- std::string logPath = (std::filesystem::path(dragonx::util::Platform::getObsidianDragonDir()) / "dragonx-debug.log").string();
+ const std::string logPath =
+ (std::filesystem::path(dragonx::util::Platform::getObsidianDragonDir()) / "dragonx-debug.log").string();
+ dragonx::util::Logger::instance().init(logPath);
+ }
+
+#if !defined(_WIN32)
+ // W7-3: install the POSIX crash handler (the Windows SEH filter is installed below). A segfault or
+ // abort now leaves a backtrace in dragonx-crash.log instead of vanishing silently on Linux/macOS.
+ {
+ const std::string crashPath =
+ (std::filesystem::path(dragonx::util::Platform::getObsidianDragonDir()) / "dragonx-crash.log").string();
+ installPosixCrashHandler(crashPath);
+ }
+#endif
+
+#ifdef _WIN32
+ // Redirect raw stdout/stderr (library / daemon-pipe writes) to a log file so it's visible even when
+ // built as a GUI app (WIN32_EXECUTABLE hides the console). Separate file from the structured Logger
+ // above so the two writers don't interleave/contend on one file.
+ {
+ std::string logPath = (std::filesystem::path(dragonx::util::Platform::getObsidianDragonDir()) / "dragonx-stdout.log").string();
freopen(logPath.c_str(), "w", stdout);
freopen(logPath.c_str(), "a", stderr);
}
@@ -1178,6 +1263,36 @@ int main(int argc, char* argv[])
SDL_SetWindowMinimumSize(window, (int)(1024 * currentDpiScale), (int)(720 * currentDpiScale));
}
+ // DEV/TEST hook (dormant unless the env is set): DRAGONX_WIN_GEOM="WxH" forces an exact window
+ // size, placing it on the largest display that can hold it and bypassing the primary-monitor
+ // clamp. Lets a headless WSLg sweep render at sizes wider than the 1280 primary (e.g. 2560x1440
+ // on the mirrored 4K/1440p outputs). Needs the x11 backend so absolute positioning takes effect.
+ int wantW = 0, wantH = 0;
+ if (const char* geom = std::getenv("DRAGONX_WIN_GEOM"))
+ sscanf(geom, "%dx%d", &wantW, &wantH);
+ if (wantW > 0 && wantH > 0) {
+ int count = 0;
+ SDL_DisplayID* disp = SDL_GetDisplays(&count);
+ SDL_DisplayID best = 0; SDL_Rect bestUsable{0, 0, 0, 0};
+ for (int i = 0; i < count; ++i) {
+ SDL_Rect u;
+ if (!SDL_GetDisplayUsableBounds(disp[i], &u)) continue;
+ bool fits = (u.w >= wantW && u.h >= wantH);
+ bool bestFits = (bestUsable.w >= wantW && bestUsable.h >= wantH);
+ // Prefer a display that fits; among those, the smallest; else the largest available.
+ if ((fits && !bestFits) ||
+ (fits && bestFits && (long)u.w * u.h < (long)bestUsable.w * bestUsable.h) ||
+ (!fits && !bestFits && (long)u.w * u.h > (long)bestUsable.w * bestUsable.h)) {
+ bestUsable = u; best = disp[i];
+ }
+ }
+ if (disp) SDL_free(disp);
+ if (best) {
+ SDL_SetWindowMinimumSize(window, 320, 240);
+ SDL_SetWindowPosition(window, bestUsable.x + 10, bestUsable.y + 10);
+ SDL_SetWindowSize(window, wantW, wantH);
+ }
+ } else {
// Clamp to the current display's work area — runs on EVERY startup (this clamp used to live
// inside the HiDPI branch, so a size saved on a larger/disconnected monitor could open the
// window off-screen or bigger than the screen on a same-DPI cold start).
@@ -1196,6 +1311,7 @@ int main(int argc, char* argv[])
DEBUG_LOGF("Startup: window fitted %dx%d -> %dx%d (scale %.2f)\n",
curW, curH, newW, newH, currentDpiScale);
}
+ }
}
#endif
winlog("STARTUP savedSize=%dx%d currentDpiScale=%.3f", savedWinW, savedWinH, currentDpiScale);
@@ -1384,6 +1500,7 @@ int main(int argc, char* argv[])
// WINDOW_RESIZED events during the transition can't corrupt savedSizeForScale / lastKnownW/H.
int dpiSettleFrames = 0;
SDL_DisplayID lastLoggedDisplay = 0; // [WINLOG] throttle: log MOVED only when the display changes
+ Uint64 minimizedLastTickMs = 0; // real-clock tick for the minimized "keep syncing" update
{
float s = dragonx::ui::material::Typography::instance().getDpiScale();
int w = 0, h = 0;
@@ -1467,6 +1584,7 @@ int main(int argc, char* argv[])
// Window restored from minimized — trigger immediate data refresh
if (waitEvent.type == SDL_EVENT_WINDOW_RESTORED &&
waitEvent.window.windowID == SDL_GetWindowID(window)) {
+ app.skipDaemonOutputBacklog(); // drop the minimized-period backlog (avoids a spurious "rescan complete" toast)
app.refreshNow();
}
// Handle DPI change that arrived while idle (same logic as poll loop)
@@ -1596,6 +1714,7 @@ int main(int argc, char* argv[])
// Window restored from minimized — trigger immediate data refresh
if (event.type == SDL_EVENT_WINDOW_RESTORED &&
event.window.windowID == SDL_GetWindowID(window)) {
+ app.skipDaemonOutputBacklog(); // drop the minimized-period backlog (avoids a spurious "rescan complete" toast)
app.refreshNow();
}
// Handle DPI/display scale changes (e.g. window dragged to a
@@ -1616,13 +1735,28 @@ int main(int argc, char* argv[])
// Check if window is minimized
if (SDL_GetWindowFlags(window) & SDL_WINDOW_MINIMIZED) {
- // Still check shouldQuit while minimized to avoid hang
- if (app.shouldQuit()) {
- running = false;
- }
- SDL_Delay(10);
+ // Keep the wallet syncing while minimized: run the logic update (drains RPC results, ticks the
+ // refresh scheduler, keeps the daemon connection/reconnect + sync status live) but skip the
+ // ImGui frame + GPU present since nothing is visible. app.update() only reads
+ // GetIO()/GetTime()/IsAnyItemActive() — all valid outside a frame — so it's safe without a
+ // NewFrame; feed it a real-clock DeltaTime (NewFrame, which normally sets it, is skipped) and
+ // let app.update() clamp it. Throttled to ~5 Hz so CPU stays near-idle (refresh cadences are
+ // seconds-scale). shouldQuit is still checked so a quit request never hangs behind minimize.
+ Uint64 nowMs = SDL_GetTicks();
+ float minDelta = (minimizedLastTickMs == 0) ? 0.001f
+ : (float)(nowMs - minimizedLastTickMs) / 1000.0f;
+ minimizedLastTickMs = nowMs;
+ ImGui::GetIO().DeltaTime = (minDelta > 0.0f) ? minDelta : 0.001f;
+ try {
+ app.update();
+ } catch (const std::exception& e) {
+ DEBUG_LOGF("[Main] minimized app.update() threw: %s\n", e.what());
+ } catch (...) {}
+ if (app.shouldQuit()) running = false;
+ SDL_Delay(200);
continue;
}
+ minimizedLastTickMs = 0; // visible again — reset the minimized clock
// --- PerfLog: begin frame ---
dragonx::util::PerfLog::instance().beginFrame();
@@ -2042,6 +2176,7 @@ int main(int argc, char* argv[])
// deadlocks waiting for detached pthreads. On Linux, static
// destructors and atexit handlers can also block. _Exit() bypasses
// all of that.
+ app.wipeSecrets(); // _Exit() below bypasses ~App(), so scrub secret buffers here (L-05)
fflush(stdout);
fflush(stderr);
_Exit(0);
diff --git a/src/resources/embedded_resources.cpp b/src/resources/embedded_resources.cpp
index f1f2c92..e144eb9 100644
--- a/src/resources/embedded_resources.cpp
+++ b/src/resources/embedded_resources.cpp
@@ -40,6 +40,9 @@ static const EmbeddedResource s_resources[] = {
{ g_dragonx_cli_exe_data, g_dragonx_cli_exe_size, RESOURCE_DRAGONX_CLI },
{ g_dragonx_tx_exe_data, g_dragonx_tx_exe_size, RESOURCE_DRAGONX_TX },
#endif
+#ifdef HAS_EMBEDDED_WALLET_REBUILD
+ { g_dragonx_wallet_rebuild_exe_data, g_dragonx_wallet_rebuild_exe_size, RESOURCE_DRAGONX_WALLET_REBUILD },
+#endif
#ifdef HAS_EMBEDDED_XMRIG
{ g_xmrig_exe_data, g_xmrig_exe_size, RESOURCE_XMRIG },
#endif
@@ -436,6 +439,24 @@ bool extractEmbeddedResources()
}
#endif
+#ifdef HAS_EMBEDDED_WALLET_REBUILD
+ // Offline wallet-rebuild recovery helper — extracted next to the daemon so a bare, self-extracting
+ // ObsidianDragon.exe still offers "Repair automatically" (findWalletRebuildHelper() checks this dir).
+ const EmbeddedResource* rebuildRes = getEmbeddedResource(RESOURCE_DRAGONX_WALLET_REBUILD);
+ if (rebuildRes) {
+ std::string dest = daemonDir + pathSep + RESOURCE_DRAGONX_WALLET_REBUILD;
+ if (!std::filesystem::exists(dest)) {
+ DEBUG_LOGF("[INFO] Extracting dragonx-wallet-rebuild (%zu MB)...\n", rebuildRes->size / (1024*1024));
+ if (!extractResource(rebuildRes, dest)) {
+ success = false;
+ }
+#ifndef _WIN32
+ else { chmod(dest.c_str(), 0755); }
+#endif
+ }
+ }
+#endif
+
// Best-effort cleanup of any ".old" binaries left behind by a previous in-use replacement.
// Once the old daemon/xmrig process has exited, the file is no longer locked and removes cleanly;
// if it's still running, the remove fails harmlessly and we retry on the next startup.
@@ -450,6 +471,32 @@ bool extractEmbeddedResources()
return success;
}
+std::string ensureWalletRebuildHelperExtracted()
+{
+#ifdef HAS_EMBEDDED_WALLET_REBUILD
+ const EmbeddedResource* res = getEmbeddedResource(RESOURCE_DRAGONX_WALLET_REBUILD);
+ if (!res || res->size == 0) return {};
+#ifdef _WIN32
+ const char sep = '\\';
+#else
+ const char sep = '/';
+#endif
+ const std::string dir = getDaemonDirectory();
+ const std::string dest = dir + sep + RESOURCE_DRAGONX_WALLET_REBUILD;
+ std::error_code ec;
+ if (std::filesystem::exists(dest, ec)) return dest; // already extracted
+ std::filesystem::create_directories(dir, ec);
+ if (!extractResource(res, dest)) return {};
+#ifndef _WIN32
+ chmod(dest.c_str(), 0755);
+#endif
+ DEBUG_LOGF("[INFO] Extracted wallet-rebuild helper on demand: %s\n", dest.c_str());
+ return dest;
+#else
+ return {};
+#endif
+}
+
std::string getDaemonDirectory()
{
// Daemon binaries live in %APPDATA%/ObsidianDragon/dragonx/ (Windows) or
diff --git a/src/resources/embedded_resources.h b/src/resources/embedded_resources.h
index a45b13d..ae8f0a7 100644
--- a/src/resources/embedded_resources.h
+++ b/src/resources/embedded_resources.h
@@ -55,6 +55,12 @@ BundledDaemonInfo getBundledDaemonInfo();
// caller should stop the daemon first. Returns true if all present resources were written.
bool reextractBundledDaemon();
+// Ensure the embedded offline wallet-rebuild recovery helper is extracted to the daemon dir, and
+// return its path ("" if not embedded in this build or extraction failed). Idempotent — extracts only
+// when missing. Unlike the first-run extractEmbeddedResources() (gated on needsParamsExtraction()),
+// this runs on demand so recovery works from a self-contained exe on ANY run, not just the first.
+std::string ensureWalletRebuildHelperExtracted();
+
// Resource names
constexpr const char* RESOURCE_SAPLING_SPEND = "sapling-spend.params";
constexpr const char* RESOURCE_SAPLING_OUTPUT = "sapling-output.params";
@@ -62,6 +68,7 @@ constexpr const char* RESOURCE_ASMAP = "asmap.dat";
constexpr const char* RESOURCE_DRAGONXD = "dragonxd.exe";
constexpr const char* RESOURCE_DRAGONX_CLI = "dragonx-cli.exe";
constexpr const char* RESOURCE_DRAGONX_TX = "dragonx-tx.exe";
+constexpr const char* RESOURCE_DRAGONX_WALLET_REBUILD = "dragonx-wallet-rebuild.exe";
constexpr const char* RESOURCE_XMRIG = "xmrig.exe";
constexpr const char* RESOURCE_DARK_GRADIENT = "dark_gradient.png";
constexpr const char* RESOURCE_LOGO = "logo_ObsidianDragon_dark.png";
diff --git a/src/rpc/connection.cpp b/src/rpc/connection.cpp
index 276cbaa..9c8bfc1 100644
--- a/src/rpc/connection.cpp
+++ b/src/rpc/connection.cpp
@@ -14,8 +14,12 @@
#include
#include
#include
+#include
+#include
#include "../util/logger.h"
+#include "../util/platform.h"
+#include "../util/xmrig_updater.h" // util::sha256Hex
#ifdef _WIN32
#include