Compare commits
51 Commits
fix/audit-
...
ac49f44f84
| Author | SHA1 | Date | |
|---|---|---|---|
| ac49f44f84 | |||
| 3216debc7d | |||
| 8ffcd9cc8c | |||
| bc183257b7 | |||
| b2e037bb67 | |||
| 975650f11b | |||
| 384d64ea5d | |||
| 3b7423f3a1 | |||
| d136916e80 | |||
| f88304fed2 | |||
| 5296dd7ae5 | |||
| a1d3964e34 | |||
| 5b6ba5094b | |||
| c3e81a5fa6 | |||
| d603a54618 | |||
| c61c211dfe | |||
| 16244d84a0 | |||
| 32be868dbc | |||
| 8bb3198562 | |||
| 4b3f0fa92b | |||
| c7c3440a7b | |||
| e779ded2e8 | |||
| 940dd21464 | |||
| 207f9074db | |||
| 05b00b158b | |||
| f9ddab059e | |||
| de1ae736de | |||
| 03c1b63e03 | |||
| 8c12b27c0a | |||
| c7d163f44a | |||
| 7e4822c021 | |||
| f9b622cb25 | |||
| 9204fa148a | |||
| da0e9f5915 | |||
| d188a08db7 | |||
| ff5f5ddf23 | |||
| 56f9802fb9 | |||
| efb271cb9a | |||
| eb69e491b9 | |||
| 2675b8ab93 | |||
| b3444e0a89 | |||
| 45b652f514 | |||
| fffee9f0b5 | |||
| 00ffc959e5 | |||
| b561406c23 | |||
| 5a0743a17b | |||
| a7b0770ad0 | |||
| 7d8323a622 | |||
| 320944fd18 | |||
| 6d5e0ac614 | |||
| 21da9e75fc |
10
.gitignore
vendored
10
.gitignore
vendored
@@ -11,8 +11,8 @@ prebuilt-binaries/dragonxd-win/*
|
|||||||
!prebuilt-binaries/dragonxd-win/.gitkeep
|
!prebuilt-binaries/dragonxd-win/.gitkeep
|
||||||
prebuilt-binaries/dragonxd-mac/*
|
prebuilt-binaries/dragonxd-mac/*
|
||||||
!prebuilt-binaries/dragonxd-mac/.gitkeep
|
!prebuilt-binaries/dragonxd-mac/.gitkeep
|
||||||
prebuilt-binaries/xmrig-hac/*
|
prebuilt-binaries/drg-xmrig/*
|
||||||
!prebuilt-binaries/xmrig-hac/.gitkeep
|
!prebuilt-binaries/drg-xmrig/.gitkeep
|
||||||
|
|
||||||
|
|
||||||
# External sources / toolchains (created by scripts/setup.sh)
|
# External sources / toolchains (created by scripts/setup.sh)
|
||||||
@@ -33,7 +33,11 @@ imgui.ini
|
|||||||
*.bak*
|
*.bak*
|
||||||
*.params
|
*.params
|
||||||
asmap.dat
|
asmap.dat
|
||||||
/external/xmrig-hac
|
# Wallet files hold PRIVATE KEYS — never commit them
|
||||||
|
wallet.dat
|
||||||
|
wallet-*.dat
|
||||||
|
wallet.dat.*
|
||||||
|
/external/drg-xmrig
|
||||||
/memory
|
/memory
|
||||||
/todo.md
|
/todo.md
|
||||||
/.github/
|
/.github/
|
||||||
|
|||||||
58
CHANGELOG.md
Normal file
58
CHANGELOG.md
Normal file
@@ -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 <dir>" 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`.
|
||||||
@@ -15,7 +15,7 @@ if(APPLE)
|
|||||||
endif()
|
endif()
|
||||||
|
|
||||||
project(ObsidianDragon
|
project(ObsidianDragon
|
||||||
VERSION 2.0.0
|
VERSION 2.0.1
|
||||||
LANGUAGES C CXX
|
LANGUAGES C CXX
|
||||||
DESCRIPTION "DragonX Cryptocurrency Wallet"
|
DESCRIPTION "DragonX Cryptocurrency Wallet"
|
||||||
)
|
)
|
||||||
@@ -136,6 +136,13 @@ if(DRAGONX_ENABLE_LITE_BACKEND)
|
|||||||
set_target_properties(dragonx_lite_backend PROPERTIES
|
set_target_properties(dragonx_lite_backend PROPERTIES
|
||||||
IMPORTED_LOCATION "${DRAGONX_LITE_BACKEND_LIBRARY}"
|
IMPORTED_LOCATION "${DRAGONX_LITE_BACKEND_LIBRARY}"
|
||||||
)
|
)
|
||||||
|
if(APPLE)
|
||||||
|
# The Rust backend's TLS stack (security-framework / core-foundation crates)
|
||||||
|
# references Secure Transport (SSL*) + CoreFoundation symbols. Link the frameworks
|
||||||
|
# that provide them, or the static lib leaves ~130 symbols undefined at link time.
|
||||||
|
set_property(TARGET dragonx_lite_backend APPEND PROPERTY
|
||||||
|
INTERFACE_LINK_LIBRARIES "-framework Security" "-framework CoreFoundation")
|
||||||
|
endif()
|
||||||
if(DRAGONX_LITE_BACKEND_INCLUDE_DIR)
|
if(DRAGONX_LITE_BACKEND_INCLUDE_DIR)
|
||||||
if(NOT IS_DIRECTORY "${DRAGONX_LITE_BACKEND_INCLUDE_DIR}")
|
if(NOT IS_DIRECTORY "${DRAGONX_LITE_BACKEND_INCLUDE_DIR}")
|
||||||
message(FATAL_ERROR "DRAGONX_LITE_BACKEND_INCLUDE_DIR does not exist: ${DRAGONX_LITE_BACKEND_INCLUDE_DIR}")
|
message(FATAL_ERROR "DRAGONX_LITE_BACKEND_INCLUDE_DIR does not exist: ${DRAGONX_LITE_BACKEND_INCLUDE_DIR}")
|
||||||
@@ -284,6 +291,15 @@ FetchContent_Declare(
|
|||||||
GIT_REPOSITORY https://github.com/webmproject/libwebp.git
|
GIT_REPOSITORY https://github.com/webmproject/libwebp.git
|
||||||
GIT_TAG v1.4.0
|
GIT_TAG v1.4.0
|
||||||
GIT_SHALLOW TRUE
|
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
|
||||||
|
# (-arch arm64;x86_64) that probe fails, so the flags land on the x86_64 slice,
|
||||||
|
# where -mno-sse2 disables _Float16 and breaks the SDK's <math.h>. Neutralize
|
||||||
|
# those disable flags (SSE2 is x86_64 baseline). Portable + idempotent; a no-op
|
||||||
|
# for single-arch Linux/Windows/x86_64 builds. See cmake/patch-libwebp-simd.cmake.
|
||||||
|
PATCH_COMMAND ${CMAKE_COMMAND}
|
||||||
|
-DCPU_CMAKE=<SOURCE_DIR>/cmake/cpu.cmake
|
||||||
|
-P ${CMAKE_CURRENT_SOURCE_DIR}/cmake/patch-libwebp-simd.cmake
|
||||||
)
|
)
|
||||||
set(WEBP_LINK_STATIC ON CACHE BOOL "" FORCE)
|
set(WEBP_LINK_STATIC ON CACHE BOOL "" FORCE)
|
||||||
set(WEBP_BUILD_ANIM_UTILS OFF CACHE BOOL "" FORCE)
|
set(WEBP_BUILD_ANIM_UTILS OFF CACHE BOOL "" FORCE)
|
||||||
@@ -528,6 +544,7 @@ set(APP_SOURCES
|
|||||||
src/util/async_task_manager.cpp
|
src/util/async_task_manager.cpp
|
||||||
src/util/amount_format.cpp
|
src/util/amount_format.cpp
|
||||||
src/util/address_validation.cpp
|
src/util/address_validation.cpp
|
||||||
|
src/util/seed_phrase.cpp
|
||||||
src/util/base64.cpp
|
src/util/base64.cpp
|
||||||
src/util/single_instance.cpp
|
src/util/single_instance.cpp
|
||||||
src/util/i18n.cpp
|
src/util/i18n.cpp
|
||||||
@@ -1058,6 +1075,32 @@ install(DIRECTORY ${CMAKE_RUNTIME_OUTPUT_DIRECTORY}/res
|
|||||||
OPTIONAL
|
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
|
# Tests
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
@@ -1106,6 +1149,7 @@ if(BUILD_TESTING)
|
|||||||
src/util/payment_uri.cpp
|
src/util/payment_uri.cpp
|
||||||
src/util/amount_format.cpp
|
src/util/amount_format.cpp
|
||||||
src/util/address_validation.cpp
|
src/util/address_validation.cpp
|
||||||
|
src/util/seed_phrase.cpp
|
||||||
src/util/i18n.cpp
|
src/util/i18n.cpp
|
||||||
src/util/text_format.cpp
|
src/util/text_format.cpp
|
||||||
src/data/wallet_state.cpp
|
src/data/wallet_state.cpp
|
||||||
@@ -1113,6 +1157,7 @@ if(BUILD_TESTING)
|
|||||||
src/data/address_book.cpp
|
src/data/address_book.cpp
|
||||||
src/data/wallet_index.cpp
|
src/data/wallet_index.cpp
|
||||||
src/daemon/lifecycle_adapters.cpp
|
src/daemon/lifecycle_adapters.cpp
|
||||||
|
src/daemon/embedded_daemon.cpp
|
||||||
src/rpc/connection.cpp
|
src/rpc/connection.cpp
|
||||||
src/config/settings.cpp
|
src/config/settings.cpp
|
||||||
src/resources/embedded_resources.cpp
|
src/resources/embedded_resources.cpp
|
||||||
|
|||||||
@@ -81,8 +81,8 @@ Download linux and windows binaries of latest releases and place in binary direc
|
|||||||
- prebuilt-binaries/dragonxd-win/
|
- prebuilt-binaries/dragonxd-win/
|
||||||
- prebuilt-binaries/dragonxd-mac/
|
- prebuilt-binaries/dragonxd-mac/
|
||||||
|
|
||||||
**xmrig HAC fork** (https://git.dragonx.is/dragonx/xmrig-hac):
|
**DRG-XMRig fork** (https://git.dragonx.is/DragonX/drg-xmrig):
|
||||||
- prebuilt-binaries/xmrig-hac/
|
- prebuilt-binaries/drg-xmrig/
|
||||||
|
|
||||||
|
|
||||||
## Build Steps
|
## Build Steps
|
||||||
|
|||||||
85
build.sh
85
build.sh
@@ -131,7 +131,7 @@ fi
|
|||||||
# truth): the full-node app uses project() VERSION + DRAGONX_VERSION_SUFFIX; ObsidianDragonLite uses
|
# truth): the full-node app uses project() VERSION + DRAGONX_VERSION_SUFFIX; ObsidianDragonLite uses
|
||||||
# DRAGONX_LITE_VERSION + DRAGONX_LITE_VERSION_SUFFIX.
|
# DRAGONX_LITE_VERSION + DRAGONX_LITE_VERSION_SUFFIX.
|
||||||
_cml="$SCRIPT_DIR/CMakeLists.txt"
|
_cml="$SCRIPT_DIR/CMakeLists.txt"
|
||||||
_full_ver=$(sed -n 's/^[[:space:]]*VERSION[[:space:]]\+\([0-9][0-9.]*\).*/\1/p' "$_cml" | head -1)
|
_full_ver=$(sed -n 's/^[[:space:]]*VERSION[[:space:]][[:space:]]*\([0-9][0-9.]*\).*/\1/p' "$_cml" | head -1)
|
||||||
_full_suffix=$(sed -n 's/^set(DRAGONX_VERSION_SUFFIX[[:space:]]*"\([^"]*\)").*/\1/p' "$_cml" | head -1)
|
_full_suffix=$(sed -n 's/^set(DRAGONX_VERSION_SUFFIX[[:space:]]*"\([^"]*\)").*/\1/p' "$_cml" | head -1)
|
||||||
_lite_ver=$(sed -n 's/^set(DRAGONX_LITE_VERSION[[:space:]]*"\([^"]*\)").*/\1/p' "$_cml" | head -1)
|
_lite_ver=$(sed -n 's/^set(DRAGONX_LITE_VERSION[[:space:]]*"\([^"]*\)").*/\1/p' "$_cml" | head -1)
|
||||||
_lite_suffix=$(sed -n 's/^set(DRAGONX_LITE_VERSION_SUFFIX[[:space:]]*"\([^"]*\)").*/\1/p' "$_cml" | head -1)
|
_lite_suffix=$(sed -n 's/^set(DRAGONX_LITE_VERSION_SUFFIX[[:space:]]*"\([^"]*\)").*/\1/p' "$_cml" | head -1)
|
||||||
@@ -286,6 +286,14 @@ bundle_linux_daemon() {
|
|||||||
# asmap.dat
|
# asmap.dat
|
||||||
find_asmap && cp "$ASMAP_DAT" "$dest/asmap.dat" && info " Bundled asmap.dat"
|
find_asmap && cp "$ASMAP_DAT" "$dest/asmap.dat" && info " Bundled asmap.dat"
|
||||||
|
|
||||||
|
# dragonx-wallet-rebuild helper (offline recovery for a BDB-inconsistent wallet.dat)
|
||||||
|
for p in "$SCRIPT_DIR/build/linux/bin/dragonx-wallet-rebuild" "$SCRIPT_DIR/../dragonx-wallet-rebuild"; do
|
||||||
|
if [[ -f "$p" ]]; then
|
||||||
|
cp "$p" "$dest/dragonx-wallet-rebuild"; chmod +x "$dest/dragonx-wallet-rebuild"
|
||||||
|
info " Bundled dragonx-wallet-rebuild"; break
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
|
||||||
return $found
|
return $found
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -336,11 +344,20 @@ build_release_linux() {
|
|||||||
mkdir -p "$bd" && cd "$bd"
|
mkdir -p "$bd" && cd "$bd"
|
||||||
|
|
||||||
# ── Compile ──────────────────────────────────────────────────────────────
|
# ── 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" )
|
||||||
|
fi
|
||||||
info "Configuring ..."
|
info "Configuring ..."
|
||||||
cmake "$SCRIPT_DIR" \
|
cmake "$SCRIPT_DIR" \
|
||||||
-DCMAKE_BUILD_TYPE=Release \
|
-DCMAKE_BUILD_TYPE=Release \
|
||||||
-DCMAKE_CXX_FLAGS_RELEASE="-O3 -DNDEBUG" \
|
-DCMAKE_CXX_FLAGS_RELEASE="-O3 -DNDEBUG" \
|
||||||
-DDRAGONX_USE_SYSTEM_SDL3=ON \
|
-DDRAGONX_USE_SYSTEM_SDL3=ON \
|
||||||
|
"${BDB_ARGS[@]}" \
|
||||||
"${CMAKE_LITE_ARGS[@]}"
|
"${CMAKE_LITE_ARGS[@]}"
|
||||||
|
|
||||||
info "Building with $JOBS jobs ..."
|
info "Building with $JOBS jobs ..."
|
||||||
@@ -350,6 +367,7 @@ build_release_linux() {
|
|||||||
|
|
||||||
info "Stripping ..."
|
info "Stripping ..."
|
||||||
strip "bin/${APP_BASENAME}"
|
strip "bin/${APP_BASENAME}"
|
||||||
|
[[ -f "bin/dragonx-wallet-rebuild" ]] && strip "bin/dragonx-wallet-rebuild"
|
||||||
info "Binary: $(du -h "bin/${APP_BASENAME}" | cut -f1)"
|
info "Binary: $(du -h "bin/${APP_BASENAME}" | cut -f1)"
|
||||||
|
|
||||||
if should_bundle_full_node_assets; then
|
if should_bundle_full_node_assets; then
|
||||||
@@ -386,7 +404,7 @@ build_release_linux() {
|
|||||||
[[ -f bin/sapling-output.params ]] && cp bin/sapling-output.params "$dist_dir/"
|
[[ -f bin/sapling-output.params ]] && cp bin/sapling-output.params "$dist_dir/"
|
||||||
fi
|
fi
|
||||||
# Bundle xmrig for mining support
|
# Bundle xmrig for mining support
|
||||||
local XMRIG_LINUX="$SCRIPT_DIR/prebuilt-binaries/xmrig-hac/xmrig"
|
local XMRIG_LINUX="$SCRIPT_DIR/prebuilt-binaries/drg-xmrig/xmrig"
|
||||||
[[ -f "$XMRIG_LINUX" ]] && { cp "$XMRIG_LINUX" "$dist_dir/"; chmod +x "$dist_dir/xmrig"; info "Bundled xmrig"; } || warn "xmrig not found — mining unavailable in zip"
|
[[ -f "$XMRIG_LINUX" ]] && { cp "$XMRIG_LINUX" "$dist_dir/"; chmod +x "$dist_dir/xmrig"; info "Bundled xmrig"; } || warn "xmrig not found — mining unavailable in zip"
|
||||||
cp -r bin/res "$dist_dir/" 2>/dev/null || true
|
cp -r bin/res "$dist_dir/" 2>/dev/null || true
|
||||||
|
|
||||||
@@ -419,7 +437,7 @@ build_release_linux() {
|
|||||||
[[ -f bin/sapling-output.params ]] && cp bin/sapling-output.params "$APPDIR/usr/bin/"
|
[[ -f bin/sapling-output.params ]] && cp bin/sapling-output.params "$APPDIR/usr/bin/"
|
||||||
fi
|
fi
|
||||||
# Bundle xmrig for mining support
|
# Bundle xmrig for mining support
|
||||||
local XMRIG_LINUX_AI="$SCRIPT_DIR/prebuilt-binaries/xmrig-hac/xmrig"
|
local XMRIG_LINUX_AI="$SCRIPT_DIR/prebuilt-binaries/drg-xmrig/xmrig"
|
||||||
[[ -f "$XMRIG_LINUX_AI" ]] && { cp "$XMRIG_LINUX_AI" "$APPDIR/usr/bin/"; chmod +x "$APPDIR/usr/bin/xmrig"; }
|
[[ -f "$XMRIG_LINUX_AI" ]] && { cp "$XMRIG_LINUX_AI" "$APPDIR/usr/bin/"; chmod +x "$APPDIR/usr/bin/xmrig"; }
|
||||||
|
|
||||||
# Desktop entry
|
# Desktop entry
|
||||||
@@ -638,8 +656,8 @@ HDR
|
|||||||
info "Lite mode: skipping embedded daemon binaries"
|
info "Lite mode: skipping embedded daemon binaries"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# ── xmrig binary (from prebuilt-binaries/xmrig-hac/) ────────────────
|
# ── xmrig binary (from prebuilt-binaries/drg-xmrig/) ────────────────
|
||||||
local XMRIG_DIR="$SCRIPT_DIR/prebuilt-binaries/xmrig-hac"
|
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
|
# The published DRG-XMRig archives ship the binary inside a versioned subdir, not as a flat
|
||||||
# xmrig.exe. Extract it from the matching win-x64 zip if it isn't already staged — otherwise
|
# xmrig.exe. Extract it from the matching win-x64 zip if it isn't already staged — otherwise
|
||||||
# the embed below never fires (HAS_EMBEDDED_XMRIG stays undefined) and the wallet ships with
|
# the embed below never fires (HAS_EMBEDDED_XMRIG stays undefined) and the wallet ships with
|
||||||
@@ -750,11 +768,20 @@ HDR
|
|||||||
fi
|
fi
|
||||||
|
|
||||||
# ── CMake + build ────────────────────────────────────────────────────────
|
# ── 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" )
|
||||||
|
fi
|
||||||
info "Configuring (cross-compile) ..."
|
info "Configuring (cross-compile) ..."
|
||||||
cmake "$SCRIPT_DIR" \
|
cmake "$SCRIPT_DIR" \
|
||||||
-DCMAKE_TOOLCHAIN_FILE="$bd/mingw-toolchain.cmake" \
|
-DCMAKE_TOOLCHAIN_FILE="$bd/mingw-toolchain.cmake" \
|
||||||
-DCMAKE_BUILD_TYPE=Release \
|
-DCMAKE_BUILD_TYPE=Release \
|
||||||
-DDRAGONX_USE_SYSTEM_SDL3=OFF \
|
-DDRAGONX_USE_SYSTEM_SDL3=OFF \
|
||||||
|
"${BDB_ARGS[@]}" \
|
||||||
"${FT_CMAKE_ARG[@]}" \
|
"${FT_CMAKE_ARG[@]}" \
|
||||||
"${CMAKE_LITE_ARGS[@]}"
|
"${CMAKE_LITE_ARGS[@]}"
|
||||||
|
|
||||||
@@ -779,6 +806,8 @@ HDR
|
|||||||
for f in dragonxd.exe dragonx-cli.exe dragonx-tx.exe; do
|
for f in dragonxd.exe dragonx-cli.exe dragonx-tx.exe; do
|
||||||
[[ -f "$DD/$f" ]] && cp "$DD/$f" "$dist_dir/"
|
[[ -f "$DD/$f" ]] && cp "$DD/$f" "$dist_dir/"
|
||||||
done
|
done
|
||||||
|
# dragonx-wallet-rebuild helper (offline recovery for a BDB-inconsistent wallet.dat)
|
||||||
|
[[ -f "bin/dragonx-wallet-rebuild.exe" ]] && { cp "bin/dragonx-wallet-rebuild.exe" "$dist_dir/"; info " Bundled dragonx-wallet-rebuild.exe"; }
|
||||||
|
|
||||||
# Bundle Sapling params + asmap for the zip distribution
|
# Bundle Sapling params + asmap for the zip distribution
|
||||||
# (The single-file exe has these embedded via INCBIN, but the zip
|
# (The single-file exe has these embedded via INCBIN, but the zip
|
||||||
@@ -791,7 +820,7 @@ HDR
|
|||||||
fi
|
fi
|
||||||
|
|
||||||
# Bundle xmrig for mining support
|
# Bundle xmrig for mining support
|
||||||
local XMRIG_WIN="$SCRIPT_DIR/prebuilt-binaries/xmrig-hac/xmrig.exe"
|
local XMRIG_WIN="$SCRIPT_DIR/prebuilt-binaries/drg-xmrig/xmrig.exe"
|
||||||
[[ -f "$XMRIG_WIN" ]] && { cp "$XMRIG_WIN" "$dist_dir/"; info "Bundled xmrig.exe"; } || warn "xmrig.exe not found — mining unavailable in zip"
|
[[ -f "$XMRIG_WIN" ]] && { cp "$XMRIG_WIN" "$dist_dir/"; info "Bundled xmrig.exe"; } || warn "xmrig.exe not found — mining unavailable in zip"
|
||||||
|
|
||||||
cp -r bin/res "$dist_dir/" 2>/dev/null || true
|
cp -r bin/res "$dist_dir/" 2>/dev/null || true
|
||||||
@@ -901,8 +930,26 @@ build_release_mac() {
|
|||||||
fi
|
fi
|
||||||
info "macOS cross-compiler: $OSXCROSS_CXX (arch: $MAC_ARCH)"
|
info "macOS cross-compiler: $OSXCROSS_CXX (arch: $MAC_ARCH)"
|
||||||
else
|
else
|
||||||
# Native macOS: build universal binary (arm64 + x86_64)
|
# Native macOS: build universal (arm64 + x86_64) by default. Override with
|
||||||
MAC_ARCH="universal"
|
# DRAGONX_MAC_ARCHS (e.g. "x86_64").
|
||||||
|
MAC_ARCHS="${DRAGONX_MAC_ARCHS:-arm64;x86_64}"
|
||||||
|
# When linking the real lite backend, the app can only include architectures
|
||||||
|
# the backend static library actually provides. Its pinned ring 0.16.11 has no
|
||||||
|
# Apple-Silicon assembly, so that artifact is x86_64-only — constrain the app
|
||||||
|
# arch to the backend's (unless the user explicitly forced DRAGONX_MAC_ARCHS),
|
||||||
|
# otherwise the arm64 slice fails to link.
|
||||||
|
if $DO_LITE_BACKEND && [[ -z "${DRAGONX_MAC_ARCHS:-}" && -n "${lb_lib:-}" ]] && command -v lipo &>/dev/null; then
|
||||||
|
local _backend_archs; _backend_archs=$(lipo -archs "$lb_lib" 2>/dev/null | tr ' ' ';')
|
||||||
|
if [[ -n "$_backend_archs" && "$_backend_archs" != "$MAC_ARCHS" ]]; then
|
||||||
|
warn "Lite backend provides only [$_backend_archs] — building the app for that instead of universal."
|
||||||
|
MAC_ARCHS="$_backend_archs"
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
if [[ "$MAC_ARCHS" == *";"* || "$MAC_ARCHS" == *","* ]]; then
|
||||||
|
MAC_ARCH="universal"
|
||||||
|
else
|
||||||
|
MAC_ARCH="$MAC_ARCHS"
|
||||||
|
fi
|
||||||
export MACOSX_DEPLOYMENT_TARGET="11.0"
|
export MACOSX_DEPLOYMENT_TARGET="11.0"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
@@ -990,7 +1037,7 @@ TOOLCHAIN
|
|||||||
need_sodium=true
|
need_sodium=true
|
||||||
elif [[ -f "$SCRIPT_DIR/libs/libsodium/lib/libsodium.a" ]]; then
|
elif [[ -f "$SCRIPT_DIR/libs/libsodium/lib/libsodium.a" ]]; then
|
||||||
# Rebuild if existing lib is not universal (single-arch won't link)
|
# Rebuild if existing lib is not universal (single-arch won't link)
|
||||||
if ! lipo -info "$SCRIPT_DIR/libs/libsodium/lib/libsodium.a" 2>/dev/null | grep -q "arm64.*x86_64\|x86_64.*arm64"; then
|
if ! lipo -info "$SCRIPT_DIR/libs/libsodium/lib/libsodium.a" 2>/dev/null | grep -Eq "arm64.*x86_64|x86_64.*arm64"; then
|
||||||
info "Existing libsodium is not universal — rebuilding ..."
|
info "Existing libsodium is not universal — rebuilding ..."
|
||||||
rm -rf "$SCRIPT_DIR/libs/libsodium"
|
rm -rf "$SCRIPT_DIR/libs/libsodium"
|
||||||
need_sodium=true
|
need_sodium=true
|
||||||
@@ -1001,13 +1048,13 @@ TOOLCHAIN
|
|||||||
"$SCRIPT_DIR/scripts/fetch-libsodium.sh"
|
"$SCRIPT_DIR/scripts/fetch-libsodium.sh"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
info "Configuring (native universal arm64+x86_64) ..."
|
info "Configuring (native macOS, arch: $MAC_ARCHS) ..."
|
||||||
cmake "$SCRIPT_DIR" \
|
cmake "$SCRIPT_DIR" \
|
||||||
-DCMAKE_BUILD_TYPE=Release \
|
-DCMAKE_BUILD_TYPE=Release \
|
||||||
-DCMAKE_CXX_FLAGS_RELEASE="-O3 -DNDEBUG" \
|
-DCMAKE_CXX_FLAGS_RELEASE="-O3 -DNDEBUG" \
|
||||||
-DDRAGONX_USE_SYSTEM_SDL3=OFF \
|
-DDRAGONX_USE_SYSTEM_SDL3=OFF \
|
||||||
-DCMAKE_OSX_DEPLOYMENT_TARGET=11.0 \
|
-DCMAKE_OSX_DEPLOYMENT_TARGET=11.0 \
|
||||||
-DCMAKE_OSX_ARCHITECTURES="arm64;x86_64" \
|
-DCMAKE_OSX_ARCHITECTURES="$MAC_ARCHS" \
|
||||||
"${CMAKE_LITE_ARGS[@]}"
|
"${CMAKE_LITE_ARGS[@]}"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
@@ -1037,8 +1084,12 @@ TOOLCHAIN
|
|||||||
info "Binary: $(du -h "bin/${APP_BASENAME}" | cut -f1)"
|
info "Binary: $(du -h "bin/${APP_BASENAME}" | cut -f1)"
|
||||||
|
|
||||||
# ── Create .app bundle ───────────────────────────────────────────────────
|
# ── Create .app bundle ───────────────────────────────────────────────────
|
||||||
rm -rf "$out"
|
|
||||||
mkdir -p "$out"
|
mkdir -p "$out"
|
||||||
|
# Clean only THIS variant's prior artifacts so full-node and lite releases can
|
||||||
|
# coexist in release/mac/ (Linux/Windows scope their cleanup the same way). The
|
||||||
|
# "ObsidianDragon-" glob never matches "ObsidianDragonLite-" (and vice versa),
|
||||||
|
# and the ".app" names are exact.
|
||||||
|
rm -rf "$out/${APP_BASENAME}.app" "$out/${APP_BASENAME}-"*.app.zip "$out/${APP_BASENAME}-"*.dmg
|
||||||
|
|
||||||
local APP="$out/${APP_BASENAME}.app"
|
local APP="$out/${APP_BASENAME}.app"
|
||||||
local CONTENTS="$APP/Contents"
|
local CONTENTS="$APP/Contents"
|
||||||
@@ -1088,8 +1139,8 @@ TOOLCHAIN
|
|||||||
info "Lite mode: skipping macOS daemon and Sapling/asmap bundling"
|
info "Lite mode: skipping macOS daemon and Sapling/asmap bundling"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# xmrig binary (from prebuilt-binaries/xmrig-hac/)
|
# xmrig binary (from prebuilt-binaries/drg-xmrig/)
|
||||||
local XMRIG_MAC="$SCRIPT_DIR/prebuilt-binaries/xmrig-hac/xmrig"
|
local XMRIG_MAC="$SCRIPT_DIR/prebuilt-binaries/drg-xmrig/xmrig"
|
||||||
if [[ -f "$XMRIG_MAC" ]]; then
|
if [[ -f "$XMRIG_MAC" ]]; then
|
||||||
cp "$XMRIG_MAC" "$MACOS/xmrig"
|
cp "$XMRIG_MAC" "$MACOS/xmrig"
|
||||||
chmod +x "$MACOS/xmrig"
|
chmod +x "$MACOS/xmrig"
|
||||||
@@ -1238,8 +1289,10 @@ PLIST
|
|||||||
fi
|
fi
|
||||||
|
|
||||||
# ── Create DMG ───────────────────────────────────────────────────────────
|
# ── Create DMG ───────────────────────────────────────────────────────────
|
||||||
local DMG_BASENAME="DragonX_Wallet"
|
# DMG filename matches the app bundle name (ObsidianDragon / ObsidianDragonLite).
|
||||||
$DO_LITE && DMG_BASENAME="DragonX_Wallet_Lite"
|
# The mounted volume + CFBundleName keep the "DragonX Wallet" display branding
|
||||||
|
# (APP_DISPLAY_NAME above).
|
||||||
|
local DMG_BASENAME="${APP_BASENAME}"
|
||||||
local DMG_NAME="${DMG_BASENAME}-${VERSION}-macOS-${MAC_ARCH}.dmg"
|
local DMG_NAME="${DMG_BASENAME}-${VERSION}-macOS-${MAC_ARCH}.dmg"
|
||||||
|
|
||||||
if command -v create-dmg &>/dev/null; then
|
if command -v create-dmg &>/dev/null; then
|
||||||
|
|||||||
31
cmake/patch-libwebp-simd.cmake
Normal file
31
cmake/patch-libwebp-simd.cmake
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
# patch-libwebp-simd.cmake — portable, idempotent FetchContent patch for libwebp.
|
||||||
|
#
|
||||||
|
# libwebp's cmake/cpu.cmake compiles its scalar *reference* DSP files with the
|
||||||
|
# SSE-disable flags "-mno-sse4.1;-mno-sse2" whenever it can't positively detect
|
||||||
|
# SSE support. Under a macOS *universal* build (-arch arm64;x86_64) the per-arch
|
||||||
|
# SSE flag probe fails (a flag valid for x86_64 is invalid for arm64), so those
|
||||||
|
# disable flags get applied to the x86_64 slice. clang gates the _Float16 type on
|
||||||
|
# SSE2 for x86_64, and the macOS 15+/26 SDK's <math.h> declares _Float16 math
|
||||||
|
# functions unconditionally — so any TU including <math.h> fails to compile with
|
||||||
|
# "_Float16 is not supported on this target".
|
||||||
|
#
|
||||||
|
# SSE2 is part of the x86_64 baseline ABI, so disabling it on the reference files
|
||||||
|
# is unnecessary on every platform we target. Blanking the SSE entries (indices
|
||||||
|
# must stay aligned with WEBP_SIMD_FLAGS) fixes the universal build and is a no-op
|
||||||
|
# for single-arch Linux/Windows/x86_64 builds. Idempotent: re-running is a no-op.
|
||||||
|
if(NOT DEFINED CPU_CMAKE OR NOT EXISTS "${CPU_CMAKE}")
|
||||||
|
message(FATAL_ERROR "patch-libwebp-simd: cpu.cmake not found at '${CPU_CMAKE}'")
|
||||||
|
endif()
|
||||||
|
|
||||||
|
file(READ "${CPU_CMAKE}" _contents)
|
||||||
|
string(REPLACE
|
||||||
|
"set(SIMD_DISABLE_FLAGS \"-mno-sse4.1;-mno-sse2;;-mno-dspr2;;-mno-msa\")"
|
||||||
|
"set(SIMD_DISABLE_FLAGS \";;;-mno-dspr2;;-mno-msa\")"
|
||||||
|
_patched "${_contents}")
|
||||||
|
|
||||||
|
if(_patched STREQUAL _contents)
|
||||||
|
message(STATUS "patch-libwebp-simd: no change (already patched or pattern absent)")
|
||||||
|
else()
|
||||||
|
file(WRITE "${CPU_CMAKE}" "${_patched}")
|
||||||
|
message(STATUS "patch-libwebp-simd: neutralized x86 SSE-disable flags in cpu.cmake")
|
||||||
|
endif()
|
||||||
549
docs/daemon-startup-hardening.md
Normal file
549
docs/daemon-startup-hardening.md
Normal file
@@ -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> 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 `<params_dir>/.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.
|
||||||
167
docs/wallet-hardening.md
Normal file
167
docs/wallet-hardening.md
Normal file
@@ -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<ts>` 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(<config>/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(<config>/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(<config>/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(<config>/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 <path>". It now reads "**Saved an UNENCRYPTED seed file — move it to secure offline storage and delete this copy**: <path>", 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.)
|
||||||
0
prebuilt-binaries/drg-xmrig/.gitkeep
Normal file
0
prebuilt-binaries/drg-xmrig/.gitkeep
Normal file
@@ -48,6 +48,10 @@
|
|||||||
"advanced": "ERWEITERT",
|
"advanced": "ERWEITERT",
|
||||||
"advanced_effects": "Erweiterte Effekte...",
|
"advanced_effects": "Erweiterte Effekte...",
|
||||||
"ago": "her",
|
"ago": "her",
|
||||||
|
"alerts_clear": "Meldungsverlauf löschen",
|
||||||
|
"alerts_history_tooltip": "Letzte Meldungen",
|
||||||
|
"alerts_none": "Noch keine Meldungen",
|
||||||
|
"alerts_recent": "LETZTE MELDUNGEN",
|
||||||
"all_filter": "Alle",
|
"all_filter": "Alle",
|
||||||
"allow_custom_fees": "Benutzerdefinierte Gebühren erlauben",
|
"allow_custom_fees": "Benutzerdefinierte Gebühren erlauben",
|
||||||
"amount": "Betrag",
|
"amount": "Betrag",
|
||||||
@@ -451,6 +455,8 @@
|
|||||||
"daemon_update_version": "Version:",
|
"daemon_update_version": "Version:",
|
||||||
"daemon_version": "Daemon",
|
"daemon_version": "Daemon",
|
||||||
"dark": "Dunkel",
|
"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": "Datum",
|
||||||
"date_label": "Datum:",
|
"date_label": "Datum:",
|
||||||
"debug_logging": "FEHLERPROTOKOLLIERUNG",
|
"debug_logging": "FEHLERPROTOKOLLIERUNG",
|
||||||
@@ -734,6 +740,9 @@
|
|||||||
"lite_working": "In Arbeit…",
|
"lite_working": "In Arbeit…",
|
||||||
"loading": "Laden...",
|
"loading": "Laden...",
|
||||||
"loading_addresses": "Adressen werden geladen...",
|
"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",
|
"loading_transactions": "Transaktionen werden geladen",
|
||||||
"local_hashrate": "Lokale Hashrate",
|
"local_hashrate": "Lokale Hashrate",
|
||||||
"low_spec_mode": "Energiesparmodus",
|
"low_spec_mode": "Energiesparmodus",
|
||||||
@@ -953,6 +962,11 @@
|
|||||||
"no_transactions": "Keine Transaktionen gefunden",
|
"no_transactions": "Keine Transaktionen gefunden",
|
||||||
"no_transactions_yet": "Noch keine Transaktionen",
|
"no_transactions_yet": "Noch keine Transaktionen",
|
||||||
"node": "KNOTEN",
|
"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",
|
"node_security": "KNOTEN & SICHERHEIT",
|
||||||
"noise": "Rauschen",
|
"noise": "Rauschen",
|
||||||
"not_connected": "Nicht mit Daemon verbunden...",
|
"not_connected": "Nicht mit Daemon verbunden...",
|
||||||
@@ -1154,6 +1168,8 @@
|
|||||||
"sb_connecting_external": "Verbindung zu externem Daemon...",
|
"sb_connecting_external": "Verbindung zu externem Daemon...",
|
||||||
"sb_connecting_generic": "Verbindung zum Daemon...",
|
"sb_connecting_generic": "Verbindung zum Daemon...",
|
||||||
"sb_daemon_crashed": "Daemon ist %d mal abgestürzt",
|
"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_not_found": "Daemon nicht gefunden",
|
||||||
"sb_daemon_start_failed": "dragonxd konnte nicht gestartet werden",
|
"sb_daemon_start_failed": "dragonxd konnte nicht gestartet werden",
|
||||||
"sb_dragonxd_running": "dragonxd läuft",
|
"sb_dragonxd_running": "dragonxd läuft",
|
||||||
@@ -1169,6 +1185,7 @@
|
|||||||
"sb_net_mhs": "Netz: %.2f MH/s",
|
"sb_net_mhs": "Netz: %.2f MH/s",
|
||||||
"sb_no_conf": "DRAGONX.conf nicht gefunden",
|
"sb_no_conf": "DRAGONX.conf nicht gefunden",
|
||||||
"sb_peers": "Peers: %zu",
|
"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": "Neuscan",
|
||||||
"sb_rescanning_pct": "Neuscan %.0f%%",
|
"sb_rescanning_pct": "Neuscan %.0f%%",
|
||||||
"sb_restarting_daemon": "Daemon wird neu gestartet...",
|
"sb_restarting_daemon": "Daemon wird neu gestartet...",
|
||||||
@@ -1285,12 +1302,14 @@
|
|||||||
"settings_configure_explorer": "Externe Block-Explorer-Links konfigurieren",
|
"settings_configure_explorer": "Externe Block-Explorer-Links konfigurieren",
|
||||||
"settings_configure_rpc": "Verbindung zum dragonxd-Daemon konfigurieren",
|
"settings_configure_rpc": "Verbindung zum dragonxd-Daemon konfigurieren",
|
||||||
"settings_connection": "Verbindung",
|
"settings_connection": "Verbindung",
|
||||||
|
"settings_copy_diagnostics": "Diagnose kopieren",
|
||||||
"settings_copyright": "Copyright 2024-2026 DragonX-Entwickler | GPLv3-Lizenz",
|
"settings_copyright": "Copyright 2024-2026 DragonX-Entwickler | GPLv3-Lizenz",
|
||||||
"settings_custom": "Benutzerdefiniert",
|
"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_changed": "Debug-Kategorien geändert — Daemon neu starten zum Anwenden",
|
||||||
"settings_debug_restart_note": "Änderungen werden nach einem Neustart des Daemons wirksam.",
|
"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_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_first_pin": "Verschlüsseln Sie zuerst die Wallet, um PIN zu aktivieren",
|
||||||
"settings_encrypt_wallet": "Wallet verschlüsseln",
|
"settings_encrypt_wallet": "Wallet verschlüsseln",
|
||||||
"settings_explorer_hint": "URLs sollten einen abschließenden Schrägstrich enthalten. Die txid/Adresse wird angehängt.",
|
"settings_explorer_hint": "URLs sollten einen abschließenden Schrägstrich enthalten. Die txid/Adresse wird angehängt.",
|
||||||
@@ -1311,6 +1330,7 @@
|
|||||||
"settings_not_found": "Nicht gefunden",
|
"settings_not_found": "Nicht gefunden",
|
||||||
"settings_open_app_dir": "App-Ordner öffnen",
|
"settings_open_app_dir": "App-Ordner öffnen",
|
||||||
"settings_open_data_dir": "Datenordner öffnen",
|
"settings_open_data_dir": "Datenordner öffnen",
|
||||||
|
"settings_open_log_folder": "Log-Ordner öffnen",
|
||||||
"settings_other": "Sonstiges",
|
"settings_other": "Sonstiges",
|
||||||
"settings_pin_active": "PIN",
|
"settings_pin_active": "PIN",
|
||||||
"settings_privacy": "Datenschutz",
|
"settings_privacy": "Datenschutz",
|
||||||
@@ -1470,6 +1490,7 @@
|
|||||||
"tt_chat_timestamp": "Zeitstempelformat nur für diesen Tab: der app-weiten Uhr folgen oder 24-hour bzw. 12-hour erzwingen",
|
"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_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_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_fees": "Manuelle Gebühreneingabe beim Senden von Transaktionen aktivieren",
|
||||||
"tt_custom_theme": "Benutzerdefiniertes Theme aktiv",
|
"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",
|
"tt_daemon_install_bundled": "Node stoppen, den installierten dragonxd mit der in diesem Wallet-Build enthaltenen Version überschreiben und dann neu starten",
|
||||||
@@ -1523,6 +1544,7 @@
|
|||||||
"tt_open_app_dir": "Den ObsidianDragon-Ordner (Einstellungen, Themes, Logs) im Dateimanager öffnen",
|
"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_data_dir": "Den Ordner mit Ihren Wallet- und Blockchain-Daten im Dateimanager öffnen",
|
||||||
"tt_open_dir": "Klicken, um im Dateimanager zu ö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_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_encrypt": "Verschlüsselung entfernen und Wallet ungeschützt speichern",
|
||||||
"tt_remove_pin": "PIN entfernen und Passphrase zum Entsperren erfordern",
|
"tt_remove_pin": "PIN entfernen und Passphrase zum Entsperren erfordern",
|
||||||
|
|||||||
@@ -48,6 +48,10 @@
|
|||||||
"advanced": "AVANZADO",
|
"advanced": "AVANZADO",
|
||||||
"advanced_effects": "Efectos Avanzados...",
|
"advanced_effects": "Efectos Avanzados...",
|
||||||
"ago": "atrás",
|
"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",
|
"all_filter": "Todos",
|
||||||
"allow_custom_fees": "Permitir comisiones personalizadas",
|
"allow_custom_fees": "Permitir comisiones personalizadas",
|
||||||
"amount": "Cantidad",
|
"amount": "Cantidad",
|
||||||
@@ -451,6 +455,8 @@
|
|||||||
"daemon_update_version": "Versión:",
|
"daemon_update_version": "Versión:",
|
||||||
"daemon_version": "Daemon",
|
"daemon_version": "Daemon",
|
||||||
"dark": "Oscuro",
|
"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": "Fecha",
|
||||||
"date_label": "Fecha:",
|
"date_label": "Fecha:",
|
||||||
"debug_logging": "REGISTRO DE DEPURACIÓN",
|
"debug_logging": "REGISTRO DE DEPURACIÓN",
|
||||||
@@ -734,6 +740,9 @@
|
|||||||
"lite_working": "Trabajando…",
|
"lite_working": "Trabajando…",
|
||||||
"loading": "Cargando...",
|
"loading": "Cargando...",
|
||||||
"loading_addresses": "Cargando direcciones...",
|
"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",
|
"loading_transactions": "Cargando transacciones",
|
||||||
"local_hashrate": "Tasa Hash Local",
|
"local_hashrate": "Tasa Hash Local",
|
||||||
"low_spec_mode": "Modo bajo rendimiento",
|
"low_spec_mode": "Modo bajo rendimiento",
|
||||||
@@ -953,6 +962,11 @@
|
|||||||
"no_transactions": "No se encontraron transacciones",
|
"no_transactions": "No se encontraron transacciones",
|
||||||
"no_transactions_yet": "Aún no hay transacciones",
|
"no_transactions_yet": "Aún no hay transacciones",
|
||||||
"node": "NODO",
|
"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",
|
"node_security": "NODO Y SEGURIDAD",
|
||||||
"noise": "Ruido",
|
"noise": "Ruido",
|
||||||
"not_connected": "No conectado al daemon...",
|
"not_connected": "No conectado al daemon...",
|
||||||
@@ -1154,6 +1168,8 @@
|
|||||||
"sb_connecting_external": "Conectando a daemon externo...",
|
"sb_connecting_external": "Conectando a daemon externo...",
|
||||||
"sb_connecting_generic": "Conectando al daemon...",
|
"sb_connecting_generic": "Conectando al daemon...",
|
||||||
"sb_daemon_crashed": "El daemon se bloqueó %d veces",
|
"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_not_found": "Daemon no encontrado",
|
||||||
"sb_daemon_start_failed": "No se pudo iniciar dragonxd",
|
"sb_daemon_start_failed": "No se pudo iniciar dragonxd",
|
||||||
"sb_dragonxd_running": "dragonxd ejecutándose",
|
"sb_dragonxd_running": "dragonxd ejecutándose",
|
||||||
@@ -1169,6 +1185,7 @@
|
|||||||
"sb_net_mhs": "Red: %.2f MH/s",
|
"sb_net_mhs": "Red: %.2f MH/s",
|
||||||
"sb_no_conf": "DRAGONX.conf no encontrado",
|
"sb_no_conf": "DRAGONX.conf no encontrado",
|
||||||
"sb_peers": "Pares: %zu",
|
"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": "Reescaneando",
|
||||||
"sb_rescanning_pct": "Reescaneando %.0f%%",
|
"sb_rescanning_pct": "Reescaneando %.0f%%",
|
||||||
"sb_restarting_daemon": "Reiniciando daemon...",
|
"sb_restarting_daemon": "Reiniciando daemon...",
|
||||||
@@ -1285,12 +1302,14 @@
|
|||||||
"settings_configure_explorer": "Configurar enlaces de explorador de bloques externo",
|
"settings_configure_explorer": "Configurar enlaces de explorador de bloques externo",
|
||||||
"settings_configure_rpc": "Configurar conexión al daemon dragonxd",
|
"settings_configure_rpc": "Configurar conexión al daemon dragonxd",
|
||||||
"settings_connection": "Conexión",
|
"settings_connection": "Conexión",
|
||||||
|
"settings_copy_diagnostics": "Copiar diagnósticos",
|
||||||
"settings_copyright": "Copyright 2024-2026 Desarrolladores de DragonX | Licencia GPLv3",
|
"settings_copyright": "Copyright 2024-2026 Desarrolladores de DragonX | Licencia GPLv3",
|
||||||
"settings_custom": "Personalizado",
|
"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_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_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_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_first_pin": "Primero cifre la billetera para habilitar el PIN",
|
||||||
"settings_encrypt_wallet": "Cifrar billetera",
|
"settings_encrypt_wallet": "Cifrar billetera",
|
||||||
"settings_explorer_hint": "Las URLs deben incluir una barra final. Se añadirá el txid/dirección.",
|
"settings_explorer_hint": "Las URLs deben incluir una barra final. Se añadirá el txid/dirección.",
|
||||||
@@ -1311,6 +1330,7 @@
|
|||||||
"settings_not_found": "No encontrado",
|
"settings_not_found": "No encontrado",
|
||||||
"settings_open_app_dir": "Abrir carpeta de la aplicación",
|
"settings_open_app_dir": "Abrir carpeta de la aplicación",
|
||||||
"settings_open_data_dir": "Abrir carpeta de datos",
|
"settings_open_data_dir": "Abrir carpeta de datos",
|
||||||
|
"settings_open_log_folder": "Abrir carpeta de registros",
|
||||||
"settings_other": "Otros",
|
"settings_other": "Otros",
|
||||||
"settings_pin_active": "PIN",
|
"settings_pin_active": "PIN",
|
||||||
"settings_privacy": "Privacidad",
|
"settings_privacy": "Privacidad",
|
||||||
@@ -1470,6 +1490,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_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_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_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_fees": "Habilitar entrada manual de comisiones al enviar transacciones",
|
||||||
"tt_custom_theme": "Tema personalizado activo",
|
"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",
|
"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",
|
||||||
@@ -1523,6 +1544,7 @@
|
|||||||
"tt_open_app_dir": "Abrir la carpeta de ObsidianDragon (configuración, temas, registros) en el explorador de archivos",
|
"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_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_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_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_encrypt": "Quitar cifrado y almacenar la billetera sin protección",
|
||||||
"tt_remove_pin": "Quitar PIN y requerir contraseña para desbloquear",
|
"tt_remove_pin": "Quitar PIN y requerir contraseña para desbloquear",
|
||||||
|
|||||||
@@ -48,6 +48,10 @@
|
|||||||
"advanced": "AVANCÉ",
|
"advanced": "AVANCÉ",
|
||||||
"advanced_effects": "Effets avancés...",
|
"advanced_effects": "Effets avancés...",
|
||||||
"ago": "passé",
|
"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",
|
"all_filter": "Tout",
|
||||||
"allow_custom_fees": "Autoriser les frais personnalisés",
|
"allow_custom_fees": "Autoriser les frais personnalisés",
|
||||||
"amount": "Montant",
|
"amount": "Montant",
|
||||||
@@ -451,6 +455,8 @@
|
|||||||
"daemon_update_version": "Version :",
|
"daemon_update_version": "Version :",
|
||||||
"daemon_version": "Daemon",
|
"daemon_version": "Daemon",
|
||||||
"dark": "Sombre",
|
"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": "Date",
|
||||||
"date_label": "Date :",
|
"date_label": "Date :",
|
||||||
"debug_logging": "JOURNALISATION DE DÉBOGAGE",
|
"debug_logging": "JOURNALISATION DE DÉBOGAGE",
|
||||||
@@ -734,6 +740,9 @@
|
|||||||
"lite_working": "En cours…",
|
"lite_working": "En cours…",
|
||||||
"loading": "Chargement...",
|
"loading": "Chargement...",
|
||||||
"loading_addresses": "Chargement des adresses...",
|
"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",
|
"loading_transactions": "Chargement des transactions",
|
||||||
"local_hashrate": "Hashrate local",
|
"local_hashrate": "Hashrate local",
|
||||||
"low_spec_mode": "Mode économie",
|
"low_spec_mode": "Mode économie",
|
||||||
@@ -953,6 +962,11 @@
|
|||||||
"no_transactions": "Aucune transaction trouvée",
|
"no_transactions": "Aucune transaction trouvée",
|
||||||
"no_transactions_yet": "Aucune transaction pour le moment",
|
"no_transactions_yet": "Aucune transaction pour le moment",
|
||||||
"node": "NŒUD",
|
"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É",
|
"node_security": "NŒUD & SÉCURITÉ",
|
||||||
"noise": "Bruit",
|
"noise": "Bruit",
|
||||||
"not_connected": "Non connecté au daemon...",
|
"not_connected": "Non connecté au daemon...",
|
||||||
@@ -1154,6 +1168,8 @@
|
|||||||
"sb_connecting_external": "Connexion au daemon externe...",
|
"sb_connecting_external": "Connexion au daemon externe...",
|
||||||
"sb_connecting_generic": "Connexion au daemon...",
|
"sb_connecting_generic": "Connexion au daemon...",
|
||||||
"sb_daemon_crashed": "Le daemon a planté %d fois",
|
"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_not_found": "Daemon introuvable",
|
||||||
"sb_daemon_start_failed": "Impossible de démarrer dragonxd",
|
"sb_daemon_start_failed": "Impossible de démarrer dragonxd",
|
||||||
"sb_dragonxd_running": "dragonxd en cours",
|
"sb_dragonxd_running": "dragonxd en cours",
|
||||||
@@ -1169,6 +1185,7 @@
|
|||||||
"sb_net_mhs": "Rés: %.2f MH/s",
|
"sb_net_mhs": "Rés: %.2f MH/s",
|
||||||
"sb_no_conf": "DRAGONX.conf introuvable",
|
"sb_no_conf": "DRAGONX.conf introuvable",
|
||||||
"sb_peers": "Pairs : %zu",
|
"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": "Rescan",
|
||||||
"sb_rescanning_pct": "Rescan %.0f%%",
|
"sb_rescanning_pct": "Rescan %.0f%%",
|
||||||
"sb_restarting_daemon": "Redémarrage du daemon...",
|
"sb_restarting_daemon": "Redémarrage du daemon...",
|
||||||
@@ -1285,12 +1302,14 @@
|
|||||||
"settings_configure_explorer": "Configurer les liens vers l'explorateur de blocs externe",
|
"settings_configure_explorer": "Configurer les liens vers l'explorateur de blocs externe",
|
||||||
"settings_configure_rpc": "Configurer la connexion au daemon dragonxd",
|
"settings_configure_rpc": "Configurer la connexion au daemon dragonxd",
|
||||||
"settings_connection": "Connexion",
|
"settings_connection": "Connexion",
|
||||||
|
"settings_copy_diagnostics": "Copier les diagnostics",
|
||||||
"settings_copyright": "Copyright 2024-2026 Développeurs DragonX | Licence GPLv3",
|
"settings_copyright": "Copyright 2024-2026 Développeurs DragonX | Licence GPLv3",
|
||||||
"settings_custom": "Personnalisé",
|
"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_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_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_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_first_pin": "Chiffrez d'abord le portefeuille pour activer le PIN",
|
||||||
"settings_encrypt_wallet": "Chiffrer le portefeuille",
|
"settings_encrypt_wallet": "Chiffrer le portefeuille",
|
||||||
"settings_explorer_hint": "Les URLs doivent inclure une barre oblique finale. Le txid/adresse sera ajouté.",
|
"settings_explorer_hint": "Les URLs doivent inclure une barre oblique finale. Le txid/adresse sera ajouté.",
|
||||||
@@ -1311,6 +1330,7 @@
|
|||||||
"settings_not_found": "Non trouvé",
|
"settings_not_found": "Non trouvé",
|
||||||
"settings_open_app_dir": "Ouvrir le dossier de l'application",
|
"settings_open_app_dir": "Ouvrir le dossier de l'application",
|
||||||
"settings_open_data_dir": "Ouvrir le dossier de données",
|
"settings_open_data_dir": "Ouvrir le dossier de données",
|
||||||
|
"settings_open_log_folder": "Ouvrir le dossier des journaux",
|
||||||
"settings_other": "Autres",
|
"settings_other": "Autres",
|
||||||
"settings_pin_active": "PIN",
|
"settings_pin_active": "PIN",
|
||||||
"settings_privacy": "Confidentialité",
|
"settings_privacy": "Confidentialité",
|
||||||
@@ -1470,6 +1490,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_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_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_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_fees": "Activer la saisie manuelle des frais lors de l'envoi de transactions",
|
||||||
"tt_custom_theme": "Thème personnalisé actif",
|
"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",
|
"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",
|
||||||
@@ -1523,6 +1544,7 @@
|
|||||||
"tt_open_app_dir": "Ouvrir le dossier ObsidianDragon (paramètres, thèmes, journaux) dans le gestionnaire de fichiers",
|
"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_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_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_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_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",
|
"tt_remove_pin": "Supprimer le PIN et exiger la phrase secrète pour déverrouiller",
|
||||||
|
|||||||
@@ -48,6 +48,10 @@
|
|||||||
"advanced": "詳細設定",
|
"advanced": "詳細設定",
|
||||||
"advanced_effects": "高度なエフェクト...",
|
"advanced_effects": "高度なエフェクト...",
|
||||||
"ago": "前",
|
"ago": "前",
|
||||||
|
"alerts_clear": "通知履歴を消去",
|
||||||
|
"alerts_history_tooltip": "最近の通知",
|
||||||
|
"alerts_none": "通知はまだありません",
|
||||||
|
"alerts_recent": "最近の通知",
|
||||||
"all_filter": "すべて",
|
"all_filter": "すべて",
|
||||||
"allow_custom_fees": "カスタム手数料を許可",
|
"allow_custom_fees": "カスタム手数料を許可",
|
||||||
"amount": "金額",
|
"amount": "金額",
|
||||||
@@ -451,6 +455,8 @@
|
|||||||
"daemon_update_version": "バージョン:",
|
"daemon_update_version": "バージョン:",
|
||||||
"daemon_version": "デーモン",
|
"daemon_version": "デーモン",
|
||||||
"dark": "ダーク",
|
"dark": "ダーク",
|
||||||
|
"data_stale_prefix": "更新",
|
||||||
|
"data_stale_tooltip": "残高が最新でない可能性があります。ウォレットは最近更新を受信していません。ノード接続を確認してください。",
|
||||||
"date": "日付",
|
"date": "日付",
|
||||||
"date_label": "日付:",
|
"date_label": "日付:",
|
||||||
"debug_logging": "デバッグログ",
|
"debug_logging": "デバッグログ",
|
||||||
@@ -734,6 +740,9 @@
|
|||||||
"lite_working": "処理中…",
|
"lite_working": "処理中…",
|
||||||
"loading": "読み込み中...",
|
"loading": "読み込み中...",
|
||||||
"loading_addresses": "アドレスを読み込み中...",
|
"loading_addresses": "アドレスを読み込み中...",
|
||||||
|
"loading_stall_body": "デーモンは %.0f 秒間初期化しています。アップデート後や初回起動時(ブロックインデックスの読み込みや再スキャン)は正常な場合があります。準備ができ次第、自動的に接続します。",
|
||||||
|
"loading_stall_hint": "まだ動かない場合は、設定を開いて「デーモンを再起動」を使うか、コンソールで詳細を確認してください。",
|
||||||
|
"loading_stall_title": "予想より時間がかかっています",
|
||||||
"loading_transactions": "トランザクションを読み込み中",
|
"loading_transactions": "トランザクションを読み込み中",
|
||||||
"local_hashrate": "ローカルハッシュレート",
|
"local_hashrate": "ローカルハッシュレート",
|
||||||
"low_spec_mode": "省電力モード",
|
"low_spec_mode": "省電力モード",
|
||||||
@@ -953,6 +962,11 @@
|
|||||||
"no_transactions": "取引が見つかりません",
|
"no_transactions": "取引が見つかりません",
|
||||||
"no_transactions_yet": "まだ取引がありません",
|
"no_transactions_yet": "まだ取引がありません",
|
||||||
"node": "ノード",
|
"node": "ノード",
|
||||||
|
"node_banner_crashed_title": "ノードが予期せず停止しました",
|
||||||
|
"node_banner_lite_open_failed": "ウォレットを開けませんでした",
|
||||||
|
"node_banner_offline_title": "DragonX ノードに接続されていません",
|
||||||
|
"node_banner_reconnect": "再接続",
|
||||||
|
"node_banner_restart": "ノードを再起動",
|
||||||
"node_security": "ノードとセキュリティ",
|
"node_security": "ノードとセキュリティ",
|
||||||
"noise": "ノイズ",
|
"noise": "ノイズ",
|
||||||
"not_connected": "デーモンに未接続...",
|
"not_connected": "デーモンに未接続...",
|
||||||
@@ -1285,12 +1299,14 @@
|
|||||||
"settings_configure_explorer": "外部ブロックエクスプローラーリンクを設定",
|
"settings_configure_explorer": "外部ブロックエクスプローラーリンクを設定",
|
||||||
"settings_configure_rpc": "dragonxd デーモンへの接続を設定",
|
"settings_configure_rpc": "dragonxd デーモンへの接続を設定",
|
||||||
"settings_connection": "接続",
|
"settings_connection": "接続",
|
||||||
|
"settings_copy_diagnostics": "診断情報をコピー",
|
||||||
"settings_copyright": "Copyright 2024-2026 DragonX 開発者 | GPLv3 ライセンス",
|
"settings_copyright": "Copyright 2024-2026 DragonX 開発者 | GPLv3 ライセンス",
|
||||||
"settings_custom": "カスタム",
|
"settings_custom": "カスタム",
|
||||||
"settings_data_dir": "データディレクトリ:",
|
"settings_data_dir": "データディレクトリ:",
|
||||||
"settings_debug_changed": "デバッグカテゴリが変更されました — デーモンを再起動して適用",
|
"settings_debug_changed": "デバッグカテゴリが変更されました — デーモンを再起動して適用",
|
||||||
"settings_debug_restart_note": "変更はデーモンの再起動後に有効になります。",
|
"settings_debug_restart_note": "変更はデーモンの再起動後に有効になります。",
|
||||||
"settings_debug_select": "デーモンのデバッグログを有効にするカテゴリを選択(-debug= フラグ)。",
|
"settings_debug_select": "デーモンのデバッグログを有効にするカテゴリを選択(-debug= フラグ)。",
|
||||||
|
"settings_diagnostics_copied": "診断情報をクリップボードにコピーしました",
|
||||||
"settings_encrypt_first_pin": "PIN を有効にするには、まずウォレットを暗号化してください",
|
"settings_encrypt_first_pin": "PIN を有効にするには、まずウォレットを暗号化してください",
|
||||||
"settings_encrypt_wallet": "ウォレットを暗号化",
|
"settings_encrypt_wallet": "ウォレットを暗号化",
|
||||||
"settings_explorer_hint": "URLには末尾のスラッシュを含めてください。txid/アドレスが追加されます。",
|
"settings_explorer_hint": "URLには末尾のスラッシュを含めてください。txid/アドレスが追加されます。",
|
||||||
@@ -1311,6 +1327,7 @@
|
|||||||
"settings_not_found": "見つかりません",
|
"settings_not_found": "見つかりません",
|
||||||
"settings_open_app_dir": "アプリフォルダを開く",
|
"settings_open_app_dir": "アプリフォルダを開く",
|
||||||
"settings_open_data_dir": "データフォルダを開く",
|
"settings_open_data_dir": "データフォルダを開く",
|
||||||
|
"settings_open_log_folder": "ログフォルダを開く",
|
||||||
"settings_other": "その他",
|
"settings_other": "その他",
|
||||||
"settings_pin_active": "PIN",
|
"settings_pin_active": "PIN",
|
||||||
"settings_privacy": "プライバシー",
|
"settings_privacy": "プライバシー",
|
||||||
@@ -1470,6 +1487,7 @@
|
|||||||
"tt_chat_timestamp": "このタブのみのタイムスタンプ形式:アプリ全体の時計に従うか、24-hourまたは12-hourを強制します",
|
"tt_chat_timestamp": "このタブのみのタイムスタンプ形式:アプリ全体の時計に従うか、24-hourまたは12-hourを強制します",
|
||||||
"tt_clear_ztx": "ローカルにキャッシュされた z-トランザクション履歴を削除",
|
"tt_clear_ztx": "ローカルにキャッシュされた z-トランザクション履歴を削除",
|
||||||
"tt_clock_format": "24時間または12時間表示(アプリ全体)。チャットで上書きできます。",
|
"tt_clock_format": "24時間または12時間表示(アプリ全体)。チャットで上書きできます。",
|
||||||
|
"tt_copy_diagnostics": "サポート用の概要(バージョン、デーモン/ウォレット/ログの状態 — 秘密情報なし)をクリップボードにコピーします",
|
||||||
"tt_custom_fees": "トランザクション送信時に手動手数料入力を有効化",
|
"tt_custom_fees": "トランザクション送信時に手動手数料入力を有効化",
|
||||||
"tt_custom_theme": "カスタムテーマがアクティブ",
|
"tt_custom_theme": "カスタムテーマがアクティブ",
|
||||||
"tt_daemon_install_bundled": "ノードを停止し、インストール済みの dragonxd をこのウォレットビルドにバンドルされたバージョンで上書きしてから再起動します",
|
"tt_daemon_install_bundled": "ノードを停止し、インストール済みの dragonxd をこのウォレットビルドにバンドルされたバージョンで上書きしてから再起動します",
|
||||||
@@ -1523,6 +1541,7 @@
|
|||||||
"tt_open_app_dir": "ObsidianDragon フォルダ(設定、テーマ、ログ)をファイルマネージャーで開く",
|
"tt_open_app_dir": "ObsidianDragon フォルダ(設定、テーマ、ログ)をファイルマネージャーで開く",
|
||||||
"tt_open_data_dir": "ファイルマネージャーでウォレットとブロックチェーンデータのフォルダを開きます",
|
"tt_open_data_dir": "ファイルマネージャーでウォレットとブロックチェーンデータのフォルダを開きます",
|
||||||
"tt_open_dir": "クリックしてファイルエクスプローラーで開く",
|
"tt_open_dir": "クリックしてファイルエクスプローラーで開く",
|
||||||
|
"tt_open_log_folder": "デバッグログとクラッシュログが入ったフォルダを開きます",
|
||||||
"tt_reduce_motion": "アクセシビリティのためにアニメーション遷移と残高補間を無効にする",
|
"tt_reduce_motion": "アクセシビリティのためにアニメーション遷移と残高補間を無効にする",
|
||||||
"tt_remove_encrypt": "暗号化を解除してウォレットを保護なしで保存",
|
"tt_remove_encrypt": "暗号化を解除してウォレットを保護なしで保存",
|
||||||
"tt_remove_pin": "PIN を削除しアンロックにパスフレーズを要求",
|
"tt_remove_pin": "PIN を削除しアンロックにパスフレーズを要求",
|
||||||
|
|||||||
@@ -48,6 +48,10 @@
|
|||||||
"advanced": "고급 설정",
|
"advanced": "고급 설정",
|
||||||
"advanced_effects": "고급 효과...",
|
"advanced_effects": "고급 효과...",
|
||||||
"ago": "전",
|
"ago": "전",
|
||||||
|
"alerts_clear": "알림 기록 지우기",
|
||||||
|
"alerts_history_tooltip": "최근 알림",
|
||||||
|
"alerts_none": "아직 알림이 없습니다",
|
||||||
|
"alerts_recent": "최근 알림",
|
||||||
"all_filter": "전체",
|
"all_filter": "전체",
|
||||||
"allow_custom_fees": "사용자 정의 수수료 허용",
|
"allow_custom_fees": "사용자 정의 수수료 허용",
|
||||||
"amount": "금액",
|
"amount": "금액",
|
||||||
@@ -451,6 +455,8 @@
|
|||||||
"daemon_update_version": "버전:",
|
"daemon_update_version": "버전:",
|
||||||
"daemon_version": "데몬",
|
"daemon_version": "데몬",
|
||||||
"dark": "다크",
|
"dark": "다크",
|
||||||
|
"data_stale_prefix": "업데이트",
|
||||||
|
"data_stale_tooltip": "잔액이 오래되었을 수 있습니다 — 지갑이 최근에 업데이트를 받지 못했습니다. 노드 연결을 확인하세요.",
|
||||||
"date": "날짜",
|
"date": "날짜",
|
||||||
"date_label": "날짜:",
|
"date_label": "날짜:",
|
||||||
"debug_logging": "디버그 로깅",
|
"debug_logging": "디버그 로깅",
|
||||||
@@ -734,6 +740,8 @@
|
|||||||
"lite_working": "작업 중…",
|
"lite_working": "작업 중…",
|
||||||
"loading": "로딩 중...",
|
"loading": "로딩 중...",
|
||||||
"loading_addresses": "주소 로딩 중...",
|
"loading_addresses": "주소 로딩 중...",
|
||||||
|
"loading_stall_body": "데몬이 %.0f초 동안 초기화 중입니다. 업데이트 후나 첫 실행 시(블록 인덱스 로드 또는 재스캔)에는 정상일 수 있습니다. 준비되면 자동으로 연결됩니다.",
|
||||||
|
"loading_stall_title": "예상보다 오래 걸리고 있습니다",
|
||||||
"loading_transactions": "거래를 불러오는 중",
|
"loading_transactions": "거래를 불러오는 중",
|
||||||
"local_hashrate": "로컬 해시레이트",
|
"local_hashrate": "로컬 해시레이트",
|
||||||
"low_spec_mode": "저사양 모드",
|
"low_spec_mode": "저사양 모드",
|
||||||
@@ -953,6 +961,11 @@
|
|||||||
"no_transactions": "거래 내역이 없습니다",
|
"no_transactions": "거래 내역이 없습니다",
|
||||||
"no_transactions_yet": "아직 거래 내역이 없습니다",
|
"no_transactions_yet": "아직 거래 내역이 없습니다",
|
||||||
"node": "노드",
|
"node": "노드",
|
||||||
|
"node_banner_crashed_title": "노드가 예기치 않게 중지되었습니다",
|
||||||
|
"node_banner_lite_open_failed": "지갑을 열 수 없습니다",
|
||||||
|
"node_banner_offline_title": "DragonX 노드에 연결되지 않음",
|
||||||
|
"node_banner_reconnect": "재연결",
|
||||||
|
"node_banner_restart": "노드 재시작",
|
||||||
"node_security": "노드 및 보안",
|
"node_security": "노드 및 보안",
|
||||||
"noise": "노이즈",
|
"noise": "노이즈",
|
||||||
"not_connected": "데몬에 연결되지 않음...",
|
"not_connected": "데몬에 연결되지 않음...",
|
||||||
@@ -1154,6 +1167,8 @@
|
|||||||
"sb_connecting_external": "외부 데몬에 연결 중...",
|
"sb_connecting_external": "외부 데몬에 연결 중...",
|
||||||
"sb_connecting_generic": "데몬에 연결 중...",
|
"sb_connecting_generic": "데몬에 연결 중...",
|
||||||
"sb_daemon_crashed": "데몬이 %d회 충돌함",
|
"sb_daemon_crashed": "데몬이 %d회 충돌함",
|
||||||
|
"sb_daemon_extract_failed": "데몬 파일을 쓰지 못했습니다. 디스크 여유 공간과 권한을 확인하세요.",
|
||||||
|
"sb_daemon_files_failed": "%s에 데몬 파일을 쓰지 못했습니다. 디스크 여유 공간과 권한을 확인하세요.",
|
||||||
"sb_daemon_not_found": "데몬을 찾을 수 없음",
|
"sb_daemon_not_found": "데몬을 찾을 수 없음",
|
||||||
"sb_daemon_start_failed": "dragonxd를 시작할 수 없습니다",
|
"sb_daemon_start_failed": "dragonxd를 시작할 수 없습니다",
|
||||||
"sb_dragonxd_running": "dragonxd 실행 중",
|
"sb_dragonxd_running": "dragonxd 실행 중",
|
||||||
@@ -1169,6 +1184,7 @@
|
|||||||
"sb_net_mhs": "네트: %.2f MH/s",
|
"sb_net_mhs": "네트: %.2f MH/s",
|
||||||
"sb_no_conf": "DRAGONX.conf를 찾을 수 없음",
|
"sb_no_conf": "DRAGONX.conf를 찾을 수 없음",
|
||||||
"sb_peers": "피어: %zu",
|
"sb_peers": "피어: %zu",
|
||||||
|
"sb_plaintext_remote_blocked": "원격 호스트로 RPC 자격 증명을 평문으로 보내는 것을 거부했습니다. 허용하려면 DRAGONX.conf에 rpcallowplaintext=1을 추가하거나 rpctls=1로 TLS를 활성화하세요.",
|
||||||
"sb_rescanning": "재스캔",
|
"sb_rescanning": "재스캔",
|
||||||
"sb_rescanning_pct": "재스캔 %.0f%%",
|
"sb_rescanning_pct": "재스캔 %.0f%%",
|
||||||
"sb_restarting_daemon": "데몬 재시작 중...",
|
"sb_restarting_daemon": "데몬 재시작 중...",
|
||||||
@@ -1285,12 +1301,14 @@
|
|||||||
"settings_configure_explorer": "외부 블록 탐색기 링크 구성",
|
"settings_configure_explorer": "외부 블록 탐색기 링크 구성",
|
||||||
"settings_configure_rpc": "dragonxd 데몬 연결 구성",
|
"settings_configure_rpc": "dragonxd 데몬 연결 구성",
|
||||||
"settings_connection": "연결",
|
"settings_connection": "연결",
|
||||||
|
"settings_copy_diagnostics": "진단 정보 복사",
|
||||||
"settings_copyright": "Copyright 2024-2026 DragonX 개발자 | GPLv3 라이선스",
|
"settings_copyright": "Copyright 2024-2026 DragonX 개발자 | GPLv3 라이선스",
|
||||||
"settings_custom": "사용자 지정",
|
"settings_custom": "사용자 지정",
|
||||||
"settings_data_dir": "데이터 디렉터리:",
|
"settings_data_dir": "데이터 디렉터리:",
|
||||||
"settings_debug_changed": "디버그 카테고리가 변경되었습니다 — 데몬을 재시작하여 적용",
|
"settings_debug_changed": "디버그 카테고리가 변경되었습니다 — 데몬을 재시작하여 적용",
|
||||||
"settings_debug_restart_note": "변경 사항은 데몬을 다시 시작한 후에 적용됩니다.",
|
"settings_debug_restart_note": "변경 사항은 데몬을 다시 시작한 후에 적용됩니다.",
|
||||||
"settings_debug_select": "데몬 디버그 로깅을 활성화할 카테고리를 선택하세요 (-debug= 플래그).",
|
"settings_debug_select": "데몬 디버그 로깅을 활성화할 카테고리를 선택하세요 (-debug= 플래그).",
|
||||||
|
"settings_diagnostics_copied": "진단 정보를 클립보드에 복사했습니다",
|
||||||
"settings_encrypt_first_pin": "PIN을 활성화하려면 먼저 지갑을 암호화하세요",
|
"settings_encrypt_first_pin": "PIN을 활성화하려면 먼저 지갑을 암호화하세요",
|
||||||
"settings_encrypt_wallet": "지갑 암호화",
|
"settings_encrypt_wallet": "지갑 암호화",
|
||||||
"settings_explorer_hint": "URL에 후행 슬래시를 포함해야 합니다. txid/주소가 추가됩니다.",
|
"settings_explorer_hint": "URL에 후행 슬래시를 포함해야 합니다. txid/주소가 추가됩니다.",
|
||||||
@@ -1311,6 +1329,7 @@
|
|||||||
"settings_not_found": "찾을 수 없음",
|
"settings_not_found": "찾을 수 없음",
|
||||||
"settings_open_app_dir": "앱 폴더 열기",
|
"settings_open_app_dir": "앱 폴더 열기",
|
||||||
"settings_open_data_dir": "데이터 폴더 열기",
|
"settings_open_data_dir": "데이터 폴더 열기",
|
||||||
|
"settings_open_log_folder": "로그 폴더 열기",
|
||||||
"settings_other": "기타",
|
"settings_other": "기타",
|
||||||
"settings_pin_active": "PIN",
|
"settings_pin_active": "PIN",
|
||||||
"settings_privacy": "개인 정보",
|
"settings_privacy": "개인 정보",
|
||||||
@@ -1470,6 +1489,7 @@
|
|||||||
"tt_chat_timestamp": "이 탭에만 적용되는 타임스탬프 형식: 앱 전체 시계를 따르거나 24-hour 또는 12-hour로 강제합니다",
|
"tt_chat_timestamp": "이 탭에만 적용되는 타임스탬프 형식: 앱 전체 시계를 따르거나 24-hour 또는 12-hour로 강제합니다",
|
||||||
"tt_clear_ztx": "로컬에 캐시된 z-트랜잭션 기록 삭제",
|
"tt_clear_ztx": "로컬에 캐시된 z-트랜잭션 기록 삭제",
|
||||||
"tt_clock_format": "24시간 또는 12시간 형식(앱 전체). 채팅에서 재정의할 수 있습니다.",
|
"tt_clock_format": "24시간 또는 12시간 형식(앱 전체). 채팅에서 재정의할 수 있습니다.",
|
||||||
|
"tt_copy_diagnostics": "지원용 요약(버전, 데몬/지갑/로그 상태 — 비밀 정보 없음)을 클립보드에 복사합니다",
|
||||||
"tt_custom_fees": "거래 전송 시 수동 수수료 입력 활성화",
|
"tt_custom_fees": "거래 전송 시 수동 수수료 입력 활성화",
|
||||||
"tt_custom_theme": "사용자 지정 테마 활성화됨",
|
"tt_custom_theme": "사용자 지정 테마 활성화됨",
|
||||||
"tt_daemon_install_bundled": "노드를 중지하고 설치된 dragonxd를 이 지갑 빌드에 번들된 버전으로 덮어쓴 다음 재시작합니다",
|
"tt_daemon_install_bundled": "노드를 중지하고 설치된 dragonxd를 이 지갑 빌드에 번들된 버전으로 덮어쓴 다음 재시작합니다",
|
||||||
@@ -1523,6 +1543,7 @@
|
|||||||
"tt_open_app_dir": "파일 관리자에서 ObsidianDragon 폴더(설정, 테마, 로그)를 엽니다",
|
"tt_open_app_dir": "파일 관리자에서 ObsidianDragon 폴더(설정, 테마, 로그)를 엽니다",
|
||||||
"tt_open_data_dir": "지갑 및 블록체인 데이터가 있는 폴더를 파일 탐색기에서 엽니다",
|
"tt_open_data_dir": "지갑 및 블록체인 데이터가 있는 폴더를 파일 탐색기에서 엽니다",
|
||||||
"tt_open_dir": "파일 탐색기에서 열려면 클릭",
|
"tt_open_dir": "파일 탐색기에서 열려면 클릭",
|
||||||
|
"tt_open_log_folder": "디버그 및 충돌 로그가 있는 폴더를 엽니다",
|
||||||
"tt_reduce_motion": "접근성을 위해 애니메이션 전환 및 잔액 보간 비활성화",
|
"tt_reduce_motion": "접근성을 위해 애니메이션 전환 및 잔액 보간 비활성화",
|
||||||
"tt_remove_encrypt": "암호화를 제거하고 지갑을 보호 없이 저장",
|
"tt_remove_encrypt": "암호화를 제거하고 지갑을 보호 없이 저장",
|
||||||
"tt_remove_pin": "PIN을 제거하고 잠금 해제 시 비밀번호 요구",
|
"tt_remove_pin": "PIN을 제거하고 잠금 해제 시 비밀번호 요구",
|
||||||
|
|||||||
@@ -48,6 +48,10 @@
|
|||||||
"advanced": "AVANÇADO",
|
"advanced": "AVANÇADO",
|
||||||
"advanced_effects": "Efeitos Avançados...",
|
"advanced_effects": "Efeitos Avançados...",
|
||||||
"ago": "atrás",
|
"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",
|
"all_filter": "Todos",
|
||||||
"allow_custom_fees": "Permitir taxas personalizadas",
|
"allow_custom_fees": "Permitir taxas personalizadas",
|
||||||
"amount": "Valor",
|
"amount": "Valor",
|
||||||
@@ -451,6 +455,8 @@
|
|||||||
"daemon_update_version": "Versão:",
|
"daemon_update_version": "Versão:",
|
||||||
"daemon_version": "Daemon",
|
"daemon_version": "Daemon",
|
||||||
"dark": "Escuro",
|
"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": "Data",
|
||||||
"date_label": "Data:",
|
"date_label": "Data:",
|
||||||
"debug_logging": "REGISTRO DE DEPURAÇÃO",
|
"debug_logging": "REGISTRO DE DEPURAÇÃO",
|
||||||
@@ -734,6 +740,9 @@
|
|||||||
"lite_working": "Processando…",
|
"lite_working": "Processando…",
|
||||||
"loading": "Carregando...",
|
"loading": "Carregando...",
|
||||||
"loading_addresses": "Carregando endereços...",
|
"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",
|
"loading_transactions": "Carregando transações",
|
||||||
"local_hashrate": "Hashrate Local",
|
"local_hashrate": "Hashrate Local",
|
||||||
"low_spec_mode": "Modo econômico",
|
"low_spec_mode": "Modo econômico",
|
||||||
@@ -953,6 +962,11 @@
|
|||||||
"no_transactions": "Nenhuma transação encontrada",
|
"no_transactions": "Nenhuma transação encontrada",
|
||||||
"no_transactions_yet": "Nenhuma transação ainda",
|
"no_transactions_yet": "Nenhuma transação ainda",
|
||||||
"node": "NÓ",
|
"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",
|
"node_security": "NÓ & SEGURANÇA",
|
||||||
"noise": "Ruído",
|
"noise": "Ruído",
|
||||||
"not_connected": "Não conectado ao daemon...",
|
"not_connected": "Não conectado ao daemon...",
|
||||||
@@ -1154,6 +1168,8 @@
|
|||||||
"sb_connecting_external": "Conectando ao daemon externo...",
|
"sb_connecting_external": "Conectando ao daemon externo...",
|
||||||
"sb_connecting_generic": "Conectando ao daemon...",
|
"sb_connecting_generic": "Conectando ao daemon...",
|
||||||
"sb_daemon_crashed": "O daemon travou %d vezes",
|
"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_not_found": "Daemon não encontrado",
|
||||||
"sb_daemon_start_failed": "Não foi possível iniciar o dragonxd",
|
"sb_daemon_start_failed": "Não foi possível iniciar o dragonxd",
|
||||||
"sb_dragonxd_running": "dragonxd em execução",
|
"sb_dragonxd_running": "dragonxd em execução",
|
||||||
@@ -1169,6 +1185,7 @@
|
|||||||
"sb_net_mhs": "Rede: %.2f MH/s",
|
"sb_net_mhs": "Rede: %.2f MH/s",
|
||||||
"sb_no_conf": "DRAGONX.conf não encontrado",
|
"sb_no_conf": "DRAGONX.conf não encontrado",
|
||||||
"sb_peers": "Pares: %zu",
|
"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": "Reescaneando",
|
||||||
"sb_rescanning_pct": "Reescaneando %.0f%%",
|
"sb_rescanning_pct": "Reescaneando %.0f%%",
|
||||||
"sb_restarting_daemon": "Reiniciando daemon...",
|
"sb_restarting_daemon": "Reiniciando daemon...",
|
||||||
@@ -1285,12 +1302,14 @@
|
|||||||
"settings_configure_explorer": "Configurar links do explorador de blocos externo",
|
"settings_configure_explorer": "Configurar links do explorador de blocos externo",
|
||||||
"settings_configure_rpc": "Configurar conexão ao daemon dragonxd",
|
"settings_configure_rpc": "Configurar conexão ao daemon dragonxd",
|
||||||
"settings_connection": "Conexão",
|
"settings_connection": "Conexão",
|
||||||
|
"settings_copy_diagnostics": "Copiar diagnósticos",
|
||||||
"settings_copyright": "Copyright 2024-2026 Desenvolvedores DragonX | Licença GPLv3",
|
"settings_copyright": "Copyright 2024-2026 Desenvolvedores DragonX | Licença GPLv3",
|
||||||
"settings_custom": "Personalizado",
|
"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_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_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_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_first_pin": "Encripte a carteira primeiro para ativar o PIN",
|
||||||
"settings_encrypt_wallet": "Encriptar carteira",
|
"settings_encrypt_wallet": "Encriptar carteira",
|
||||||
"settings_explorer_hint": "As URLs devem incluir uma barra final. O txid/endereço será adicionado.",
|
"settings_explorer_hint": "As URLs devem incluir uma barra final. O txid/endereço será adicionado.",
|
||||||
@@ -1311,6 +1330,7 @@
|
|||||||
"settings_not_found": "Não encontrado",
|
"settings_not_found": "Não encontrado",
|
||||||
"settings_open_app_dir": "Abrir pasta do aplicativo",
|
"settings_open_app_dir": "Abrir pasta do aplicativo",
|
||||||
"settings_open_data_dir": "Abrir pasta de dados",
|
"settings_open_data_dir": "Abrir pasta de dados",
|
||||||
|
"settings_open_log_folder": "Abrir pasta de logs",
|
||||||
"settings_other": "Outros",
|
"settings_other": "Outros",
|
||||||
"settings_pin_active": "PIN",
|
"settings_pin_active": "PIN",
|
||||||
"settings_privacy": "Privacidade",
|
"settings_privacy": "Privacidade",
|
||||||
@@ -1470,6 +1490,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_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_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_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_fees": "Ativar entrada manual de taxas ao enviar transações",
|
||||||
"tt_custom_theme": "Tema personalizado ativo",
|
"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",
|
"tt_daemon_install_bundled": "Parar o nó, sobrescrever o dragonxd instalado com a versão incluída nesta compilação da carteira e reiniciar",
|
||||||
@@ -1523,6 +1544,7 @@
|
|||||||
"tt_open_app_dir": "Abrir a pasta ObsidianDragon (configurações, temas, logs) no gerenciador de arquivos",
|
"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_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_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_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_encrypt": "Remover encriptação e armazenar a carteira desprotegida",
|
||||||
"tt_remove_pin": "Remover PIN e exigir frase secreta para desbloquear",
|
"tt_remove_pin": "Remover PIN e exigir frase secreta para desbloquear",
|
||||||
|
|||||||
@@ -48,6 +48,10 @@
|
|||||||
"advanced": "ПРОЧЕЕ",
|
"advanced": "ПРОЧЕЕ",
|
||||||
"advanced_effects": "Расширенные эффекты...",
|
"advanced_effects": "Расширенные эффекты...",
|
||||||
"ago": "назад",
|
"ago": "назад",
|
||||||
|
"alerts_clear": "Очистить историю оповещений",
|
||||||
|
"alerts_history_tooltip": "Недавние оповещения",
|
||||||
|
"alerts_none": "Пока нет оповещений",
|
||||||
|
"alerts_recent": "НЕДАВНИЕ ОПОВЕЩЕНИЯ",
|
||||||
"all_filter": "Все",
|
"all_filter": "Все",
|
||||||
"allow_custom_fees": "Разрешить пользовательские комиссии",
|
"allow_custom_fees": "Разрешить пользовательские комиссии",
|
||||||
"amount": "Сумма",
|
"amount": "Сумма",
|
||||||
@@ -451,6 +455,8 @@
|
|||||||
"daemon_update_version": "Версия:",
|
"daemon_update_version": "Версия:",
|
||||||
"daemon_version": "Демон",
|
"daemon_version": "Демон",
|
||||||
"dark": "Тёмная",
|
"dark": "Тёмная",
|
||||||
|
"data_stale_prefix": "Обновлено",
|
||||||
|
"data_stale_tooltip": "Баланс может быть устаревшим — кошелёк давно не получал обновлений. Проверьте подключение к узлу.",
|
||||||
"date": "Дата",
|
"date": "Дата",
|
||||||
"date_label": "Дата:",
|
"date_label": "Дата:",
|
||||||
"debug_logging": "ЖУРНАЛ ОТЛАДКИ",
|
"debug_logging": "ЖУРНАЛ ОТЛАДКИ",
|
||||||
@@ -734,6 +740,9 @@
|
|||||||
"lite_working": "Обработка…",
|
"lite_working": "Обработка…",
|
||||||
"loading": "Загрузка...",
|
"loading": "Загрузка...",
|
||||||
"loading_addresses": "Загрузка адресов...",
|
"loading_addresses": "Загрузка адресов...",
|
||||||
|
"loading_stall_body": "Демон инициализируется уже %.0f с. Это может быть нормально после обновления или при первом запуске (загрузка индекса блоков или повторное сканирование) — соединение установится автоматически, когда он будет готов.",
|
||||||
|
"loading_stall_hint": "Всё ещё не отвечает? Откройте Настройки и нажмите «Перезапустить демон» или посмотрите подробности в Консоли.",
|
||||||
|
"loading_stall_title": "Занимает больше времени, чем ожидалось",
|
||||||
"loading_transactions": "Загрузка транзакций",
|
"loading_transactions": "Загрузка транзакций",
|
||||||
"local_hashrate": "Локальный хешрейт",
|
"local_hashrate": "Локальный хешрейт",
|
||||||
"low_spec_mode": "Режим экономии",
|
"low_spec_mode": "Режим экономии",
|
||||||
@@ -953,6 +962,11 @@
|
|||||||
"no_transactions": "Транзакции не найдены",
|
"no_transactions": "Транзакции не найдены",
|
||||||
"no_transactions_yet": "Транзакций пока нет",
|
"no_transactions_yet": "Транзакций пока нет",
|
||||||
"node": "УЗЕЛ",
|
"node": "УЗЕЛ",
|
||||||
|
"node_banner_crashed_title": "Узел неожиданно остановился",
|
||||||
|
"node_banner_lite_open_failed": "Не удалось открыть кошелёк",
|
||||||
|
"node_banner_offline_title": "Нет подключения к узлу DragonX",
|
||||||
|
"node_banner_reconnect": "Переподключить",
|
||||||
|
"node_banner_restart": "Перезапустить узел",
|
||||||
"node_security": "УЗЕЛ И БЕЗОПАСНОСТЬ",
|
"node_security": "УЗЕЛ И БЕЗОПАСНОСТЬ",
|
||||||
"noise": "Шум",
|
"noise": "Шум",
|
||||||
"not_connected": "Не подключено к daemon...",
|
"not_connected": "Не подключено к daemon...",
|
||||||
@@ -1154,6 +1168,8 @@
|
|||||||
"sb_connecting_external": "Подключение к внешнему демону...",
|
"sb_connecting_external": "Подключение к внешнему демону...",
|
||||||
"sb_connecting_generic": "Подключение к демону...",
|
"sb_connecting_generic": "Подключение к демону...",
|
||||||
"sb_daemon_crashed": "Демон упал %d раз",
|
"sb_daemon_crashed": "Демон упал %d раз",
|
||||||
|
"sb_daemon_extract_failed": "Не удалось записать файлы демона — проверьте свободное место на диске и права доступа.",
|
||||||
|
"sb_daemon_files_failed": "Не удалось записать файлы демона в %s — проверьте свободное место на диске и права доступа.",
|
||||||
"sb_daemon_not_found": "Демон не найден",
|
"sb_daemon_not_found": "Демон не найден",
|
||||||
"sb_daemon_start_failed": "Не удалось запустить dragonxd",
|
"sb_daemon_start_failed": "Не удалось запустить dragonxd",
|
||||||
"sb_dragonxd_running": "dragonxd запущен",
|
"sb_dragonxd_running": "dragonxd запущен",
|
||||||
@@ -1169,6 +1185,7 @@
|
|||||||
"sb_net_mhs": "Сеть: %.2f MH/s",
|
"sb_net_mhs": "Сеть: %.2f MH/s",
|
||||||
"sb_no_conf": "DRAGONX.conf не найден",
|
"sb_no_conf": "DRAGONX.conf не найден",
|
||||||
"sb_peers": "Пиры: %zu",
|
"sb_peers": "Пиры: %zu",
|
||||||
|
"sb_plaintext_remote_blocked": "Отправка учётных данных RPC открытым текстом на удалённый узел запрещена. Добавьте rpcallowplaintext=1 в DRAGONX.conf, чтобы разрешить, или включите TLS с помощью rpctls=1.",
|
||||||
"sb_rescanning": "Пересканирование",
|
"sb_rescanning": "Пересканирование",
|
||||||
"sb_rescanning_pct": "Пересканирование %.0f%%",
|
"sb_rescanning_pct": "Пересканирование %.0f%%",
|
||||||
"sb_restarting_daemon": "Перезапуск демона...",
|
"sb_restarting_daemon": "Перезапуск демона...",
|
||||||
@@ -1285,12 +1302,14 @@
|
|||||||
"settings_configure_explorer": "Настроить ссылки внешнего обозревателя блоков",
|
"settings_configure_explorer": "Настроить ссылки внешнего обозревателя блоков",
|
||||||
"settings_configure_rpc": "Настроить подключение к демону dragonxd",
|
"settings_configure_rpc": "Настроить подключение к демону dragonxd",
|
||||||
"settings_connection": "Подключение",
|
"settings_connection": "Подключение",
|
||||||
|
"settings_copy_diagnostics": "Копировать диагностику",
|
||||||
"settings_copyright": "Copyright 2024-2026 Разработчики DragonX | Лицензия GPLv3",
|
"settings_copyright": "Copyright 2024-2026 Разработчики DragonX | Лицензия GPLv3",
|
||||||
"settings_custom": "Пользовательские",
|
"settings_custom": "Пользовательские",
|
||||||
"settings_data_dir": "Каталог данных:",
|
"settings_data_dir": "Каталог данных:",
|
||||||
"settings_debug_changed": "Категории отладки изменены — перезапустите демон для применения",
|
"settings_debug_changed": "Категории отладки изменены — перезапустите демон для применения",
|
||||||
"settings_debug_restart_note": "Изменения вступают в силу после перезапуска демона.",
|
"settings_debug_restart_note": "Изменения вступают в силу после перезапуска демона.",
|
||||||
"settings_debug_select": "Выберите категории для включения журнала отладки демона (флаги -debug=).",
|
"settings_debug_select": "Выберите категории для включения журнала отладки демона (флаги -debug=).",
|
||||||
|
"settings_diagnostics_copied": "Диагностика скопирована в буфер обмена",
|
||||||
"settings_encrypt_first_pin": "Сначала зашифруйте кошелёк, чтобы включить PIN",
|
"settings_encrypt_first_pin": "Сначала зашифруйте кошелёк, чтобы включить PIN",
|
||||||
"settings_encrypt_wallet": "Зашифровать кошелёк",
|
"settings_encrypt_wallet": "Зашифровать кошелёк",
|
||||||
"settings_explorer_hint": "URL-адреса должны заканчиваться косой чертой. Txid/адрес будет добавлен.",
|
"settings_explorer_hint": "URL-адреса должны заканчиваться косой чертой. Txid/адрес будет добавлен.",
|
||||||
@@ -1311,6 +1330,7 @@
|
|||||||
"settings_not_found": "Не найден",
|
"settings_not_found": "Не найден",
|
||||||
"settings_open_app_dir": "Открыть папку приложения",
|
"settings_open_app_dir": "Открыть папку приложения",
|
||||||
"settings_open_data_dir": "Открыть папку данных",
|
"settings_open_data_dir": "Открыть папку данных",
|
||||||
|
"settings_open_log_folder": "Открыть папку журналов",
|
||||||
"settings_other": "Прочее",
|
"settings_other": "Прочее",
|
||||||
"settings_pin_active": "PIN",
|
"settings_pin_active": "PIN",
|
||||||
"settings_privacy": "Конфиденциальность",
|
"settings_privacy": "Конфиденциальность",
|
||||||
@@ -1470,6 +1490,7 @@
|
|||||||
"tt_chat_timestamp": "Формат времени только для этой вкладки: следовать общим настройкам часов приложения либо принудительно 24-hour или 12-hour",
|
"tt_chat_timestamp": "Формат времени только для этой вкладки: следовать общим настройкам часов приложения либо принудительно 24-hour или 12-hour",
|
||||||
"tt_clear_ztx": "Удалить локально кешированную историю z-транзакций",
|
"tt_clear_ztx": "Удалить локально кешированную историю z-транзакций",
|
||||||
"tt_clock_format": "24- или 12-часовой формат для всего приложения. Чат может переопределить.",
|
"tt_clock_format": "24- или 12-часовой формат для всего приложения. Чат может переопределить.",
|
||||||
|
"tt_copy_diagnostics": "Копирует сводку для поддержки (версия, состояние демона/кошелька/журналов — без секретов) в буфер обмена",
|
||||||
"tt_custom_fees": "Включить ручной ввод комиссий при отправке транзакций",
|
"tt_custom_fees": "Включить ручной ввод комиссий при отправке транзакций",
|
||||||
"tt_custom_theme": "Пользовательская тема активна",
|
"tt_custom_theme": "Пользовательская тема активна",
|
||||||
"tt_daemon_install_bundled": "Остановить узел, перезаписать установленный dragonxd версией, встроенной в эту сборку кошелька, затем перезапустить",
|
"tt_daemon_install_bundled": "Остановить узел, перезаписать установленный dragonxd версией, встроенной в эту сборку кошелька, затем перезапустить",
|
||||||
@@ -1523,6 +1544,7 @@
|
|||||||
"tt_open_app_dir": "Открыть папку ObsidianDragon (настройки, темы, логи) в файловом менеджере",
|
"tt_open_app_dir": "Открыть папку ObsidianDragon (настройки, темы, логи) в файловом менеджере",
|
||||||
"tt_open_data_dir": "Открыть в файловом менеджере папку с данными кошелька и блокчейна",
|
"tt_open_data_dir": "Открыть в файловом менеджере папку с данными кошелька и блокчейна",
|
||||||
"tt_open_dir": "Нажмите, чтобы открыть в проводнике",
|
"tt_open_dir": "Нажмите, чтобы открыть в проводнике",
|
||||||
|
"tt_open_log_folder": "Открывает папку с журналами отладки и сбоев",
|
||||||
"tt_reduce_motion": "Отключить анимированные переходы и плавное изменение баланса для доступности",
|
"tt_reduce_motion": "Отключить анимированные переходы и плавное изменение баланса для доступности",
|
||||||
"tt_remove_encrypt": "Удалить шифрование и хранить кошелёк без защиты",
|
"tt_remove_encrypt": "Удалить шифрование и хранить кошелёк без защиты",
|
||||||
"tt_remove_pin": "Удалить PIN и требовать пароль для разблокировки",
|
"tt_remove_pin": "Удалить PIN и требовать пароль для разблокировки",
|
||||||
|
|||||||
@@ -48,6 +48,10 @@
|
|||||||
"advanced": "高级",
|
"advanced": "高级",
|
||||||
"advanced_effects": "高级特效...",
|
"advanced_effects": "高级特效...",
|
||||||
"ago": "前",
|
"ago": "前",
|
||||||
|
"alerts_clear": "清除通知历史",
|
||||||
|
"alerts_history_tooltip": "最近通知",
|
||||||
|
"alerts_none": "暂无通知",
|
||||||
|
"alerts_recent": "最近通知",
|
||||||
"all_filter": "全部",
|
"all_filter": "全部",
|
||||||
"allow_custom_fees": "允许自定义手续费",
|
"allow_custom_fees": "允许自定义手续费",
|
||||||
"amount": "金额",
|
"amount": "金额",
|
||||||
@@ -451,6 +455,8 @@
|
|||||||
"daemon_update_version": "版本:",
|
"daemon_update_version": "版本:",
|
||||||
"daemon_version": "守护进程",
|
"daemon_version": "守护进程",
|
||||||
"dark": "深色",
|
"dark": "深色",
|
||||||
|
"data_stale_prefix": "更新于",
|
||||||
|
"data_stale_tooltip": "余额可能已过时 — 钱包最近未收到更新。请检查您的节点连接。",
|
||||||
"date": "日期",
|
"date": "日期",
|
||||||
"date_label": "日期:",
|
"date_label": "日期:",
|
||||||
"debug_logging": "调试日志",
|
"debug_logging": "调试日志",
|
||||||
@@ -734,6 +740,8 @@
|
|||||||
"lite_working": "处理中…",
|
"lite_working": "处理中…",
|
||||||
"loading": "加载中...",
|
"loading": "加载中...",
|
||||||
"loading_addresses": "正在加载地址...",
|
"loading_addresses": "正在加载地址...",
|
||||||
|
"loading_stall_body": "守护进程已初始化 %.0f 秒。更新后或首次启动时(加载区块索引或重新扫描)这可能是正常现象——就绪后会自动连接。",
|
||||||
|
"loading_stall_title": "耗时超出预期",
|
||||||
"loading_transactions": "正在加载交易",
|
"loading_transactions": "正在加载交易",
|
||||||
"local_hashrate": "本地算力",
|
"local_hashrate": "本地算力",
|
||||||
"low_spec_mode": "低配模式",
|
"low_spec_mode": "低配模式",
|
||||||
@@ -953,6 +961,11 @@
|
|||||||
"no_transactions": "未找到交易",
|
"no_transactions": "未找到交易",
|
||||||
"no_transactions_yet": "尚无交易",
|
"no_transactions_yet": "尚无交易",
|
||||||
"node": "节点",
|
"node": "节点",
|
||||||
|
"node_banner_crashed_title": "节点意外停止",
|
||||||
|
"node_banner_lite_open_failed": "无法打开您的钱包",
|
||||||
|
"node_banner_offline_title": "未连接到 DragonX 节点",
|
||||||
|
"node_banner_reconnect": "重新连接",
|
||||||
|
"node_banner_restart": "重启节点",
|
||||||
"node_security": "节点与安全",
|
"node_security": "节点与安全",
|
||||||
"noise": "噪点",
|
"noise": "噪点",
|
||||||
"not_connected": "未连接到守护进程...",
|
"not_connected": "未连接到守护进程...",
|
||||||
@@ -1154,6 +1167,8 @@
|
|||||||
"sb_connecting_external": "正在连接外部守护进程...",
|
"sb_connecting_external": "正在连接外部守护进程...",
|
||||||
"sb_connecting_generic": "正在连接守护进程...",
|
"sb_connecting_generic": "正在连接守护进程...",
|
||||||
"sb_daemon_crashed": "守护进程崩溃 %d 次",
|
"sb_daemon_crashed": "守护进程崩溃 %d 次",
|
||||||
|
"sb_daemon_extract_failed": "无法写入守护进程文件——请检查磁盘剩余空间和权限。",
|
||||||
|
"sb_daemon_files_failed": "无法将守护进程文件写入 %s——请检查磁盘剩余空间和权限。",
|
||||||
"sb_daemon_not_found": "未找到守护进程",
|
"sb_daemon_not_found": "未找到守护进程",
|
||||||
"sb_daemon_start_failed": "无法启动 dragonxd",
|
"sb_daemon_start_failed": "无法启动 dragonxd",
|
||||||
"sb_dragonxd_running": "dragonxd 运行中",
|
"sb_dragonxd_running": "dragonxd 运行中",
|
||||||
@@ -1285,12 +1300,14 @@
|
|||||||
"settings_configure_explorer": "配置外部区块浏览器链接",
|
"settings_configure_explorer": "配置外部区块浏览器链接",
|
||||||
"settings_configure_rpc": "配置 dragonxd 守护进程连接",
|
"settings_configure_rpc": "配置 dragonxd 守护进程连接",
|
||||||
"settings_connection": "连接",
|
"settings_connection": "连接",
|
||||||
|
"settings_copy_diagnostics": "复制诊断信息",
|
||||||
"settings_copyright": "版权所有 2024-2026 DragonX 开发者 | GPLv3 许可证",
|
"settings_copyright": "版权所有 2024-2026 DragonX 开发者 | GPLv3 许可证",
|
||||||
"settings_custom": "自定义",
|
"settings_custom": "自定义",
|
||||||
"settings_data_dir": "数据目录:",
|
"settings_data_dir": "数据目录:",
|
||||||
"settings_debug_changed": "调试类别已更改——重启守护进程以应用",
|
"settings_debug_changed": "调试类别已更改——重启守护进程以应用",
|
||||||
"settings_debug_restart_note": "更改将在重启守护进程后生效。",
|
"settings_debug_restart_note": "更改将在重启守护进程后生效。",
|
||||||
"settings_debug_select": "选择要启用的守护进程调试日志类别(-debug= 标志)。",
|
"settings_debug_select": "选择要启用的守护进程调试日志类别(-debug= 标志)。",
|
||||||
|
"settings_diagnostics_copied": "诊断信息已复制到剪贴板",
|
||||||
"settings_encrypt_first_pin": "请先加密钱包以启用 PIN",
|
"settings_encrypt_first_pin": "请先加密钱包以启用 PIN",
|
||||||
"settings_encrypt_wallet": "加密钱包",
|
"settings_encrypt_wallet": "加密钱包",
|
||||||
"settings_explorer_hint": "URL 应包含尾部斜杠。将自动附加 txid/地址。",
|
"settings_explorer_hint": "URL 应包含尾部斜杠。将自动附加 txid/地址。",
|
||||||
@@ -1311,6 +1328,7 @@
|
|||||||
"settings_not_found": "未找到",
|
"settings_not_found": "未找到",
|
||||||
"settings_open_app_dir": "打开应用文件夹",
|
"settings_open_app_dir": "打开应用文件夹",
|
||||||
"settings_open_data_dir": "打开数据文件夹",
|
"settings_open_data_dir": "打开数据文件夹",
|
||||||
|
"settings_open_log_folder": "打开日志文件夹",
|
||||||
"settings_other": "其他",
|
"settings_other": "其他",
|
||||||
"settings_pin_active": "PIN",
|
"settings_pin_active": "PIN",
|
||||||
"settings_privacy": "隐私",
|
"settings_privacy": "隐私",
|
||||||
@@ -1470,6 +1488,7 @@
|
|||||||
"tt_chat_timestamp": "仅此标签页的时间戳格式:跟随全应用时钟,或强制使用 24-hour 或 12-hour",
|
"tt_chat_timestamp": "仅此标签页的时间戳格式:跟随全应用时钟,或强制使用 24-hour 或 12-hour",
|
||||||
"tt_clear_ztx": "删除本地缓存的 z-交易历史",
|
"tt_clear_ztx": "删除本地缓存的 z-交易历史",
|
||||||
"tt_clock_format": "24 或 12 小时制,应用全局。聊天可覆盖。",
|
"tt_clock_format": "24 或 12 小时制,应用全局。聊天可覆盖。",
|
||||||
|
"tt_copy_diagnostics": "将支持诊断摘要(版本、守护进程/钱包/日志状态 — 不含机密)复制到剪贴板",
|
||||||
"tt_custom_fees": "发送交易时启用手动费用输入",
|
"tt_custom_fees": "发送交易时启用手动费用输入",
|
||||||
"tt_custom_theme": "自定义主题已激活",
|
"tt_custom_theme": "自定义主题已激活",
|
||||||
"tt_daemon_install_bundled": "停止节点,用此钱包版本内置的 dragonxd 覆盖已安装的版本,然后重启",
|
"tt_daemon_install_bundled": "停止节点,用此钱包版本内置的 dragonxd 覆盖已安装的版本,然后重启",
|
||||||
@@ -1523,6 +1542,7 @@
|
|||||||
"tt_open_app_dir": "在文件管理器中打开 ObsidianDragon 文件夹(设置、主题、日志)",
|
"tt_open_app_dir": "在文件管理器中打开 ObsidianDragon 文件夹(设置、主题、日志)",
|
||||||
"tt_open_data_dir": "在文件管理器中打开包含您钱包和区块链数据的文件夹",
|
"tt_open_data_dir": "在文件管理器中打开包含您钱包和区块链数据的文件夹",
|
||||||
"tt_open_dir": "点击在文件管理器中打开",
|
"tt_open_dir": "点击在文件管理器中打开",
|
||||||
|
"tt_open_log_folder": "打开包含调试和崩溃日志的文件夹",
|
||||||
"tt_reduce_motion": "禁用动画过渡和余额渐变以提高无障碍性",
|
"tt_reduce_motion": "禁用动画过渡和余额渐变以提高无障碍性",
|
||||||
"tt_remove_encrypt": "移除加密并以未受保护状态存储钱包",
|
"tt_remove_encrypt": "移除加密并以未受保护状态存储钱包",
|
||||||
"tt_remove_pin": "移除 PIN 并要求密码解锁",
|
"tt_remove_pin": "移除 PIN 并要求密码解锁",
|
||||||
|
|||||||
@@ -700,6 +700,12 @@ status-pill-bg-alpha = { size = 30 }
|
|||||||
status-pill-y-offset = { size = 1 }
|
status-pill-y-offset = { size = 1 }
|
||||||
confirmed-threshold = { size = 10 }
|
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]
|
[tabs.transactions]
|
||||||
search-max-width = 300.0
|
search-max-width = 300.0
|
||||||
search-width-ratio = 0.3
|
search-width-ratio = 0.3
|
||||||
@@ -1503,6 +1509,7 @@ progress-bar = { height = 6.0, radius = 3.0 }
|
|||||||
progress-width = { size = 260.0 }
|
progress-width = { size = 260.0 }
|
||||||
backdrop-alpha = { opacity = 0.80 }
|
backdrop-alpha = { opacity = 0.80 }
|
||||||
vertical-gap = { size = 8.0 }
|
vertical-gap = { size = 8.0 }
|
||||||
|
stall-timeout-sec = { size = 45.0 }
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# First-Run Wizard Screens
|
# First-Run Wizard Screens
|
||||||
|
|||||||
@@ -1,5 +1,17 @@
|
|||||||
#!/usr/bin/env bash
|
#!/usr/bin/env bash
|
||||||
|
|
||||||
|
# This script uses bash 4+ features (mapfile, safe empty-array expansion under
|
||||||
|
# `set -u`). macOS ships bash 3.2, so re-exec under a newer bash when one is
|
||||||
|
# present (Homebrew), and fail with a clear message otherwise.
|
||||||
|
if [ "${BASH_VERSINFO:-0}" -lt 4 ]; then
|
||||||
|
for _newer_bash in /opt/homebrew/bin/bash /usr/local/bin/bash; do
|
||||||
|
[ -x "$_newer_bash" ] && exec "$_newer_bash" "$0" "$@"
|
||||||
|
done
|
||||||
|
echo "ERROR: build-lite-backend-artifact.sh requires bash 4+ (found ${BASH_VERSION:-unknown})." >&2
|
||||||
|
echo " On macOS: brew install bash" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
|
|
||||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
@@ -312,6 +324,9 @@ prepare_backend_source() {
|
|||||||
ln -s "$BACKEND_SOURCE_DIR/src" "$prepared_root/src"
|
ln -s "$BACKEND_SOURCE_DIR/src" "$prepared_root/src"
|
||||||
[[ -f "$BACKEND_SOURCE_DIR/Cargo.lock" ]] && ln -s "$BACKEND_SOURCE_DIR/Cargo.lock" "$prepared_root/Cargo.lock"
|
[[ -f "$BACKEND_SOURCE_DIR/Cargo.lock" ]] && ln -s "$BACKEND_SOURCE_DIR/Cargo.lock" "$prepared_root/Cargo.lock"
|
||||||
[[ -d "$BACKEND_SOURCE_DIR/.cargo" ]] && ln -s "$BACKEND_SOURCE_DIR/.cargo" "$prepared_root/.cargo"
|
[[ -d "$BACKEND_SOURCE_DIR/.cargo" ]] && ln -s "$BACKEND_SOURCE_DIR/.cargo" "$prepared_root/.cargo"
|
||||||
|
# Honor the pinned Rust toolchain (rust-toolchain.toml) inside the prepared root too,
|
||||||
|
# so builds using --silentdragonxlitelib-dir still select rustc 1.63.
|
||||||
|
[[ -f "$BACKEND_SOURCE_DIR/rust-toolchain.toml" ]] && ln -s "$BACKEND_SOURCE_DIR/rust-toolchain.toml" "$prepared_root/rust-toolchain.toml"
|
||||||
[[ -d "$BACKEND_SOURCE_DIR/libsodium-mingw" ]] && ln -s "$BACKEND_SOURCE_DIR/libsodium-mingw" "$prepared_root/libsodium-mingw"
|
[[ -d "$BACKEND_SOURCE_DIR/libsodium-mingw" ]] && ln -s "$BACKEND_SOURCE_DIR/libsodium-mingw" "$prepared_root/libsodium-mingw"
|
||||||
# Vendored crate deps (offline builds): the .cargo/config.toml's vendored-sources directory is
|
# Vendored crate deps (offline builds): the .cargo/config.toml's vendored-sources directory is
|
||||||
# "vendor" relative to the build root, so expose it inside the prepared root too.
|
# "vendor" relative to the build root, so expose it inside the prepared root too.
|
||||||
|
|||||||
@@ -256,8 +256,8 @@ HEADER_START
|
|||||||
echo -e "${YELLOW}Note: Daemon binaries not found in prebuilt-binaries/dragonxd-win/ — wallet only${NC}"
|
echo -e "${YELLOW}Note: Daemon binaries not found in prebuilt-binaries/dragonxd-win/ — wallet only${NC}"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# ── xmrig binary (from prebuilt-binaries/xmrig-hac/) ────────────────
|
# ── xmrig binary (from prebuilt-binaries/drg-xmrig/) ────────────────
|
||||||
XMRIG_DIR="$SCRIPT_DIR/prebuilt-binaries/xmrig-hac"
|
XMRIG_DIR="$SCRIPT_DIR/prebuilt-binaries/drg-xmrig"
|
||||||
if [ -f "$XMRIG_DIR/xmrig.exe" ]; then
|
if [ -f "$XMRIG_DIR/xmrig.exe" ]; then
|
||||||
cp -f "$XMRIG_DIR/xmrig.exe" "$EMBED_RES_DIR/xmrig.exe"
|
cp -f "$XMRIG_DIR/xmrig.exe" "$EMBED_RES_DIR/xmrig.exe"
|
||||||
echo " Staged xmrig.exe ($(du -h "$XMRIG_DIR/xmrig.exe" | cut -f1))"
|
echo " Staged xmrig.exe ($(du -h "$XMRIG_DIR/xmrig.exe" | cut -f1))"
|
||||||
|
|||||||
@@ -1,25 +1,31 @@
|
|||||||
#!/usr/bin/env bash
|
#!/usr/bin/env bash
|
||||||
# Sign dragonx full-node release archives for the wallet's in-app daemon updater (ed25519).
|
# Package the prebuilt dragonx full-node binaries into per-platform release archives and sign them
|
||||||
|
# for the wallet's in-app daemon updater (ed25519 over the EXACT archive bytes).
|
||||||
#
|
#
|
||||||
# The wallet verifies a detached ed25519 signature over the EXACT archive bytes against a public
|
# The wallet verifies a detached ed25519 signature over the archive bytes against a public key
|
||||||
# key pinned in src/util/daemon_updater.h (kDaemonSignaturePublicKeyBase64). Verification is
|
# pinned in src/util/daemon_updater.h (kDaemonSignaturePublicKeyBase64). Verification is MANDATORY
|
||||||
# MANDATORY (kDaemonRequireSignature = true): an in-app update is refused unless a valid signature
|
# (kDaemonRequireSignature = true): an in-app update is refused unless a valid "<archive>.sig" is
|
||||||
# is published. For each archive <name>.zip this produces <name>.zip.sig holding the base64 of the
|
# published next to the archive. The wallet also checks each archive's SHA-256 against a markdown
|
||||||
# raw 64-byte ed25519 signature — upload that .sig next to the .zip as a release asset.
|
# checksum table in the release body, so `release` prints that table for you to paste in.
|
||||||
#
|
#
|
||||||
# Uses OpenSSL (>= 1.1.1) only — no Python/PyNaCl needed. OpenSSL's ed25519 is PureEdDSA (RFC 8032),
|
# Uses OpenSSL (>= 1.1.1) only — no Python/PyNaCl. OpenSSL's ed25519 is PureEdDSA (RFC 8032), the
|
||||||
# the same primitive libsodium's crypto_sign_verify_detached checks, so signatures are compatible
|
# same primitive libsodium's crypto_sign_verify_detached checks, so the signatures are compatible.
|
||||||
# (the same flow the wallet's unit tests verify for the miner updater).
|
|
||||||
#
|
#
|
||||||
# Usage:
|
# Usage:
|
||||||
# scripts/sign-daemon-release.sh keygen [out-prefix] # -> <prefix>.ed25519.{key,pub.b64}
|
# scripts/sign-daemon-release.sh keygen [out-prefix] # -> <prefix>.ed25519.{key,pub.b64}
|
||||||
# scripts/sign-daemon-release.sh pubkey <secret.key> # print the base64 public key to pin
|
# scripts/sign-daemon-release.sh pubkey <secret.key> # print the base64 public key to pin
|
||||||
# scripts/sign-daemon-release.sh sign <secret.key> <file>...# -> <file>.sig per file
|
# scripts/sign-daemon-release.sh sign <secret.key> <file>... # sign existing files -> <file>.sig
|
||||||
|
# scripts/sign-daemon-release.sh release <secret.key> <version> [--src DIR] [--out DIR]
|
||||||
|
# # zip prebuilt-binaries/dragonxd-{linux,mac,win}/ into dragonx-<version>-{linux-amd64,macos,
|
||||||
|
# # win64}.zip, sign each, and print the SHA-256 checksum table. Platforms with no dragonxd
|
||||||
|
# # binary staged are skipped.
|
||||||
#
|
#
|
||||||
# Keep the secret key (.ed25519.key) OFFLINE. Paste the base64 public key into
|
# Keep the secret key (.ed25519.key) OFFLINE (mode 600). Paste the base64 public key into
|
||||||
# kDaemonSignaturePublicKeyBase64 in src/util/daemon_updater.h.
|
# kDaemonSignaturePublicKeyBase64 in src/util/daemon_updater.h.
|
||||||
|
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
|
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
|
PROJECT_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"
|
||||||
die() { echo "error: $*" >&2; exit 1; }
|
die() { echo "error: $*" >&2; exit 1; }
|
||||||
command -v openssl >/dev/null || die "openssl not found (need >= 1.1.1 with ed25519)"
|
command -v openssl >/dev/null || die "openssl not found (need >= 1.1.1 with ed25519)"
|
||||||
|
|
||||||
@@ -27,6 +33,26 @@ command -v openssl >/dev/null || die "openssl not found (need >= 1.1.1 with ed25
|
|||||||
# ed25519 is a fixed 12-byte prefix + the 32-byte key, so the trailing 32 bytes are the raw key.
|
# ed25519 is a fixed 12-byte prefix + the 32-byte key, so the trailing 32 bytes are the raw key.
|
||||||
pubkey_b64() { openssl pkey -in "$1" -pubout -outform DER | tail -c 32 | openssl base64 -A; }
|
pubkey_b64() { openssl pkey -in "$1" -pubout -outform DER | tail -c 32 | openssl base64 -A; }
|
||||||
|
|
||||||
|
sha256_of() {
|
||||||
|
if command -v sha256sum >/dev/null 2>&1; then sha256sum "$1" | awk '{print $1}';
|
||||||
|
else shasum -a 256 "$1" | awk '{print $1}'; fi
|
||||||
|
}
|
||||||
|
|
||||||
|
# Detached ed25519 signature over the raw file bytes -> <file>.sig (base64 of the 64-byte sig).
|
||||||
|
sign_file() {
|
||||||
|
local key="$1" f="$2" raw
|
||||||
|
raw="$(mktemp)"
|
||||||
|
openssl pkeyutl -sign -inkey "$key" -rawin -in "$f" -out "$raw"
|
||||||
|
openssl base64 -A -in "$raw" > "$f.sig"
|
||||||
|
printf '\n' >> "$f.sig"
|
||||||
|
rm -f "$raw"
|
||||||
|
}
|
||||||
|
|
||||||
|
# platform -> (staging dir under prebuilt-binaries, release token, expected daemon binary name)
|
||||||
|
plat_dir() { case "$1" in linux) echo dragonxd-linux;; mac) echo dragonxd-mac;; win) echo dragonxd-win;; esac; }
|
||||||
|
plat_token() { case "$1" in linux) echo linux-amd64;; mac) echo macos;; win) echo win64;; esac; }
|
||||||
|
plat_daemon() { case "$1" in win) echo dragonxd.exe;; *) echo dragonxd;; esac; }
|
||||||
|
|
||||||
cmd="${1:-}"; shift || true
|
cmd="${1:-}"; shift || true
|
||||||
case "$cmd" in
|
case "$cmd" in
|
||||||
keygen)
|
keygen)
|
||||||
@@ -42,26 +68,90 @@ case "$cmd" in
|
|||||||
echo "Pin this in src/util/daemon_updater.h (kDaemonSignaturePublicKeyBase64):"
|
echo "Pin this in src/util/daemon_updater.h (kDaemonSignaturePublicKeyBase64):"
|
||||||
echo " $pub"
|
echo " $pub"
|
||||||
;;
|
;;
|
||||||
|
|
||||||
pubkey)
|
pubkey)
|
||||||
[ $# -ge 1 ] || die "usage: pubkey <secret.key>"
|
[ $# -ge 1 ] || die "usage: pubkey <secret.key>"
|
||||||
pubkey_b64 "$1"
|
pubkey_b64 "$1"
|
||||||
;;
|
;;
|
||||||
|
|
||||||
sign)
|
sign)
|
||||||
[ $# -ge 2 ] || die "usage: sign <secret.key> <file>..."
|
[ $# -ge 2 ] || die "usage: sign <secret.key> <file>..."
|
||||||
key="$1"; shift
|
key="$1"; shift
|
||||||
[ -f "$key" ] || die "no such key: $key"
|
[ -f "$key" ] || die "no such key: $key"
|
||||||
for f in "$@"; do
|
for f in "$@"; do
|
||||||
[ -f "$f" ] || die "no such file: $f"
|
[ -f "$f" ] || die "no such file: $f"
|
||||||
raw="$(mktemp)"
|
sign_file "$key" "$f"
|
||||||
openssl pkeyutl -sign -inkey "$key" -rawin -in "$f" -out "$raw"
|
|
||||||
openssl base64 -A -in "$raw" > "$f.sig"
|
|
||||||
printf '\n' >> "$f.sig"
|
|
||||||
rm -f "$raw"
|
|
||||||
echo "signed: $f -> $f.sig"
|
echo "signed: $f -> $f.sig"
|
||||||
done
|
done
|
||||||
echo "Upload each .sig as a release asset next to its archive."
|
echo "Upload each .sig as a release asset next to its archive."
|
||||||
;;
|
;;
|
||||||
|
|
||||||
|
release)
|
||||||
|
[ $# -ge 2 ] || die "usage: release <secret.key> <version> [--src DIR] [--out DIR]"
|
||||||
|
key="$1"; version="$2"; shift 2
|
||||||
|
src="$PROJECT_ROOT/prebuilt-binaries"
|
||||||
|
out="$PROJECT_ROOT/release/daemon"
|
||||||
|
while [ $# -gt 0 ]; do
|
||||||
|
case "$1" in
|
||||||
|
--src) [ $# -ge 2 ] || die "--src needs a value"; src="$2"; shift 2 ;;
|
||||||
|
--out) [ $# -ge 2 ] || die "--out needs a value"; out="$2"; shift 2 ;;
|
||||||
|
*) die "unknown option: $1" ;;
|
||||||
|
esac
|
||||||
|
done
|
||||||
|
[ -f "$key" ] || die "no such key: $key"
|
||||||
|
[ -d "$src" ] || die "no such source dir: $src"
|
||||||
|
command -v zip >/dev/null 2>&1 || die "zip not found (install 'zip')"
|
||||||
|
mkdir -p "$out"
|
||||||
|
|
||||||
|
# Sanity: warn if this key does not match the public key pinned in the wallet (the wallet would
|
||||||
|
# then reject every signature made with it — only expected when deliberately rotating the key).
|
||||||
|
pinned="$(grep -oE '"[A-Za-z0-9+/]{43}="' "$PROJECT_ROOT/src/util/daemon_updater.h" 2>/dev/null | head -1 | tr -d '"')"
|
||||||
|
mine="$(pubkey_b64 "$key")"
|
||||||
|
if [ -n "$pinned" ] && [ "$pinned" != "$mine" ]; then
|
||||||
|
echo "WARNING: this key's public key does not match the one pinned in daemon_updater.h:" >&2
|
||||||
|
echo " signing key -> $mine" >&2
|
||||||
|
echo " pinned key -> $pinned" >&2
|
||||||
|
echo " The wallet will REJECT these signatures unless you are rotating the pinned key." >&2
|
||||||
|
echo >&2
|
||||||
|
fi
|
||||||
|
|
||||||
|
made=0
|
||||||
|
table=""
|
||||||
|
for plat in linux mac win; do
|
||||||
|
d="$src/$(plat_dir "$plat")"
|
||||||
|
daemon="$d/$(plat_daemon "$plat")"
|
||||||
|
if [ ! -f "$daemon" ]; then
|
||||||
|
echo "skip $plat: no $(plat_daemon "$plat") staged in $d" >&2
|
||||||
|
continue
|
||||||
|
fi
|
||||||
|
archive="dragonx-$version-$(plat_token "$plat").zip"
|
||||||
|
apath="$out/$archive"
|
||||||
|
rm -f "$apath"
|
||||||
|
# Zip the staged files at the archive root (binaries + sapling params + asmap), excluding
|
||||||
|
# the .gitkeep placeholder. The updater flattens paths via baseName(), so a flat zip is fine.
|
||||||
|
files=()
|
||||||
|
while IFS= read -r fn; do files+=("$fn"); done < <(cd "$d" && ls -A | grep -vx '.gitkeep')
|
||||||
|
[ "${#files[@]}" -gt 0 ] || { echo "skip $plat: nothing to package in $d" >&2; continue; }
|
||||||
|
( cd "$d" && zip -q -X "$apath" "${files[@]}" )
|
||||||
|
sign_file "$key" "$apath"
|
||||||
|
sum="$(sha256_of "$apath")"
|
||||||
|
table+="| $archive | \`$sum\` |"$'\n'
|
||||||
|
echo "packaged + signed: $apath (+ .sig) sha256=$sum"
|
||||||
|
made=$((made + 1))
|
||||||
|
done
|
||||||
|
[ "$made" -gt 0 ] || die "no platform had a staged daemon binary under $src/dragonxd-{linux,mac,win}/"
|
||||||
|
|
||||||
|
echo
|
||||||
|
echo "Checksum table (paste into the release body so the wallet can verify SHA-256):"
|
||||||
|
echo "| Archive | SHA-256 |"
|
||||||
|
echo "|---|---|"
|
||||||
|
printf '%s' "$table"
|
||||||
|
echo
|
||||||
|
echo "Upload each .zip AND its .zip.sig as release assets. Wallet enforces the ed25519 signature"
|
||||||
|
echo "(kDaemonRequireSignature=true) and the SHA-256 from the table above."
|
||||||
|
;;
|
||||||
|
|
||||||
*)
|
*)
|
||||||
die "usage: $0 {keygen [prefix] | pubkey <secret.key> | sign <secret.key> <file>...}"
|
die "usage: $0 {keygen [prefix] | pubkey <secret.key> | sign <secret.key> <file>... | release <secret.key> <version> [--src DIR] [--out DIR]}"
|
||||||
;;
|
;;
|
||||||
esac
|
esac
|
||||||
|
|||||||
67
setup.sh
67
setup.sh
@@ -133,7 +133,7 @@ pkgs_core_arch="base-devel cmake git pkg-config
|
|||||||
libxkbcommon wayland libsodium curl
|
libxkbcommon wayland libsodium curl
|
||||||
autoconf automake libtool wget python xxd"
|
autoconf automake libtool wget python xxd"
|
||||||
|
|
||||||
pkgs_core_macos="cmake python xxd"
|
pkgs_core_macos="bash cmake python xxd"
|
||||||
|
|
||||||
# Windows cross-compile (from Linux)
|
# Windows cross-compile (from Linux)
|
||||||
pkgs_win_debian="mingw-w64 zip"
|
pkgs_win_debian="mingw-w64 zip"
|
||||||
@@ -284,18 +284,27 @@ fi
|
|||||||
header "Windows Cross-Compile"
|
header "Windows Cross-Compile"
|
||||||
|
|
||||||
if $SETUP_WIN; then
|
if $SETUP_WIN; then
|
||||||
win_pkgs="$(get_pkgs win)"
|
# Only touch apt / update-alternatives (which need sudo) when the toolchain is missing. If it is
|
||||||
if [[ -n "$win_pkgs" ]]; then
|
# already installed, skip them so `./setup.sh --win` can run WITHOUT sudo — important because the
|
||||||
install_pkgs "$win_pkgs" "Windows cross-compile"
|
# daemon cross-compile that follows should run as the invoking user. Running the whole setup under
|
||||||
fi
|
# sudo leaves root-owned build artifacts under external/dragonx, which then break `make clean` on
|
||||||
|
# a later non-sudo build (stale objects get relinked -> the mingw link failure recurs).
|
||||||
|
if has_cmd x86_64-w64-mingw32-g++-posix || has_cmd x86_64-w64-mingw32-g++; then
|
||||||
|
ok "Windows cross-compile toolchain already present — skipping apt install"
|
||||||
|
else
|
||||||
|
win_pkgs="$(get_pkgs win)"
|
||||||
|
if [[ -n "$win_pkgs" ]]; then
|
||||||
|
install_pkgs "$win_pkgs" "Windows cross-compile"
|
||||||
|
fi
|
||||||
|
|
||||||
# Set posix thread model if available
|
# Set posix thread model if available
|
||||||
if has_cmd update-alternatives && [[ "$PKG" == "apt" ]]; then
|
if has_cmd update-alternatives && [[ "$PKG" == "apt" ]]; then
|
||||||
if ! $CHECK_ONLY; then
|
if ! $CHECK_ONLY; then
|
||||||
sudo update-alternatives --set x86_64-w64-mingw32-gcc \
|
sudo update-alternatives --set x86_64-w64-mingw32-gcc \
|
||||||
/usr/bin/x86_64-w64-mingw32-gcc-posix 2>/dev/null || true
|
/usr/bin/x86_64-w64-mingw32-gcc-posix 2>/dev/null || true
|
||||||
sudo update-alternatives --set x86_64-w64-mingw32-g++ \
|
sudo update-alternatives --set x86_64-w64-mingw32-g++ \
|
||||||
/usr/bin/x86_64-w64-mingw32-g++-posix 2>/dev/null || true
|
/usr/bin/x86_64-w64-mingw32-g++-posix 2>/dev/null || true
|
||||||
|
fi
|
||||||
fi
|
fi
|
||||||
fi
|
fi
|
||||||
|
|
||||||
@@ -699,11 +708,13 @@ if [[ "$STALE_DAEMON" -eq 1 ]]; then
|
|||||||
warn " Linux: ./setup.sh · Windows: ./setup.sh --win · macOS: ./setup.sh --mac"
|
warn " Linux: ./setup.sh · Windows: ./setup.sh --win · macOS: ./setup.sh --mac"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# ── 7. xmrig-hac (mining binary) ────────────────────────────────────────────
|
# ── 7. drg-xmrig (mining binary) ────────────────────────────────────────────
|
||||||
header "xmrig-hac Mining Binary"
|
header "drg-xmrig Mining Binary"
|
||||||
|
|
||||||
XMRIG_SRC="$PROJECT_DIR/external/xmrig-hac"
|
XMRIG_SRC="$PROJECT_DIR/external/drg-xmrig"
|
||||||
XMRIG_PREBUILT="$PROJECT_DIR/prebuilt-binaries/xmrig-hac"
|
# Output dir bundled by build.sh (Linux zip, AppImage, Windows embed, mac .app)
|
||||||
|
# and scripts/legacy/build-windows.sh — keep this path in sync with those.
|
||||||
|
XMRIG_PREBUILT="$PROJECT_DIR/prebuilt-binaries/drg-xmrig"
|
||||||
|
|
||||||
# Clean previous prebuilt xmrig binaries so we always rebuild
|
# Clean previous prebuilt xmrig binaries so we always rebuild
|
||||||
# Only clean the binary for the platform(s) we are actually building,
|
# Only clean the binary for the platform(s) we are actually building,
|
||||||
@@ -715,14 +726,14 @@ if ! $CHECK_ONLY; then
|
|||||||
fi
|
fi
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# Helper: clone xmrig-hac if not present
|
# Helper: clone drg-xmrig if not present
|
||||||
clone_xmrig_if_needed() {
|
clone_xmrig_if_needed() {
|
||||||
if [[ ! -d "$XMRIG_SRC" ]]; then
|
if [[ ! -d "$XMRIG_SRC" ]]; then
|
||||||
info "Cloning xmrig-hac..."
|
info "Cloning drg-xmrig..."
|
||||||
git clone https://git.dragonx.is/dragonx/xmrig-hac.git "$XMRIG_SRC"
|
git clone https://git.dragonx.is/DragonX/drg-xmrig.git "$XMRIG_SRC"
|
||||||
else
|
else
|
||||||
ok "xmrig-hac source already present"
|
ok "drg-xmrig source already present"
|
||||||
info "Pulling latest xmrig-hac..."
|
info "Pulling latest drg-xmrig..."
|
||||||
(cd "$XMRIG_SRC" && git pull --ff-only 2>/dev/null || true)
|
(cd "$XMRIG_SRC" && git pull --ff-only 2>/dev/null || true)
|
||||||
fi
|
fi
|
||||||
}
|
}
|
||||||
@@ -743,15 +754,15 @@ else
|
|||||||
rm -rf "$XMRIG_SRC/build"
|
rm -rf "$XMRIG_SRC/build"
|
||||||
|
|
||||||
# Build dependencies (libuv, hwloc, openssl)
|
# Build dependencies (libuv, hwloc, openssl)
|
||||||
info "Building xmrig-hac dependencies (libuv, hwloc, openssl)..."
|
info "Building drg-xmrig dependencies (libuv, hwloc, openssl)..."
|
||||||
(
|
(
|
||||||
cd "$XMRIG_SRC/scripts"
|
cd "$XMRIG_SRC/scripts"
|
||||||
sh build_deps.sh
|
sh build_deps.sh
|
||||||
)
|
)
|
||||||
ok "xmrig-hac dependencies built"
|
ok "drg-xmrig dependencies built"
|
||||||
|
|
||||||
# Build xmrig
|
# Build xmrig
|
||||||
info "Building xmrig-hac (Linux)..."
|
info "Building drg-xmrig (Linux)..."
|
||||||
mkdir -p "$XMRIG_SRC/build"
|
mkdir -p "$XMRIG_SRC/build"
|
||||||
(
|
(
|
||||||
cd "$XMRIG_SRC/build"
|
cd "$XMRIG_SRC/build"
|
||||||
@@ -768,7 +779,7 @@ else
|
|||||||
mkdir -p "$XMRIG_PREBUILT"
|
mkdir -p "$XMRIG_PREBUILT"
|
||||||
if [[ -f "$XMRIG_SRC/build/xmrig" ]]; then
|
if [[ -f "$XMRIG_SRC/build/xmrig" ]]; then
|
||||||
cp "$XMRIG_SRC/build/xmrig" "$XMRIG_LINUX"
|
cp "$XMRIG_SRC/build/xmrig" "$XMRIG_LINUX"
|
||||||
ok "xmrig (Linux) built and installed to prebuilt-binaries/xmrig-hac/"
|
ok "xmrig (Linux) built and installed to prebuilt-binaries/drg-xmrig/"
|
||||||
else
|
else
|
||||||
err "xmrig (Linux) build failed — binary not found"
|
err "xmrig (Linux) build failed — binary not found"
|
||||||
MISSING=$((MISSING + 1))
|
MISSING=$((MISSING + 1))
|
||||||
@@ -792,7 +803,7 @@ else
|
|||||||
# Clean previous Windows build
|
# Clean previous Windows build
|
||||||
rm -rf "$XMRIG_SRC/build-windows"
|
rm -rf "$XMRIG_SRC/build-windows"
|
||||||
|
|
||||||
info "Building xmrig-hac (Windows cross-compile)..."
|
info "Building drg-xmrig (Windows cross-compile)..."
|
||||||
(
|
(
|
||||||
cd "$XMRIG_SRC/scripts"
|
cd "$XMRIG_SRC/scripts"
|
||||||
bash build_windows.sh
|
bash build_windows.sh
|
||||||
@@ -802,7 +813,7 @@ else
|
|||||||
mkdir -p "$XMRIG_PREBUILT"
|
mkdir -p "$XMRIG_PREBUILT"
|
||||||
if [[ -f "$XMRIG_SRC/build-windows/xmrig.exe" ]]; then
|
if [[ -f "$XMRIG_SRC/build-windows/xmrig.exe" ]]; then
|
||||||
cp "$XMRIG_SRC/build-windows/xmrig.exe" "$XMRIG_WIN"
|
cp "$XMRIG_SRC/build-windows/xmrig.exe" "$XMRIG_WIN"
|
||||||
ok "xmrig.exe (Windows) built and installed to prebuilt-binaries/xmrig-hac/"
|
ok "xmrig.exe (Windows) built and installed to prebuilt-binaries/drg-xmrig/"
|
||||||
else
|
else
|
||||||
err "xmrig.exe (Windows) build failed — binary not found"
|
err "xmrig.exe (Windows) build failed — binary not found"
|
||||||
MISSING=$((MISSING + 1))
|
MISSING=$((MISSING + 1))
|
||||||
@@ -812,7 +823,7 @@ fi
|
|||||||
# ── 8. Binary directories ───────────────────────────────────────────────────
|
# ── 8. Binary directories ───────────────────────────────────────────────────
|
||||||
header "Binary Directories"
|
header "Binary Directories"
|
||||||
|
|
||||||
for platform in dragonxd-linux dragonxd-win dragonxd-mac xmrig; do
|
for platform in dragonxd-linux dragonxd-win dragonxd-mac drg-xmrig; do
|
||||||
dir="$PROJECT_DIR/prebuilt-binaries/$platform"
|
dir="$PROJECT_DIR/prebuilt-binaries/$platform"
|
||||||
if [[ -d "$dir" ]]; then
|
if [[ -d "$dir" ]]; then
|
||||||
# Count actual files (not .gitkeep)
|
# Count actual files (not .gitkeep)
|
||||||
|
|||||||
658
src/app.cpp
658
src/app.cpp
@@ -68,10 +68,13 @@
|
|||||||
#include "ui/material/draw_helpers.h"
|
#include "ui/material/draw_helpers.h"
|
||||||
#include "ui/widgets/copy_field.h"
|
#include "ui/widgets/copy_field.h"
|
||||||
#include "ui/notifications.h"
|
#include "ui/notifications.h"
|
||||||
|
#include "ui/node_status_banner.h"
|
||||||
#include "util/i18n.h"
|
#include "util/i18n.h"
|
||||||
|
#include "util/connect_stall.h"
|
||||||
#include "util/platform.h"
|
#include "util/platform.h"
|
||||||
#include "util/text_format.h"
|
#include "util/text_format.h"
|
||||||
#include "util/payment_uri.h"
|
#include "util/payment_uri.h"
|
||||||
|
#include "util/seed_phrase.h"
|
||||||
#include "util/texture_loader.h"
|
#include "util/texture_loader.h"
|
||||||
#include "util/svg_texture.h"
|
#include "util/svg_texture.h"
|
||||||
#include "ui/material/colors.h"
|
#include "ui/material/colors.h"
|
||||||
@@ -104,6 +107,7 @@
|
|||||||
#include <unordered_set>
|
#include <unordered_set>
|
||||||
#include <fstream>
|
#include <fstream>
|
||||||
#include <filesystem>
|
#include <filesystem>
|
||||||
|
#include <sstream>
|
||||||
#include <thread>
|
#include <thread>
|
||||||
#include <chrono>
|
#include <chrono>
|
||||||
#include <exception>
|
#include <exception>
|
||||||
@@ -340,6 +344,28 @@ bool App::init()
|
|||||||
// Ensure ObsidianDragon config directory and template files exist
|
// Ensure ObsidianDragon config directory and template files exist
|
||||||
util::Platform::ensureObsidianDragonSetup();
|
util::Platform::ensureObsidianDragonSetup();
|
||||||
|
|
||||||
|
// W1-1 (startup): if the recorded active wallet file was moved/deleted between sessions, don't hand a
|
||||||
|
// missing -wallet=<name> 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(
|
||||||
|
"Your last-used wallet file (" + active + ") was not found — opened the default wallet "
|
||||||
|
"instead. If you moved it, restore it and switch back from the wallet list.", 20.0f);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Initialize PIN vault, scoped to the active wallet so one wallet's stored passphrase is never
|
// 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).
|
// offered for another (the default wallet keeps the legacy vault.dat).
|
||||||
vault_ = std::make_unique<util::SecureVault>(settings_ ? settings_->getActiveWalletFile() : "");
|
vault_ = std::make_unique<util::SecureVault>(settings_ ? settings_->getActiveWalletFile() : "");
|
||||||
@@ -804,6 +830,7 @@ void App::update()
|
|||||||
|
|
||||||
// Pick up progress/result from a running seed-wallet migration (create/sweep/adopt).
|
// Pick up progress/result from a running seed-wallet migration (create/sweep/adopt).
|
||||||
pumpSeedMigration();
|
pumpSeedMigration();
|
||||||
|
pumpWalletRestore();
|
||||||
// While confirming the sweep, poll the tx confirmations + legacy balance every ~5s.
|
// While confirming the sweep, poll the tx confirmations + legacy balance every ~5s.
|
||||||
if (show_seed_migration_ && seed_migration_step_ == SeedMigrationStep::Confirming) {
|
if (show_seed_migration_ && seed_migration_step_ == SeedMigrationStep::Confirming) {
|
||||||
seed_migration_poll_timer_ -= ImGui::GetIO().DeltaTime;
|
seed_migration_poll_timer_ -= ImGui::GetIO().DeltaTime;
|
||||||
@@ -1103,6 +1130,7 @@ void App::update()
|
|||||||
auto* rpc = (fast_rpc_ && fast_rpc_->isConnected()) ? fast_rpc_.get() : rpc_.get();
|
auto* rpc = (fast_rpc_ && fast_rpc_->isConnected()) ? fast_rpc_.get() : rpc_.get();
|
||||||
if (!rpc) return [this](){ opid_poll_in_progress_ = false; };
|
if (!rpc) return [this](){ opid_poll_in_progress_ = false; };
|
||||||
json result;
|
json result;
|
||||||
|
services::NetworkRefreshService::OperationStatusPollResult parsed;
|
||||||
try {
|
try {
|
||||||
rpc::RPCClient::TraceScope trace("Send tab / Operation status");
|
rpc::RPCClient::TraceScope trace("Send tab / Operation status");
|
||||||
// No per-opid filter: this daemon rejects z_getoperationstatus(["opid"]) with
|
// No per-opid filter: this daemon rejects z_getoperationstatus(["opid"]) with
|
||||||
@@ -1110,10 +1138,12 @@ void App::update()
|
|||||||
// "Waiting for operation". The no-arg form returns ALL operations;
|
// "Waiting for operation". The no-arg form returns ALL operations;
|
||||||
// parseOperationStatusPoll() filters down to the opids we're tracking.
|
// parseOperationStatusPoll() filters down to the opids we're tracking.
|
||||||
result = rpc->call("z_getoperationstatus", json::array());
|
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 (...) {
|
} catch (...) {
|
||||||
return [this](){ opid_poll_in_progress_ = false; };
|
return [this](){ opid_poll_in_progress_ = false; };
|
||||||
}
|
}
|
||||||
auto parsed = services::NetworkRefreshService::parseOperationStatusPoll(result, opids);
|
|
||||||
return [this, parsed = std::move(parsed)]() mutable {
|
return [this, parsed = std::move(parsed)]() mutable {
|
||||||
opid_poll_in_progress_ = false;
|
opid_poll_in_progress_ = false;
|
||||||
|
|
||||||
@@ -1770,6 +1800,11 @@ void App::render()
|
|||||||
|
|
||||||
ImGui::BeginChild("##ContentArea", ImVec2(0, contentH), false, contentFlags);
|
ImGui::BeginChild("##ContentArea", ImVec2(0, 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
|
// Capture vertex start for edge fade mask
|
||||||
ImDrawList* caDL = ImGui::GetWindowDrawList();
|
ImDrawList* caDL = ImGui::GetWindowDrawList();
|
||||||
int caVtxStart = caDL->VtxBuffer.Size;
|
int caVtxStart = caDL->VtxBuffer.Size;
|
||||||
@@ -2109,6 +2144,8 @@ void App::render()
|
|||||||
renderDecryptWalletDialog();
|
renderDecryptWalletDialog();
|
||||||
renderPinDialogs();
|
renderPinDialogs();
|
||||||
renderSwitchStopDaemonDialog();
|
renderSwitchStopDaemonDialog();
|
||||||
|
renderBlockDbReindexDialog();
|
||||||
|
renderWalletRecoveredDialog();
|
||||||
|
|
||||||
// Render notifications (toast messages)
|
// Render notifications (toast messages)
|
||||||
ui::Notifications::instance().render();
|
ui::Notifications::instance().render();
|
||||||
@@ -2118,6 +2155,234 @@ void App::render()
|
|||||||
ui::material::LatchBlurOverlayActive();
|
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;
|
||||||
|
// 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.
|
||||||
|
const float bannerH = std::max(minH * ui::Layout::dpiScale(), baseH * ui::Layout::vScale());
|
||||||
|
|
||||||
|
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;
|
||||||
|
|
||||||
|
ImFont* icoFont = m::Type().iconSmall();
|
||||||
|
ImFont* txtFont = m::Type().body2();
|
||||||
|
|
||||||
|
// Icon — centered on its own metrics.
|
||||||
|
ImGui::SetCursorPos(ImVec2(padX, (bannerH - icoFont->LegacySize) * 0.5f));
|
||||||
|
ImGui::PushFont(icoFont);
|
||||||
|
ImGui::PushStyleColor(ImGuiCol_Text, sevCol);
|
||||||
|
ImGui::TextUnformatted(icon);
|
||||||
|
ImGui::PopStyleColor();
|
||||||
|
ImGui::PopFont();
|
||||||
|
|
||||||
|
const float txtCy = (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<unsigned char>(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, (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. Height adapts to the entry count but caps so a busy session
|
||||||
|
// scrolls inside the panel instead of blowing past the popup's max height.
|
||||||
|
const float perEntry = txtF->LegacySize * 2.0f + 14.0f * dp; // message line + time line + spacing
|
||||||
|
const float listH = std::min(300.0f * dp, static_cast<float>(hist.size()) * perEntry);
|
||||||
|
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();
|
||||||
|
// 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()
|
void App::renderStatusBar()
|
||||||
{
|
{
|
||||||
// Status bar layout from unified UI schema
|
// Status bar layout from unified UI schema
|
||||||
@@ -2394,9 +2659,80 @@ void App::renderStatusBar()
|
|||||||
float cbX = occupiedX - cbW - gap;
|
float cbX = occupiedX - cbW - gap;
|
||||||
ImGui::SameLine(cbX);
|
ImGui::SameLine(cbX);
|
||||||
ImGui::TextUnformatted(cb.c_str());
|
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.
|
||||||
|
{
|
||||||
|
const float dp = ui::Layout::dpiScale();
|
||||||
|
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<size_t>(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;
|
||||||
|
}
|
||||||
|
|
||||||
// Version always at far right
|
// Version always at far right
|
||||||
ImGui::SameLine(versionX);
|
ImGui::SameLine(versionX);
|
||||||
ImGui::Text("%s", versionBuf);
|
ImGui::Text("%s", versionBuf);
|
||||||
@@ -2745,22 +3081,16 @@ void App::renderLiteFirstRunPrompt()
|
|||||||
}
|
}
|
||||||
ImGui::Spacing(); ImGui::Spacing();
|
ImGui::Spacing(); ImGui::Spacing();
|
||||||
|
|
||||||
// Trim surrounding whitespace from the entered seed.
|
// Normalize the entered seed (trim, fold exotic Unicode whitespace like NBSP to plain
|
||||||
std::string seedTrim(restoreSeed);
|
// spaces) so the word count and the phrase we submit agree regardless of paste source.
|
||||||
while (!seedTrim.empty() && std::isspace((unsigned char)seedTrim.front())) seedTrim.erase(seedTrim.begin());
|
std::string seedTrim = util::normalizeSeedPhrase(restoreSeed);
|
||||||
while (!seedTrim.empty() && std::isspace((unsigned char)seedTrim.back())) seedTrim.pop_back();
|
|
||||||
|
|
||||||
// Require a valid BIP39 word count before enabling Restore — otherwise a truncated or
|
// Require a COMPLETE 24-word phrase before enabling Restore. The SDXL backend only
|
||||||
// garbage phrase (previously any non-empty text passed) is submitted and fails opaquely.
|
// accepts 24-word / 32-byte-entropy seeds; a shorter valid-BIP39 phrase (12/15/18/21)
|
||||||
int seedWords = 0;
|
// panics it uncaught across the restore FFI, so it must be refused here (matches the
|
||||||
{ bool inWord = false;
|
// Settings restore gate — both go through util::isCompleteRecoveryPhrase).
|
||||||
for (char c : seedTrim) {
|
int seedWords = util::seedPhraseWordCount(seedTrim);
|
||||||
bool sp = (c == ' ' || c == '\t' || c == '\n' || c == '\r');
|
bool seedLenOk = util::isCompleteRecoveryPhrase(seedWords);
|
||||||
if (!sp && !inWord) { seedWords++; inWord = true; }
|
|
||||||
else if (sp) inWord = false;
|
|
||||||
} }
|
|
||||||
bool seedLenOk = (seedWords == 12 || seedWords == 15 || seedWords == 18 ||
|
|
||||||
seedWords == 21 || seedWords == 24);
|
|
||||||
if (!seedTrim.empty() && !seedLenOk) {
|
if (!seedTrim.empty() && !seedLenOk) {
|
||||||
ImGui::PushStyleColor(ImGuiCol_Text, ImGui::ColorConvertU32ToFloat4(ui::material::Warning()));
|
ImGui::PushStyleColor(ImGuiCol_Text, ImGui::ColorConvertU32ToFloat4(ui::material::Warning()));
|
||||||
ImGui::PushTextWrapPos(ImGui::GetCursorPosX() + 380.0f);
|
ImGui::PushTextWrapPos(ImGui::GetCursorPosX() + 380.0f);
|
||||||
@@ -2774,7 +3104,7 @@ void App::renderLiteFirstRunPrompt()
|
|||||||
ImGui::BeginDisabled(!seedLenOk);
|
ImGui::BeginDisabled(!seedLenOk);
|
||||||
if (ui::material::TactileButton(TR("lite_restore_btn"), ImVec2(btnW, 0))) {
|
if (ui::material::TactileButton(TR("lite_restore_btn"), ImVec2(btnW, 0))) {
|
||||||
wallet::LiteWalletRestoreRequest req;
|
wallet::LiteWalletRestoreRequest req;
|
||||||
req.seedPhrase = seedTrim;
|
req.seedPhrase = seedTrim; // normalized: NBSP-glued pastes restore correctly
|
||||||
req.birthday = static_cast<unsigned long long>(std::max(0, restoreBirthday));
|
req.birthday = static_cast<unsigned long long>(std::max(0, restoreBirthday));
|
||||||
req.overwrite = lite_wallet_->walletExists(); // replace any existing wallet file
|
req.overwrite = lite_wallet_->walletExists(); // replace any existing wallet file
|
||||||
if (lite_wallet_->beginRestoreWalletAsync(std::move(req))) {
|
if (lite_wallet_->beginRestoreWalletAsync(std::move(req))) {
|
||||||
@@ -3953,6 +4283,82 @@ void App::renderAntivirusHelpDialog()
|
|||||||
#endif
|
#endif
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Auto-shown when the node auto-recovered (salvaged) wallet.dat: the real wallet is safe in a
|
||||||
|
// wallet.<timestamp>.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");
|
||||||
|
ov.p_open = &show_wallet_recovered_dialog_;
|
||||||
|
ov.style = ui::material::OverlayStyle::BlurFloat;
|
||||||
|
ov.cardWidth = 560.0f;
|
||||||
|
ov.idSuffix = "walletrecovered";
|
||||||
|
if (!ui::material::BeginOverlayDialog(ov)) return;
|
||||||
|
const float dp = ui::Layout::dpiScale();
|
||||||
|
|
||||||
|
ui::material::DialogWarningHeader(TR("wallet_recovered_warn"));
|
||||||
|
ImGui::Dummy(ImVec2(0, ui::Layout::spacingSm()));
|
||||||
|
ImGui::TextWrapped("%s", TR("wallet_recovered_body"));
|
||||||
|
ImGui::Dummy(ImVec2(0, ui::Layout::spacingMd()));
|
||||||
|
// Preferred fix when available: REBUILD the wallet database. Plain "Restore original" hands the same
|
||||||
|
// BDB-inconsistent file back and the daemon just re-salvages it (the cascade); the rebuild produces a
|
||||||
|
// fresh, consistent copy of every key that the daemon loads cleanly. Copy/rename-only — never deletes.
|
||||||
|
if (walletRebuildAvailable()) {
|
||||||
|
if (ui::material::TactileButton(TR("wallet_recovered_rebuild"), ImVec2(280.0f * dp, 0))) {
|
||||||
|
rebuildWalletDatabase(); // clears show_wallet_recovered_dialog_
|
||||||
|
}
|
||||||
|
ImGui::Dummy(ImVec2(0, ui::Layout::spacingSm()));
|
||||||
|
}
|
||||||
|
// Restore of the untouched original (stops the node, swaps the .bak back over the salvaged copy,
|
||||||
|
// clears the stale BDB env, restarts).
|
||||||
|
if (ui::material::TactileButton(TR("wallet_recovered_restore"), ImVec2(260.0f * dp, 0))) {
|
||||||
|
restoreOriginalWallet(); // clears show_wallet_recovered_dialog_
|
||||||
|
}
|
||||||
|
ImGui::SameLine();
|
||||||
|
if (ui::material::TactileButton(TR("wallet_recovered_open_folder"), ImVec2(200.0f * dp, 0))) {
|
||||||
|
util::Platform::openFolder(util::Platform::getDragonXDataDir()); // manual restore instead
|
||||||
|
}
|
||||||
|
ImGui::SameLine();
|
||||||
|
if (ui::material::TactileButton(TR("wallet_recovered_dismiss"), ImVec2(150.0f * dp, 0))) {
|
||||||
|
show_wallet_recovered_dialog_ = false; // acknowledged; keeps the salvaged wallet loaded
|
||||||
|
}
|
||||||
|
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()
|
void App::renderSwitchStopDaemonDialog()
|
||||||
{
|
{
|
||||||
const bool confirm = show_switch_stop_daemon_confirm_;
|
const bool confirm = show_switch_stop_daemon_confirm_;
|
||||||
@@ -4149,7 +4555,11 @@ bool App::startEmbeddedDaemon()
|
|||||||
if (resources::hasEmbeddedResources()) {
|
if (resources::hasEmbeddedResources()) {
|
||||||
DEBUG_LOGF("Extracting embedded Sapling params...\n");
|
DEBUG_LOGF("Extracting embedded Sapling params...\n");
|
||||||
daemon_status_ = TR("sb_extracting_sapling");
|
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
|
// Check again after extraction
|
||||||
if (!rpc::Connection::verifySaplingParams()) {
|
if (!rpc::Connection::verifySaplingParams()) {
|
||||||
@@ -4168,8 +4578,13 @@ bool App::startEmbeddedDaemon()
|
|||||||
const char* paramFiles[] = { "sapling-spend.params", "sapling-output.params", "asmap.dat" };
|
const char* paramFiles[] = { "sapling-spend.params", "sapling-output.params", "asmap.dat" };
|
||||||
bool copied = false;
|
bool copied = false;
|
||||||
if (!exe_dir.empty()) {
|
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;
|
std::error_code ec;
|
||||||
fs::create_directories(daemon_dir, ec);
|
|
||||||
|
|
||||||
// On macOS .app bundles, params are in Contents/Resources/
|
// On macOS .app bundles, params are in Contents/Resources/
|
||||||
// while the executable is in Contents/MacOS/
|
// while the executable is in Contents/MacOS/
|
||||||
@@ -4214,8 +4629,13 @@ bool App::startEmbeddedDaemon()
|
|||||||
std::string exe_dir = util::Platform::getExecutableDirectory();
|
std::string exe_dir = util::Platform::getExecutableDirectory();
|
||||||
std::string daemon_dir = resources::getDaemonDirectory();
|
std::string daemon_dir = resources::getDaemonDirectory();
|
||||||
if (!exe_dir.empty()) {
|
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;
|
std::error_code ec;
|
||||||
fs::create_directories(daemon_dir, ec);
|
|
||||||
|
|
||||||
std::vector<std::string> searchDirs = { exe_dir };
|
std::vector<std::string> searchDirs = { exe_dir };
|
||||||
#ifdef __APPLE__
|
#ifdef __APPLE__
|
||||||
@@ -4226,18 +4646,31 @@ bool App::startEmbeddedDaemon()
|
|||||||
}
|
}
|
||||||
#endif
|
#endif
|
||||||
const char* extraFiles[] = { "asmap.dat", "dragonxd", "dragonx-cli", "dragonx-tx" };
|
const char* extraFiles[] = { "asmap.dat", "dragonxd", "dragonx-cli", "dragonx-tx" };
|
||||||
|
bool copyFailed = false;
|
||||||
for (const char* name : extraFiles) {
|
for (const char* name : extraFiles) {
|
||||||
fs::path dst = fs::path(daemon_dir) / name;
|
fs::path dst = fs::path(daemon_dir) / name;
|
||||||
if (fs::exists(dst)) continue;
|
if (fs::exists(dst)) continue;
|
||||||
for (const auto& dir : searchDirs) {
|
for (const auto& dir : searchDirs) {
|
||||||
fs::path src = fs::path(dir) / name;
|
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());
|
DEBUG_LOGF("Copying bundled %s from %s to %s\n", name, dir.c_str(), daemon_dir.c_str());
|
||||||
fs::copy_file(src, dst, ec);
|
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;
|
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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -5246,26 +5679,99 @@ void App::renderLoadingOverlay(float contentH)
|
|||||||
IM_COL32(255, 90, 90, 255), errTitle);
|
IM_COL32(255, 90, 90, 255), errTitle);
|
||||||
curY += ts.y + gap * 0.5f;
|
curY += ts.y + gap * 0.5f;
|
||||||
|
|
||||||
// Error details (wrapped) — show full diagnostic info
|
// Wallet auto-recovery/salvage takes over the error card: a concise message + prominent one-click
|
||||||
const std::string& errDetail = daemon_controller_->lastError();
|
// actions, RIGHT HERE in the overlay the user is looking at (the separate dialog can be occluded
|
||||||
if (!errDetail.empty()) {
|
// by this full-frame overlay while the node is down). Skip the verbose daemon dump so the buttons
|
||||||
float wrapW = ws.x * 0.8f;
|
// stay on-screen. Same handlers as the dialog.
|
||||||
if (wrapW > 700.0f) wrapW = 700.0f;
|
if (wallet_auto_recovered_) {
|
||||||
ImVec2 es = capFont->CalcTextSizeA(capFont->LegacySize, FLT_MAX, wrapW, errDetail.c_str());
|
const char* msg = TR("wallet_recovered_warn");
|
||||||
dl->AddText(capFont, capFont->LegacySize,
|
float wrapW = ws.x * 0.8f; if (wrapW > 640.0f) wrapW = 640.0f;
|
||||||
ImVec2(wp.x + cx - wrapW * 0.5f, curY),
|
ImVec2 ms = capFont->CalcTextSizeA(capFont->LegacySize, FLT_MAX, wrapW, msg);
|
||||||
IM_COL32(255, 180, 180, 220), errDetail.c_str(), nullptr, wrapW);
|
dl->AddText(capFont, capFont->LegacySize, ImVec2(wp.x + cx - wrapW * 0.5f, curY),
|
||||||
curY += es.y + gap;
|
IM_COL32(230, 210, 210, 235), msg, nullptr, wrapW);
|
||||||
|
curY += ms.y + gap;
|
||||||
|
const float dpi = ui::Layout::dpiScale();
|
||||||
|
const float bw = 340.0f * dpi;
|
||||||
|
auto placeBtn = [&](const char* label) -> bool {
|
||||||
|
ImGui::SetCursorScreenPos(ImVec2(wp.x + cx - bw * 0.5f, curY));
|
||||||
|
const bool clicked = ui::material::TactileButton(label, ImVec2(bw, 0));
|
||||||
|
curY = ImGui::GetItemRectMax().y + gap * 0.4f;
|
||||||
|
return clicked;
|
||||||
|
};
|
||||||
|
if (walletRebuildAvailable() && placeBtn(TR("wallet_recovered_rebuild"))) rebuildWalletDatabase();
|
||||||
|
if (placeBtn(TR("wallet_recovered_restore"))) restoreOriginalWallet();
|
||||||
|
if (placeBtn(TR("wallet_recovered_open_folder")))
|
||||||
|
util::Platform::openFolder(util::Platform::getDragonXDataDir());
|
||||||
|
} else {
|
||||||
|
// Error details (wrapped) — full diagnostic info.
|
||||||
|
const std::string& errDetail = daemon_controller_->lastError();
|
||||||
|
if (!errDetail.empty()) {
|
||||||
|
float wrapW = ws.x * 0.8f;
|
||||||
|
if (wrapW > 700.0f) wrapW = 700.0f;
|
||||||
|
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";
|
||||||
|
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),
|
||||||
|
IM_COL32(200, 200, 200, 180), hint);
|
||||||
|
curY += hs2.y + gap;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Crash count hint
|
// -------------------------------------------------------------------
|
||||||
if (daemon_controller_->crashCount() >= 3) {
|
// 3d. "Taking longer than expected" notice — the daemon is reachable/launching but
|
||||||
const char* hint = "Use Settings > Restart Daemon to try again";
|
// hasn't become ready within the stall threshold. The connect loop keeps retrying
|
||||||
ImVec2 hs2 = capFont->CalcTextSizeA(capFont->LegacySize, FLT_MAX, 0.0f, hint);
|
// underneath (this notice clears itself the instant it connects); it just stops the
|
||||||
|
// user staring at a silent spinner forever. Guarded off while the daemon is in the
|
||||||
|
// Error state — that case is owned by the crash block (3c) above.
|
||||||
|
// -------------------------------------------------------------------
|
||||||
|
if (connect_stall_since_ > 0.0 &&
|
||||||
|
!(daemon_controller_ &&
|
||||||
|
daemon_controller_->state() == daemon::EmbeddedDaemon::State::Error) &&
|
||||||
|
util::connectHasStalled(connect_stall_since_, ImGui::GetTime(),
|
||||||
|
loadElem("stall-timeout-sec", util::kConnectStallDefaultSeconds))) {
|
||||||
|
curY += gap;
|
||||||
|
ImFont* bodyFont2 = Type().body2();
|
||||||
|
if (!bodyFont2) bodyFont2 = ImGui::GetFont();
|
||||||
|
ImFont* capFont = Type().caption();
|
||||||
|
if (!capFont) capFont = ImGui::GetFont();
|
||||||
|
|
||||||
|
// Title
|
||||||
|
const char* title = TR("loading_stall_title");
|
||||||
|
ImVec2 ts = bodyFont2->CalcTextSizeA(bodyFont2->LegacySize, FLT_MAX, 0.0f, title);
|
||||||
|
dl->AddText(bodyFont2, bodyFont2->LegacySize,
|
||||||
|
ImVec2(wp.x + cx - ts.x * 0.5f, curY),
|
||||||
|
IM_COL32(255, 210, 90, 235), title);
|
||||||
|
curY += ts.y + gap * 0.5f;
|
||||||
|
|
||||||
|
// Body (wrapped) — reassure + show elapsed seconds
|
||||||
|
char stallBody[256];
|
||||||
|
snprintf(stallBody, sizeof(stallBody), TR("loading_stall_body"),
|
||||||
|
(float)(ImGui::GetTime() - connect_stall_since_));
|
||||||
|
float wrapW = ws.x * 0.8f;
|
||||||
|
if (wrapW > 640.0f) wrapW = 640.0f;
|
||||||
|
ImVec2 bs = capFont->CalcTextSizeA(capFont->LegacySize, FLT_MAX, wrapW, stallBody);
|
||||||
|
dl->AddText(capFont, capFont->LegacySize,
|
||||||
|
ImVec2(wp.x + cx - wrapW * 0.5f, curY),
|
||||||
|
IM_COL32(200, 200, 200, 210), stallBody, nullptr, wrapW);
|
||||||
|
curY += bs.y + gap * 0.5f;
|
||||||
|
|
||||||
|
// Actionable guidance (full-node only — lite has no daemon to restart)
|
||||||
|
if (supportsFullNodeLifecycleActions()) {
|
||||||
|
const char* hint = TR("loading_stall_hint");
|
||||||
|
ImVec2 hs = capFont->CalcTextSizeA(capFont->LegacySize, FLT_MAX, 0.0f, hint);
|
||||||
dl->AddText(capFont, capFont->LegacySize,
|
dl->AddText(capFont, capFont->LegacySize,
|
||||||
ImVec2(wp.x + cx - hs2.x * 0.5f, curY),
|
ImVec2(wp.x + cx - hs.x * 0.5f, curY),
|
||||||
IM_COL32(200, 200, 200, 180), hint);
|
IM_COL32(180, 180, 180, 190), hint);
|
||||||
curY += hs2.y + gap;
|
curY += hs.y + gap;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -5559,6 +6065,62 @@ void App::maybeFinishTransactionSendProgress()
|
|||||||
if (addresses_dirty_ || network_refresh_.jobInProgress(Job::Addresses)) return;
|
if (addresses_dirty_ || network_refresh_.jobInProgress(Job::Addresses)) return;
|
||||||
send_progress_active_ = false;
|
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()
|
void App::restartDaemon()
|
||||||
{
|
{
|
||||||
if (!supportsFullNodeLifecycleActions()) {
|
if (!supportsFullNodeLifecycleActions()) {
|
||||||
@@ -5595,4 +6157,24 @@ 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("Full-node lifecycle actions are unavailable in lite build");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!daemon_controller_) return;
|
||||||
|
daemon_controller_->setReindexOnNextStart(true);
|
||||||
|
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
|
} // namespace dragonx
|
||||||
|
|||||||
44
src/app.h
44
src/app.h
@@ -147,6 +147,10 @@ public:
|
|||||||
bool isLiteBuild() const { return wallet::isLiteBuild(walletCapabilities()); }
|
bool isLiteBuild() const { return wallet::isLiteBuild(walletCapabilities()); }
|
||||||
bool supportsEmbeddedDaemon() const { return wallet::supportsEmbeddedDaemon(walletCapabilities()); }
|
bool supportsEmbeddedDaemon() const { return wallet::supportsEmbeddedDaemon(walletCapabilities()); }
|
||||||
bool supportsFullNodeLifecycleActions() const { return wallet::supportsFullNodeLifecycleActions(walletCapabilities()); }
|
bool supportsFullNodeLifecycleActions() const { return wallet::supportsFullNodeLifecycleActions(walletCapabilities()); }
|
||||||
|
|
||||||
|
// 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 supportsSoloMining() const { return wallet::supportsSoloMining(walletCapabilities()); }
|
||||||
bool supportsPoolMining() const { return wallet::supportsPoolMining(walletCapabilities()); }
|
bool supportsPoolMining() const { return wallet::supportsPoolMining(walletCapabilities()); }
|
||||||
bool supportsLiteBackend() const { return wallet::supportsLiteBackend(walletCapabilities()); }
|
bool supportsLiteBackend() const { return wallet::supportsLiteBackend(walletCapabilities()); }
|
||||||
@@ -800,6 +804,10 @@ private:
|
|||||||
void pumpSeedMigration(); // main thread: pick up background progress/result each frame
|
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.
|
// 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)
|
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<void(bool, const std::string&)> makeSweepCompletionCallback(bool resumed);
|
||||||
void beginSweepToSeedWallet(); // z_mergetoaddress ["ANY_TADDR","ANY_ZADDR"] -> dest
|
void beginSweepToSeedWallet(); // z_mergetoaddress ["ANY_TADDR","ANY_ZADDR"] -> dest
|
||||||
void pollSweepStatus(); // Confirming step: poll sweep confirmations + legacy balance
|
void pollSweepStatus(); // Confirming step: poll sweep confirmations + legacy balance
|
||||||
void beginAdoptSeedWallet(); // stop daemon -> swap wallet.dat -> restart with -rescan
|
void beginAdoptSeedWallet(); // stop daemon -> swap wallet.dat -> restart with -rescan
|
||||||
@@ -887,6 +895,23 @@ private:
|
|||||||
// sets these and defers to renderSwitchStopDaemonDialog; confirming re-calls switchToWallet(w, true).
|
// sets these and defers to renderSwitchStopDaemonDialog; confirming re-calls switchToWallet(w, true).
|
||||||
bool show_switch_stop_daemon_confirm_ = false;
|
bool show_switch_stop_daemon_confirm_ = false;
|
||||||
std::string pending_switch_wallet_file_;
|
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.<ts>.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 show_wallet_recovered_dialog_ = false; // auto-shown warning dialog
|
||||||
|
// "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 →
|
// 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;
|
// 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.
|
// dialog_open_ gates rendering — both atomic since onConnected/the worker may run off the main thread.
|
||||||
@@ -1023,6 +1048,10 @@ private:
|
|||||||
std::uint64_t clipboard_secret_hash_ = 0;
|
std::uint64_t clipboard_secret_hash_ = 0;
|
||||||
double clipboard_clear_deadline_ = 0.0;
|
double clipboard_clear_deadline_ = 0.0;
|
||||||
float loading_timer_ = 0.0f; // spinner animation for loading overlay
|
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)
|
// Current page (sidebar navigation)
|
||||||
ui::NavPage current_page_ = ui::NavPage::Overview;
|
ui::NavPage current_page_ = ui::NavPage::Overview;
|
||||||
@@ -1288,6 +1317,13 @@ private:
|
|||||||
|
|
||||||
// Private methods - rendering
|
// Private methods - rendering
|
||||||
void renderStatusBar();
|
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 renderLiteFirstRunPrompt(); // lite-only welcome modal when no wallet exists yet
|
||||||
void renderLiteUnlockPrompt(); // lite-only send-time unlock modal
|
void renderLiteUnlockPrompt(); // lite-only send-time unlock modal
|
||||||
void renderImportKeyDialog();
|
void renderImportKeyDialog();
|
||||||
@@ -1305,6 +1341,14 @@ private:
|
|||||||
void renderPinDialogs();
|
void renderPinDialogs();
|
||||||
void renderAntivirusHelpDialog();
|
void renderAntivirusHelpDialog();
|
||||||
void renderSwitchStopDaemonDialog(); // confirm before stopping an adopted node to switch wallets
|
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 detectWalletAutoRecovery(); // scan daemon output for a salvage; fire the warning once/session
|
||||||
|
void restoreOriginalWallet(); // swap the wallet.<ts>.bak back over the salvaged copy + restart
|
||||||
|
void 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();
|
void processDeferredEncryption();
|
||||||
|
|
||||||
// Private methods - connection
|
// Private methods - connection
|
||||||
|
|||||||
@@ -35,6 +35,7 @@
|
|||||||
#include "rpc/connection.h"
|
#include "rpc/connection.h"
|
||||||
#include "chat/chat_identity.h" // deriveChatIdentityFromSecret for HushChat identity provisioning
|
#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/chat_tab.h" // ui::ResetChatTab — wipe chat UI plaintext on a wallet switch
|
||||||
|
#include "ui/windows/mining_pool_panel.h" // ui::resolveMiningUserAddress
|
||||||
#include <sodium.h> // sodium_memzero for wiping the fetched mnemonic
|
#include <sodium.h> // sodium_memzero for wiping the fetched mnemonic
|
||||||
#include <cctype>
|
#include <cctype>
|
||||||
#include "config/settings.h"
|
#include "config/settings.h"
|
||||||
@@ -43,6 +44,7 @@
|
|||||||
#include "wallet/lite_diagnostics.h" // liteLog — chat note-buffer coordinator diagnostics
|
#include "wallet/lite_diagnostics.h" // liteLog — chat note-buffer coordinator diagnostics
|
||||||
#include "config/version.h"
|
#include "config/version.h"
|
||||||
#include "daemon/daemon_controller.h"
|
#include "daemon/daemon_controller.h"
|
||||||
|
#include "daemon/daemon_startup_diagnosis.h"
|
||||||
#include "daemon/embedded_daemon.h"
|
#include "daemon/embedded_daemon.h"
|
||||||
#include "daemon/seed_wallet_creator.h"
|
#include "daemon/seed_wallet_creator.h"
|
||||||
#include "daemon/xmrig_manager.h"
|
#include "daemon/xmrig_manager.h"
|
||||||
@@ -54,7 +56,11 @@
|
|||||||
#include "util/http_download.h"
|
#include "util/http_download.h"
|
||||||
#include "data/exchange_info.h"
|
#include "data/exchange_info.h"
|
||||||
#include "data/exchange_candles.h"
|
#include "data/exchange_candles.h"
|
||||||
|
#include "data/seed_migration_resume.h"
|
||||||
#include "util/platform.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 <cstdio> // popen the rebuild helper
|
||||||
#include "util/perf_log.h"
|
#include "util/perf_log.h"
|
||||||
#include "util/i18n.h"
|
#include "util/i18n.h"
|
||||||
#include "util/secure_vault.h"
|
#include "util/secure_vault.h"
|
||||||
@@ -196,10 +202,14 @@ static WarmupText translateWarmup(const std::string& raw)
|
|||||||
// Used to offer a -salvagewallet repair when a switch fails because the target wallet is corrupt.
|
// Used to offer a -salvagewallet repair when a switch fails because the target wallet is corrupt.
|
||||||
static bool walletOutputLooksCorrupt(const std::string& out)
|
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
|
return out.find("Failed to rename") != std::string::npos
|
||||||
|| out.find("salvage failed") != std::string::npos
|
|| out.find("salvage failed") != std::string::npos
|
||||||
|| out.find("wallet.dat corrupt") != 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()
|
// Phrases dragonxd prints to its console while initializing, in the order translateWarmup()
|
||||||
@@ -219,6 +229,24 @@ static constexpr int kDaemonWaitWarnAttempts = 4;
|
|||||||
// Connection Management
|
// Connection Management
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
|
|
||||||
|
// dragonxd moves wallet.dat to wallet.<ts>.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().error(TR("wallet_recovered_notify"), 30.0f);
|
||||||
|
VERBOSE_LOGF("[recovery] Daemon auto-recovered/salvaged wallet.dat — surfacing the recovery dialog\n");
|
||||||
|
}
|
||||||
|
|
||||||
void App::tryConnect()
|
void App::tryConnect()
|
||||||
{
|
{
|
||||||
// Lite builds have no full node / RPC daemon, so never run the RPC connection state machine
|
// Lite builds have no full node / RPC daemon, so never run the RPC connection state machine
|
||||||
@@ -226,6 +254,10 @@ void App::tryConnect()
|
|||||||
// derived from it each frame in App::update(), which also gates the wallet UI (isConnected()).
|
// derived from it each frame in App::update(), which also gates the wallet UI (isConnected()).
|
||||||
if (isLiteBuild()) return;
|
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 (connection_in_progress_) return;
|
if (connection_in_progress_) return;
|
||||||
|
|
||||||
// Don't fight an in-progress restart/adopt orchestration: while it stops the daemon, swaps
|
// Don't fight an in-progress restart/adopt orchestration: while it stops the daemon, swaps
|
||||||
@@ -241,6 +273,16 @@ void App::tryConnect()
|
|||||||
// Auto-detect configuration (file I/O — fast, safe on main thread)
|
// Auto-detect configuration (file I/O — fast, safe on main thread)
|
||||||
auto config = rpc::Connection::autoDetectConfig();
|
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()) {
|
if (config.rpcuser.empty() || config.rpcpassword.empty()) {
|
||||||
connection_in_progress_ = false;
|
connection_in_progress_ = false;
|
||||||
std::string confPath = rpc::Connection::getDefaultConfPath();
|
std::string confPath = rpc::Connection::getDefaultConfPath();
|
||||||
@@ -310,11 +352,21 @@ void App::tryConnect()
|
|||||||
VERBOSE_LOGF("[connect #%d] Connecting to %s:%s (user=%s)\n",
|
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());
|
connect_attempt, config.host.c_str(), config.port.c_str(), config.rpcuser.c_str());
|
||||||
|
|
||||||
if (rpc::Connection::usesPlaintextRemote(config) && !remote_rpc_plaintext_warning_shown_) {
|
if (rpc::Connection::usesPlaintextRemote(config) &&
|
||||||
remote_rpc_plaintext_warning_shown_ = true;
|
!rpc::Connection::allowsPlaintextRemote(config)) {
|
||||||
ui::Notifications::instance().warning(
|
// Refuse to send Basic-auth credentials in cleartext to a remote host — a local-network
|
||||||
"Remote RPC is using plaintext HTTP. Add rpctls=1 to DRAGONX.conf if your daemon supports TLS.",
|
// MITM would otherwise capture rpcuser:rpcpassword. This is a deliberate behaviour change
|
||||||
10.0f);
|
// 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
|
// Run the blocking rpc_->connect() on the worker thread so the UI
|
||||||
@@ -385,6 +437,7 @@ void App::tryConnect()
|
|||||||
// fail until warmup completes. Set the warmup state so
|
// fail until warmup completes. Set the warmup state so
|
||||||
// the UI shows status instead of a blocking overlay.
|
// the UI shows status instead of a blocking overlay.
|
||||||
state_.warming_up = true;
|
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);
|
auto wt = translateWarmup(warmupStatus);
|
||||||
state_.warmup_status = wt.title;
|
state_.warmup_status = wt.title;
|
||||||
state_.warmup_description = wt.description;
|
state_.warmup_description = wt.description;
|
||||||
@@ -474,8 +527,25 @@ void App::tryConnect()
|
|||||||
VERBOSE_LOGF("[connect #%d] RPC connection failed — no daemon starting, no external detected\n", attempt);
|
VERBOSE_LOGF("[connect #%d] RPC connection failed — no daemon starting, no external detected\n", attempt);
|
||||||
|
|
||||||
if (isUsingEmbeddedDaemon() && !isEmbeddedDaemonRunning()) {
|
if (isUsingEmbeddedDaemon() && !isEmbeddedDaemonRunning()) {
|
||||||
|
// 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
|
// 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()) {
|
if (wallet_switch_pending_confirm_.load()) {
|
||||||
// The just-switched-to wallet's daemon keeps crashing (e.g. a wallet that
|
// 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
|
// fails LATE in init, past the fast start grace) — revert to the previous
|
||||||
@@ -526,10 +596,13 @@ void App::onConnected()
|
|||||||
}
|
}
|
||||||
state_.daemon_initializing = false; // RPC is answering now; clear the "initializing" overlay
|
state_.daemon_initializing = false; // RPC is answering now; clear the "initializing" overlay
|
||||||
daemon_wait_attempts_ = 0; // re-arm the port-busy / start-failure notifications
|
daemon_wait_attempts_ = 0; // re-arm the port-busy / start-failure notifications
|
||||||
|
connect_stall_since_ = 0.0; // connected — clear the "taking too long" clock
|
||||||
daemon_start_error_shown_ = false;
|
daemon_start_error_shown_ = false;
|
||||||
daemon_last_seen_crashes_ = 0; // (onConnected resets the daemon's crash count too)
|
daemon_last_seen_crashes_ = 0; // (onConnected resets the daemon's crash count too)
|
||||||
connection_status_ = TR("connected");
|
connection_status_ = TR("connected");
|
||||||
|
|
||||||
|
detectWalletAutoRecovery(); // also runs every tryConnect tick — catches a salvage even if we never connect
|
||||||
|
|
||||||
// Stamp the active wallet as opened in the index (last-opened + size + synced-here). Balance +
|
// Stamp the active wallet as opened in the index (last-opened + size + synced-here). Balance +
|
||||||
// address count fill in on the first address refresh (addresses aren't loaded yet here).
|
// address count fill in on the first address refresh (addresses aren't loaded yet here).
|
||||||
updateWalletIndexForActiveWallet(/*markOpened=*/true);
|
updateWalletIndexForActiveWallet(/*markOpened=*/true);
|
||||||
@@ -606,6 +679,7 @@ void App::onDisconnected(const std::string& reason)
|
|||||||
state_.connected = false;
|
state_.connected = false;
|
||||||
state_.warming_up = false;
|
state_.warming_up = false;
|
||||||
state_.warmup_status.clear();
|
state_.warmup_status.clear();
|
||||||
|
connect_stall_since_ = 0.0; // reset the "taking too long" clock (App member, untouched by state_.clear())
|
||||||
state_.clear();
|
state_.clear();
|
||||||
connection_status_ = reason;
|
connection_status_ = reason;
|
||||||
|
|
||||||
@@ -660,6 +734,7 @@ void App::onDisconnected(const std::string& reason)
|
|||||||
std::string App::applyDaemonInitStatus(bool reachableButBusy)
|
std::string App::applyDaemonInitStatus(bool reachableButBusy)
|
||||||
{
|
{
|
||||||
state_.daemon_initializing = true;
|
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
|
// 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, …).
|
// what the node is doing (loading the block index, verifying, activating best chain, …).
|
||||||
@@ -1067,9 +1142,16 @@ void App::updateWalletIndexForActiveWallet(bool markOpened)
|
|||||||
if (!ec) e.sizeBytesAtLastOpen = static_cast<long long>(sz);
|
if (!ec) e.sizeBytesAtLastOpen = static_cast<long long>(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) {
|
if (markOpened) {
|
||||||
e.lastOpenedEpoch = static_cast<long long>(std::time(nullptr));
|
e.lastOpenedEpoch = static_cast<long long>(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();
|
if (wallet_index_.upsert(e)) wallet_index_.save();
|
||||||
@@ -1096,7 +1178,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.");
|
ui::Notifications::instance().warning("A rescan or repair is in progress — try again once it finishes.");
|
||||||
return;
|
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.");
|
ui::Notifications::instance().warning("Finish or cancel the seed migration before switching wallets.");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -1108,6 +1192,19 @@ void App::switchToWallet(const std::string& walletFile, bool stopDaemonConfirmed
|
|||||||
ui::Notifications::instance().warning("Finish or cancel the pending send before switching wallets.");
|
ui::Notifications::instance().warning("Finish or cancel the pending send before switching wallets.");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
// W1-1: verify the target wallet file actually exists before switching. dragonxd auto-CREATES a
|
||||||
|
// fresh empty wallet for a missing -wallet=<name>, 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
|
// 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
|
// 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
|
// one), confirm before stopping it: switching must stop+restart it on the new wallet, but the user may
|
||||||
@@ -1488,6 +1585,7 @@ void App::refreshCoreData()
|
|||||||
state_.warming_up = false;
|
state_.warming_up = false;
|
||||||
state_.warmup_status.clear();
|
state_.warmup_status.clear();
|
||||||
state_.warmup_description.clear();
|
state_.warmup_description.clear();
|
||||||
|
connect_stall_since_ = 0.0; // warmup finished — clear the "taking too long" clock
|
||||||
connection_status_ = TR("connected");
|
connection_status_ = TR("connected");
|
||||||
VERBOSE_LOGF("[warmup] Daemon ready, warmup complete\n");
|
VERBOSE_LOGF("[warmup] Daemon ready, warmup complete\n");
|
||||||
|
|
||||||
@@ -2288,27 +2386,23 @@ void App::startPoolMining(int threads)
|
|||||||
cfg.tls = settings_->getPoolTls();
|
cfg.tls = settings_->getPoolTls();
|
||||||
cfg.hugepages = settings_->getPoolHugepages();
|
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) {
|
for (const auto& addr : state_.z_addresses) {
|
||||||
if (!addr.address.empty()) {
|
if (!addr.address.empty()) { firstShielded = addr.address; break; }
|
||||||
cfg.wallet_address = addr.address;
|
}
|
||||||
|
for (const auto& addr : state_.addresses) {
|
||||||
|
if (addr.type == "transparent" && !addr.address.empty()) {
|
||||||
|
firstTransparent = addr.address;
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (cfg.wallet_address.empty()) {
|
cfg.wallet_address = ui::resolveMiningUserAddress(cfg.worker_name, firstShielded, firstTransparent);
|
||||||
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;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (cfg.wallet_address.empty()) {
|
if (cfg.wallet_address.empty()) {
|
||||||
DEBUG_LOGF("[ERROR] Pool mining: No wallet address available\n");
|
DEBUG_LOGF("[ERROR] Pool mining: No wallet address available\n");
|
||||||
@@ -3761,6 +3855,8 @@ void App::exportAllKeys(std::function<void(const std::string&, int, int)> callba
|
|||||||
(*pending)--;
|
(*pending)--;
|
||||||
if (*pending == 0 && callback) {
|
if (*pending == 0 && callback) {
|
||||||
callback(*keys_result, *exported, *total);
|
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 +3879,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
|
// 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).
|
// it to the daemon (the dialog's indicator and this guard share isRecognizedImportKey).
|
||||||
if (!services::WalletSecurityController::isRecognizedImportKey(key)) {
|
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;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -3792,7 +3890,7 @@ void App::importPrivateKey(const std::string& rawKey, int startHeight,
|
|||||||
== services::WalletSecurityController::KeyKind::Shielded;
|
== services::WalletSecurityController::KeyKind::Shielded;
|
||||||
// Run on the worker thread — import requests a full rescan (rescan=true), so the
|
// 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.
|
// 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;
|
std::string err, addr;
|
||||||
try {
|
try {
|
||||||
rpc::RPCClient::TraceScope trace("Settings / Import key");
|
rpc::RPCClient::TraceScope trace("Settings / Import key");
|
||||||
@@ -3804,6 +3902,11 @@ void App::importPrivateKey(const std::string& rawKey, int startHeight,
|
|||||||
// A start height (shielded RPCs only) rescans from that block instead of genesis.
|
// A start height (shielded RPCs only) rescans from that block instead of genesis.
|
||||||
if (startHeight > 0 && (viewing || shielded)) params.push_back(startHeight);
|
if (startHeight > 0 && (viewing || shielded)) params.push_back(startHeight);
|
||||||
nlohmann::json r = rpc_->call(method, params);
|
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<std::string&>();
|
||||||
|
if (!pk.empty()) sodium_memzero(&pk[0], pk.size());
|
||||||
|
}
|
||||||
// z_import* return {type,address}; importprivkey returns the t-address string.
|
// z_import* return {type,address}; importprivkey returns the t-address string.
|
||||||
if (r.is_object() && r.contains("address") && r["address"].is_string())
|
if (r.is_object() && r.contains("address") && r["address"].is_string())
|
||||||
addr = r["address"].get<std::string>();
|
addr = r["address"].get<std::string>();
|
||||||
@@ -3816,6 +3919,16 @@ void App::importPrivateKey(const std::string& rawKey, int startHeight,
|
|||||||
// below would never run, leaving a stuck "Importing…" spinner.
|
// below would never run, leaving a stuck "Importing…" spinner.
|
||||||
err = "Import failed (unknown error)";
|
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]() {
|
return [this, err, addr, callback]() {
|
||||||
if (!err.empty()) {
|
if (!err.empty()) {
|
||||||
if (callback) callback(false, err, "");
|
if (callback) callback(false, err, "");
|
||||||
@@ -3826,6 +3939,7 @@ void App::importPrivateKey(const std::string& rawKey, int startHeight,
|
|||||||
if (callback) callback(true, "", addr);
|
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
|
// Sweep a spending key: import it (a full rescan populates its UTXOs/notes — the stock node has no
|
||||||
@@ -3863,7 +3977,7 @@ void App::sweepPrivateKey(const std::string& rawKey, int startHeight, int destMo
|
|||||||
const bool shielded = services::WalletSecurityController::classifyPrivateKey(key)
|
const bool shielded = services::WalletSecurityController::classifyPrivateKey(key)
|
||||||
== services::WalletSecurityController::KeyKind::Shielded;
|
== services::WalletSecurityController::KeyKind::Shielded;
|
||||||
const double fee = DRAGONX_DEFAULT_FEE;
|
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;
|
std::string err, dest, sourceAddr, amountStr;
|
||||||
double amount = 0.0;
|
double amount = 0.0;
|
||||||
try {
|
try {
|
||||||
@@ -3887,6 +4001,11 @@ void App::sweepPrivateKey(const std::string& rawKey, int startHeight, int destMo
|
|||||||
else { method = "importprivkey"; params = {key, "", true}; }
|
else { method = "importprivkey"; params = {key, "", true}; }
|
||||||
if (startHeight > 0 && shielded) params.push_back(startHeight);
|
if (startHeight > 0 && shielded) params.push_back(startHeight);
|
||||||
nlohmann::json r = rpc_->call(method, params);
|
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<std::string&>();
|
||||||
|
if (!pk.empty()) sodium_memzero(&pk[0], pk.size());
|
||||||
|
}
|
||||||
|
|
||||||
// 2. Determine the swept address. importprivkey returns the t-address string; z_importkey
|
// 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.
|
// returns null, so diff the z-address list to find the one the key just added.
|
||||||
@@ -3945,6 +4064,8 @@ void App::sweepPrivateKey(const std::string& rawKey, int startHeight, int destMo
|
|||||||
} catch (...) {
|
} catch (...) {
|
||||||
err = "Sweep failed (unknown error)";
|
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]() {
|
return [this, err, sourceAddr, dest, amount, amountStr, fee]() {
|
||||||
invalidateAddressValidationCache();
|
invalidateAddressValidationCache();
|
||||||
refreshAddresses();
|
refreshAddresses();
|
||||||
@@ -3981,6 +4102,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<void(bool, bool, const std::string&, const std::string&)> callback)
|
void App::exportSeedPhrase(std::function<void(bool, bool, const std::string&, const std::string&)> callback)
|
||||||
@@ -4111,27 +4233,56 @@ void App::showSeedMigrationDialog()
|
|||||||
// Resume a pending migration. If a sweep was already submitted (txid persisted), resume at the
|
// 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
|
// 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.
|
// at the Sweep step. With no pending migration, start fresh at the intro.
|
||||||
if (settings_ && settings_->getSeedMigrationPending() && !settings_->getSeedMigrationDest().empty()) {
|
const bool pending = settings_ && settings_->getSeedMigrationPending();
|
||||||
seed_migration_dest_ = settings_->getSeedMigrationDest();
|
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_temp_dir_ = settings_->getSeedMigrationTempDir();
|
||||||
seed_migration_sweep_txid_ = settings_->getSeedMigrationSweepTxid();
|
seed_migration_sweep_txid_.clear();
|
||||||
if (!seed_migration_sweep_txid_.empty()) {
|
seed_migration_step_ = SeedMigrationStep::Sweep;
|
||||||
seed_migration_sweep_confs_ = 0;
|
seed_migration_balance_loaded_ = false;
|
||||||
seed_migration_legacy_remaining_ = -1.0;
|
seed_migration_nofunds_confirmed_ = false;
|
||||||
seed_migration_poll_timer_ = 0.0f; // poll immediately
|
refreshSeedMigrationBalance();
|
||||||
seed_migration_step_ = SeedMigrationStep::Confirming;
|
break;
|
||||||
} else {
|
case MigrationResume::Intro:
|
||||||
seed_migration_step_ = SeedMigrationStep::Sweep;
|
default:
|
||||||
seed_migration_balance_loaded_ = false;
|
|
||||||
seed_migration_nofunds_confirmed_ = false;
|
|
||||||
refreshSeedMigrationBalance();
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
seed_migration_step_ = SeedMigrationStep::Intro;
|
seed_migration_step_ = SeedMigrationStep::Intro;
|
||||||
// Fresh start: the Intro step will pre-flight the wallet (legacy vs already-seeded vs old
|
// Fresh start: the Intro step will pre-flight the wallet (legacy vs already-seeded vs old
|
||||||
// daemon) before offering to create anything.
|
// daemon) before offering to create anything.
|
||||||
seed_migration_precheck_ = SeedMigrationPrecheck::Pending;
|
seed_migration_precheck_ = SeedMigrationPrecheck::Pending;
|
||||||
seed_migration_precheck_started_ = false;
|
seed_migration_precheck_started_ = false;
|
||||||
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -4206,29 +4357,71 @@ void App::beginSweepToSeedWallet()
|
|||||||
seed_migration_step_ = SeedMigrationStep::Error;
|
seed_migration_step_ = SeedMigrationStep::Error;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
pending_send_callbacks_[opid] = [this](bool ok, const std::string& result) {
|
// W3-3: adopt this new opid atomically — persist it AND clear any prior sweep txid in the
|
||||||
if (ok) {
|
// SAME settings write. Persisting the opid lets an app-close during Sweeping (opid
|
||||||
seed_migration_sweep_txid_ = result;
|
// submitted, not yet resolved to a txid) re-poll it on resume instead of dropping it. Doing
|
||||||
// Persist the txid so a restart resumes at the confirm/adopt stage and never
|
// the swap HERE — only once the new submit has succeeded — rather than speculatively at
|
||||||
// re-sweeps from scratch. The Confirming step gates adopt on this tx being mined
|
// function entry means a FAILED "Sweep remaining" remainder re-sweep leaves the
|
||||||
// (>= 1 confirmation) AND the legacy balance dropping to ~0.
|
// already-mined first sweep's txid intact and resumable to Confirming; and the txid and
|
||||||
if (settings_) { settings_->setSeedMigrationSweepTxid(result); settings_->save(); }
|
// opid are never both authoritative at once (torn-write safe; resume checks txid first).
|
||||||
seed_migration_sweep_confs_ = 0;
|
seed_migration_sweep_txid_.clear();
|
||||||
seed_migration_legacy_remaining_ = -1.0;
|
if (settings_) {
|
||||||
seed_migration_poll_timer_ = 0.0f;
|
settings_->setSeedMigrationSweepTxid("");
|
||||||
seed_migration_status_.clear();
|
settings_->setSeedMigrationSweepOpid(opid);
|
||||||
seed_migration_step_ = SeedMigrationStep::Confirming;
|
settings_->save();
|
||||||
} else {
|
}
|
||||||
seed_migration_status_ = result.empty() ? "The sweep transaction failed." : result;
|
pending_send_callbacks_[opid] = makeSweepCompletionCallback(/*resumed=*/false);
|
||||||
seed_migration_step_ = SeedMigrationStep::Error;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
trackOperation(opid);
|
trackOperation(opid);
|
||||||
seed_migration_status_ = "Waiting for the sweep transaction to be accepted…";
|
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<void(bool, const std::string&)> 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
|
// 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
|
// 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
|
// never swap wallet.dat while the funds could still bounce back (dropped/reorged tx) or while a
|
||||||
@@ -4282,7 +4475,13 @@ void App::beginAdoptSeedWallet()
|
|||||||
// has its own passphrase; the user can re-enable PIN quick-unlock for it).
|
// has its own passphrase; the user can re-enable PIN quick-unlock for it).
|
||||||
if (vault_) vault_->removeVault();
|
if (vault_) vault_->removeVault();
|
||||||
const std::string base = seed_migration_temp_dir_;
|
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;
|
namespace fs = std::filesystem;
|
||||||
std::string err; // fatal (swap did not happen; migration incomplete)
|
std::string err; // fatal (swap did not happen; migration incomplete)
|
||||||
std::string warn; // non-fatal (swap done but the daemon did not restart)
|
std::string warn; // non-fatal (swap done but the daemon did not restart)
|
||||||
@@ -4302,7 +4501,7 @@ void App::beginAdoptSeedWallet()
|
|||||||
// 2. Swap wallet.dat. Move the legacy one aside to a timestamped backup (NEVER
|
// 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.
|
// delete), then copy the new seed wallet in. On any failure, restore the legacy.
|
||||||
const std::string datadir = util::Platform::getDragonXDataDir();
|
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";
|
const std::string newWallet = base + "/DRAGONX/wallet.dat";
|
||||||
std::time_t t = std::time(nullptr);
|
std::time_t t = std::time(nullptr);
|
||||||
std::tm tmv{}; // thread-safe local time (the UI thread also uses localtime)
|
std::tm tmv{}; // thread-safe local time (the UI thread also uses localtime)
|
||||||
@@ -4363,6 +4562,285 @@ void App::beginAdoptSeedWallet()
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Undo a daemon wallet auto-recovery: swap the untouched original (wallet.<ts>.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;
|
||||||
|
}
|
||||||
|
show_wallet_recovered_dialog_ = false;
|
||||||
|
{ std::lock_guard<std::mutex> 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<std::pair<std::string, unsigned long long>> 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<unsigned long long>(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<std::mutex> 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<std::mutex> 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;
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
|
||||||
|
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; }
|
||||||
|
|
||||||
|
show_wallet_recovered_dialog_ = false;
|
||||||
|
{ std::lock_guard<std::mutex> 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<unsigned long long>(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.
|
||||||
|
std::string cmd = "\"" + helper + "\" \"" + src + "\" \"" + tmpOut + "\"";
|
||||||
|
#ifdef _WIN32
|
||||||
|
cmd = "\"" + cmd + "\""; // cmd.exe strips the outermost quotes
|
||||||
|
FILE* fp = _popen(cmd.c_str(), "r");
|
||||||
|
#else
|
||||||
|
FILE* fp = popen(cmd.c_str(), "r");
|
||||||
|
#endif
|
||||||
|
std::string jout;
|
||||||
|
if (fp) { char b[512]; while (std::fgets(b, sizeof b, fp)) jout += b; }
|
||||||
|
#ifdef _WIN32
|
||||||
|
const int rc = fp ? _pclose(fp) : -1;
|
||||||
|
#else
|
||||||
|
const int rc = fp ? pclose(fp) : -1;
|
||||||
|
#endif
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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<std::mutex> 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()
|
void App::pumpSeedMigration()
|
||||||
{
|
{
|
||||||
if (capture_mode_) return; // no live ops during a UI sweep (steps are set directly)
|
if (capture_mode_) return; // no live ops during a UI sweep (steps are set directly)
|
||||||
@@ -4386,6 +4864,7 @@ void App::pumpSeedMigration()
|
|||||||
settings_->setSeedMigrationDest("");
|
settings_->setSeedMigrationDest("");
|
||||||
settings_->setSeedMigrationTempDir("");
|
settings_->setSeedMigrationTempDir("");
|
||||||
settings_->setSeedMigrationSweepTxid("");
|
settings_->setSeedMigrationSweepTxid("");
|
||||||
|
settings_->setSeedMigrationSweepOpid(""); // W3-3
|
||||||
settings_->save();
|
settings_->save();
|
||||||
}
|
}
|
||||||
seed_migration_status_ = err; // a non-empty warning here (e.g. restart hiccup) is shown on Done
|
seed_migration_status_ = err; // a non-empty warning here (e.g. restart hiccup) is shown on Done
|
||||||
@@ -4428,6 +4907,11 @@ void App::pumpSeedMigration()
|
|||||||
settings_->setSeedMigrationPending(true);
|
settings_->setSeedMigrationPending(true);
|
||||||
settings_->setSeedMigrationDest(seed_migration_dest_);
|
settings_->setSeedMigrationDest(seed_migration_dest_);
|
||||||
settings_->setSeedMigrationTempDir(seed_migration_temp_dir_);
|
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();
|
settings_->save();
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
@@ -4456,13 +4940,12 @@ void App::backupWallet(const std::string& destination, std::function<void(bool,
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
std::ofstream file(destination);
|
// Write the key backup atomically and owner-only (0600) — it must never be even briefly
|
||||||
if (!file.is_open()) {
|
// world-readable, and the previous std::ofstream left it at the umask default.
|
||||||
if (callback) callback(false, "Could not open file: " + destination);
|
if (!util::Platform::writeFileAtomically(destination, keys, /*restrictPermissions=*/true)) {
|
||||||
|
if (callback) callback(false, "Could not write file: " + destination);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
file << keys;
|
|
||||||
file.close();
|
|
||||||
|
|
||||||
std::string msg = "Wallet backup saved to " + destination + " — "
|
std::string msg = "Wallet backup saved to " + destination + " — "
|
||||||
+ std::to_string(exported) + " of " + std::to_string(total) + " keys.";
|
+ std::to_string(exported) + " of " + std::to_string(total) + " keys.";
|
||||||
|
|||||||
@@ -33,6 +33,10 @@
|
|||||||
#include <ctime>
|
#include <ctime>
|
||||||
#include <cstdint>
|
#include <cstdint>
|
||||||
#include <filesystem>
|
#include <filesystem>
|
||||||
|
#include <fstream>
|
||||||
|
#include <vector>
|
||||||
|
#include <utility>
|
||||||
|
#include <sodium.h>
|
||||||
#include <functional>
|
#include <functional>
|
||||||
#include <memory>
|
#include <memory>
|
||||||
#include <utility>
|
#include <utility>
|
||||||
@@ -483,7 +487,17 @@ void App::lockWallet() {
|
|||||||
state_.locked = true;
|
state_.locked = true;
|
||||||
state_.unlocked_until = 0;
|
state_.unlocked_until = 0;
|
||||||
resetTransactionHistoryCacheSession();
|
resetTransactionHistoryCacheSession();
|
||||||
|
lock_failure_warned_ = false;
|
||||||
DEBUG_LOGF("[App] Wallet locked\n");
|
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(
|
||||||
|
"Couldn't lock the wallet — it is still unlocked. Check the daemon connection.", 12.0f);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
@@ -560,6 +574,12 @@ void App::refreshWalletEncryptionState() {
|
|||||||
state_.unlocked_until = until;
|
state_.unlocked_until = until;
|
||||||
state_.locked = (until == 0);
|
state_.locked = (until == 0);
|
||||||
state_.encryption_state_known = true;
|
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) {
|
if (state_.locked) {
|
||||||
resetTransactionHistoryCacheSession();
|
resetTransactionHistoryCacheSession();
|
||||||
} else if (state_.transactions.empty()) {
|
} else if (state_.transactions.empty()) {
|
||||||
@@ -572,6 +592,19 @@ void App::refreshWalletEncryptionState() {
|
|||||||
state_.locked = false;
|
state_.locked = false;
|
||||||
state_.unlocked_until = 0;
|
state_.unlocked_until = 0;
|
||||||
state_.encryption_state_known = true;
|
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(
|
||||||
|
"Wallet encryption did not complete — your wallet is NOT encrypted. "
|
||||||
|
"Open Settings to finish encrypting it.", 30.0f);
|
||||||
|
}
|
||||||
if (state_.transactions.empty()) {
|
if (state_.transactions.empty()) {
|
||||||
loadTransactionHistoryCacheIfAvailable();
|
loadTransactionHistoryCacheIfAvailable();
|
||||||
} else {
|
} else {
|
||||||
@@ -1478,12 +1511,14 @@ void App::renderDecryptWalletDialog() {
|
|||||||
|
|
||||||
// Run entire decrypt flow on worker thread
|
// Run entire decrypt flow on worker thread
|
||||||
if (worker_) {
|
if (worker_) {
|
||||||
worker_->post([this, passphrase]() -> rpc::RPCWorker::MainCb {
|
worker_->post([this, passphrase = std::move(passphrase)]() mutable -> rpc::RPCWorker::MainCb {
|
||||||
WalletSecurityDecryptRpcAdapter decryptRpc(rpc_.get(),
|
WalletSecurityDecryptRpcAdapter decryptRpc(rpc_.get(),
|
||||||
[this](rpc::RPCClient& client, const char* context) {
|
[this](rpc::RPCClient& client, const char* context) {
|
||||||
return sendStopCommandSafely(client, context);
|
return sendStopCommandSafely(client, context);
|
||||||
});
|
});
|
||||||
auto unlock = services::WalletSecurityWorkflowExecutor::unlockWallet(passphrase, decryptRpc);
|
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) {
|
if (!unlock.ok) {
|
||||||
return [this]() {
|
return [this]() {
|
||||||
wallet_security_workflow_.failEntry("Incorrect passphrase");
|
wallet_security_workflow_.failEntry("Incorrect passphrase");
|
||||||
@@ -1606,6 +1641,27 @@ void App::renderDecryptWalletDialog() {
|
|||||||
WalletSecurityImportRpcAdapter importAdapter(rpc_.get(), saved_config_);
|
WalletSecurityImportRpcAdapter importAdapter(rpc_.get(), saved_config_);
|
||||||
auto importResult = services::WalletSecurityWorkflowExecutor::importWallet(
|
auto importResult = services::WalletSecurityWorkflowExecutor::importWallet(
|
||||||
importAdapter, exportPath);
|
importAdapter, exportPath);
|
||||||
|
|
||||||
|
// The plaintext key export (obsidiandecryptexport…) has served its purpose now
|
||||||
|
// that the import attempt has resolved — scrub and remove it so a full cleartext
|
||||||
|
// dump of every private key isn't left on disk forever. Recovery, if ever needed,
|
||||||
|
// is the encrypted backup (wallet.dat.encrypted.bak), never this file.
|
||||||
|
{
|
||||||
|
std::error_code delEc;
|
||||||
|
const auto sz = std::filesystem::file_size(exportPath, delEc);
|
||||||
|
if (!delEc && sz > 0) {
|
||||||
|
std::fstream scrub(exportPath,
|
||||||
|
std::ios::binary | std::ios::in | std::ios::out);
|
||||||
|
if (scrub) {
|
||||||
|
const std::vector<char> zeros(static_cast<size_t>(sz), 0);
|
||||||
|
scrub.write(zeros.data(), static_cast<std::streamsize>(sz));
|
||||||
|
scrub.flush();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
std::filesystem::remove(exportPath, delEc);
|
||||||
|
DEBUG_LOGF("[decrypt] removed plaintext key export after import\n");
|
||||||
|
}
|
||||||
|
|
||||||
if (!importResult.ok) {
|
if (!importResult.ok) {
|
||||||
std::string err = importResult.error;
|
std::string err = importResult.error;
|
||||||
if (worker_) {
|
if (worker_) {
|
||||||
|
|||||||
@@ -1338,6 +1338,10 @@ void App::renderFirstRunWizard() {
|
|||||||
wallet_security_.beginDeferredEncryption(
|
wallet_security_.beginDeferredEncryption(
|
||||||
std::string(encrypt_pass_buf_),
|
std::string(encrypt_pass_buf_),
|
||||||
(pinEntered && pinOk) ? pinStr : std::string());
|
(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
|
// Clear sensitive buffers
|
||||||
memset(encrypt_pass_buf_, 0, sizeof(encrypt_pass_buf_));
|
memset(encrypt_pass_buf_, 0, sizeof(encrypt_pass_buf_));
|
||||||
|
|||||||
@@ -231,12 +231,14 @@ bool Settings::load(const std::string& path)
|
|||||||
}
|
}
|
||||||
loadScalar(j, "wizard_completed", wizard_completed_);
|
loadScalar(j, "wizard_completed", wizard_completed_);
|
||||||
loadScalar(j, "seed_backup_reminded", seed_backup_reminded_);
|
loadScalar(j, "seed_backup_reminded", seed_backup_reminded_);
|
||||||
|
loadScalar(j, "encryption_pending", encryption_pending_);
|
||||||
loadScalar(j, "daemon_update_prompted_size", daemon_update_prompted_size_);
|
loadScalar(j, "daemon_update_prompted_size", daemon_update_prompted_size_);
|
||||||
loadScalar(j, "active_wallet_file", active_wallet_file_);
|
loadScalar(j, "active_wallet_file", active_wallet_file_);
|
||||||
loadScalar(j, "seed_migration_pending", seed_migration_pending_);
|
loadScalar(j, "seed_migration_pending", seed_migration_pending_);
|
||||||
loadScalar(j, "seed_migration_dest", seed_migration_dest_);
|
loadScalar(j, "seed_migration_dest", seed_migration_dest_);
|
||||||
loadScalar(j, "seed_migration_temp_dir", seed_migration_temp_dir_);
|
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_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, "auto_lock_timeout", auto_lock_timeout_);
|
||||||
loadScalar(j, "unlock_duration", unlock_duration_);
|
loadScalar(j, "unlock_duration", unlock_duration_);
|
||||||
loadScalar(j, "pin_enabled", pin_enabled_);
|
loadScalar(j, "pin_enabled", pin_enabled_);
|
||||||
@@ -497,12 +499,14 @@ bool Settings::save(const std::string& path)
|
|||||||
}
|
}
|
||||||
j["wizard_completed"] = wizard_completed_;
|
j["wizard_completed"] = wizard_completed_;
|
||||||
j["seed_backup_reminded"] = seed_backup_reminded_;
|
j["seed_backup_reminded"] = seed_backup_reminded_;
|
||||||
|
j["encryption_pending"] = encryption_pending_;
|
||||||
j["daemon_update_prompted_size"] = daemon_update_prompted_size_;
|
j["daemon_update_prompted_size"] = daemon_update_prompted_size_;
|
||||||
j["active_wallet_file"] = active_wallet_file_;
|
j["active_wallet_file"] = active_wallet_file_;
|
||||||
j["seed_migration_pending"] = seed_migration_pending_;
|
j["seed_migration_pending"] = seed_migration_pending_;
|
||||||
j["seed_migration_dest"] = seed_migration_dest_;
|
j["seed_migration_dest"] = seed_migration_dest_;
|
||||||
j["seed_migration_temp_dir"] = seed_migration_temp_dir_;
|
j["seed_migration_temp_dir"] = seed_migration_temp_dir_;
|
||||||
j["seed_migration_sweep_txid"] = seed_migration_sweep_txid_;
|
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["auto_lock_timeout"] = auto_lock_timeout_;
|
||||||
j["unlock_duration"] = unlock_duration_;
|
j["unlock_duration"] = unlock_duration_;
|
||||||
j["pin_enabled"] = pin_enabled_;
|
j["pin_enabled"] = pin_enabled_;
|
||||||
|
|||||||
@@ -327,6 +327,12 @@ public:
|
|||||||
bool getSeedBackupReminded() const { return seed_backup_reminded_; }
|
bool getSeedBackupReminded() const { return seed_backup_reminded_; }
|
||||||
void setSeedBackupReminded(bool v) { seed_backup_reminded_ = v; }
|
void setSeedBackupReminded(bool v) { seed_backup_reminded_ = v; }
|
||||||
|
|
||||||
|
// 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
|
// 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.
|
// "a newer node is bundled — update?" prompt fire once per wallet version, never re-nagging.
|
||||||
long long getDaemonUpdatePromptedSize() const { return daemon_update_prompted_size_; }
|
long long getDaemonUpdatePromptedSize() const { return daemon_update_prompted_size_; }
|
||||||
@@ -350,6 +356,11 @@ public:
|
|||||||
// migration is past the sweep, so a resume goes to the confirm/adopt stage (not sweep again).
|
// 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_; }
|
std::string getSeedMigrationSweepTxid() const { return seed_migration_sweep_txid_; }
|
||||||
void setSeedMigrationSweepTxid(const std::string& v) { seed_migration_sweep_txid_ = v; }
|
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)
|
// Security — auto-lock timeout (seconds; 0 = disabled)
|
||||||
int getAutoLockTimeout() const { return auto_lock_timeout_; }
|
int getAutoLockTimeout() const { return auto_lock_timeout_; }
|
||||||
@@ -574,12 +585,14 @@ private:
|
|||||||
std::map<std::string, AddressMeta> address_meta_;
|
std::map<std::string, AddressMeta> address_meta_;
|
||||||
bool wizard_completed_ = false;
|
bool wizard_completed_ = false;
|
||||||
bool seed_backup_reminded_ = false;
|
bool seed_backup_reminded_ = false;
|
||||||
|
bool encryption_pending_ = false;
|
||||||
long long daemon_update_prompted_size_ = 0; // bundled daemon size last offered via the update prompt
|
long long daemon_update_prompted_size_ = 0; // bundled daemon size last offered via the update prompt
|
||||||
std::string active_wallet_file_ = "wallet.dat"; // -wallet=<name> the daemon loads (multi-wallet)
|
std::string active_wallet_file_ = "wallet.dat"; // -wallet=<name> the daemon loads (multi-wallet)
|
||||||
bool seed_migration_pending_ = false;
|
bool seed_migration_pending_ = false;
|
||||||
std::string seed_migration_dest_;
|
std::string seed_migration_dest_;
|
||||||
std::string seed_migration_temp_dir_;
|
std::string seed_migration_temp_dir_;
|
||||||
std::string seed_migration_sweep_txid_;
|
std::string seed_migration_sweep_txid_;
|
||||||
|
std::string seed_migration_sweep_opid_;
|
||||||
int auto_lock_timeout_ = 900; // 15 minutes
|
int auto_lock_timeout_ = 900; // 15 minutes
|
||||||
int unlock_duration_ = 600; // 10 minutes
|
int unlock_duration_ = 600; // 10 minutes
|
||||||
bool pin_enabled_ = false;
|
bool pin_enabled_ = false;
|
||||||
|
|||||||
@@ -126,6 +126,11 @@ void DaemonController::setSalvageOnNextStart(bool enabled)
|
|||||||
daemon_->setSalvageOnNextStart(enabled);
|
daemon_->setSalvageOnNextStart(enabled);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void DaemonController::setReindexOnNextStart(bool enabled)
|
||||||
|
{
|
||||||
|
daemon_->setReindexOnNextStart(enabled);
|
||||||
|
}
|
||||||
|
|
||||||
bool DaemonController::zapOnNextStart() const
|
bool DaemonController::zapOnNextStart() const
|
||||||
{
|
{
|
||||||
return daemon_->zapOnNextStart();
|
return daemon_->zapOnNextStart();
|
||||||
|
|||||||
@@ -108,6 +108,7 @@ public:
|
|||||||
void setZapOnNextStart(bool enabled);
|
void setZapOnNextStart(bool enabled);
|
||||||
bool zapOnNextStart() const;
|
bool zapOnNextStart() const;
|
||||||
void setSalvageOnNextStart(bool enabled);
|
void setSalvageOnNextStart(bool enabled);
|
||||||
|
void setReindexOnNextStart(bool enabled); // -reindex: rebuild the block DB from raw blocks on next start
|
||||||
|
|
||||||
static ShutdownDecision evaluateShutdownPolicy(bool hasDaemon,
|
static ShutdownDecision evaluateShutdownPolicy(bool hasDaemon,
|
||||||
bool externalDaemonDetected,
|
bool externalDaemonDetected,
|
||||||
|
|||||||
97
src/daemon/daemon_startup_diagnosis.h
Normal file
97
src/daemon/daemon_startup_diagnosis.h
Normal file
@@ -0,0 +1,97 @@
|
|||||||
|
// 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 <string>
|
||||||
|
#include <utility>
|
||||||
|
#include <vector>
|
||||||
|
|
||||||
|
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.<ts>.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 <wallet> to wallet.<ts>.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
|
||||||
|
}
|
||||||
|
|
||||||
|
// If `name` is a daemon salvage backup "wallet.<unixtime>.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<std::string>& 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<std::pair<std::string, unsigned long long>>& 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
|
||||||
@@ -489,6 +489,34 @@ bool EmbeddedDaemon::start(const std::string& binary_path)
|
|||||||
}
|
}
|
||||||
external_daemon_detected_ = 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...");
|
setState(State::Starting, "Looking for dragonxd binary...");
|
||||||
|
|
||||||
std::string daemon_path = binary_path;
|
std::string daemon_path = binary_path;
|
||||||
@@ -543,6 +571,14 @@ bool EmbeddedDaemon::start(const std::string& binary_path)
|
|||||||
args.push_back("-rescan");
|
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
|
// 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
|
// 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)
|
// revert to the normal datadir. The datadir's basename MUST be the assetchain name (DRAGONX)
|
||||||
@@ -557,8 +593,14 @@ bool EmbeddedDaemon::start(const std::string& binary_path)
|
|||||||
override_extra_args_.clear();
|
override_extra_args_.clear();
|
||||||
|
|
||||||
if (!startProcess(daemon_path, args)) {
|
if (!startProcess(daemon_path, args)) {
|
||||||
DEBUG_LOGF("[ERROR] Failed to start dragonxd process: %s\\n", last_error_.c_str());
|
// startProcess() sets a precise last_error_ (e.g. "dragonxd could not be executed:
|
||||||
setState(State::Error, "Failed to start dragonxd process");
|
// ... 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;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -963,17 +1005,37 @@ bool EmbeddedDaemon::startProcess(const std::string& binary_path, const std::vec
|
|||||||
return false;
|
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();
|
pid_t pid = fork();
|
||||||
if (pid == -1) {
|
if (pid == -1) {
|
||||||
last_error_ = "Fork failed: " + std::string(strerror(errno));
|
last_error_ = "Fork failed: " + std::string(strerror(errno));
|
||||||
close(pipefd[0]);
|
close(pipefd[0]);
|
||||||
close(pipefd[1]);
|
close(pipefd[1]);
|
||||||
|
close(execpipe[0]);
|
||||||
|
close(execpipe[1]);
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (pid == 0) {
|
if (pid == 0) {
|
||||||
// Child process
|
// 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
|
// Put child in its own process group so we can kill the entire
|
||||||
// group later (including dragonxd spawned by a wrapper script).
|
// group later (including dragonxd spawned by a wrapper script).
|
||||||
@@ -1040,17 +1102,56 @@ bool EmbeddedDaemon::startProcess(const std::string& binary_path, const std::vec
|
|||||||
execv(binary_path.c_str(), argv.data());
|
execv(binary_path.c_str(), argv.data());
|
||||||
}
|
}
|
||||||
|
|
||||||
// If we get here, exec failed
|
// If we get here, execv() failed — the child never became dragonxd.
|
||||||
fprintf(stderr, "execv failed: %s\n", strerror(errno));
|
// 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);
|
_exit(127);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Parent process
|
// 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<char*>(&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<size_t>(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];
|
stdout_fd_ = pipefd[0];
|
||||||
|
|
||||||
// Also set process group from parent side (race with child's setpgid)
|
// Best-effort: the child already calls setpgid(0, 0); this parent-side call
|
||||||
setpgid(pid, pid);
|
// 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
|
// Set non-blocking
|
||||||
int flags = fcntl(stdout_fd_, F_GETFL, 0);
|
int flags = fcntl(stdout_fd_, F_GETFL, 0);
|
||||||
@@ -1135,17 +1236,21 @@ double EmbeddedDaemon::getMemoryUsageMB() const
|
|||||||
|
|
||||||
bool EmbeddedDaemon::isRunning() 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;
|
if (process_pid_ <= 0) return false;
|
||||||
|
|
||||||
int status;
|
const State s = state_.load(std::memory_order_relaxed);
|
||||||
pid_t result = waitpid(process_pid_, &status, WNOHANG);
|
// State::Stopping is included: stop()'s graceful/SIGTERM wait loops poll
|
||||||
|
// isRunning() while state_ == Stopping — before the process has actually
|
||||||
if (result == 0) {
|
// terminated — and must keep seeing "alive" to wait/escalate correctly.
|
||||||
// Still running
|
return (s == State::Running || s == State::Stopping);
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
return false;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
void EmbeddedDaemon::drainOutput()
|
void EmbeddedDaemon::drainOutput()
|
||||||
|
|||||||
@@ -206,6 +206,13 @@ public:
|
|||||||
void setSalvageOnNextStart(bool v) { salvage_on_next_start_ = v; }
|
void setSalvageOnNextStart(bool v) { salvage_on_next_start_ = v; }
|
||||||
bool salvageOnNextStart() const { return salvage_on_next_start_.load(); }
|
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
|
* @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
|
* different datadir (with its own DRAGONX.conf) plus the given extra args. Used by the
|
||||||
@@ -235,6 +242,32 @@ public:
|
|||||||
*/
|
*/
|
||||||
static bool isDaemonProcessRunning();
|
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) */
|
/** @brief Is an arbitrary TCP port currently in use on localhost? (used to pick a free port) */
|
||||||
static bool tcpPortInUse(int port);
|
static bool tcpPortInUse(int port);
|
||||||
|
|
||||||
@@ -280,6 +313,7 @@ private:
|
|||||||
std::atomic<bool> rescan_on_next_start_{false}; // -rescan flag for next start
|
std::atomic<bool> rescan_on_next_start_{false}; // -rescan flag for next start
|
||||||
std::atomic<bool> zap_on_next_start_{false}; // -zapwallettxes=2 flag for next start
|
std::atomic<bool> zap_on_next_start_{false}; // -zapwallettxes=2 flag for next start
|
||||||
std::atomic<bool> salvage_on_next_start_{false}; // -salvagewallet flag for next start
|
std::atomic<bool> salvage_on_next_start_{false}; // -salvagewallet flag for next start
|
||||||
|
std::atomic<bool> 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::string override_datadir_; // one-shot: -datadir for the next start
|
||||||
std::vector<std::string> override_extra_args_; // one-shot: extra args for the next start
|
std::vector<std::string> override_extra_args_; // one-shot: extra args for the next start
|
||||||
bool skip_port_check_ = false; // isolated instance on a non-default port
|
bool skip_port_check_ = false; // isolated instance on a non-default port
|
||||||
|
|||||||
@@ -54,6 +54,16 @@ SeedWalletResult SeedWalletCreator::create(bool keepDatadir,
|
|||||||
// RPC port. So the wallet lives in <base>/DRAGONX; `base` is the migration root we clean up.
|
// RPC port. So the wallet lives in <base>/DRAGONX; `base` is the migration root we clean up.
|
||||||
const std::string base = util::Platform::getConfigDir() + "/seed-migrate";
|
const std::string base = util::Platform::getConfigDir() + "/seed-migrate";
|
||||||
const std::string dataDir = base + "/DRAGONX";
|
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::remove_all(base, ec);
|
||||||
fs::create_directories(dataDir, ec);
|
fs::create_directories(dataDir, ec);
|
||||||
if (ec) { r.error = "Could not create the temporary wallet directory."; return r; }
|
if (ec) { r.error = "Could not create the temporary wallet directory."; return r; }
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
// Copyright 2024-2026 The Hush Developers
|
// Copyright 2024-2026 The Hush Developers
|
||||||
// Released under the GPLv3
|
// Released under the GPLv3
|
||||||
//
|
//
|
||||||
// xmrig_manager.cpp — Pool mining process management via xmrig-hac.
|
// xmrig_manager.cpp — Pool mining process management via drg-xmrig.
|
||||||
// Spawns xmrig, monitors via HTTP API, tracks hashrate and shares.
|
// Spawns xmrig, monitors via HTTP API, tracks hashrate and shares.
|
||||||
|
|
||||||
#include "xmrig_manager.h"
|
#include "xmrig_manager.h"
|
||||||
|
|||||||
@@ -46,20 +46,25 @@ bool AddressBook::load()
|
|||||||
entries_.clear();
|
entries_.clear();
|
||||||
|
|
||||||
if (j.contains("entries") && j["entries"].is_array()) {
|
if (j.contains("entries") && j["entries"].is_array()) {
|
||||||
|
size_t skipped = 0;
|
||||||
for (const auto& entry : j["entries"]) {
|
for (const auto& entry : j["entries"]) {
|
||||||
AddressBookEntry e;
|
// W6-3: skip (and count) a malformed element rather than letting one bad entry throw and
|
||||||
e.label = entry.value("label", "");
|
// abort the whole load — which would discard EVERY contact (entries_ was already cleared).
|
||||||
e.address = entry.value("address", "");
|
if (!entry.is_object()) { ++skipped; continue; }
|
||||||
e.notes = entry.value("notes", "");
|
try {
|
||||||
// Legacy entries (no "scope") migrate to "global" so nothing disappears when
|
AddressBookEntry e;
|
||||||
// multi-wallet scoping lands — a contact you already had stays visible everywhere.
|
e.label = entry.value("label", "");
|
||||||
e.scope = entry.value("scope", "global");
|
e.address = entry.value("address", "");
|
||||||
e.avatar = entry.value("avatar", "");
|
e.notes = entry.value("notes", "");
|
||||||
|
// Legacy entries (no "scope") migrate to "global" so nothing disappears when
|
||||||
if (!e.address.empty()) {
|
// multi-wallet scoping lands — a contact you already had stays visible everywhere.
|
||||||
entries_.push_back(e);
|
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());
|
DEBUG_LOGF("Address book loaded: %zu entries\n", entries_.size());
|
||||||
|
|||||||
37
src/data/seed_migration_resume.h
Normal file
37
src/data/seed_migration_resume.h
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include <string>
|
||||||
|
|
||||||
|
// 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
|
||||||
@@ -335,6 +335,15 @@ struct WalletState {
|
|||||||
transactions.clear();
|
transactions.clear();
|
||||||
peers.clear();
|
peers.clear();
|
||||||
bannedPeers.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_tx_update = last_peer_update = last_mining_update = 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Rebuild combined addresses list from z/t lists
|
// Rebuild combined addresses list from z/t lists
|
||||||
|
|||||||
97
src/main.cpp
97
src/main.cpp
@@ -721,20 +721,105 @@ static void handleDisplayScaleChange(SDL_Window* window, float newScale,
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#if !defined(_WIN32)
|
||||||
|
#include <csignal>
|
||||||
|
#include <cstring>
|
||||||
|
#include <unistd.h>
|
||||||
|
#include <fcntl.h>
|
||||||
|
#if defined(__has_include)
|
||||||
|
# if __has_include(<execinfo.h>)
|
||||||
|
# include <execinfo.h>
|
||||||
|
# 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[])
|
int main(int argc, char* argv[])
|
||||||
{
|
{
|
||||||
// Ensure ObsidianDragon config directory exists early (before any file I/O)
|
// Ensure ObsidianDragon config directory exists early (before any file I/O)
|
||||||
{
|
{
|
||||||
std::string odDir = dragonx::util::Platform::getObsidianDragonDir();
|
std::string odDir = dragonx::util::Platform::getObsidianDragonDir();
|
||||||
std::error_code ec;
|
std::string odErr;
|
||||||
std::filesystem::create_directories(odDir, ec);
|
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
|
// W7-2: initialize the app-level Logger's file sink on ALL platforms so LOG/LOGF/VERBOSE_LOGF are
|
||||||
// Redirect stdout/stderr to a log file so diagnostic output is visible
|
// actually persisted to dragonx-debug.log. Previously init() was never called, so on Linux/macOS the
|
||||||
// even when built as a GUI app (WIN32_EXECUTABLE hides the console).
|
// 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(), "w", stdout);
|
||||||
freopen(logPath.c_str(), "a", stderr);
|
freopen(logPath.c_str(), "a", stderr);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,8 +14,12 @@
|
|||||||
#include <filesystem>
|
#include <filesystem>
|
||||||
#include <algorithm>
|
#include <algorithm>
|
||||||
#include <cctype>
|
#include <cctype>
|
||||||
|
#include <vector>
|
||||||
|
#include <chrono>
|
||||||
|
|
||||||
#include "../util/logger.h"
|
#include "../util/logger.h"
|
||||||
|
#include "../util/platform.h"
|
||||||
|
#include "../util/xmrig_updater.h" // util::sha256Hex
|
||||||
|
|
||||||
#ifdef _WIN32
|
#ifdef _WIN32
|
||||||
#include <shlobj.h>
|
#include <shlobj.h>
|
||||||
@@ -120,30 +124,121 @@ std::string Connection::getSaplingParamsDir()
|
|||||||
return resources::getDaemonDirectory();
|
return resources::getDaemonDirectory();
|
||||||
}
|
}
|
||||||
|
|
||||||
bool Connection::verifySaplingParams()
|
namespace {
|
||||||
|
|
||||||
|
std::string joinParamPath(const std::string& dir, const std::string& file) {
|
||||||
|
#ifdef _WIN32
|
||||||
|
return dir + "\\" + file;
|
||||||
|
#else
|
||||||
|
return dir + "/" + file;
|
||||||
|
#endif
|
||||||
|
}
|
||||||
|
|
||||||
|
// "<size>:<mtime>" fingerprint used to skip re-hashing an unchanged file. Empty on error.
|
||||||
|
std::string paramStatLine(const std::string& path) {
|
||||||
|
std::error_code ec;
|
||||||
|
auto sz = fs::file_size(path, ec);
|
||||||
|
if (ec) return {};
|
||||||
|
auto mtime = fs::last_write_time(path, ec);
|
||||||
|
long long ticks = ec ? 0 :
|
||||||
|
std::chrono::duration_cast<std::chrono::seconds>(mtime.time_since_epoch()).count();
|
||||||
|
return std::to_string(static_cast<unsigned long long>(sz)) + ":" + std::to_string(ticks);
|
||||||
|
}
|
||||||
|
|
||||||
|
bool paramHashMatches(const std::string& path, const std::string& expectedHex) {
|
||||||
|
std::ifstream f(path, std::ios::binary | std::ios::ate);
|
||||||
|
if (!f) return false;
|
||||||
|
std::streamsize sz = f.tellg();
|
||||||
|
if (sz <= 0) return false;
|
||||||
|
f.seekg(0, std::ios::beg);
|
||||||
|
std::vector<char> buf(static_cast<size_t>(sz));
|
||||||
|
if (!f.read(buf.data(), sz)) return false;
|
||||||
|
std::string got = util::sha256Hex(buf.data(), buf.size());
|
||||||
|
return !got.empty() && got == expectedHex;
|
||||||
|
}
|
||||||
|
|
||||||
|
// The verification cache: <params_dir>/.sapling_verified holds one paramStatLine per param,
|
||||||
|
// in list order, from the last successful hash check.
|
||||||
|
bool saplingMarkerMatches(const std::string& markerPath, const std::vector<std::string>& expected) {
|
||||||
|
for (const auto& s : expected) if (s.empty()) return false; // couldn't stat -> don't trust
|
||||||
|
std::ifstream f(markerPath);
|
||||||
|
if (!f) return false;
|
||||||
|
std::vector<std::string> lines;
|
||||||
|
std::string l;
|
||||||
|
while (std::getline(f, l)) lines.push_back(l);
|
||||||
|
return lines == expected;
|
||||||
|
}
|
||||||
|
|
||||||
|
void writeSaplingMarker(const std::string& markerPath, const std::vector<std::string>& lines) {
|
||||||
|
std::ofstream f(markerPath, std::ios::trunc);
|
||||||
|
if (!f) return;
|
||||||
|
for (const auto& l : lines) f << l << "\n";
|
||||||
|
}
|
||||||
|
|
||||||
|
// Canonical Zcash-family Sapling trusted-setup param digests — identical bytes across every
|
||||||
|
// fork/platform. Source of truth: scripts/build-lite-backend-artifact.sh ensure_sapling_params().
|
||||||
|
// Keep in sync if the params are ever rotated.
|
||||||
|
const std::pair<std::string, std::string> kSaplingParamDigests[] = {
|
||||||
|
{ "sapling-spend.params", "8e48ffd23abb3a5fd9c5589204f32d9c31285a04b78096ba40a79b75677efc13" },
|
||||||
|
{ "sapling-output.params", "2f0ebbcbb9bb0bcffe95a397e7eba89c29eb4dde6191c339db88570e3f3fb0e4" },
|
||||||
|
};
|
||||||
|
|
||||||
|
} // namespace
|
||||||
|
|
||||||
|
bool Connection::verifySaplingParamsIn(
|
||||||
|
const std::string& dir,
|
||||||
|
const std::vector<std::pair<std::string, std::string>>& digests)
|
||||||
{
|
{
|
||||||
std::string params_dir = getSaplingParamsDir();
|
if (dir.empty()) {
|
||||||
if (params_dir.empty()) {
|
|
||||||
DEBUG_LOGF("verifySaplingParams: params dir is empty\n");
|
DEBUG_LOGF("verifySaplingParams: params dir is empty\n");
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
if (digests.empty()) return false;
|
||||||
|
|
||||||
#ifdef _WIN32
|
// 1) Every param must exist.
|
||||||
std::string spend_path = params_dir + "\\sapling-spend.params";
|
std::vector<std::string> paths;
|
||||||
std::string output_path = params_dir + "\\sapling-output.params";
|
paths.reserve(digests.size());
|
||||||
#else
|
for (const auto& d : digests) {
|
||||||
std::string spend_path = params_dir + "/sapling-spend.params";
|
std::string p = joinParamPath(dir, d.first);
|
||||||
std::string output_path = params_dir + "/sapling-output.params";
|
if (!fs::exists(p)) {
|
||||||
#endif
|
DEBUG_LOGF("verifySaplingParams: %s MISSING\n", p.c_str());
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
paths.push_back(std::move(p));
|
||||||
|
}
|
||||||
|
|
||||||
bool spend_exists = fs::exists(spend_path);
|
// 2) Fast path: if the cached marker matches the current size:mtime of every param, trust
|
||||||
bool output_exists = fs::exists(output_path);
|
// the previous successful hash instead of re-hashing ~48MB on every startup.
|
||||||
|
const std::string markerPath = joinParamPath(dir, ".sapling_verified");
|
||||||
|
std::vector<std::string> current;
|
||||||
|
current.reserve(paths.size());
|
||||||
|
for (const auto& p : paths) current.push_back(paramStatLine(p));
|
||||||
|
if (saplingMarkerMatches(markerPath, current)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
DEBUG_LOGF("verifySaplingParams: dir=%s\n", params_dir.c_str());
|
// 3) Integrity-check each param against its pinned SHA-256. A truncated or corrupt param
|
||||||
DEBUG_LOGF(" spend: %s -> %s\n", spend_path.c_str(), spend_exists ? "found" : "MISSING");
|
// (a partial extraction, or a Linux bundle where the file merely *exists*) is rejected
|
||||||
DEBUG_LOGF(" output: %s -> %s\n", output_path.c_str(), output_exists ? "found" : "MISSING");
|
// here instead of being handed to the daemon and failing later on a shielded operation.
|
||||||
|
for (size_t i = 0; i < paths.size(); ++i) {
|
||||||
|
if (!paramHashMatches(paths[i], digests[i].second)) {
|
||||||
|
DEBUG_LOGF("verifySaplingParams: %s FAILED integrity check (truncated or corrupt)\n",
|
||||||
|
paths[i].c_str());
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return spend_exists && output_exists;
|
// 4) Record the verified state so later startups take the fast path.
|
||||||
|
writeSaplingMarker(markerPath, current);
|
||||||
|
DEBUG_LOGF("verifySaplingParams: %zu params verified (sha256)\n", paths.size());
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool Connection::verifySaplingParams()
|
||||||
|
{
|
||||||
|
std::vector<std::pair<std::string, std::string>> digests;
|
||||||
|
for (const auto& d : kSaplingParamDigests) digests.emplace_back(d.first, d.second);
|
||||||
|
return verifySaplingParamsIn(getSaplingParamsDir(), digests);
|
||||||
}
|
}
|
||||||
|
|
||||||
ConnectionConfig Connection::parseConfFile(const std::string& path)
|
ConnectionConfig Connection::parseConfFile(const std::string& path)
|
||||||
@@ -195,6 +290,8 @@ ConnectionConfig Connection::parseConfFile(const std::string& path)
|
|||||||
config.proxy = value;
|
config.proxy = value;
|
||||||
} else if (key == "rpctls" || key == "rpcssl" || key == "use_tls" || key == "rpcuse_tls") {
|
} else if (key == "rpctls" || key == "rpcssl" || key == "use_tls" || key == "rpcuse_tls") {
|
||||||
config.use_tls = parseBoolValue(value);
|
config.use_tls = parseBoolValue(value);
|
||||||
|
} else if (key == "rpcallowplaintext") {
|
||||||
|
config.allow_plaintext_remote = parseBoolValue(value);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -209,11 +306,14 @@ ConnectionConfig Connection::autoDetectConfig()
|
|||||||
{
|
{
|
||||||
ConnectionConfig config;
|
ConnectionConfig config;
|
||||||
|
|
||||||
// Ensure data directory exists
|
// Ensure the data directory exists. Use the non-throwing helper and report any failure
|
||||||
|
// via config.dir_error so callers can surface it — the old throwing create_directories()
|
||||||
|
// overload could raise an uncaught filesystem_error straight through autoDetectConfig()'s
|
||||||
|
// callers (read-only home, permission denied, etc.).
|
||||||
std::string data_dir = getDefaultDataDir();
|
std::string data_dir = getDefaultDataDir();
|
||||||
if (!fs::exists(data_dir)) {
|
if (!util::Platform::ensureDirectory(data_dir, &config.dir_error)) {
|
||||||
DEBUG_LOGF("Creating data directory: %s\n", data_dir.c_str());
|
DEBUG_LOGF("[ERROR] autoDetectConfig: %s\n", config.dir_error.c_str());
|
||||||
fs::create_directories(data_dir);
|
return config; // data dir unusable — bail early with dir_error set
|
||||||
}
|
}
|
||||||
|
|
||||||
// Try to find DRAGONX.conf
|
// Try to find DRAGONX.conf
|
||||||
@@ -268,6 +368,31 @@ bool Connection::buildCookieAuthConfig(const ConnectionConfig& base, ConnectionC
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// True only for a well-formed IPv4 loopback literal (127.0.0.0/8): exactly four dot-separated
|
||||||
|
// 0-255 octets with the first == 127. Rejects "127.evil.com", "127.0.0.1.attacker",
|
||||||
|
// "127.300.0.1", "1270.0.0.1", etc. — the old rfind("127.",0)==0 prefix matched all of those.
|
||||||
|
static bool isExactIPv4Loopback(const std::string& host)
|
||||||
|
{
|
||||||
|
int octets = 0, value = 0, digits = 0;
|
||||||
|
bool firstIs127 = false;
|
||||||
|
for (size_t i = 0; i <= host.size(); ++i) {
|
||||||
|
const char c = (i < host.size()) ? host[i] : '.'; // trailing sentinel flushes the last octet
|
||||||
|
if (c == '.') {
|
||||||
|
if (digits == 0 || digits > 3 || value > 255) return false;
|
||||||
|
if (octets == 0) firstIs127 = (value == 127);
|
||||||
|
++octets;
|
||||||
|
value = 0;
|
||||||
|
digits = 0;
|
||||||
|
} else if (c >= '0' && c <= '9') {
|
||||||
|
value = value * 10 + (c - '0');
|
||||||
|
++digits;
|
||||||
|
} else {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return octets == 4 && firstIs127;
|
||||||
|
}
|
||||||
|
|
||||||
bool Connection::isLocalHost(const std::string& host)
|
bool Connection::isLocalHost(const std::string& host)
|
||||||
{
|
{
|
||||||
std::string lowered = lowercase(host);
|
std::string lowered = lowercase(host);
|
||||||
@@ -277,7 +402,7 @@ bool Connection::isLocalHost(const std::string& host)
|
|||||||
|
|
||||||
return lowered == "localhost" || lowered == "localhost." ||
|
return lowered == "localhost" || lowered == "localhost." ||
|
||||||
lowered == "::1" || lowered == "0:0:0:0:0:0:0:1" ||
|
lowered == "::1" || lowered == "0:0:0:0:0:0:0:1" ||
|
||||||
lowered == "127.0.0.1" || lowered.rfind("127.", 0) == 0;
|
isExactIPv4Loopback(lowered);
|
||||||
}
|
}
|
||||||
|
|
||||||
bool Connection::usesPlaintextRemote(const ConnectionConfig& config)
|
bool Connection::usesPlaintextRemote(const ConnectionConfig& config)
|
||||||
@@ -285,6 +410,13 @@ bool Connection::usesPlaintextRemote(const ConnectionConfig& config)
|
|||||||
return !config.use_tls && !isLocalHost(config.host);
|
return !config.use_tls && !isLocalHost(config.host);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
bool Connection::allowsPlaintextRemote(const ConnectionConfig& config)
|
||||||
|
{
|
||||||
|
// Explicit opt-in (DRAGONX.conf: rpcallowplaintext=1) to send credentials over a plaintext
|
||||||
|
// link to a remote host. Off by default — see usesPlaintextRemote().
|
||||||
|
return config.allow_plaintext_remote;
|
||||||
|
}
|
||||||
|
|
||||||
const char* Connection::authSourceName(AuthSource source)
|
const char* Connection::authSourceName(AuthSource source)
|
||||||
{
|
{
|
||||||
switch (source) {
|
switch (source) {
|
||||||
|
|||||||
@@ -5,6 +5,8 @@
|
|||||||
#pragma once
|
#pragma once
|
||||||
|
|
||||||
#include <string>
|
#include <string>
|
||||||
|
#include <vector>
|
||||||
|
#include <utility>
|
||||||
|
|
||||||
namespace dragonx {
|
namespace dragonx {
|
||||||
namespace rpc {
|
namespace rpc {
|
||||||
@@ -27,7 +29,11 @@ struct ConnectionConfig {
|
|||||||
std::string proxy; // SOCKS5 proxy for Tor
|
std::string proxy; // SOCKS5 proxy for Tor
|
||||||
bool use_embedded = true;
|
bool use_embedded = true;
|
||||||
bool use_tls = false;
|
bool use_tls = false;
|
||||||
|
bool allow_plaintext_remote = false; // rpcallowplaintext=1 — opt in to plaintext creds to a remote host
|
||||||
AuthSource auth_source = AuthSource::Missing;
|
AuthSource auth_source = AuthSource::Missing;
|
||||||
|
// Non-empty when autoDetectConfig() could not create the data directory; callers
|
||||||
|
// should surface it and abort the connect rather than proceeding blindly.
|
||||||
|
std::string dir_error;
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -69,6 +75,14 @@ public:
|
|||||||
*/
|
*/
|
||||||
static bool verifySaplingParams();
|
static bool verifySaplingParams();
|
||||||
|
|
||||||
|
// Verify the Sapling params in `dir` against a { filename, expected-sha256-hex } list.
|
||||||
|
// Exposed with an injectable dir + digest list so the integrity + marker-cache logic is
|
||||||
|
// unit-testable without the real ~48MB params; verifySaplingParams() calls it with the
|
||||||
|
// pinned production digests and getSaplingParamsDir().
|
||||||
|
static bool verifySaplingParamsIn(
|
||||||
|
const std::string& dir,
|
||||||
|
const std::vector<std::pair<std::string, std::string>>& digests);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @brief Get the Sapling params directory
|
* @brief Get the Sapling params directory
|
||||||
*/
|
*/
|
||||||
@@ -119,6 +133,11 @@ public:
|
|||||||
*/
|
*/
|
||||||
static bool usesPlaintextRemote(const ConnectionConfig& config);
|
static bool usesPlaintextRemote(const ConnectionConfig& config);
|
||||||
|
|
||||||
|
// Whether plaintext credentials to a remote host are explicitly allowed (opt-in via the
|
||||||
|
// DRAGONX.conf rpcallowplaintext key). Off by default: usesPlaintextRemote() && !this
|
||||||
|
// means the connect is refused.
|
||||||
|
static bool allowsPlaintextRemote(const ConnectionConfig& config);
|
||||||
|
|
||||||
static const char* authSourceName(AuthSource source);
|
static const char* authSourceName(AuthSource source);
|
||||||
|
|
||||||
private:
|
private:
|
||||||
|
|||||||
@@ -426,12 +426,21 @@ std::optional<NetworkRefreshService::PriceRefreshResult> NetworkRefreshService::
|
|||||||
if (!parsed.contains("dragonx-2")) return std::nullopt;
|
if (!parsed.contains("dragonx-2")) return std::nullopt;
|
||||||
|
|
||||||
const auto& data = parsed["dragonx-2"];
|
const auto& data = parsed["dragonx-2"];
|
||||||
|
// CoinGecko emits JSON null (not an omitted key) for fields it can't currently compute —
|
||||||
|
// commonly usd_24h_change on illiquid/newly-listed tokens — while still returning a valid
|
||||||
|
// spot price in the same object. .value(key, default) throws type_error on a PRESENT null,
|
||||||
|
// which the outer catch turns into "no price update at all", so read null-tolerantly and
|
||||||
|
// keep the valid usd/btc rather than discarding the whole refresh.
|
||||||
|
auto num = [&data](const char* key, double def) {
|
||||||
|
auto it = data.find(key);
|
||||||
|
return (it != data.end() && it->is_number()) ? it->get<double>() : def;
|
||||||
|
};
|
||||||
PriceRefreshResult result;
|
PriceRefreshResult result;
|
||||||
result.market.price_usd = data.value("usd", 0.0);
|
result.market.price_usd = num("usd", 0.0);
|
||||||
result.market.price_btc = data.value("btc", 0.0);
|
result.market.price_btc = num("btc", 0.0);
|
||||||
result.market.change_24h = data.value("usd_24h_change", 0.0);
|
result.market.change_24h = num("usd_24h_change", 0.0);
|
||||||
result.market.volume_24h = data.value("usd_24h_vol", 0.0);
|
result.market.volume_24h = num("usd_24h_vol", 0.0);
|
||||||
result.market.market_cap = data.value("usd_market_cap", 0.0);
|
result.market.market_cap = num("usd_market_cap", 0.0);
|
||||||
|
|
||||||
char buf[64];
|
char buf[64];
|
||||||
// Runs on the RPC worker thread — std::localtime shares a process-wide static tm, so use the
|
// Runs on the RPC worker thread — std::localtime shares a process-wide static tm, so use the
|
||||||
@@ -1101,12 +1110,16 @@ NetworkRefreshService::OperationStatusPollResult NetworkRefreshService::parseOpe
|
|||||||
std::set<std::string> reported;
|
std::set<std::string> reported;
|
||||||
for (const auto& op : result) {
|
for (const auto& op : result) {
|
||||||
if (!op.is_object()) continue;
|
if (!op.is_object()) continue;
|
||||||
std::string opid = op.value("id", std::string());
|
// Type-checked reads: .value(key, default) throws if the key is PRESENT with a non-string
|
||||||
|
// type, which would abort the whole poll (and wedge it for the session — see the call site).
|
||||||
|
if (!op.contains("id") || !op["id"].is_string()) continue;
|
||||||
|
std::string opid = op["id"].get<std::string>();
|
||||||
if (opid.empty()) continue;
|
if (opid.empty()) continue;
|
||||||
if (requested.find(opid) == requested.end()) continue; // not one of ours — ignore
|
if (requested.find(opid) == requested.end()) continue; // not one of ours — ignore
|
||||||
reported.insert(opid);
|
reported.insert(opid);
|
||||||
|
|
||||||
std::string status = op.value("status", std::string());
|
std::string status = (op.contains("status") && op["status"].is_string())
|
||||||
|
? op["status"].get<std::string>() : std::string();
|
||||||
if (status == "success") {
|
if (status == "success") {
|
||||||
parsed.doneOpids.push_back(opid);
|
parsed.doneOpids.push_back(opid);
|
||||||
parsed.anySuccess = true;
|
parsed.anySuccess = true;
|
||||||
|
|||||||
@@ -1,9 +1,12 @@
|
|||||||
#include "wallet_security_controller.h"
|
#include "wallet_security_controller.h"
|
||||||
#include "../util/secure_vault.h"
|
#include "../util/secure_vault.h"
|
||||||
|
#include "../util/address_validation.h"
|
||||||
|
|
||||||
#include <cctype>
|
#include <cctype>
|
||||||
|
#include <cstdint>
|
||||||
#include <cstdio>
|
#include <cstdio>
|
||||||
#include <utility>
|
#include <utility>
|
||||||
|
#include <vector>
|
||||||
|
|
||||||
namespace dragonx {
|
namespace dragonx {
|
||||||
namespace services {
|
namespace services {
|
||||||
@@ -108,18 +111,35 @@ WalletSecurityController::KeyKind WalletSecurityController::classifyPrivateKey(c
|
|||||||
|
|
||||||
bool WalletSecurityController::isViewingKey(const std::string& key)
|
bool WalletSecurityController::isViewingKey(const std::string& key)
|
||||||
{
|
{
|
||||||
// Sapling extended full viewing key (mainnet HRP "zxviews"; "zxview" also matches the prefix the
|
// DragonX's z_exportviewingkey returns a Sapling *incoming* viewing key (mainnet HRP "zivks");
|
||||||
// lite backend recognizes). Watch-only: reveals the address's funds but cannot spend them.
|
// z_importviewingkey only decodes that form. Recognize it structurally — a valid Bech32 checksum
|
||||||
return key.rfind("zxview", 0) == 0;
|
// plus a known HRP — instead of a bare prefix, and cover testnet/regtest too. (The old check
|
||||||
|
// looked for Zcash's "zxview" extended-FVK HRP, which DragonX never emits, so every real viewing
|
||||||
|
// key was rejected client-side.) Watch-only: reveals the address's funds but cannot spend them.
|
||||||
|
const std::string hrp = util::bech32Hrp(key);
|
||||||
|
return hrp == "zivks" // mainnet
|
||||||
|
|| hrp == "zivktestsapling" // testnet
|
||||||
|
|| hrp == "zivkregtestsapling"; // regtest
|
||||||
}
|
}
|
||||||
|
|
||||||
bool WalletSecurityController::isRecognizedPrivateKey(const std::string& key)
|
bool WalletSecurityController::isRecognizedPrivateKey(const std::string& key)
|
||||||
{
|
{
|
||||||
|
// Sapling z spending key (HRP "secret-extended-key-{main,test,regtest}"). These run ~300 chars,
|
||||||
|
// past the Bech32 length cap, so match by HRP prefix and let the daemon vet the payload.
|
||||||
if (key.rfind("secret-extended-key-", 0) == 0) return true; // Sapling z spending key
|
if (key.rfind("secret-extended-key-", 0) == 0) return true; // Sapling z spending key
|
||||||
if (key.size() >= 2 && key[0] == 'S' && key[1] == 'K') return true; // Sprout z spending key
|
if (key.size() >= 2 && key[0] == 'S' && key[1] == 'K') return true; // Sprout z spending key
|
||||||
// Transparent WIF: base58, ~51-52 chars, common version prefixes.
|
// Transparent WIF: decode Base58Check and confirm it is actually a secret key — version byte plus
|
||||||
if (key.size() >= 51 && key.size() <= 52 &&
|
// a 32-byte key, optionally a compression flag (payload 33 or 34 bytes). This accepts BOTH the
|
||||||
(key[0] == '5' || key[0] == 'K' || key[0] == 'L' || key[0] == 'U')) return true;
|
// compressed ("U…") and uncompressed ("7…") mainnet forms and the testnet form, and rejects
|
||||||
|
// addresses / typos via the real checksum — the old length+first-char heuristic dropped the
|
||||||
|
// uncompressed mainnet key (which starts with '7', not one of 5/K/L/U).
|
||||||
|
std::vector<std::uint8_t> payload;
|
||||||
|
if (util::decodeBase58Check(key, payload) &&
|
||||||
|
(payload.size() == 33 || payload.size() == 34) &&
|
||||||
|
(payload[0] == 188 /* DragonX main/regtest SECRET_KEY */ ||
|
||||||
|
payload[0] == 128 /* DragonX testnet SECRET_KEY */)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -74,7 +74,7 @@ public:
|
|||||||
std::size_t minLength = 4);
|
std::size_t minLength = 4);
|
||||||
static KeyKind classifyAddress(const std::string& address);
|
static KeyKind classifyAddress(const std::string& address);
|
||||||
static KeyKind classifyPrivateKey(const std::string& key);
|
static KeyKind classifyPrivateKey(const std::string& key);
|
||||||
// True if `key` is a shielded viewing key (extended full viewing key, "zxview…" — watch-only).
|
// True if `key` is a shielded viewing key (Sapling incoming viewing key, "zivks…" — watch-only).
|
||||||
static bool isViewingKey(const std::string& key);
|
static bool isViewingKey(const std::string& key);
|
||||||
// True if `key` looks like a recognized Z (Sapling/Sprout spending) or T (WIF) private key.
|
// True if `key` looks like a recognized Z (Sapling/Sprout spending) or T (WIF) private key.
|
||||||
static bool isRecognizedPrivateKey(const std::string& key);
|
static bool isRecognizedPrivateKey(const std::string& key);
|
||||||
|
|||||||
111
src/ui/node_status_banner.h
Normal file
111
src/ui/node_status_banner.h
Normal file
@@ -0,0 +1,111 @@
|
|||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include <string>
|
||||||
|
|
||||||
|
// Persistent node-connectivity banner shown at the top of the content column when the wallet
|
||||||
|
// cannot reach its node. Distinct from the transient toast notifications: it stays visible for
|
||||||
|
// as long as the fault persists, so an offline wallet is never silently mistaken for a working
|
||||||
|
// one. The decision (whether to show, how severe, which action) is a pure function of a state
|
||||||
|
// snapshot so it can be unit-tested; App::renderNodeStatusBanner() feeds it the live state and
|
||||||
|
// draws the strip. See src/app.cpp.
|
||||||
|
namespace dragonx::ui {
|
||||||
|
|
||||||
|
// Visual weight. Warning (amber) = recoverable / a reconnect is offered; Error (red) = a hard
|
||||||
|
// fault the user must act on (the daemon gave up crashing, or a lite wallet failed to open).
|
||||||
|
enum class NodeBannerSeverity {
|
||||||
|
Warning,
|
||||||
|
Error,
|
||||||
|
};
|
||||||
|
|
||||||
|
// What the banner's action button does. App maps this to the concrete call.
|
||||||
|
enum class NodeBannerAction {
|
||||||
|
None, // no button — nothing the user can usefully do from here
|
||||||
|
Reconnect, // full node: re-run the RPC connect state machine (App::tryConnect)
|
||||||
|
RestartNode, // full node: the embedded daemon crashed & auto-restart gave up (App::restartDaemon)
|
||||||
|
};
|
||||||
|
|
||||||
|
// Why the banner is up. App maps this to a translated headline; `detail` carries the live,
|
||||||
|
// already-human-readable status text (connection_status_ / daemon lastError / lite open error).
|
||||||
|
enum class NodeBannerReason {
|
||||||
|
None,
|
||||||
|
FullNodeOffline, // a reachable node was lost, or never came up; reconnect offered
|
||||||
|
DaemonCrashed, // the embedded daemon crashed repeatedly and auto-restart stopped
|
||||||
|
LiteOpenFailed, // lite build: the wallet failed to open
|
||||||
|
};
|
||||||
|
|
||||||
|
struct NodeBannerState {
|
||||||
|
bool show = false;
|
||||||
|
NodeBannerSeverity severity = NodeBannerSeverity::Warning;
|
||||||
|
NodeBannerReason reason = NodeBannerReason::None;
|
||||||
|
NodeBannerAction action = NodeBannerAction::None;
|
||||||
|
std::string detail; // passthrough status/error text (may be empty)
|
||||||
|
};
|
||||||
|
|
||||||
|
// Snapshot of the connection state the banner reads. Plain values so the decision is testable
|
||||||
|
// without an App instance.
|
||||||
|
struct NodeBannerInputs {
|
||||||
|
bool lite = false; // lite build (no embedded daemon / RPC)
|
||||||
|
bool connected = false; // state_.connected — the master "online" flag
|
||||||
|
bool warming_up = false; // daemon reachable, RPC warmup (code -28)
|
||||||
|
bool daemon_initializing = false; // daemon launching / block index loading
|
||||||
|
bool connection_in_progress = false; // a connect attempt is actively running
|
||||||
|
|
||||||
|
// Full-node embedded-daemon crash signal.
|
||||||
|
bool using_embedded_daemon = false;
|
||||||
|
bool has_daemon_controller = false;
|
||||||
|
bool daemon_running = false;
|
||||||
|
int daemon_crash_count = 0;
|
||||||
|
|
||||||
|
std::string connection_status; // human-readable status line (already translated)
|
||||||
|
std::string daemon_last_error; // DaemonController::lastError() (may be empty)
|
||||||
|
std::string lite_open_error; // lite: last wallet-open failure reason
|
||||||
|
};
|
||||||
|
|
||||||
|
// Auto-restart give-up threshold — mirrors the crash cap in app_network.cpp's connect loop.
|
||||||
|
inline constexpr int kNodeBannerCrashGiveUpCount = 3;
|
||||||
|
|
||||||
|
inline NodeBannerState evaluateNodeStatusBanner(const NodeBannerInputs& in) {
|
||||||
|
NodeBannerState s;
|
||||||
|
|
||||||
|
if (in.lite) {
|
||||||
|
// Lite has no daemon/RPC; "online" == wallet open. Only a genuine open failure is a
|
||||||
|
// fault worth a persistent banner (a not-yet-created wallet is handled by the normal
|
||||||
|
// "No wallet open" prompt, and leaves lite_open_error empty).
|
||||||
|
if (!in.connected && !in.lite_open_error.empty()) {
|
||||||
|
s.show = true;
|
||||||
|
s.severity = NodeBannerSeverity::Error;
|
||||||
|
s.reason = NodeBannerReason::LiteOpenFailed;
|
||||||
|
s.action = NodeBannerAction::None;
|
||||||
|
s.detail = in.lite_open_error;
|
||||||
|
}
|
||||||
|
return s;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Full node. Connected, or in an expected startup phase → the loading/warmup overlay owns
|
||||||
|
// the screen, so no banner. An active connect attempt likewise shows progress, not an
|
||||||
|
// error — don't flicker a banner over it.
|
||||||
|
if (in.connected) return s;
|
||||||
|
if (in.warming_up || in.daemon_initializing) return s;
|
||||||
|
if (in.connection_in_progress) return s;
|
||||||
|
|
||||||
|
// Genuinely offline. Distinguish "the embedded daemon crashed and we stopped retrying" (a
|
||||||
|
// hard fault needing a manual restart) from an ordinary lost/failed connection (retryable).
|
||||||
|
if (in.using_embedded_daemon && in.has_daemon_controller && !in.daemon_running &&
|
||||||
|
in.daemon_crash_count >= kNodeBannerCrashGiveUpCount) {
|
||||||
|
s.show = true;
|
||||||
|
s.severity = NodeBannerSeverity::Error;
|
||||||
|
s.reason = NodeBannerReason::DaemonCrashed;
|
||||||
|
s.action = NodeBannerAction::RestartNode;
|
||||||
|
s.detail = !in.daemon_last_error.empty() ? in.daemon_last_error : in.connection_status;
|
||||||
|
return s;
|
||||||
|
}
|
||||||
|
|
||||||
|
s.show = true;
|
||||||
|
s.severity = NodeBannerSeverity::Warning;
|
||||||
|
s.reason = NodeBannerReason::FullNodeOffline;
|
||||||
|
s.action = NodeBannerAction::Reconnect;
|
||||||
|
s.detail = in.connection_status;
|
||||||
|
return s;
|
||||||
|
}
|
||||||
|
|
||||||
|
} // namespace dragonx::ui
|
||||||
@@ -9,6 +9,8 @@
|
|||||||
#include <chrono>
|
#include <chrono>
|
||||||
#include <functional>
|
#include <functional>
|
||||||
#include <cstdio>
|
#include <cstdio>
|
||||||
|
#include <cstdint>
|
||||||
|
#include <ctime>
|
||||||
#include "../util/logger.h"
|
#include "../util/logger.h"
|
||||||
#include "schema/ui_schema.h"
|
#include "schema/ui_schema.h"
|
||||||
|
|
||||||
@@ -22,6 +24,15 @@ enum class NotificationType {
|
|||||||
Error
|
Error
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// A retained alert for the persistent history panel. Unlike a live Notification (which fades and is
|
||||||
|
// erased within seconds), this keeps a wall-clock epoch so its age can be shown as "3m ago" long
|
||||||
|
// after the toast is gone. See App::renderAlertHistoryPanel.
|
||||||
|
struct AlertRecord {
|
||||||
|
std::string message;
|
||||||
|
NotificationType type;
|
||||||
|
std::int64_t epoch; // std::time(nullptr) at push — wall-clock, for relative-age display
|
||||||
|
};
|
||||||
|
|
||||||
struct Notification {
|
struct Notification {
|
||||||
std::string message;
|
std::string message;
|
||||||
NotificationType type;
|
NotificationType type;
|
||||||
@@ -85,6 +96,16 @@ public:
|
|||||||
void push(const std::string& message, NotificationType type, float duration = 5.0f) {
|
void push(const std::string& message, NotificationType type, float duration = 5.0f) {
|
||||||
notifications_.emplace_back(message, type, duration);
|
notifications_.emplace_back(message, type, duration);
|
||||||
|
|
||||||
|
// Retain a copy in the persistent history (the toast above will fade in seconds; this
|
||||||
|
// survives so the user can review what happened). Thread note: every push is on the UI
|
||||||
|
// thread (RPC results run as main-thread MainCb callbacks), so this container needs no lock,
|
||||||
|
// consistent with the rest of this class. Do NOT push from a raw worker thread.
|
||||||
|
history_.push_back(AlertRecord{message, type, static_cast<std::int64_t>(std::time(nullptr))});
|
||||||
|
++total_pushed_;
|
||||||
|
while (history_.size() > kMaxHistory) {
|
||||||
|
history_.pop_front();
|
||||||
|
}
|
||||||
|
|
||||||
// Log errors and warnings (debug-only output)
|
// Log errors and warnings (debug-only output)
|
||||||
if (type == NotificationType::Error) {
|
if (type == NotificationType::Error) {
|
||||||
DEBUG_LOGF("[ERROR] Notification: %s\n", message.c_str());
|
DEBUG_LOGF("[ERROR] Notification: %s\n", message.c_str());
|
||||||
@@ -127,6 +148,15 @@ public:
|
|||||||
max_notifications_ = max;
|
max_notifications_ = max;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Persistent alert history (for the status-bar bell panel) ──
|
||||||
|
/// Retained alerts, oldest first (capped at kMaxHistory; the toast deque is separate).
|
||||||
|
const std::deque<AlertRecord>& history() const { return history_; }
|
||||||
|
bool hasHistory() const { return !history_.empty(); }
|
||||||
|
void clearHistory() { history_.clear(); }
|
||||||
|
/// Monotonic count of every alert ever pushed this session — survives capping/clearing, so it is
|
||||||
|
/// the correct basis for an "unseen since last opened" count (deque size is not).
|
||||||
|
std::uint64_t totalPushed() const { return total_pushed_; }
|
||||||
|
|
||||||
private:
|
private:
|
||||||
Notifications() = default;
|
Notifications() = default;
|
||||||
~Notifications() = default;
|
~Notifications() = default;
|
||||||
@@ -137,6 +167,10 @@ private:
|
|||||||
size_t max_notifications_ = 5;
|
size_t max_notifications_ = 5;
|
||||||
std::function<void(const std::string&, bool)> console_callback_;
|
std::function<void(const std::string&, bool)> console_callback_;
|
||||||
|
|
||||||
|
std::deque<AlertRecord> history_;
|
||||||
|
std::uint64_t total_pushed_ = 0;
|
||||||
|
static constexpr size_t kMaxHistory = 100;
|
||||||
|
|
||||||
static float schemaDuration(const char* key, float fallback) {
|
static float schemaDuration(const char* key, float fallback) {
|
||||||
float v = schema::UI().drawElement("components.notifications", key).size;
|
float v = schema::UI().drawElement("components.notifications", key).size;
|
||||||
return v > 0.0f ? v : fallback;
|
return v > 0.0f ? v : fallback;
|
||||||
|
|||||||
@@ -16,6 +16,7 @@
|
|||||||
#include "../windows/console_tab.h"
|
#include "../windows/console_tab.h"
|
||||||
#include "../../util/i18n.h"
|
#include "../../util/i18n.h"
|
||||||
#include "../../util/platform.h"
|
#include "../../util/platform.h"
|
||||||
|
#include "../../util/seed_phrase.h"
|
||||||
#include "../../resources/embedded_resources.h"
|
#include "../../resources/embedded_resources.h"
|
||||||
#include <ctime>
|
#include <ctime>
|
||||||
#include "../../rpc/rpc_client.h"
|
#include "../../rpc/rpc_client.h"
|
||||||
@@ -231,16 +232,12 @@ static void exitLowSpec(bool applyEffects) {
|
|||||||
s_settingsState.low_spec_snapshot.valid = false;
|
s_settingsState.low_spec_snapshot.valid = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Count whitespace-separated words in a (seed) buffer — used to validate/guide restore input.
|
// Count words in a (seed) buffer — used to validate/guide restore input. Normalizes exotic Unicode
|
||||||
|
// whitespace (NBSP etc.) first so the count matches the phrase actually submitted (shared with the
|
||||||
|
// first-run restore gate via util::seed_phrase).
|
||||||
static int liteSeedWordCount(const char* s) {
|
static int liteSeedWordCount(const char* s) {
|
||||||
int words = 0;
|
return dragonx::util::seedPhraseWordCount(
|
||||||
bool inWord = false;
|
dragonx::util::normalizeSeedPhrase(s ? std::string(s) : std::string()));
|
||||||
for (; s && *s; ++s) {
|
|
||||||
const bool space = std::isspace(static_cast<unsigned char>(*s)) != 0;
|
|
||||||
if (space) inWord = false;
|
|
||||||
else if (!inWord) { inWord = true; ++words; }
|
|
||||||
}
|
|
||||||
return words;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
static wallet::LiteWalletLifecycleOperation liteLifecycleOperationFromPageState() {
|
static wallet::LiteWalletLifecycleOperation liteLifecycleOperationFromPageState() {
|
||||||
@@ -277,7 +274,9 @@ static void evaluateLiteLifecycleRequestFromPageState(App* app) {
|
|||||||
break;
|
break;
|
||||||
case wallet::LiteWalletLifecycleOperation::RestoreFromSeed:
|
case wallet::LiteWalletLifecycleOperation::RestoreFromSeed:
|
||||||
input.request.restoreRequest.walletPath = s_settingsState.lite_wallet_path;
|
input.request.restoreRequest.walletPath = s_settingsState.lite_wallet_path;
|
||||||
input.request.restoreRequest.seedPhrase = s_settingsState.lite_restore_seed;
|
// Normalize (fold NBSP/exotic whitespace to plain spaces) so an NBSP-pasted phrase the
|
||||||
|
// gate counted as 24 words also restores correctly at the backend.
|
||||||
|
input.request.restoreRequest.seedPhrase = dragonx::util::normalizeSeedPhrase(s_settingsState.lite_restore_seed);
|
||||||
input.request.restoreRequest.passphrase = s_settingsState.lite_lifecycle_passphrase;
|
input.request.restoreRequest.passphrase = s_settingsState.lite_lifecycle_passphrase;
|
||||||
input.request.restoreRequest.birthday = static_cast<unsigned long long>(std::max(0, s_settingsState.lite_restore_birthday));
|
input.request.restoreRequest.birthday = static_cast<unsigned long long>(std::max(0, s_settingsState.lite_restore_birthday));
|
||||||
input.request.restoreRequest.account = static_cast<unsigned long long>(std::max(0, s_settingsState.lite_restore_account));
|
input.request.restoreRequest.account = static_cast<unsigned long long>(std::max(0, s_settingsState.lite_restore_account));
|
||||||
@@ -320,7 +319,7 @@ static void evaluateLiteLifecycleRequestFromPageState(App* app) {
|
|||||||
// entered secret on this return path).
|
// entered secret on this return path).
|
||||||
if (input.request.operation == wallet::LiteWalletLifecycleOperation::RestoreFromSeed) {
|
if (input.request.operation == wallet::LiteWalletLifecycleOperation::RestoreFromSeed) {
|
||||||
const int words = liteSeedWordCount(s_settingsState.lite_restore_seed);
|
const int words = liteSeedWordCount(s_settingsState.lite_restore_seed);
|
||||||
if (words != 24) {
|
if (!dragonx::util::isCompleteRecoveryPhrase(words)) {
|
||||||
s_settingsState.lite_lifecycle_status =
|
s_settingsState.lite_lifecycle_status =
|
||||||
"Enter all 24 seed words to restore (got " + std::to_string(words) + ")";
|
"Enter all 24 seed words to restore (got " + std::to_string(words) + ")";
|
||||||
s_settingsState.lite_lifecycle_summary.clear();
|
s_settingsState.lite_lifecycle_summary.clear();
|
||||||
@@ -1351,6 +1350,21 @@ void RenderSettingsPage(App* app) {
|
|||||||
if (ImGui::IsItemHovered()) material::Tooltip("%s", TR("tt_verbose"));
|
if (ImGui::IsItemHovered()) material::Tooltip("%s", TR("tt_verbose"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// W7 QoL: quick diagnostics actions — open the log folder, and copy a plaintext support bundle
|
||||||
|
// (version, variant, daemon/RPC/wallet/log state) to the clipboard.
|
||||||
|
{
|
||||||
|
const float diagBtnW = (contentW - Layout::spacingMd()) * 0.5f;
|
||||||
|
if (TactileButton(TR("settings_open_log_folder"), ImVec2(diagBtnW, 0), S.resolveFont("button")))
|
||||||
|
dragonx::util::Platform::openFolder(dragonx::util::Platform::getObsidianDragonDir());
|
||||||
|
if (ImGui::IsItemHovered()) material::Tooltip("%s", TR("tt_open_log_folder"));
|
||||||
|
ImGui::SameLine(0, Layout::spacingMd());
|
||||||
|
if (TactileButton(TR("settings_copy_diagnostics"), ImVec2(diagBtnW, 0), S.resolveFont("button"))) {
|
||||||
|
ImGui::SetClipboardText(app->buildDiagnosticsReport().c_str());
|
||||||
|
ui::Notifications::instance().info(TR("settings_diagnostics_copied"), 4.0f);
|
||||||
|
}
|
||||||
|
if (ImGui::IsItemHovered()) material::Tooltip("%s", TR("tt_copy_diagnostics"));
|
||||||
|
}
|
||||||
|
|
||||||
ImGui::Dummy(ImVec2(0, Layout::spacingSm()));
|
ImGui::Dummy(ImVec2(0, Layout::spacingSm()));
|
||||||
|
|
||||||
// --- Collapsible: Tools & Actions... ---
|
// --- Collapsible: Tools & Actions... ---
|
||||||
|
|||||||
48
src/ui/staleness_badge.h
Normal file
48
src/ui/staleness_badge.h
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include <cstdint>
|
||||||
|
|
||||||
|
// Refresh-staleness badge (finding W6-2). The wallet stamps WalletState::last_balance_update only on
|
||||||
|
// a *successful* balance fetch (see services/network_refresh_service.cpp), so a busy daemon that fails
|
||||||
|
// z_gettotalbalance without dropping the whole connection leaves the old balance on screen with a
|
||||||
|
// frozen timestamp — and the node-status banner (which only fires on a full disconnect) stays hidden.
|
||||||
|
// This badge is the surface that reflects that "connected but the number may be out of date" state.
|
||||||
|
//
|
||||||
|
// The decision is a pure function of (last-success timestamp, now, connected) so it is unit-testable;
|
||||||
|
// balance_tab.cpp draws the pill. Both use the same std::time(nullptr) wall-clock the refresh path
|
||||||
|
// stamps with, so age = now - last_update is consistent.
|
||||||
|
namespace dragonx::ui {
|
||||||
|
|
||||||
|
enum class StalenessSeverity {
|
||||||
|
Warning, // amber — noticeably behind
|
||||||
|
Error, // red — very stale, something is likely wrong
|
||||||
|
};
|
||||||
|
|
||||||
|
struct StalenessBadge {
|
||||||
|
bool show = false;
|
||||||
|
StalenessSeverity severity = StalenessSeverity::Warning;
|
||||||
|
std::int64_t seconds_old = 0;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Balance refreshes every ~2s on the Overview profile (and ~10s while syncing), so tens of seconds
|
||||||
|
// with no successful update means refreshes are failing, not merely slow.
|
||||||
|
inline constexpr std::int64_t kStaleAfterSeconds = 45;
|
||||||
|
inline constexpr std::int64_t kVeryStaleAfterSeconds = 180;
|
||||||
|
|
||||||
|
inline StalenessBadge evaluateStalenessBadge(std::int64_t last_update, std::int64_t now, bool connected) {
|
||||||
|
StalenessBadge b;
|
||||||
|
// Offline is the node-status banner's job; don't double up. A zero stamp means "never updated
|
||||||
|
// this session" (fresh start) or "reset on disconnect" — nothing to be stale about yet.
|
||||||
|
if (!connected || last_update <= 0) return b;
|
||||||
|
|
||||||
|
std::int64_t age = now - last_update;
|
||||||
|
if (age < 0) age = 0; // clock skew guard
|
||||||
|
if (age < kStaleAfterSeconds) return b;
|
||||||
|
|
||||||
|
b.show = true;
|
||||||
|
b.seconds_old = age;
|
||||||
|
b.severity = (age >= kVeryStaleAfterSeconds) ? StalenessSeverity::Error : StalenessSeverity::Warning;
|
||||||
|
return b;
|
||||||
|
}
|
||||||
|
|
||||||
|
} // namespace dragonx::ui
|
||||||
@@ -26,6 +26,7 @@
|
|||||||
#include "../effects/imgui_acrylic.h"
|
#include "../effects/imgui_acrylic.h"
|
||||||
#include "../sidebar.h"
|
#include "../sidebar.h"
|
||||||
#include "../notifications.h"
|
#include "../notifications.h"
|
||||||
|
#include "../staleness_badge.h"
|
||||||
#include "../../embedded/IconsMaterialDesign.h"
|
#include "../../embedded/IconsMaterialDesign.h"
|
||||||
#include "imgui.h"
|
#include "imgui.h"
|
||||||
#include <toml++/toml.hpp>
|
#include <toml++/toml.hpp>
|
||||||
@@ -421,6 +422,25 @@ static void RenderBalanceClassic(App* app)
|
|||||||
dl->AddText(capFont, capFont->LegacySize,
|
dl->AddText(capFont, capFont->LegacySize,
|
||||||
ImVec2(cx + 12 * dp, cy),
|
ImVec2(cx + 12 * dp, cy),
|
||||||
WithAlpha(Success(), 200), buf);
|
WithAlpha(Success(), 200), buf);
|
||||||
|
} else {
|
||||||
|
// Refresh-staleness badge (W6-2): connected, not syncing/mining, but the balance
|
||||||
|
// hasn't refreshed in a while (a busy daemon can fail z_gettotalbalance without
|
||||||
|
// dropping the whole connection). The node banner only covers full disconnects, so
|
||||||
|
// this pill is the sole signal that the shown number may be out of date.
|
||||||
|
StalenessBadge badge = evaluateStalenessBadge(
|
||||||
|
state.last_balance_update, std::time(nullptr), state.connected);
|
||||||
|
if (badge.show) {
|
||||||
|
const bool err = (badge.severity == StalenessSeverity::Error);
|
||||||
|
ImU32 fg = err ? Error() : Warning();
|
||||||
|
ImU32 bg = WithAlpha(fg, 38);
|
||||||
|
ImU32 bd = WithAlpha(fg, 90);
|
||||||
|
snprintf(buf, sizeof(buf), "%s %s",
|
||||||
|
TR("data_stale_prefix"), timeAgo(state.last_balance_update).c_str());
|
||||||
|
ImVec2 pillSz = DrawPill(dl, ImVec2(cx, cy), buf, capFont, fg, bg, bd);
|
||||||
|
// Hover → explain what stale means and how old the data actually is.
|
||||||
|
if (material::IsRectHovered(ImVec2(cx, cy), ImVec2(cx + pillSz.x, cy + pillSz.y)))
|
||||||
|
Tooltip("%s", TR("data_stale_tooltip"));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Hover glow
|
// Hover glow
|
||||||
|
|||||||
@@ -1416,8 +1416,11 @@ bool ConsoleTab::submitConsoleCommand(ConsoleCommandExecutor& exec, const std::s
|
|||||||
{
|
{
|
||||||
if (cmd.empty()) return false;
|
if (cmd.empty()) return false;
|
||||||
|
|
||||||
addLine("> " + cmd, ConsoleChannel::Command);
|
// Redact secret-bearing commands (walletpassphrase, z_importkey, …) before they reach the visible
|
||||||
AppendConsoleHistory(command_history_, cmd, 100);
|
// log and the recall history. The real `cmd` below is still executed unredacted.
|
||||||
|
const std::string display = RedactConsoleCommand(cmd);
|
||||||
|
addLine("> " + display, ConsoleChannel::Command);
|
||||||
|
AppendConsoleHistory(command_history_, display, 100);
|
||||||
history_index_ = -1;
|
history_index_ = -1;
|
||||||
|
|
||||||
// First token, lowercased, for built-in interception.
|
// First token, lowercased, for built-in interception.
|
||||||
|
|||||||
@@ -1,10 +1,34 @@
|
|||||||
#include "console_tab_helpers.h"
|
#include "console_tab_helpers.h"
|
||||||
|
|
||||||
#include <algorithm>
|
#include <algorithm>
|
||||||
|
#include <cctype>
|
||||||
|
|
||||||
namespace dragonx {
|
namespace dragonx {
|
||||||
namespace ui {
|
namespace ui {
|
||||||
|
|
||||||
|
namespace {
|
||||||
|
// First tokens (lowercase) of console/RPC commands that carry a secret argument on the command line.
|
||||||
|
// Output-secret commands (dumpprivkey / z_exportkey / z_exportmnemonic) are deliberately absent —
|
||||||
|
// their secret is in the RESULT, which is a separate redaction concern.
|
||||||
|
const char* const kSecretConsoleCommands[] = {
|
||||||
|
"walletpassphrase", "walletpassphrasechange", "encryptwallet",
|
||||||
|
"importprivkey", "importwallet", "importmulti",
|
||||||
|
"z_importkey", "z_importviewingkey", "z_importwallet",
|
||||||
|
"signrawtransaction", "magicrecoverkey", "sethdseed", "importmnemonic",
|
||||||
|
};
|
||||||
|
|
||||||
|
std::string firstConsoleTokenLower(const std::string& cmd, size_t& tokenEnd) {
|
||||||
|
size_t b = cmd.find_first_not_of(" \t");
|
||||||
|
if (b == std::string::npos) { tokenEnd = cmd.size(); return {}; }
|
||||||
|
size_t e = cmd.find_first_of(" \t", b);
|
||||||
|
tokenEnd = (e == std::string::npos) ? cmd.size() : e;
|
||||||
|
std::string t = cmd.substr(b, tokenEnd - b);
|
||||||
|
std::transform(t.begin(), t.end(), t.begin(),
|
||||||
|
[](unsigned char c) { return static_cast<char>(std::tolower(c)); });
|
||||||
|
return t;
|
||||||
|
}
|
||||||
|
} // namespace
|
||||||
|
|
||||||
float ComputeConsoleInputHeight(float frameHeightWithSpacing,
|
float ComputeConsoleInputHeight(float frameHeightWithSpacing,
|
||||||
float itemSpacingY,
|
float itemSpacingY,
|
||||||
float spacingSm,
|
float spacingSm,
|
||||||
@@ -27,5 +51,27 @@ float ClampConsoleWrapWidth(float contentWidth, float paddingX)
|
|||||||
return std::max(50.0f, contentWidth - paddingX * 2.0f);
|
return std::max(50.0f, contentWidth - paddingX * 2.0f);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
bool ConsoleCommandCarriesSecret(const std::string& cmd)
|
||||||
|
{
|
||||||
|
size_t end = 0;
|
||||||
|
const std::string name = firstConsoleTokenLower(cmd, end);
|
||||||
|
if (name.empty()) return false;
|
||||||
|
for (const char* s : kSecretConsoleCommands) if (name == s) return true;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
std::string RedactConsoleCommand(const std::string& cmd)
|
||||||
|
{
|
||||||
|
size_t end = 0;
|
||||||
|
const std::string name = firstConsoleTokenLower(cmd, end);
|
||||||
|
if (name.empty()) return cmd;
|
||||||
|
bool secret = false;
|
||||||
|
for (const char* s : kSecretConsoleCommands) if (name == s) { secret = true; break; }
|
||||||
|
if (!secret) return cmd;
|
||||||
|
// Only redact if there are actually arguments after the command name.
|
||||||
|
if (cmd.find_first_not_of(" \t", end) == std::string::npos) return cmd;
|
||||||
|
return cmd.substr(0, end) + " ****";
|
||||||
|
}
|
||||||
|
|
||||||
} // namespace ui
|
} // namespace ui
|
||||||
} // namespace dragonx
|
} // namespace dragonx
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
#pragma once
|
#pragma once
|
||||||
|
|
||||||
|
#include <string>
|
||||||
|
|
||||||
namespace dragonx {
|
namespace dragonx {
|
||||||
namespace ui {
|
namespace ui {
|
||||||
|
|
||||||
@@ -14,5 +16,14 @@ float ComputeConsoleOutputHeight(float availableHeight,
|
|||||||
float minHeightRatio);
|
float minHeightRatio);
|
||||||
float ClampConsoleWrapWidth(float contentWidth, float paddingX);
|
float ClampConsoleWrapWidth(float contentWidth, float paddingX);
|
||||||
|
|
||||||
|
// True if `cmd`'s first token names a console/RPC command that carries a SECRET on its command line
|
||||||
|
// (passphrase, private/spending/viewing key, mnemonic). Output-secret commands (dumpprivkey,
|
||||||
|
// z_exportkey, z_exportmnemonic) are NOT covered — their secret is in the result, a separate concern.
|
||||||
|
bool ConsoleCommandCarriesSecret(const std::string& cmd);
|
||||||
|
|
||||||
|
// A display/history-safe copy of `cmd`: the command name with its arguments replaced by "****" when
|
||||||
|
// it carries a secret, else `cmd` unchanged. The real command is still executed unredacted.
|
||||||
|
std::string RedactConsoleCommand(const std::string& cmd);
|
||||||
|
|
||||||
} // namespace ui
|
} // namespace ui
|
||||||
} // namespace dragonx
|
} // namespace dragonx
|
||||||
|
|||||||
@@ -20,6 +20,17 @@ std::string defaultPoolWorkerAddress(const std::vector<AddressInfo>& addresses)
|
|||||||
return {};
|
return {};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
std::string resolveMiningUserAddress(const std::string& payoutAddress,
|
||||||
|
const std::string& firstShieldedAddress,
|
||||||
|
const std::string& firstTransparentAddress)
|
||||||
|
{
|
||||||
|
// The configured payout address is the pool login rewards go to, so it wins over
|
||||||
|
// the wallet's own addresses. "x" is the placeholder for an unset field.
|
||||||
|
if (!payoutAddress.empty() && payoutAddress != "x") return payoutAddress;
|
||||||
|
if (!firstShieldedAddress.empty()) return firstShieldedAddress;
|
||||||
|
return firstTransparentAddress; // may be empty -> caller reports "no address"
|
||||||
|
}
|
||||||
|
|
||||||
bool miningValueAlreadySaved(const std::vector<std::string>& savedValues,
|
bool miningValueAlreadySaved(const std::vector<std::string>& savedValues,
|
||||||
const std::string& value)
|
const std::string& value)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -10,6 +10,14 @@ namespace ui {
|
|||||||
|
|
||||||
bool shouldDefaultPoolWorker(const std::string& currentWorker, bool alreadyDefaulted);
|
bool shouldDefaultPoolWorker(const std::string& currentWorker, bool alreadyDefaulted);
|
||||||
std::string defaultPoolWorkerAddress(const std::vector<AddressInfo>& addresses);
|
std::string defaultPoolWorkerAddress(const std::vector<AddressInfo>& addresses);
|
||||||
|
|
||||||
|
// The xmrig "user" — the pool login block rewards are credited to. The user-entered
|
||||||
|
// payout address wins; otherwise fall back to the wallet's own first shielded, then
|
||||||
|
// transparent, address. "x" is the empty-field placeholder and counts as unset. The
|
||||||
|
// result may be empty (no address anywhere), which the caller treats as an error.
|
||||||
|
std::string resolveMiningUserAddress(const std::string& payoutAddress,
|
||||||
|
const std::string& firstShieldedAddress,
|
||||||
|
const std::string& firstTransparentAddress);
|
||||||
bool miningValueAlreadySaved(const std::vector<std::string>& savedValues,
|
bool miningValueAlreadySaved(const std::vector<std::string>& savedValues,
|
||||||
const std::string& value);
|
const std::string& value);
|
||||||
const char* defaultPoolUrl();
|
const char* defaultPoolUrl();
|
||||||
|
|||||||
@@ -251,11 +251,15 @@ static void RenderLeftPoolCard(App* app, const WalletState& state, ImDrawList* d
|
|||||||
}
|
}
|
||||||
y += gap * 0.5f;
|
y += gap * 0.5f;
|
||||||
|
|
||||||
|
// The pool list = official pools ∪ user-saved favorites ∪ the current custom pool.
|
||||||
|
const auto effective = util::effectivePools(app->settings()->getPoolUrl(),
|
||||||
|
app->settings()->getSavedPoolUrls());
|
||||||
|
|
||||||
// --- POOLS (N) header + Refresh ---
|
// --- POOLS (N) header + Refresh ---
|
||||||
{
|
{
|
||||||
char hdr[48];
|
char hdr[48];
|
||||||
snprintf(hdr, sizeof(hdr), "%s (%d)", TR("mining_pools_header"),
|
snprintf(hdr, sizeof(hdr), "%s (%d)", TR("mining_pools_header"),
|
||||||
(int)util::knownPools().size());
|
(int)effective.size());
|
||||||
dl->AddText(ovFont, ovFont->LegacySize, ImVec2(x, y), OnSurfaceMedium(), hdr);
|
dl->AddText(ovFont, ovFont->LegacySize, ImVec2(x, y), OnSurfaceMedium(), hdr);
|
||||||
|
|
||||||
float btnS = ovFont->LegacySize + 6 * dp;
|
float btnS = ovFont->LegacySize + 6 * dp;
|
||||||
@@ -278,11 +282,11 @@ static void RenderLeftPoolCard(App* app, const WalletState& state, ImDrawList* d
|
|||||||
{
|
{
|
||||||
ImDrawList* cdl = ImGui::GetWindowDrawList();
|
ImDrawList* cdl = ImGui::GetWindowDrawList();
|
||||||
const auto snap = app->poolStatsSnapshot();
|
const auto snap = app->poolStatsSnapshot();
|
||||||
const util::KnownPool* current = util::findKnownPoolByUrl(app->settings()->getPoolUrl());
|
const util::KnownPool* current = util::findPoolByUrl(effective, app->settings()->getPoolUrl());
|
||||||
const float childW = ImGui::GetContentRegionAvail().x;
|
const float childW = ImGui::GetContentRegionAvail().x;
|
||||||
const float listRowH = capFont->LegacySize + 10 * dp;
|
const float listRowH = capFont->LegacySize + 10 * dp;
|
||||||
|
|
||||||
for (const auto& kp : util::knownPools()) {
|
for (const auto& kp : effective) {
|
||||||
ImGui::PushID(kp.id.c_str());
|
ImGui::PushID(kp.id.c_str());
|
||||||
const bool isCurrent = current && current->id == kp.id;
|
const bool isCurrent = current && current->id == kp.id;
|
||||||
const auto it = snap.byId.find(kp.id);
|
const auto it = snap.byId.find(kp.id);
|
||||||
@@ -315,7 +319,17 @@ static void RenderLeftPoolCard(App* app, const WalletState& state, ImDrawList* d
|
|||||||
|
|
||||||
char right[64];
|
char right[64];
|
||||||
std::string hrStr = haveHr ? FormatHashrate(it->second.hashrateHs) : std::string("—");
|
std::string hrStr = haveHr ? FormatHashrate(it->second.hashrateHs) : std::string("—");
|
||||||
snprintf(right, sizeof(right), "%s %.0f%% fee", hrStr.c_str(), kp.feePercent);
|
// Prefer the live fee the pool reports; fall back to the compile-time
|
||||||
|
// KnownPool.feePercent. A synthetic user pool has an unknown (<0) fee, so
|
||||||
|
// we show just its hashrate placeholder for it.
|
||||||
|
double feePct = (it != snap.byId.end() && it->second.feePercent >= 0.0)
|
||||||
|
? it->second.feePercent
|
||||||
|
: kp.feePercent;
|
||||||
|
if (feePct >= 0.0)
|
||||||
|
snprintf(right, sizeof(right), "%s %s%% fee", hrStr.c_str(),
|
||||||
|
FormatFeePercent(feePct).c_str());
|
||||||
|
else
|
||||||
|
snprintf(right, sizeof(right), "%s", hrStr.c_str());
|
||||||
ImVec2 rSz = capFont->CalcTextSizeA(capFont->LegacySize, FLT_MAX, 0, right);
|
ImVec2 rSz = capFont->CalcTextSizeA(capFont->LegacySize, FLT_MAX, 0, right);
|
||||||
cdl->AddText(capFont, capFont->LegacySize,
|
cdl->AddText(capFont, capFont->LegacySize,
|
||||||
ImVec2(rMax.x - rSz.x - 6 * dp, textY), OnSurfaceMedium(), right);
|
ImVec2(rMax.x - rSz.x - 6 * dp, textY), OnSurfaceMedium(), right);
|
||||||
|
|||||||
@@ -41,6 +41,21 @@ std::string FormatHashrate(double hashrate)
|
|||||||
return std::string(buffer);
|
return std::string(buffer);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
std::string FormatFeePercent(double feePercent)
|
||||||
|
{
|
||||||
|
// Whole fees read "1"; fractional ones keep only their significant decimals
|
||||||
|
// ("1.5", "0.9", "1.25") with no trailing zeros. Capped at 2 dp — finer than
|
||||||
|
// any pool advertises, and the caller appends the "%".
|
||||||
|
char buffer[32];
|
||||||
|
snprintf(buffer, sizeof(buffer), "%.2f", feePercent);
|
||||||
|
std::string s(buffer);
|
||||||
|
if (s.find('.') != std::string::npos) {
|
||||||
|
s.erase(s.find_last_not_of('0') + 1);
|
||||||
|
if (!s.empty() && s.back() == '.') s.pop_back();
|
||||||
|
}
|
||||||
|
return s;
|
||||||
|
}
|
||||||
|
|
||||||
double EstimateHoursToBlock(double localHashrate, double networkHashrate, double difficulty)
|
double EstimateHoursToBlock(double localHashrate, double networkHashrate, double difficulty)
|
||||||
{
|
{
|
||||||
(void)difficulty;
|
(void)difficulty;
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ int GetMaxMiningThreads();
|
|||||||
int ClampMiningThreads(int requestedThreads, int maxThreads);
|
int ClampMiningThreads(int requestedThreads, int maxThreads);
|
||||||
bool IsPoolMiningActive(bool poolMode, bool xmrigRunning, bool soloMiningRunning);
|
bool IsPoolMiningActive(bool poolMode, bool xmrigRunning, bool soloMiningRunning);
|
||||||
std::string FormatHashrate(double hashrate);
|
std::string FormatHashrate(double hashrate);
|
||||||
|
std::string FormatFeePercent(double feePercent);
|
||||||
double EstimateHoursToBlock(double localHashrate, double networkHashrate, double difficulty);
|
double EstimateHoursToBlock(double localHashrate, double networkHashrate, double difficulty);
|
||||||
std::string FormatEstTime(double estimatedHours);
|
std::string FormatEstTime(double estimatedHours);
|
||||||
|
|
||||||
|
|||||||
@@ -148,14 +148,14 @@ static double GetAvailableBalance(App* app) {
|
|||||||
return 0.0;
|
return 0.0;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Recipient validity = prefix/length pre-filter AND a real encoding-checksum check, so a
|
// Recipient validity via the shared, structure-based recognizers: a real encoding-checksum check
|
||||||
// transcription error that still matches the prefix/length is no longer labelled "Valid".
|
// plus the actual DragonX address types — so a transcription error is never "Valid", and a valid
|
||||||
// The checksum verifiers are version-agnostic, so they never reject a genuine address.
|
// P2SH/multisig ('b…') recipient is no longer dropped by a hardcoded 'R'-only prefix filter.
|
||||||
static bool IsValidShieldedAddr(const char* a) {
|
static bool IsValidShieldedAddr(const char* a) {
|
||||||
return a[0] == 'z' && a[1] == 's' && strlen(a) > 60 && dragonx::util::isValidBech32(a);
|
return a && dragonx::util::isShieldedAddress(a);
|
||||||
}
|
}
|
||||||
static bool IsValidTransparentAddr(const char* a) {
|
static bool IsValidTransparentAddr(const char* a) {
|
||||||
return a[0] == 'R' && strlen(a) >= 34 && dragonx::util::isValidBase58Check(a);
|
return a && dragonx::util::isTransparentAddress(a);
|
||||||
}
|
}
|
||||||
|
|
||||||
static std::string timeAgo(int64_t timestamp) {
|
static std::string timeAgo(int64_t timestamp) {
|
||||||
@@ -1318,8 +1318,7 @@ void RenderSendTab(App* app)
|
|||||||
trimmed.erase(trimmed.begin());
|
trimmed.erase(trimmed.begin());
|
||||||
while (!trimmed.empty() && (trimmed.back() == ' ' || trimmed.back() == '\n' || trimmed.back() == '\r' || trimmed.back() == '\t'))
|
while (!trimmed.empty() && (trimmed.back() == ' ' || trimmed.back() == '\n' || trimmed.back() == '\r' || trimmed.back() == '\t'))
|
||||||
trimmed.pop_back();
|
trimmed.pop_back();
|
||||||
bool looksValid = (trimmed.size() > 30 &&
|
bool looksValid = dragonx::util::isValidRecipientAddress(trimmed);
|
||||||
((trimmed[0] == 'z' && trimmed[1] == 's') || trimmed[0] == 'R'));
|
|
||||||
if (looksValid && s_to_address[0] == '\0') {
|
if (looksValid && s_to_address[0] == '\0') {
|
||||||
s_preview_text = trimmed;
|
s_preview_text = trimmed;
|
||||||
s_paste_previewing = true;
|
s_paste_previewing = true;
|
||||||
|
|||||||
@@ -288,17 +288,21 @@ public:
|
|||||||
// encryption — so absence of a lock never falsely reads as "unencrypted" on a huge wallet.
|
// encryption — so absence of a lock never falsely reads as "unencrypted" on a huge wallet.
|
||||||
const ProbeResult pres = probeAt(i); // from the frame-consistent snapshot above
|
const ProbeResult pres = probeAt(i); // from the frame-consistent snapshot above
|
||||||
const bool bLock = pres.probed && pres.encrypted;
|
const bool bLock = pres.probed && pres.encrypted;
|
||||||
// Seed-phrase vs legacy. Runtime status (z_exportmnemonic → activeWalletSeedBadge) is
|
// Seed-phrase vs legacy. The offline probe reads the hdchain record's fMnemonicSeed flag
|
||||||
// authoritative for the ACTIVE wallet; otherwise the offline probe reads the hdchain
|
// straight off disk (pres.mnemonic: 1 = BIP39 seed phrase, 2 = HD/legacy with no phrase,
|
||||||
// record's fMnemonicSeed flag directly (pres.mnemonic: 1 = BIP39 seed phrase, 2 = HD/legacy
|
// 0 = couldn't tell). That is the SAME flag the daemon's IsMnemonicSeed()/z_exportmnemonic
|
||||||
// with no phrase, 0 = couldn't tell) — which, unlike bare HD-record presence, actually
|
// consult, so a definitive read is authoritative and takes precedence. The runtime badge
|
||||||
// distinguishes the two. seed uses the same 1/2/0 encoding.
|
// (activeWalletSeedBadge, reset only on disconnect) is used ONLY when the offline probe
|
||||||
const int activeBadge = rowActive[i] ? app->activeWalletSeedBadge() : 0;
|
// couldn't decide — it must NEVER override a definitive on-disk read, or a stale
|
||||||
int seed = activeBadge;
|
// HasMnemonic carried from a previously-active mnemonic wallet mislabels a legacy wallet as
|
||||||
if (seed == 0 && pres.probed) {
|
// a seed-phrase wallet. seed uses the same 1/2/0 encoding.
|
||||||
if (pres.mnemonic != 0) seed = pres.mnemonic; // read the flag off disk
|
int seed = 0;
|
||||||
else if (pres.complete && !pres.hdSeed) seed = 2; // no HD records at all → no phrase
|
if (pres.probed && pres.mnemonic != 0)
|
||||||
}
|
seed = pres.mnemonic; // definitive on-disk flag wins
|
||||||
|
else if (rowActive[i] && app->activeWalletSeedBadge() != 0)
|
||||||
|
seed = app->activeWalletSeedBadge(); // runtime fallback (active row only)
|
||||||
|
else if (pres.probed && pres.complete && !pres.hdSeed)
|
||||||
|
seed = 2; // no HD records at all → no phrase
|
||||||
const bool bSeed = (seed == 1);
|
const bool bSeed = (seed == 1);
|
||||||
const bool bLegacy = (seed == 2);
|
const bool bLegacy = (seed == 2);
|
||||||
// seed==0 splits by what the probe DID learn: if it saw HD records we know it's an HD
|
// seed==0 splits by what the probe DID learn: if it saw HD records we know it's an HD
|
||||||
@@ -792,6 +796,14 @@ private:
|
|||||||
} else {
|
} else {
|
||||||
const auto pr = util::probeWalletFile(t.first, std::min(budget, kPerFile));
|
const auto pr = util::probeWalletFile(t.first, std::min(budget, kPerFile));
|
||||||
res = ProbeResult{ pr.isBerkeleyDB, pr.scanComplete, pr.encrypted, pr.hdSeed };
|
res = ProbeResult{ pr.isBerkeleyDB, pr.scanComplete, pr.encrypted, pr.hdSeed };
|
||||||
|
// A cap-truncated btree walk still yields DEFINITIVE positives (a found marker is
|
||||||
|
// authoritative even when the scan didn't finish), so carry what it read — notably
|
||||||
|
// the fMnemonicSeed flag. Otherwise a large wallet probed after the shared budget is
|
||||||
|
// spent loses its seed/legacy classification and the row falls back to the (possibly
|
||||||
|
// stale) runtime badge, mislabelling a legacy wallet as a seed-phrase wallet.
|
||||||
|
if (bt.mnemonicSeed != 0) res.mnemonic = bt.mnemonicSeed;
|
||||||
|
if (bt.hdSeed) res.hdSeed = true;
|
||||||
|
if (bt.encrypted) res.encrypted = true;
|
||||||
budget -= std::min(budget, std::max(bt.bytesRead, pr.bytesRead));
|
budget -= std::min(budget, std::max(bt.bytesRead, pr.bytesRead));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -76,7 +76,7 @@ std::vector<int> bech32HrpExpand(const std::string& hrp)
|
|||||||
|
|
||||||
} // namespace
|
} // namespace
|
||||||
|
|
||||||
bool isValidBase58Check(const std::string& s)
|
bool decodeBase58Check(const std::string& s, std::vector<std::uint8_t>& payloadOut)
|
||||||
{
|
{
|
||||||
if (s.size() < 5 || s.size() > 256) return false;
|
if (s.size() < 5 || s.size() > 256) return false;
|
||||||
std::vector<std::uint8_t> data;
|
std::vector<std::uint8_t> data;
|
||||||
@@ -88,7 +88,15 @@ bool isValidBase58Check(const std::string& s)
|
|||||||
unsigned char h2[crypto_hash_sha256_BYTES];
|
unsigned char h2[crypto_hash_sha256_BYTES];
|
||||||
crypto_hash_sha256(h1, data.data(), payloadLen);
|
crypto_hash_sha256(h1, data.data(), payloadLen);
|
||||||
crypto_hash_sha256(h2, h1, sizeof(h1));
|
crypto_hash_sha256(h2, h1, sizeof(h1));
|
||||||
return std::memcmp(h2, data.data() + payloadLen, 4) == 0;
|
if (std::memcmp(h2, data.data() + payloadLen, 4) != 0) return false;
|
||||||
|
payloadOut.assign(data.begin(), data.begin() + payloadLen);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool isValidBase58Check(const std::string& s)
|
||||||
|
{
|
||||||
|
std::vector<std::uint8_t> payload;
|
||||||
|
return decodeBase58Check(s, payload);
|
||||||
}
|
}
|
||||||
|
|
||||||
bool isValidBech32(const std::string& s)
|
bool isValidBech32(const std::string& s)
|
||||||
@@ -127,5 +135,39 @@ bool isValidBech32(const std::string& s)
|
|||||||
return bech32Polymod(combined) == 1; // original Bech32 constant (Sapling, not Bech32m)
|
return bech32Polymod(combined) == 1; // original Bech32 constant (Sapling, not Bech32m)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
std::string bech32Hrp(const std::string& s)
|
||||||
|
{
|
||||||
|
if (!isValidBech32(s)) return {};
|
||||||
|
// isValidBech32 already rejected mixed case and guaranteed a non-empty HRP before the
|
||||||
|
// final '1' separator, so lower-casing and splitting there recovers the HRP verbatim.
|
||||||
|
std::string lower(s);
|
||||||
|
std::transform(lower.begin(), lower.end(), lower.begin(),
|
||||||
|
[](unsigned char c) { return static_cast<char>(std::tolower(c)); });
|
||||||
|
const std::size_t sep = lower.rfind('1');
|
||||||
|
if (sep == std::string::npos) return {};
|
||||||
|
return lower.substr(0, sep);
|
||||||
|
}
|
||||||
|
|
||||||
|
bool isTransparentAddress(const std::string& s)
|
||||||
|
{
|
||||||
|
std::vector<std::uint8_t> payload;
|
||||||
|
// version byte (1) + hash160 (20) = 21 bytes, checksum stripped. Covers P2PKH ('R', v60) and
|
||||||
|
// P2SH/multisig ('b', v85); the daemon vets the exact version byte for the active network.
|
||||||
|
return decodeBase58Check(s, payload) && payload.size() == 21;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool isShieldedAddress(const std::string& s)
|
||||||
|
{
|
||||||
|
const std::string hrp = bech32Hrp(s);
|
||||||
|
return hrp == "zs" // mainnet Sapling payment address
|
||||||
|
|| hrp == "ztestsapling" // testnet
|
||||||
|
|| hrp == "zregtestsapling"; // regtest
|
||||||
|
}
|
||||||
|
|
||||||
|
bool isValidRecipientAddress(const std::string& s)
|
||||||
|
{
|
||||||
|
return isTransparentAddress(s) || isShieldedAddress(s);
|
||||||
|
}
|
||||||
|
|
||||||
} // namespace util
|
} // namespace util
|
||||||
} // namespace dragonx
|
} // namespace dragonx
|
||||||
|
|||||||
@@ -11,7 +11,9 @@
|
|||||||
|
|
||||||
#pragma once
|
#pragma once
|
||||||
|
|
||||||
|
#include <cstdint>
|
||||||
#include <string>
|
#include <string>
|
||||||
|
#include <vector>
|
||||||
|
|
||||||
namespace dragonx {
|
namespace dragonx {
|
||||||
namespace util {
|
namespace util {
|
||||||
@@ -20,9 +22,31 @@ namespace util {
|
|||||||
// (transparent R-addresses). Version-byte agnostic by design.
|
// (transparent R-addresses). Version-byte agnostic by design.
|
||||||
bool isValidBase58Check(const std::string& s);
|
bool isValidBase58Check(const std::string& s);
|
||||||
|
|
||||||
|
// Decodes `s` as Base58Check; on success returns true and fills `payloadOut` with the
|
||||||
|
// decoded bytes EXCLUDING the trailing 4-byte checksum (i.e. version byte + data). Lets
|
||||||
|
// callers inspect the version byte / payload length (e.g. to tell a WIF from an address).
|
||||||
|
bool decodeBase58Check(const std::string& s, std::vector<std::uint8_t>& payloadOut);
|
||||||
|
|
||||||
// True if `s` is a valid Bech32 string (Sapling zs-addresses). The HRP is taken
|
// True if `s` is a valid Bech32 string (Sapling zs-addresses). The HRP is taken
|
||||||
// from the string itself and folded into the checksum, so no HRP is hardcoded.
|
// from the string itself and folded into the checksum, so no HRP is hardcoded.
|
||||||
bool isValidBech32(const std::string& s);
|
bool isValidBech32(const std::string& s);
|
||||||
|
|
||||||
|
// Returns the (lower-cased) human-readable prefix of a valid Bech32 string, or "" if
|
||||||
|
// `s` is not valid Bech32. The HRP identifies the key/address type (e.g. "zivks").
|
||||||
|
std::string bech32Hrp(const std::string& s);
|
||||||
|
|
||||||
|
// True if `s` is a transparent (Base58Check) address — P2PKH *or* P2SH/multisig. Accepts any
|
||||||
|
// address whose payload is a 21-byte version+hash160, so it covers both the 'R…' (v60) and 'b…'
|
||||||
|
// (v85 script) forms on every DragonX network and rejects WIF keys / typos by checksum. Version-byte
|
||||||
|
// agnostic by design — a bare prefix check ('R' only) silently drops valid P2SH recipients.
|
||||||
|
bool isTransparentAddress(const std::string& s);
|
||||||
|
|
||||||
|
// True if `s` is a shielded Sapling payment address (HRP "zs" / "ztestsapling" / "zregtestsapling"),
|
||||||
|
// with a valid Bech32 checksum. Distinguishes a payment address from a viewing key (e.g. "zivks…").
|
||||||
|
bool isShieldedAddress(const std::string& s);
|
||||||
|
|
||||||
|
// True if `s` is any address a payment can be sent to (transparent or shielded).
|
||||||
|
bool isValidRecipientAddress(const std::string& s);
|
||||||
|
|
||||||
} // namespace util
|
} // namespace util
|
||||||
} // namespace dragonx
|
} // namespace dragonx
|
||||||
|
|||||||
27
src/util/connect_stall.h
Normal file
27
src/util/connect_stall.h
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
// DragonX Wallet - ImGui Edition
|
||||||
|
// Copyright 2024-2026 The Hush Developers
|
||||||
|
// Released under the GPLv3
|
||||||
|
|
||||||
|
#pragma once
|
||||||
|
|
||||||
|
namespace dragonx {
|
||||||
|
namespace util {
|
||||||
|
|
||||||
|
// Default "taking longer than expected" threshold (seconds) for the daemon connect loop,
|
||||||
|
// overridable via ui.toml [screens.loading].stall-timeout-sec. Kept as a free function with
|
||||||
|
// no ImGui/App dependency so it is directly unit-testable from tests/test_phase4.cpp.
|
||||||
|
constexpr float kConnectStallDefaultSeconds = 45.0f;
|
||||||
|
|
||||||
|
// True once a daemon that is reachable-but-not-ready has stayed that way past the threshold.
|
||||||
|
// stallSince : timestamp (same clock as `now`) when the stall began; <= 0 means "not stalling".
|
||||||
|
// now : current time in the same units as stallSince.
|
||||||
|
// thresholdSec: how long to wait before considering it stalled; <= 0 disables the feature.
|
||||||
|
inline bool connectHasStalled(double stallSince, double now, float thresholdSec)
|
||||||
|
{
|
||||||
|
if (stallSince <= 0.0) return false; // not currently in a stall-tracked state
|
||||||
|
if (thresholdSec <= 0.0f) return false; // 0/negative disables the notice defensively
|
||||||
|
return (now - stallSince) >= static_cast<double>(thresholdSec);
|
||||||
|
}
|
||||||
|
|
||||||
|
} // namespace util
|
||||||
|
} // namespace dragonx
|
||||||
@@ -140,15 +140,16 @@ std::map<std::string, std::string> parseDaemonChecksums(const std::string& body)
|
|||||||
// | File | SHA-256 |
|
// | File | SHA-256 |
|
||||||
// |------|---------|
|
// |------|---------|
|
||||||
// | dragonx-1.0.2-linux-amd64.zip | `85f1dd…16` |
|
// | dragonx-1.0.2-linux-amd64.zip | `85f1dd…16` |
|
||||||
// Per line: blank out the table/code delimiters ('|' and '`'), then find the 64-hex token (the
|
// Per line: blank out the table/code/emphasis delimiters ('|', '`', and markdown '*'/'_' so a
|
||||||
// hash) and a token ending in ".zip" (the archive name). Header/separator/prose rows lack one
|
// bolded **archive.zip** still tokenizes), then find the 64-hex token (the hash) and a token
|
||||||
// or the other and are skipped, so this is robust to surrounding text and column order.
|
// ending in ".zip" (the archive name). Header/separator/prose rows lack one or the other and are
|
||||||
|
// skipped, so this is robust to surrounding text and column order.
|
||||||
std::map<std::string, std::string> out;
|
std::map<std::string, std::string> out;
|
||||||
std::istringstream in(body);
|
std::istringstream in(body);
|
||||||
std::string line;
|
std::string line;
|
||||||
while (std::getline(in, line)) {
|
while (std::getline(in, line)) {
|
||||||
for (char& c : line)
|
for (char& c : line)
|
||||||
if (c == '|' || c == '`') c = ' ';
|
if (c == '|' || c == '`' || c == '*' || c == '_') c = ' ';
|
||||||
std::istringstream ls(line);
|
std::istringstream ls(line);
|
||||||
std::string tok, hash, name;
|
std::string tok, hash, name;
|
||||||
while (ls >> tok) {
|
while (ls >> tok) {
|
||||||
|
|||||||
@@ -320,7 +320,7 @@ void I18n::loadBuiltinEnglish()
|
|||||||
strings_["seed_backup_load_failed"] = "Could not load the seed phrase.";
|
strings_["seed_backup_load_failed"] = "Could not load the seed phrase.";
|
||||||
strings_["seed_backup_copy"] = "Copy";
|
strings_["seed_backup_copy"] = "Copy";
|
||||||
strings_["seed_backup_save"] = "Save to file…";
|
strings_["seed_backup_save"] = "Save to file…";
|
||||||
strings_["seed_backup_saved"] = "Saved to ";
|
strings_["seed_backup_saved"] = "Saved an UNENCRYPTED seed file — move it to secure offline storage and delete this copy: ";
|
||||||
strings_["seed_backup_save_failed"] = "Could not write ";
|
strings_["seed_backup_save_failed"] = "Could not write ";
|
||||||
strings_["seed_backup_close"] = "Close";
|
strings_["seed_backup_close"] = "Close";
|
||||||
strings_["seed_backup_reminder"] = "Your wallet has a 24-word recovery seed phrase. Back it up now in Settings → Node & Security.";
|
strings_["seed_backup_reminder"] = "Your wallet has a 24-word recovery seed phrase. Back it up now in Settings → Node & Security.";
|
||||||
@@ -1185,6 +1185,41 @@ void I18n::loadBuiltinEnglish()
|
|||||||
strings_["switch_corrupt_body"] = "This wallet appears corrupt — the node couldn't open it. Restore it from a backup, re-create it, or try to repair it.";
|
strings_["switch_corrupt_body"] = "This wallet appears corrupt — the node couldn't open it. Restore it from a backup, re-create it, or try to repair it.";
|
||||||
strings_["switch_corrupt_repair"] = "Try to repair (salvage)";
|
strings_["switch_corrupt_repair"] = "Try to repair (salvage)";
|
||||||
|
|
||||||
|
// Block-database recovery (offered when the node aborts on an unreadable/format-mismatched block DB).
|
||||||
|
strings_["block_db_reindex_title"] = "Rebuild block database?";
|
||||||
|
strings_["block_db_reindex_warn"] = "The node can't read its block database.";
|
||||||
|
strings_["block_db_reindex_body"] = "This usually happens after a daemon update changes the on-disk format, or if the block index is damaged. Your wallet and coins are safe — the node just can't load the chain, so balances show as zero.\n\nRebuilding re-reads your existing block files and can take a while (it also rescans your wallet). Nothing is downloaded.";
|
||||||
|
strings_["block_db_reindex_confirm"] = "Rebuild block database";
|
||||||
|
strings_["block_db_reindex_notify"] = "The node can't read its block database (often after a daemon update). Rebuild it to restore your balance — see the prompt, or Settings › Node.";
|
||||||
|
strings_["block_db_reindex_started"] = "Rebuilding the block database from your blocks — this can take a while.";
|
||||||
|
|
||||||
|
// Wallet auto-recovery warning (the node moved wallet.dat aside and loaded a salvaged copy).
|
||||||
|
strings_["wallet_recovered_title"] = "Your wallet was auto-recovered";
|
||||||
|
strings_["wallet_recovered_warn"] = "The node moved your wallet aside and loaded a salvaged copy.";
|
||||||
|
strings_["wallet_recovered_body"] = "On startup the node decided your wallet.dat looked damaged and recovered it automatically. Your ORIGINAL wallet was NOT deleted — it was renamed to \"wallet.<numbers>.bak\" in your data folder, and a salvaged copy is loaded now.\n\nThe salvaged copy may be incomplete, so the balance shown here could be wrong — don't treat it as final.\n\nThis is often a false alarm caused by leftover database files (e.g. after moving the wallet between machines). To restore your original: quit the wallet, then in the data folder rename the current wallet.dat aside, rename \"wallet.<numbers>.bak\" back to \"wallet.dat\", delete the \"database\" folder and any \"__db.*\" files, and reopen.";
|
||||||
|
strings_["wallet_recovered_open_folder"] = "Open data folder";
|
||||||
|
strings_["wallet_recovered_dismiss"] = "Keep salvaged copy";
|
||||||
|
strings_["wallet_recovered_restore"] = "Restore original wallet";
|
||||||
|
strings_["wallet_recovered_notify"] = "The node recovered your wallet and moved the original to a .bak — your shown balance may be incomplete. See the prompt to restore it.";
|
||||||
|
// One-click "Restore original wallet" flow.
|
||||||
|
strings_["wallet_restore_started"] = "Restoring your original wallet and restarting the node…";
|
||||||
|
strings_["wallet_restore_busy"] = "The node is busy restarting — try again in a moment.";
|
||||||
|
strings_["wallet_restore_ok"] = "Original wallet restored. The node is loading it now.";
|
||||||
|
strings_["wallet_restore_no_backup"] = "Couldn't find a wallet.<timestamp>.bak to restore. Nothing was changed.";
|
||||||
|
strings_["wallet_restore_bad_backup"] = "The backup wallet file looks unreadable, so it was NOT restored — your current wallet is unchanged. Restore from your own backup instead.";
|
||||||
|
strings_["wallet_restore_stop_failed"] = "The node didn't stop in time, so nothing was changed. Try again.";
|
||||||
|
strings_["wallet_restore_move_failed"] = "Couldn't set the current wallet aside — nothing was changed.";
|
||||||
|
strings_["wallet_restore_copy_failed"] = "Couldn't install the backup wallet; your current wallet was left in place.";
|
||||||
|
strings_["wallet_restore_no_restart"] = "Your original wallet was restored, but the node didn't restart — start it from Settings.";
|
||||||
|
// One-click "Rebuild wallet database" flow (fixes a BDB-inconsistent wallet that keeps getting salvaged).
|
||||||
|
strings_["wallet_recovered_rebuild"] = "Rebuild wallet database (recommended)";
|
||||||
|
strings_["wallet_rebuild_started"] = "Rebuilding your wallet database and restarting the node…";
|
||||||
|
strings_["wallet_rebuild_ok"] = "Wallet database rebuilt — the node is loading it and rescanning for your balance.";
|
||||||
|
strings_["wallet_rebuild_no_helper"] = "The wallet-rebuild helper isn't available in this build. Use Restore, or rebuild manually.";
|
||||||
|
strings_["wallet_rebuild_no_source"] = "Couldn't find a readable wallet to rebuild. Nothing was changed.";
|
||||||
|
strings_["wallet_rebuild_failed"] = "The rebuild didn't produce a valid wallet, so nothing was changed. Your wallet is untouched.";
|
||||||
|
strings_["wallet_rebuild_install_failed"] = "Couldn't install the rebuilt wallet; your current wallet was left in place.";
|
||||||
|
|
||||||
// Receive Tab
|
// Receive Tab
|
||||||
strings_["receiving_addresses"] = "Your Receiving Addresses";
|
strings_["receiving_addresses"] = "Your Receiving Addresses";
|
||||||
strings_["new_z_shielded"] = "New z-Address (Shielded)";
|
strings_["new_z_shielded"] = "New z-Address (Shielded)";
|
||||||
@@ -1309,6 +1344,24 @@ void I18n::loadBuiltinEnglish()
|
|||||||
strings_["sb_connecting_err"] = "Connecting to daemon — %s";
|
strings_["sb_connecting_err"] = "Connecting to daemon — %s";
|
||||||
strings_["sb_daemon_crashed"] = "Daemon crashed %d times";
|
strings_["sb_daemon_crashed"] = "Daemon crashed %d times";
|
||||||
strings_["sb_daemon_start_failed"] = "Couldn't start dragonxd";
|
strings_["sb_daemon_start_failed"] = "Couldn't start dragonxd";
|
||||||
|
strings_["sb_block_db_unreadable"] = "Block database unreadable — rebuild required";
|
||||||
|
strings_["sb_wallet_needs_recovery"] = "Wallet needs recovery — see the prompt";
|
||||||
|
// Persistent node-status banner (App::renderNodeStatusBanner).
|
||||||
|
strings_["node_banner_offline_title"] = "Not connected to the DragonX node";
|
||||||
|
strings_["node_banner_crashed_title"] = "The node stopped unexpectedly";
|
||||||
|
strings_["node_banner_lite_open_failed"] = "Couldn't open your wallet";
|
||||||
|
strings_["node_banner_reconnect"] = "Reconnect";
|
||||||
|
strings_["node_banner_restart"] = "Restart node";
|
||||||
|
// Refresh-staleness badge (W6-2) on the Total Balance card.
|
||||||
|
strings_["data_stale_prefix"] = "Updated";
|
||||||
|
strings_["data_stale_tooltip"] =
|
||||||
|
"Balance may be out of date — the wallet hasn't received a fresh update recently. "
|
||||||
|
"Check your node connection.";
|
||||||
|
// Persistent alert-history panel (status-bar bell).
|
||||||
|
strings_["alerts_history_tooltip"] = "Recent alerts";
|
||||||
|
strings_["alerts_recent"] = "RECENT ALERTS";
|
||||||
|
strings_["alerts_none"] = "No alerts yet";
|
||||||
|
strings_["alerts_clear"] = "Clear alert history";
|
||||||
strings_["daemon_port_busy_warn"] =
|
strings_["daemon_port_busy_warn"] =
|
||||||
"Port " DRAGONX_DEFAULT_RPC_PORT " is in use but isn't responding as a DragonX node. "
|
"Port " DRAGONX_DEFAULT_RPC_PORT " is in use but isn't responding as a DragonX node. "
|
||||||
"Close the program using it (or free the port), then restart — the wallet can't start "
|
"Close the program using it (or free the port), then restart — the wallet can't start "
|
||||||
@@ -1316,6 +1369,17 @@ void I18n::loadBuiltinEnglish()
|
|||||||
strings_["sb_extracting_sapling"] = "Extracting Sapling parameters...";
|
strings_["sb_extracting_sapling"] = "Extracting Sapling parameters...";
|
||||||
strings_["sb_sapling_failed"] = "Failed to extract Sapling parameters.";
|
strings_["sb_sapling_failed"] = "Failed to extract Sapling parameters.";
|
||||||
strings_["sb_sapling_not_found"] = "Sapling parameters not found.";
|
strings_["sb_sapling_not_found"] = "Sapling parameters not found.";
|
||||||
|
strings_["sb_daemon_extract_failed"] = "Failed to write daemon files — check free disk space and permissions.";
|
||||||
|
strings_["sb_daemon_files_failed"] = "Failed to write daemon files to %s — check free disk space and permissions.";
|
||||||
|
strings_["loading_stall_title"] = "Taking longer than expected";
|
||||||
|
strings_["loading_stall_body"] = "The daemon has been initializing for %.0fs. This can be normal after an update or on first launch (loading the block index or rescanning) — it will connect automatically once ready.";
|
||||||
|
strings_["loading_stall_hint"] = "Still stuck? Open Settings and use Restart Daemon, or check the Console for details.";
|
||||||
|
strings_["sb_plaintext_remote_blocked"] = "Refusing to send RPC credentials over plaintext to a remote host. Add rpcallowplaintext=1 to DRAGONX.conf to allow it, or enable TLS with rpctls=1.";
|
||||||
|
strings_["settings_open_log_folder"] = "Open log folder";
|
||||||
|
strings_["settings_copy_diagnostics"] = "Copy diagnostics";
|
||||||
|
strings_["settings_diagnostics_copied"] = "Diagnostics copied to clipboard";
|
||||||
|
strings_["tt_open_log_folder"] = "Open the folder containing the debug and crash logs";
|
||||||
|
strings_["tt_copy_diagnostics"] = "Copy a support snapshot (version, daemon/wallet/log state — no secrets) to the clipboard";
|
||||||
strings_["sb_dragonxd_running"] = "dragonxd running";
|
strings_["sb_dragonxd_running"] = "dragonxd running";
|
||||||
strings_["sb_dragonxd_stopping"] = "Stopping dragonxd...";
|
strings_["sb_dragonxd_stopping"] = "Stopping dragonxd...";
|
||||||
strings_["sb_dragonxd_stopped"] = "dragonxd stopped";
|
strings_["sb_dragonxd_stopped"] = "dragonxd stopped";
|
||||||
|
|||||||
@@ -5,10 +5,12 @@
|
|||||||
#include "logger.h"
|
#include "logger.h"
|
||||||
|
|
||||||
#include <cstdarg>
|
#include <cstdarg>
|
||||||
|
#include <cstdint>
|
||||||
#include <ctime>
|
#include <ctime>
|
||||||
#include <chrono>
|
#include <chrono>
|
||||||
#include <iomanip>
|
#include <iomanip>
|
||||||
#include <sstream>
|
#include <sstream>
|
||||||
|
#include <filesystem>
|
||||||
|
|
||||||
namespace dragonx {
|
namespace dragonx {
|
||||||
namespace util {
|
namespace util {
|
||||||
@@ -36,11 +38,27 @@ bool Logger::init(const std::string& path)
|
|||||||
file_.close();
|
file_.close();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// W7-4: cap the log's growth — if the existing file is already large, rotate it to a single .1
|
||||||
|
// backup before reopening in append mode, so a long-lived or verbose session can't grow it
|
||||||
|
// without bound.
|
||||||
|
{
|
||||||
|
std::error_code ec;
|
||||||
|
const auto sz = std::filesystem::file_size(path, ec);
|
||||||
|
constexpr std::uintmax_t kMaxLogBytes = 10ull * 1024ull * 1024ull; // 10 MB
|
||||||
|
if (!ec && sz > kMaxLogBytes) {
|
||||||
|
std::filesystem::rename(path, path + ".1", ec); // replaces any previous .1 backup
|
||||||
|
if (ec) std::filesystem::remove(path, ec); // fall back to truncation if rename fails
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
file_.open(path, std::ios::out | std::ios::app);
|
file_.open(path, std::ios::out | std::ios::app);
|
||||||
initialized_ = file_.is_open();
|
initialized_ = file_.is_open();
|
||||||
|
|
||||||
if (initialized_) {
|
if (initialized_) {
|
||||||
write("=== Logger initialized ===");
|
// Write the banner directly, NOT via write(): write() re-locks the non-recursive mutex_ we
|
||||||
|
// already hold here, which would deadlock (latent — init() was previously never called, W7-2).
|
||||||
|
file_ << "=== Logger initialized ===" << std::endl;
|
||||||
|
file_.flush();
|
||||||
}
|
}
|
||||||
|
|
||||||
return initialized_;
|
return initialized_;
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
// Released under the GPLv3
|
// Released under the GPLv3
|
||||||
|
|
||||||
#include "payment_uri.h"
|
#include "payment_uri.h"
|
||||||
|
#include "address_validation.h"
|
||||||
|
|
||||||
#include <sstream>
|
#include <sstream>
|
||||||
#include <iomanip>
|
#include <iomanip>
|
||||||
@@ -161,20 +162,11 @@ PaymentURI parsePaymentURI(const std::string& uri)
|
|||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Basic address format check. NOTE: this is format-only by design — the send flow
|
// Address format check via the shared, structure-based recognizers (checksum + real DragonX
|
||||||
// checksum-validates the recipient (isValidBase58Check / shielded check) before broadcasting,
|
// address types). This accepts shielded ("zs…"), P2PKH ("R…") and P2SH/multisig ("b…") forms —
|
||||||
// so an invalid-checksum address parsed here can never actually be sent to.
|
// the old prefix/length heuristic rejected P2SH and hardcoded a 't' prefix DragonX never emits.
|
||||||
bool validFormat = false;
|
const bool validFormat = isShieldedAddress(result.address) ||
|
||||||
|
isTransparentAddress(result.address);
|
||||||
// z-address: starts with 'zs' and is 78+ chars
|
|
||||||
if (result.address[0] == 'z' && result.address.size() >= 78) {
|
|
||||||
validFormat = true;
|
|
||||||
}
|
|
||||||
// t-address: starts with 'R' (DragonX) or 't' (HUSH) and is ~34 chars
|
|
||||||
else if ((result.address[0] == 'R' || result.address[0] == 't') &&
|
|
||||||
result.address.size() >= 26 && result.address.size() <= 36) {
|
|
||||||
validFormat = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!validFormat) {
|
if (!validFormat) {
|
||||||
result.error = "Invalid address format";
|
result.error = "Invalid address format";
|
||||||
|
|||||||
@@ -126,6 +126,27 @@ bool Platform::openUrl(const std::string& url)
|
|||||||
#endif
|
#endif
|
||||||
}
|
}
|
||||||
|
|
||||||
|
bool Platform::ensureDirectory(const std::string& dir, std::string* outError)
|
||||||
|
{
|
||||||
|
if (dir.empty()) {
|
||||||
|
if (outError) *outError = "Cannot create directory: empty path.";
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
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.";
|
||||||
|
}
|
||||||
|
DEBUG_LOGF("[ERROR] ensureDirectory failed for %s: %s\n", dir.c_str(), ec.message().c_str());
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
bool Platform::openFolder(const std::string& path, bool createIfMissing)
|
bool Platform::openFolder(const std::string& path, bool createIfMissing)
|
||||||
{
|
{
|
||||||
if (path.empty()) return false;
|
if (path.empty()) return false;
|
||||||
|
|||||||
@@ -128,6 +128,17 @@ public:
|
|||||||
*/
|
*/
|
||||||
static void ensureObsidianDragonSetup();
|
static void ensureObsidianDragonSetup();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @brief Create a directory (and parents) if missing, with a clear error on failure.
|
||||||
|
*
|
||||||
|
* Uses the non-throwing std::error_code overload internally. On failure sets *outError
|
||||||
|
* (when non-null) to one consistent, user-facing message:
|
||||||
|
* "Cannot create <dir>: <reason>. Check permissions / free space."
|
||||||
|
*
|
||||||
|
* @return true if the directory exists (already did, or was just created).
|
||||||
|
*/
|
||||||
|
static bool ensureDirectory(const std::string& dir, std::string* outError = nullptr);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @brief Get total system RAM in megabytes
|
* @brief Get total system RAM in megabytes
|
||||||
* @return Total physical RAM in MB, or 0 on failure
|
* @return Total physical RAM in MB, or 0 on failure
|
||||||
|
|||||||
@@ -45,16 +45,30 @@ struct PoolHashrate {
|
|||||||
std::string id;
|
std::string id;
|
||||||
double hashrateHs = 0.0;
|
double hashrateHs = 0.0;
|
||||||
bool ok = false;
|
bool ok = false;
|
||||||
|
// Live pool fee (%) read from the same stats JSON. <0 means "not available" —
|
||||||
|
// callers fall back to the compile-time KnownPool.feePercent.
|
||||||
|
double feePercent = -1.0;
|
||||||
};
|
};
|
||||||
|
|
||||||
// The built-in official pools (PPLNS only — never a SOLO pool, whose hashrate is
|
// The built-in official pools (PPLNS only — never a SOLO pool, whose hashrate is
|
||||||
// meaningless to balance against). Stable order.
|
// meaningless to balance against). Stable order.
|
||||||
const std::vector<KnownPool>& knownPools();
|
const std::vector<KnownPool>& knownPools();
|
||||||
|
|
||||||
// The known pool whose stratum matches `url` (host, and port when both specify one),
|
// The pool in `pools` whose stratum matches `url` (host, and port when both specify
|
||||||
// or nullptr. `url` may be a bare host, host:port, or carry a scheme/userinfo/path.
|
// one), or nullptr. `url` may be a bare host, host:port, or carry a scheme/path.
|
||||||
|
const KnownPool* findPoolByUrl(const std::vector<KnownPool>& pools, const std::string& url);
|
||||||
|
|
||||||
|
// Same, over the built-in official pools only.
|
||||||
const KnownPool* findKnownPoolByUrl(const std::string& url);
|
const KnownPool* findKnownPoolByUrl(const std::string& url);
|
||||||
|
|
||||||
|
// The full list the UI should show: the official knownPools(), plus a row for every
|
||||||
|
// user-saved pool URL and for `currentPoolUrl` when it isn't one of those — so a
|
||||||
|
// custom/bookmarked pool is a first-class, selectable row. Synthetic (user) rows are
|
||||||
|
// official=false and carry no statsUrl (feePercent<0, no live hashrate), and endpoints
|
||||||
|
// are de-duplicated so a saved URL that equals an official pool isn't listed twice.
|
||||||
|
std::vector<KnownPool> effectivePools(const std::string& currentPoolUrl,
|
||||||
|
const std::vector<std::string>& savedPoolUrls);
|
||||||
|
|
||||||
// The algo xmrig must use for `url`: the matching known pool's algo, else `fallback`.
|
// The algo xmrig must use for `url`: the matching known pool's algo, else `fallback`.
|
||||||
std::string resolvePoolAlgo(const std::string& url, const std::string& fallback);
|
std::string resolvePoolAlgo(const std::string& url, const std::string& fallback);
|
||||||
|
|
||||||
@@ -64,6 +78,13 @@ std::string resolvePoolAlgo(const std::string& url, const std::string& fallback)
|
|||||||
double parsePoolHashrate(PoolStatsSchema schema, const std::string& json,
|
double parsePoolHashrate(PoolStatsSchema schema, const std::string& json,
|
||||||
const std::string& miningcorePoolId, bool& ok);
|
const std::string& miningcorePoolId, bool& ok);
|
||||||
|
|
||||||
|
// Parse a pool's advertised fee (%) out of the same stats JSON (DragonXIs:
|
||||||
|
// pools.<name>.poolFee; Miningcore: pools[id].poolFeePercent). Selects the same
|
||||||
|
// pool entry as parsePoolHashrate. Sets ok=false and returns 0 when the field is
|
||||||
|
// absent / malformed, so the caller keeps the compile-time fallback.
|
||||||
|
double parsePoolFee(PoolStatsSchema schema, const std::string& json,
|
||||||
|
const std::string& miningcorePoolId, bool& ok);
|
||||||
|
|
||||||
// Weighted-random pick among the usable (ok==true) pools: probability is inversely
|
// Weighted-random pick among the usable (ok==true) pools: probability is inversely
|
||||||
// proportional to hashrate (smaller pools favored), so miners spread out instead of
|
// proportional to hashrate (smaller pools favored), so miners spread out instead of
|
||||||
// all stampeding to the single lowest pool. The current pool (`currentId`, may be
|
// all stampeding to the single lowest pool. The current pool (`currentId`, may be
|
||||||
|
|||||||
@@ -28,7 +28,7 @@ const std::vector<KnownPool>& knownPools()
|
|||||||
KnownPool{
|
KnownPool{
|
||||||
"dragonx-is", "pool.dragonx.is", "pool.dragonx.is:3433", "rx/hush",
|
"dragonx-is", "pool.dragonx.is", "pool.dragonx.is:3433", "rx/hush",
|
||||||
"https://pool.dragonx.is/api/stats", PoolStatsSchema::DragonXIs,
|
"https://pool.dragonx.is/api/stats", PoolStatsSchema::DragonXIs,
|
||||||
/*miningcorePoolId=*/"", /*feePercent=*/0.0, /*official=*/true,
|
/*miningcorePoolId=*/"", /*feePercent=*/1.0, /*official=*/true,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
return pools;
|
return pools;
|
||||||
@@ -83,13 +83,62 @@ bool sameEndpoint(const std::string& a, const std::string& b)
|
|||||||
return pa == pb;
|
return pa == pb;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Build a synthetic, selectable pool row for a user-supplied URL (a saved favorite
|
||||||
|
// or the current custom pool). We don't know its stats API, so it carries no
|
||||||
|
// statsUrl / live hashrate and an unknown (<0) fee — the UI falls back to "—".
|
||||||
|
KnownPool makeUserPool(const std::string& url)
|
||||||
|
{
|
||||||
|
KnownPool p;
|
||||||
|
const std::string hp = hostPortOf(url);
|
||||||
|
std::string host, port;
|
||||||
|
splitHostPort(hp, host, port);
|
||||||
|
p.id = "user:" + trimmed(url); // stable + unique (used as the ImGui id)
|
||||||
|
p.label = host.empty() ? hp : host;
|
||||||
|
p.stratum = trimmed(url); // what the miner connects to / a row-click restores
|
||||||
|
p.algo = ""; // unknown; xmrig resolves via resolvePoolAlgo's fallback
|
||||||
|
p.statsUrl = ""; // no known stats endpoint -> no live hashrate/fee
|
||||||
|
p.schema = PoolStatsSchema::DragonXIs;
|
||||||
|
p.miningcorePoolId = "";
|
||||||
|
p.feePercent = -1.0; // unknown fee
|
||||||
|
p.official = false;
|
||||||
|
return p;
|
||||||
|
}
|
||||||
|
|
||||||
} // namespace
|
} // namespace
|
||||||
|
|
||||||
|
const KnownPool* findPoolByUrl(const std::vector<KnownPool>& pools, const std::string& url)
|
||||||
|
{
|
||||||
|
for (const auto& p : pools)
|
||||||
|
if (sameEndpoint(p.stratum, url)) return &p;
|
||||||
|
return nullptr;
|
||||||
|
}
|
||||||
|
|
||||||
const KnownPool* findKnownPoolByUrl(const std::string& url)
|
const KnownPool* findKnownPoolByUrl(const std::string& url)
|
||||||
{
|
{
|
||||||
for (const auto& p : knownPools())
|
return findPoolByUrl(knownPools(), url);
|
||||||
if (sameEndpoint(p.stratum, url)) return &p;
|
}
|
||||||
return nullptr;
|
|
||||||
|
std::vector<KnownPool> effectivePools(const std::string& currentPoolUrl,
|
||||||
|
const std::vector<std::string>& savedPoolUrls)
|
||||||
|
{
|
||||||
|
std::vector<KnownPool> pools = knownPools();
|
||||||
|
|
||||||
|
// Skip anything whose endpoint already appears (official or an earlier user row).
|
||||||
|
auto listed = [&](const std::string& url) {
|
||||||
|
return findPoolByUrl(pools, url) != nullptr;
|
||||||
|
};
|
||||||
|
|
||||||
|
for (const auto& url : savedPoolUrls) {
|
||||||
|
if (trimmed(url).empty() || listed(url)) continue;
|
||||||
|
pools.push_back(makeUserPool(url));
|
||||||
|
}
|
||||||
|
|
||||||
|
// The pool currently being mined, if not already shown, so the active pool is
|
||||||
|
// always visible even before it's bookmarked.
|
||||||
|
if (!trimmed(currentPoolUrl).empty() && !listed(currentPoolUrl))
|
||||||
|
pools.push_back(makeUserPool(currentPoolUrl));
|
||||||
|
|
||||||
|
return pools;
|
||||||
}
|
}
|
||||||
|
|
||||||
std::string resolvePoolAlgo(const std::string& url, const std::string& fallback)
|
std::string resolvePoolAlgo(const std::string& url, const std::string& fallback)
|
||||||
@@ -159,6 +208,64 @@ double parsePoolHashrate(PoolStatsSchema schema, const std::string& jsonStr,
|
|||||||
return 0.0;
|
return 0.0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
double parsePoolFee(PoolStatsSchema schema, const std::string& jsonStr,
|
||||||
|
const std::string& miningcorePoolId, bool& ok)
|
||||||
|
{
|
||||||
|
ok = false;
|
||||||
|
try {
|
||||||
|
const json j = json::parse(jsonStr);
|
||||||
|
|
||||||
|
if (schema == PoolStatsSchema::DragonXIs) {
|
||||||
|
// { "pools": { "dragonx": { "poolFee": <num>, ... }, ... } }
|
||||||
|
if (j.contains("pools") && j["pools"].is_object()) {
|
||||||
|
const auto& pools = j["pools"];
|
||||||
|
auto readFee = [&](const json& pool, double& out) -> bool {
|
||||||
|
if (pool.is_object() && pool.contains("poolFee") &&
|
||||||
|
pool["poolFee"].is_number()) {
|
||||||
|
out = pool["poolFee"].get<double>();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
double fee = 0.0;
|
||||||
|
if (pools.contains("dragonx") && readFee(pools["dragonx"], fee)) {
|
||||||
|
ok = true;
|
||||||
|
return fee;
|
||||||
|
}
|
||||||
|
for (auto it = pools.begin(); it != pools.end(); ++it) {
|
||||||
|
if (readFee(it.value(), fee)) {
|
||||||
|
ok = true;
|
||||||
|
return fee;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else { // Miningcore: pools[id].poolFeePercent
|
||||||
|
if (j.contains("pools") && j["pools"].is_array()) {
|
||||||
|
const json* chosen = nullptr;
|
||||||
|
for (const auto& pool : j["pools"]) {
|
||||||
|
if (!pool.is_object()) continue;
|
||||||
|
if (!miningcorePoolId.empty()) {
|
||||||
|
if (pool.value("id", std::string{}) == miningcorePoolId) {
|
||||||
|
chosen = &pool;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
} else if (!chosen) {
|
||||||
|
chosen = &pool; // first pool when no id requested
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (chosen && chosen->contains("poolFeePercent") &&
|
||||||
|
(*chosen)["poolFeePercent"].is_number()) {
|
||||||
|
ok = true;
|
||||||
|
return (*chosen)["poolFeePercent"].get<double>();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (...) {
|
||||||
|
// fall through — ok stays false
|
||||||
|
}
|
||||||
|
return 0.0;
|
||||||
|
}
|
||||||
|
|
||||||
std::string chooseWeightedPool(const std::vector<PoolHashrate>& pools,
|
std::string chooseWeightedPool(const std::vector<PoolHashrate>& pools,
|
||||||
const std::string& currentId,
|
const std::string& currentId,
|
||||||
std::mt19937& rng)
|
std::mt19937& rng)
|
||||||
|
|||||||
@@ -94,6 +94,12 @@ void PoolStatsService::run(std::vector<KnownPool> pools)
|
|||||||
const double v = parsePoolHashrate(p.schema, body, p.miningcorePoolId, ok);
|
const double v = parsePoolHashrate(p.schema, body, p.miningcorePoolId, ok);
|
||||||
hr.ok = ok;
|
hr.ok = ok;
|
||||||
hr.hashrateHs = ok ? v : 0.0;
|
hr.hashrateHs = ok ? v : 0.0;
|
||||||
|
|
||||||
|
bool feeOk = false;
|
||||||
|
const double fee = parsePoolFee(p.schema, body, p.miningcorePoolId, feeOk);
|
||||||
|
// Only trust a sane fee; anything else leaves feePercent < 0 so the UI
|
||||||
|
// falls back to the compile-time KnownPool.feePercent.
|
||||||
|
if (feeOk && fee >= 0.0 && fee <= 100.0) hr.feePercent = fee;
|
||||||
}
|
}
|
||||||
results[p.id] = hr;
|
results[p.id] = hr;
|
||||||
}
|
}
|
||||||
|
|||||||
77
src/util/seed_phrase.cpp
Normal file
77
src/util/seed_phrase.cpp
Normal file
@@ -0,0 +1,77 @@
|
|||||||
|
// DragonX Wallet - ImGui Edition
|
||||||
|
// Copyright 2024-2026 The Hush Developers
|
||||||
|
// Released under the GPLv3
|
||||||
|
|
||||||
|
#include "seed_phrase.h"
|
||||||
|
|
||||||
|
#include <cctype>
|
||||||
|
|
||||||
|
namespace dragonx {
|
||||||
|
namespace util {
|
||||||
|
|
||||||
|
std::string normalizeSeedPhrase(const std::string& raw)
|
||||||
|
{
|
||||||
|
// Unicode whitespace encoded as UTF-8, each mapped to a single ASCII space; and zero-width marks
|
||||||
|
// to strip. We substitute only these EXACT byte sequences, so ordinary (ASCII) word bytes are
|
||||||
|
// never touched — the common all-ASCII phrase just gets its spacing collapsed and trimmed.
|
||||||
|
static const char* const kSpaces[] = {
|
||||||
|
"\xC2\xA0", // U+00A0 NBSP
|
||||||
|
"\xC2\x85", // U+0085 NEL
|
||||||
|
"\xE1\x9A\x80", // U+1680 ogham space
|
||||||
|
"\xE2\x80\x80", "\xE2\x80\x81", "\xE2\x80\x82", "\xE2\x80\x83", // U+2000–2003
|
||||||
|
"\xE2\x80\x84", "\xE2\x80\x85", "\xE2\x80\x86", "\xE2\x80\x87", // U+2004–2007
|
||||||
|
"\xE2\x80\x88", "\xE2\x80\x89", "\xE2\x80\x8A", // U+2008–200A
|
||||||
|
"\xE2\x80\xAF", // U+202F narrow NBSP
|
||||||
|
"\xE2\x81\x9F", // U+205F math space
|
||||||
|
"\xE3\x80\x80", // U+3000 ideographic
|
||||||
|
};
|
||||||
|
static const char* const kZeroWidth[] = {
|
||||||
|
"\xE2\x80\x8B", "\xE2\x80\x8C", "\xE2\x80\x8D", // U+200B/C/D
|
||||||
|
"\xEF\xBB\xBF", // U+FEFF BOM / ZWNBSP
|
||||||
|
};
|
||||||
|
|
||||||
|
std::string s = raw;
|
||||||
|
auto replaceAll = [&s](const std::string& from, const std::string& to) {
|
||||||
|
if (from.empty()) return;
|
||||||
|
std::size_t pos = 0;
|
||||||
|
while ((pos = s.find(from, pos)) != std::string::npos) {
|
||||||
|
s.replace(pos, from.size(), to);
|
||||||
|
pos += to.size();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
for (const char* zw : kZeroWidth) replaceAll(zw, "");
|
||||||
|
for (const char* sp : kSpaces) replaceAll(sp, " ");
|
||||||
|
|
||||||
|
// Collapse ASCII whitespace runs to a single space and trim ends.
|
||||||
|
std::string out;
|
||||||
|
out.reserve(s.size());
|
||||||
|
bool pendingSpace = false;
|
||||||
|
bool sawWord = false;
|
||||||
|
for (unsigned char c : s) {
|
||||||
|
if (std::isspace(c)) { pendingSpace = sawWord; continue; }
|
||||||
|
if (pendingSpace) { out.push_back(' '); pendingSpace = false; }
|
||||||
|
out.push_back(static_cast<char>(c));
|
||||||
|
sawWord = true;
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
int seedPhraseWordCount(const std::string& phrase)
|
||||||
|
{
|
||||||
|
int words = 0;
|
||||||
|
bool inWord = false;
|
||||||
|
for (unsigned char c : phrase) {
|
||||||
|
const bool space = std::isspace(c) != 0;
|
||||||
|
if (space) inWord = false;
|
||||||
|
else if (!inWord) { inWord = true; ++words; }
|
||||||
|
}
|
||||||
|
return words;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool isCompleteRecoveryPhrase(int words)
|
||||||
|
{
|
||||||
|
return words == 24;
|
||||||
|
}
|
||||||
|
|
||||||
|
} // namespace util
|
||||||
|
} // namespace dragonx
|
||||||
38
src/util/seed_phrase.h
Normal file
38
src/util/seed_phrase.h
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
// DragonX Wallet - ImGui Edition
|
||||||
|
// Copyright 2024-2026 The Hush Developers
|
||||||
|
// Released under the GPLv3
|
||||||
|
//
|
||||||
|
// seed_phrase.h — shared, pure helpers for validating a pasted BIP39 recovery phrase.
|
||||||
|
// One source of truth for the seed-length contract so the lite-restore gates (first-run
|
||||||
|
// wizard + Settings) cannot drift apart. No I/O, no secrets retained — safe for both variants.
|
||||||
|
|
||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include <string>
|
||||||
|
|
||||||
|
namespace dragonx {
|
||||||
|
namespace util {
|
||||||
|
|
||||||
|
// Normalize a pasted recovery phrase for consistent word counting AND backend submission:
|
||||||
|
// - every run of Unicode/ASCII whitespace (incl. NBSP U+00A0, en/em spaces U+2000–200A,
|
||||||
|
// U+202F, U+205F, ideographic U+3000, NEL, tab/newline) collapses to a single ASCII space,
|
||||||
|
// - zero-width marks (U+200B/C/D, U+FEFF BOM) are stripped,
|
||||||
|
// - leading/trailing space is trimmed.
|
||||||
|
// Word bytes are copied verbatim — only these exact whitespace byte-sequences are substituted.
|
||||||
|
// The lite backend (tiny-bip39) splits on the literal ASCII space and does NO Unicode folding, so
|
||||||
|
// a phrase pasted with NBSPs (common from PDFs/note apps) is otherwise unrestorable; normalizing
|
||||||
|
// before submit makes the words space-separated and recoverable.
|
||||||
|
std::string normalizeSeedPhrase(const std::string& raw);
|
||||||
|
|
||||||
|
// Count ASCII-whitespace-separated words. Pair with normalizeSeedPhrase so exotic spacing counts right.
|
||||||
|
int seedPhraseWordCount(const std::string& phrase);
|
||||||
|
|
||||||
|
// True if `words` is a complete recovery phrase the DragonX backends accept. DragonX seeds are
|
||||||
|
// 24-word / 256-bit / 32-byte-entropy ONLY: the SDXL lite backend's LightWallet::new copies the
|
||||||
|
// phrase entropy into a fixed [u8;32] (a shorter valid-BIP39 phrase — 12/15/18/21 words — makes it
|
||||||
|
// panic, uncaught, across the restore FFI), and the full-node daemon likewise generates 24 words.
|
||||||
|
// Both lite-restore gates MUST use this so a crash-inducing length is refused client-side.
|
||||||
|
bool isCompleteRecoveryPhrase(int words);
|
||||||
|
|
||||||
|
} // namespace util
|
||||||
|
} // namespace dragonx
|
||||||
@@ -339,5 +339,155 @@ inline WalletBtreeStats parseWalletBtree(const std::string& path,
|
|||||||
return st;
|
return st;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────────────────────────
|
||||||
|
// Tier 3: collect the raw (key,value) record BYTES — the read half of the offline wallet REBUILD that
|
||||||
|
// recovers a BDB-inconsistent wallet.dat (stale extent metadata: our tolerant walk reads records the
|
||||||
|
// daemon's Berkeley DB verify rejects and auto-salvages). A helper then writes these verbatim into a
|
||||||
|
// fresh, consistent BDB so the daemon loads it cleanly. Records are copied byte-for-byte — encrypted
|
||||||
|
// key material (ckey/csapzkey/mkey) passes through as opaque ciphertext, so no passphrase is needed.
|
||||||
|
// Values that live in BDB OVERFLOW pages (only large `tx` history records) are NOT captured (skipped +
|
||||||
|
// counted); they are irrelevant to funds — a rescan rebuilds transaction history. Same bounds-checked,
|
||||||
|
// subdb-aware, visited-set-capped walk as parseWalletBtree.
|
||||||
|
struct WalletRawRecords {
|
||||||
|
bool parsed = false; ///< the btree walked cleanly
|
||||||
|
bool complete = false; ///< the whole file was read (not cap-truncated)
|
||||||
|
std::vector<std::pair<std::string, std::string>> records; ///< inline (key,value) bytes, verbatim
|
||||||
|
int keyRecords = 0; ///< fund-critical key-type records captured (key/wkey/ckey/z*/sap*/hdseed)
|
||||||
|
int skippedOverflow = 0; ///< records whose value spilled to overflow pages (tx history) — not captured
|
||||||
|
std::size_t bytesRead = 0;
|
||||||
|
};
|
||||||
|
|
||||||
|
inline WalletRawRecords extractWalletBtreeRecords(const std::string& path,
|
||||||
|
std::size_t maxBytes = 512u * 1024u * 1024u) {
|
||||||
|
WalletRawRecords out;
|
||||||
|
std::ifstream f(path, std::ios::binary);
|
||||||
|
if (!f) return out;
|
||||||
|
std::string buf;
|
||||||
|
{
|
||||||
|
f.seekg(0, std::ios::end);
|
||||||
|
std::streamoff sz = f.tellg();
|
||||||
|
if (sz < 512) return out;
|
||||||
|
const std::size_t want = std::min<std::size_t>(static_cast<std::size_t>(sz), maxBytes);
|
||||||
|
f.seekg(0, std::ios::beg);
|
||||||
|
buf.resize(want);
|
||||||
|
f.read(&buf[0], static_cast<std::streamsize>(want));
|
||||||
|
buf.resize(static_cast<std::size_t>(std::max<std::streamsize>(0, f.gcount())));
|
||||||
|
if (buf.size() < 512) return out;
|
||||||
|
out.bytesRead = buf.size();
|
||||||
|
out.complete = (buf.size() == static_cast<std::size_t>(sz));
|
||||||
|
}
|
||||||
|
const unsigned char* B = reinterpret_cast<const unsigned char*>(buf.data());
|
||||||
|
const std::size_t N = buf.size();
|
||||||
|
|
||||||
|
auto rd32at = [&](std::size_t o, bool le) -> uint32_t {
|
||||||
|
return le ? (uint32_t)B[o] | ((uint32_t)B[o+1]<<8) | ((uint32_t)B[o+2]<<16) | ((uint32_t)B[o+3]<<24)
|
||||||
|
: (uint32_t)B[o+3] | ((uint32_t)B[o+2]<<8) | ((uint32_t)B[o+1]<<16) | ((uint32_t)B[o]<<24);
|
||||||
|
};
|
||||||
|
constexpr uint32_t kBtreeMagic = 0x00053162u;
|
||||||
|
bool le;
|
||||||
|
if (rd32at(12, true) == kBtreeMagic) le = true;
|
||||||
|
else if (rd32at(12, false) == kBtreeMagic) le = false;
|
||||||
|
else return out;
|
||||||
|
auto r32 = [&](std::size_t o) { return o + 4 <= N ? rd32at(o, le) : 0u; };
|
||||||
|
auto r16 = [&](std::size_t o) -> uint32_t {
|
||||||
|
if (o + 2 > N) return 0;
|
||||||
|
return le ? (uint32_t)B[o] | ((uint32_t)B[o+1]<<8) : (uint32_t)B[o+1] | ((uint32_t)B[o]<<8);
|
||||||
|
};
|
||||||
|
const uint32_t pagesize = r32(20);
|
||||||
|
if (pagesize < 512 || pagesize > 65536 || (pagesize & (pagesize - 1)) != 0) return out;
|
||||||
|
const uint32_t npages = static_cast<uint32_t>(N / pagesize);
|
||||||
|
const uint32_t root = r32(88);
|
||||||
|
if (npages == 0 || root == 0 || root >= npages) return out;
|
||||||
|
if (B[24] != 0 || (B[26] & 0x01)) return out; // page checksum/encryption — offsets shift; bail
|
||||||
|
|
||||||
|
constexpr uint8_t P_IBTREE = 3, P_LBTREE = 5, P_BTREEMETA = 9, B_KEYDATA = 1;
|
||||||
|
constexpr std::size_t kMaxPagesVisited = 600000;
|
||||||
|
constexpr int kMaxKeys = 4000000;
|
||||||
|
std::vector<bool> visited(npages, false);
|
||||||
|
std::size_t pagesVisited = 0;
|
||||||
|
int keys = 0;
|
||||||
|
bool aborted = false;
|
||||||
|
auto rdpgno = [&](const unsigned char* p) -> uint32_t {
|
||||||
|
return le ? (uint32_t)p[0] | ((uint32_t)p[1]<<8) | ((uint32_t)p[2]<<16) | ((uint32_t)p[3]<<24)
|
||||||
|
: (uint32_t)p[3] | ((uint32_t)p[2]<<8) | ((uint32_t)p[1]<<16) | ((uint32_t)p[0]<<24);
|
||||||
|
};
|
||||||
|
auto traverse = [&](uint32_t rootPg, auto&& fn) {
|
||||||
|
std::fill(visited.begin(), visited.end(), false);
|
||||||
|
std::vector<uint32_t> stack;
|
||||||
|
if (rootPg < npages && !visited[rootPg]) { visited[rootPg] = true; stack.push_back(rootPg); }
|
||||||
|
while (!stack.empty()) {
|
||||||
|
const uint32_t pg = stack.back(); stack.pop_back();
|
||||||
|
if (pg >= npages) continue;
|
||||||
|
if (++pagesVisited > kMaxPagesVisited) { aborted = true; return; }
|
||||||
|
const std::size_t base = static_cast<std::size_t>(pg) * pagesize;
|
||||||
|
if (base + 26 > N) continue;
|
||||||
|
const uint8_t type = B[base + 25];
|
||||||
|
const uint32_t entries = r16(base + 20);
|
||||||
|
if (26 + static_cast<std::size_t>(entries) * 2 > pagesize) continue;
|
||||||
|
if (type == P_IBTREE) {
|
||||||
|
for (uint32_t i = 0; i < entries; ++i) {
|
||||||
|
const uint32_t off = r16(base + 26 + i * 2);
|
||||||
|
if (off + 8 > pagesize) continue;
|
||||||
|
const uint32_t child = r32(base + off + 4);
|
||||||
|
if (child > 0 && child < npages && !visited[child]) { visited[child] = true; stack.push_back(child); }
|
||||||
|
}
|
||||||
|
} else if (type == P_LBTREE) {
|
||||||
|
for (uint32_t i = 0; i + 1 < entries; i += 2) {
|
||||||
|
if (++keys > kMaxKeys) { aborted = true; return; }
|
||||||
|
const uint32_t ko = r16(base + 26 + i * 2);
|
||||||
|
const uint32_t dO = r16(base + 26 + (i + 1) * 2);
|
||||||
|
if (ko + 3 > pagesize || dO + 3 > pagesize) continue;
|
||||||
|
if (B[base + ko + 2] != B_KEYDATA) continue; // overflow/dup key — never a record name
|
||||||
|
const uint32_t kl = r16(base + ko);
|
||||||
|
if (kl < 1 || ko + 3 + kl > pagesize) continue;
|
||||||
|
const uint8_t dtype = B[base + dO + 2];
|
||||||
|
const uint32_t dl = r16(base + dO);
|
||||||
|
const unsigned char* dp = (dO + 3 + dl <= pagesize) ? B + base + dO + 3 : nullptr;
|
||||||
|
fn(B + base + ko + 3, kl, dp, dl, dtype);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Master DB: subdb-name → subdb meta/root pgno (big-endian value; native fallback), same as parseWalletBtree.
|
||||||
|
std::vector<uint32_t> subRoots;
|
||||||
|
traverse(root, [&](const unsigned char*, uint32_t, const unsigned char* dp, uint32_t dl, uint8_t dtype) {
|
||||||
|
if (dtype != B_KEYDATA || dl != 4 || !dp) return;
|
||||||
|
const uint32_t cand[2] = {
|
||||||
|
(uint32_t)dp[3] | ((uint32_t)dp[2]<<8) | ((uint32_t)dp[1]<<16) | ((uint32_t)dp[0]<<24),
|
||||||
|
rdpgno(dp),
|
||||||
|
};
|
||||||
|
for (const uint32_t pgno : cand) {
|
||||||
|
if (pgno == 0 || pgno >= npages) continue;
|
||||||
|
const uint8_t pt = B[static_cast<std::size_t>(pgno) * pagesize + 25];
|
||||||
|
if (pt == P_BTREEMETA) {
|
||||||
|
const uint32_t sr = r32(static_cast<std::size_t>(pgno) * pagesize + 88);
|
||||||
|
if (sr > 0 && sr < npages) { subRoots.push_back(sr); break; }
|
||||||
|
} else if (pt == P_LBTREE || pt == P_IBTREE) { subRoots.push_back(pgno); break; }
|
||||||
|
}
|
||||||
|
});
|
||||||
|
if (aborted) return out;
|
||||||
|
if (subRoots.empty()) subRoots.push_back(root);
|
||||||
|
|
||||||
|
auto isKeyType = [](const char* nm, uint32_t nl) {
|
||||||
|
auto is = [&](const char* s) { return std::strlen(s) == nl && std::memcmp(nm, s, nl) == 0; };
|
||||||
|
return is("key") || is("wkey") || is("ckey") || is("zkey") || is("czkey")
|
||||||
|
|| is("sapzkey") || is("csapzkey") || is("hdseed") || is("chdseed");
|
||||||
|
};
|
||||||
|
for (const uint32_t sr : subRoots) {
|
||||||
|
traverse(sr, [&](const unsigned char* kp, uint32_t kl, const unsigned char* dp, uint32_t dl, uint8_t dt) {
|
||||||
|
if (dt != B_KEYDATA || !dp) { out.skippedOverflow++; return; } // overflow value (tx history) — skip
|
||||||
|
out.records.emplace_back(std::string(reinterpret_cast<const char*>(kp), kl),
|
||||||
|
std::string(reinterpret_cast<const char*>(dp), dl));
|
||||||
|
const uint32_t nlen = kp[0];
|
||||||
|
if (nlen >= 2 && nlen <= 20 && 1u + nlen <= kl && isKeyType(reinterpret_cast<const char*>(kp + 1), nlen))
|
||||||
|
out.keyRecords++;
|
||||||
|
});
|
||||||
|
if (aborted) return out;
|
||||||
|
}
|
||||||
|
out.parsed = true;
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
} // namespace util
|
} // namespace util
|
||||||
} // namespace dragonx
|
} // namespace dragonx
|
||||||
|
|||||||
@@ -80,8 +80,10 @@ bool persistAfterBroadcast(LiteClientBridge& bridge)
|
|||||||
for (int attempt = 0; attempt < 2; ++attempt) {
|
for (int attempt = 0; attempt < 2; ++attempt) {
|
||||||
if (bridge.execute("save", "").ok) return true;
|
if (bridge.execute("save", "").ok) return true;
|
||||||
}
|
}
|
||||||
// Persistent failure: the spent note will be re-derived from the chain on the next sync,
|
// Persistent failure: the spent note will be re-derived from the chain on the next sync, so this
|
||||||
// so this is a robustness gap, not fund loss. (Retry handles the common transient case.)
|
// is a robustness gap, not fund loss. Log it (W5-1) — both callers discard this return, so the
|
||||||
|
// failure was previously completely silent.
|
||||||
|
liteLog("save failed after send/shield — the wallet will re-derive it on the next sync");
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -600,7 +602,8 @@ void LiteWalletController::startSync()
|
|||||||
// The backend does NOT auto-save after a sync, so persist the freshly-scanned wallet;
|
// The backend does NOT auto-save after a sync, so persist the freshly-scanned wallet;
|
||||||
// otherwise the next launch re-scans from the checkpoint (~30 min). Set `done` only
|
// otherwise the next launch re-scans from the checkpoint (~30 min). Set `done` only
|
||||||
// after the save so a syncComplete() observer sees a fully-persisted wallet.
|
// after the save so a syncComplete() observer sees a fully-persisted wallet.
|
||||||
bridge->execute("save", "");
|
if (!bridge->execute("save", "").ok) // W5-2: don't leave a failed post-sync save silent
|
||||||
|
liteLog("save failed after sync — the next launch will re-scan from the checkpoint");
|
||||||
}
|
}
|
||||||
done->store(true);
|
done->store(true);
|
||||||
});
|
});
|
||||||
@@ -631,7 +634,8 @@ bool LiteWalletController::startRescan()
|
|||||||
// `rescan` clears the wallet's synced block cache and re-downloads/re-scans from the
|
// `rescan` clears the wallet's synced block cache and re-downloads/re-scans from the
|
||||||
// birthday height — a blocking, uninterruptible full scan, same as `sync`.
|
// birthday height — a blocking, uninterruptible full scan, same as `sync`.
|
||||||
bridge->execute("rescan", "");
|
bridge->execute("rescan", "");
|
||||||
bridge->execute("save", ""); // backend doesn't auto-save after a rescan
|
if (!bridge->execute("save", "").ok) // W5-2: don't leave a failed post-rescan save silent
|
||||||
|
liteLog("save failed after rescan — the next launch will re-scan from the checkpoint");
|
||||||
}
|
}
|
||||||
done->store(true);
|
done->store(true);
|
||||||
});
|
});
|
||||||
@@ -883,6 +887,11 @@ LiteImportResult LiteWalletController::importKey(std::string spendingOrViewingKe
|
|||||||
}
|
}
|
||||||
// Transparent WIFs begin with U/5/K/L (TImportCommand); shielded keys begin with
|
// Transparent WIFs begin with U/5/K/L (TImportCommand); shielded keys begin with
|
||||||
// "secret-..." / viewing keys "zxview...", so this prefix check usually won't collide.
|
// "secret-..." / viewing keys "zxview...", so this prefix check usually won't collide.
|
||||||
|
// NB: the lite/SDXL backend's viewing key is an *extended full* viewing key ("zxviews…",
|
||||||
|
// hrp_sapling_viewing_key), which is genuinely correct here — do NOT "harmonize" this with the
|
||||||
|
// full node, whose z_importviewingkey takes an *incoming* viewing key ("zivks…") instead. The
|
||||||
|
// two variants accept different viewing-key forms; a VK is not portable between them. Regardless,
|
||||||
|
// the two-command fallback below means a mis-guessed prefix never rejects an otherwise-valid key.
|
||||||
const char first = spendingOrViewingKey[0];
|
const char first = spendingOrViewingKey[0];
|
||||||
const bool transparentFirst = (first == 'U' || first == '5' || first == 'K' || first == 'L');
|
const bool transparentFirst = (first == 'U' || first == '5' || first == 'K' || first == 'L');
|
||||||
|
|
||||||
@@ -1172,25 +1181,47 @@ void LiteWalletController::workerLoop()
|
|||||||
LiteWalletLifecycleResult LiteWalletController::createWallet(LiteWalletCreateRequest request)
|
LiteWalletLifecycleResult LiteWalletController::createWallet(LiteWalletCreateRequest request)
|
||||||
{
|
{
|
||||||
auto result = lifecycle_.createWallet(request);
|
auto result = lifecycle_.createWallet(request);
|
||||||
secureWipeLiteSecret(request.passphrase);
|
|
||||||
onLifecycleResult(result);
|
onLifecycleResult(result);
|
||||||
|
// If the user supplied a passphrase, encrypt the brand-new wallet with it now that it's open
|
||||||
|
// (the backend encrypts + locks + saves). Previously this passphrase was collected but never
|
||||||
|
// used (W5-3) — a passphrase field that silently did nothing. encryptWallet() takes its own
|
||||||
|
// copy and wipes it.
|
||||||
|
if (walletOpen_.load() && !request.passphrase.empty()) {
|
||||||
|
const auto enc = encryptWallet(request.passphrase);
|
||||||
|
if (!enc.ok) liteLog("wallet created but encryption failed: " + enc.error);
|
||||||
|
}
|
||||||
|
secureWipeLiteSecret(request.passphrase);
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
LiteWalletLifecycleResult LiteWalletController::openWallet(LiteWalletOpenRequest request)
|
LiteWalletLifecycleResult LiteWalletController::openWallet(LiteWalletOpenRequest request)
|
||||||
{
|
{
|
||||||
auto result = lifecycle_.openWallet(request);
|
auto result = lifecycle_.openWallet(request);
|
||||||
secureWipeLiteSecret(request.passphrase);
|
|
||||||
onLifecycleResult(result);
|
onLifecycleResult(result);
|
||||||
|
// An existing wallet may be encrypted + locked — use the supplied passphrase to unlock it so it
|
||||||
|
// opens ready to use. Only meaningful when the wallet is actually locked (W5-3).
|
||||||
|
if (walletOpen_.load() && !request.passphrase.empty()) {
|
||||||
|
const auto encStatus = encryptionStatus();
|
||||||
|
if (encStatus.ok && encStatus.encrypted && encStatus.locked) {
|
||||||
|
if (!unlockWallet(request.passphrase))
|
||||||
|
liteLog("wallet opened but unlock failed (wrong passphrase?)");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
secureWipeLiteSecret(request.passphrase);
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
LiteWalletLifecycleResult LiteWalletController::restoreWallet(LiteWalletRestoreRequest request)
|
LiteWalletLifecycleResult LiteWalletController::restoreWallet(LiteWalletRestoreRequest request)
|
||||||
{
|
{
|
||||||
auto result = lifecycle_.restoreWallet(request);
|
auto result = lifecycle_.restoreWallet(request);
|
||||||
|
onLifecycleResult(result);
|
||||||
|
// If the user supplied a passphrase, encrypt the restored wallet with it now that it's open (W5-3).
|
||||||
|
if (walletOpen_.load() && !request.passphrase.empty()) {
|
||||||
|
const auto enc = encryptWallet(request.passphrase);
|
||||||
|
if (!enc.ok) liteLog("wallet restored but encryption failed: " + enc.error);
|
||||||
|
}
|
||||||
secureWipeLiteSecret(request.seedPhrase);
|
secureWipeLiteSecret(request.seedPhrase);
|
||||||
secureWipeLiteSecret(request.passphrase);
|
secureWipeLiteSecret(request.passphrase);
|
||||||
onLifecycleResult(result);
|
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -3,6 +3,9 @@
|
|||||||
#include "chat/chat_service.h"
|
#include "chat/chat_service.h"
|
||||||
#include "chat/chat_database.h"
|
#include "chat/chat_database.h"
|
||||||
#include "daemon/daemon_controller.h"
|
#include "daemon/daemon_controller.h"
|
||||||
|
#include "daemon/embedded_daemon.h"
|
||||||
|
#include "util/connect_stall.h"
|
||||||
|
#include "util/logger.h"
|
||||||
#include "data/transaction_history_cache.h"
|
#include "data/transaction_history_cache.h"
|
||||||
#include "data/address_book.h"
|
#include "data/address_book.h"
|
||||||
#include "data/wallet_index.h"
|
#include "data/wallet_index.h"
|
||||||
@@ -29,7 +32,13 @@
|
|||||||
#include "ui/windows/mining_benchmark.h"
|
#include "ui/windows/mining_benchmark.h"
|
||||||
#include "ui/windows/mining_pool_panel.h"
|
#include "ui/windows/mining_pool_panel.h"
|
||||||
#include "ui/windows/mining_tab_helpers.h"
|
#include "ui/windows/mining_tab_helpers.h"
|
||||||
|
#include "ui/node_status_banner.h"
|
||||||
|
#include "ui/staleness_badge.h"
|
||||||
|
#include "ui/notifications.h"
|
||||||
|
#include "data/seed_migration_resume.h"
|
||||||
#include "util/address_validation.h"
|
#include "util/address_validation.h"
|
||||||
|
#include "util/seed_phrase.h"
|
||||||
|
#include "daemon/daemon_startup_diagnosis.h"
|
||||||
#include "util/amount_format.h"
|
#include "util/amount_format.h"
|
||||||
#include "util/payment_uri.h"
|
#include "util/payment_uri.h"
|
||||||
#include "util/platform.h"
|
#include "util/platform.h"
|
||||||
@@ -719,7 +728,9 @@ void testConnectionConfig()
|
|||||||
|
|
||||||
void testPaymentUri()
|
void testPaymentUri()
|
||||||
{
|
{
|
||||||
std::string taddr = "R" + std::string(33, 'a');
|
// Real checksummed addresses — the parser now checksum-validates the recipient (not a bare
|
||||||
|
// prefix/length filter), so it accepts P2PKH / P2SH / shielded and rejects transcription errors.
|
||||||
|
std::string taddr = "R9NXAVJezHiBnT3ijTpg3JUZre7PxhJWti"; // P2PKH (v60)
|
||||||
auto parsed = dragonx::util::parsePaymentURI(
|
auto parsed = dragonx::util::parsePaymentURI(
|
||||||
"drgx:" + taddr + "?amount=1.25000000&label=Main+Wallet&memo=hello%20there&message=thanks");
|
"drgx:" + taddr + "?amount=1.25000000&label=Main+Wallet&memo=hello%20there&message=thanks");
|
||||||
|
|
||||||
@@ -730,11 +741,19 @@ void testPaymentUri()
|
|||||||
EXPECT_EQ(parsed.memo, std::string("hello there"));
|
EXPECT_EQ(parsed.memo, std::string("hello there"));
|
||||||
EXPECT_EQ(parsed.message, std::string("thanks"));
|
EXPECT_EQ(parsed.message, std::string("thanks"));
|
||||||
|
|
||||||
std::string zaddr = "zs" + std::string(76, 'b');
|
std::string zaddr = "zs1qqqsyqcyq5rqwzqfpg9scrgwpugpzysnzs23v9ccrydpk8qarc0jqgfzyvjz2f389q5j5ctfvp5";
|
||||||
auto zparsed = dragonx::util::parsePaymentURI("hush://" + zaddr + "?amt=0.5");
|
auto zparsed = dragonx::util::parsePaymentURI("hush://" + zaddr + "?amt=0.5");
|
||||||
EXPECT_TRUE(zparsed.valid);
|
EXPECT_TRUE(zparsed.valid);
|
||||||
EXPECT_NEAR(zparsed.amount, 0.5, 0.00000001);
|
EXPECT_NEAR(zparsed.amount, 0.5, 0.00000001);
|
||||||
|
|
||||||
|
// Regression: a P2SH/multisig recipient ("b…", v85) must parse — the old 'R'/'t'-only filter dropped it.
|
||||||
|
auto p2sh = dragonx::util::parsePaymentURI("drgx:bCpbnCkrjoJ6EHXtLx9eASHEbFYyikt35C?amount=1");
|
||||||
|
EXPECT_TRUE(p2sh.valid);
|
||||||
|
|
||||||
|
// A transcription error (flipped checksum char) is now rejected at parse time.
|
||||||
|
auto typo = dragonx::util::parsePaymentURI("drgx:R9NXAVJezHiBnT3ijTpg3JUZre7PxhJWtX?amount=1");
|
||||||
|
EXPECT_FALSE(typo.valid);
|
||||||
|
|
||||||
auto invalid = dragonx::util::parsePaymentURI("drgx:" + taddr + "?amount=-1");
|
auto invalid = dragonx::util::parsePaymentURI("drgx:" + taddr + "?amount=-1");
|
||||||
EXPECT_FALSE(invalid.valid);
|
EXPECT_FALSE(invalid.valid);
|
||||||
EXPECT_EQ(invalid.error, std::string("Invalid negative amount"));
|
EXPECT_EQ(invalid.error, std::string("Invalid negative amount"));
|
||||||
@@ -826,6 +845,18 @@ void testWalletFileProbe()
|
|||||||
EXPECT_EQ(s.txCount, 1); EXPECT_EQ(s.transparentKeys, 1); EXPECT_EQ(s.addresses(), 1);
|
EXPECT_EQ(s.txCount, 1); EXPECT_EQ(s.transparentKeys, 1); EXPECT_EQ(s.addresses(), 1);
|
||||||
EXPECT_EQ(s.createdEpoch, kCreated);
|
EXPECT_EQ(s.createdEpoch, kCreated);
|
||||||
EXPECT_FALSE(dragonx::util::parseWalletBtree((dir / "junk.dat").string()).parsed);
|
EXPECT_FALSE(dragonx::util::parseWalletBtree((dir / "junk.dat").string()).parsed);
|
||||||
|
|
||||||
|
// Byte-collecting reader (the wallet-REBUILD read half): same walk, but collects (key,value) bytes.
|
||||||
|
auto ex = dragonx::util::extractWalletBtreeRecords((dir / "btree.dat").string());
|
||||||
|
EXPECT_TRUE(ex.parsed);
|
||||||
|
EXPECT_TRUE(ex.records.size() >= static_cast<size_t>(3)); // tx + key + keymeta captured verbatim
|
||||||
|
EXPECT_EQ(ex.keyRecords, 1); // the "key" record is fund-critical
|
||||||
|
bool foundKeyRec = false; // value copied byte-for-byte, name intact
|
||||||
|
for (const auto& kv : ex.records)
|
||||||
|
if (kv.first.size() >= 4 && (unsigned char)kv.first[0] == 3 && kv.first.compare(1, 3, "key") == 0
|
||||||
|
&& !kv.second.empty()) foundKeyRec = true;
|
||||||
|
EXPECT_TRUE(foundKeyRec);
|
||||||
|
EXPECT_FALSE(dragonx::util::extractWalletBtreeRecords((dir / "junk.dat").string()).parsed);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 8) Mnemonic-flag decode (hdChainMnemonicFlag): the fMnemonicSeed byte lives at offset 52 of the
|
// 8) Mnemonic-flag decode (hdChainMnemonicFlag): the fMnemonicSeed byte lives at offset 52 of the
|
||||||
@@ -2014,6 +2045,20 @@ void testNetworkRefreshResultModels()
|
|||||||
EXPECT_EQ(state.market.price_history.size(), static_cast<size_t>(1));
|
EXPECT_EQ(state.market.price_history.size(), static_cast<size_t>(1));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Regression: CoinGecko emits JSON null (not an omitted key) for fields it can't compute —
|
||||||
|
// commonly usd_24h_change on illiquid tokens like DRGX — while still returning a valid spot
|
||||||
|
// price. The parser must keep the valid usd/btc, not discard the whole update on the null.
|
||||||
|
auto priceNull = Refresh::parseCoinGeckoPriceResponse(
|
||||||
|
R"({"dragonx-2":{"usd":0.42,"btc":0.000009,"usd_24h_change":null,"usd_24h_vol":null,"usd_market_cap":50000}})",
|
||||||
|
0);
|
||||||
|
EXPECT_TRUE(priceNull.has_value());
|
||||||
|
if (priceNull) {
|
||||||
|
EXPECT_NEAR(priceNull->market.price_usd, 0.42, 0.00000001);
|
||||||
|
EXPECT_NEAR(priceNull->market.price_btc, 0.000009, 0.00000001);
|
||||||
|
EXPECT_NEAR(priceNull->market.change_24h, 0.0, 0.0001); // null -> default, not a throw
|
||||||
|
EXPECT_NEAR(priceNull->market.market_cap, 50000.0, 0.0001);
|
||||||
|
}
|
||||||
|
|
||||||
Refresh::markPriceRefreshStarted(state);
|
Refresh::markPriceRefreshStarted(state);
|
||||||
Refresh::applyPriceRefreshFailure(state, "timeout");
|
Refresh::applyPriceRefreshFailure(state, "timeout");
|
||||||
EXPECT_FALSE(state.market.price_loading);
|
EXPECT_FALSE(state.market.price_loading);
|
||||||
@@ -2195,6 +2240,20 @@ void testOperationStatusPollParsing()
|
|||||||
EXPECT_FALSE(malformed.anySuccess);
|
EXPECT_FALSE(malformed.anySuccess);
|
||||||
EXPECT_TRUE(malformed.doneOpids.empty());
|
EXPECT_TRUE(malformed.doneOpids.empty());
|
||||||
EXPECT_TRUE(malformed.staleOpids.empty());
|
EXPECT_TRUE(malformed.staleOpids.empty());
|
||||||
|
|
||||||
|
// Regression: a type-anomalous element (non-string "id"/"status") must be skipped, not throw —
|
||||||
|
// a throw here would escape the parser and permanently wedge opid polling for the session. A
|
||||||
|
// valid tracked opid alongside the anomaly must still be processed.
|
||||||
|
auto typeSafe = Refresh::parseOperationStatusPoll(json::array({
|
||||||
|
json{{"id", 12345}, {"status", "failed"}}, // non-string id -> skipped, no throw
|
||||||
|
json{{"id", "op-ok"}, {"status", 7}}, // non-string status -> "" (not done)
|
||||||
|
json{{"id", "op-good"}, {"status", "success"}, {"result", json{{"txid", "tx-good"}}}}
|
||||||
|
}), {"op-ok", "op-good", "op-x"});
|
||||||
|
EXPECT_TRUE(typeSafe.anySuccess);
|
||||||
|
EXPECT_EQ(typeSafe.successTxidsByOpid.at("op-good"), std::string("tx-good"));
|
||||||
|
EXPECT_TRUE(typeSafe.failureMessages.empty()); // the non-string-id "failed" was skipped
|
||||||
|
EXPECT_EQ(typeSafe.staleOpids.size(), static_cast<size_t>(1)); // op-x absent; op-ok was seen (not stale)
|
||||||
|
EXPECT_EQ(typeSafe.staleOpids[0], std::string("op-x"));
|
||||||
}
|
}
|
||||||
|
|
||||||
void testSecureVaultScope()
|
void testSecureVaultScope()
|
||||||
@@ -2477,6 +2536,451 @@ void testDaemonShutdownPolicy()
|
|||||||
EXPECT_TRUE(bootstrap.disconnectRpc);
|
EXPECT_TRUE(bootstrap.disconnectRpc);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void testIsLocalHost()
|
||||||
|
{
|
||||||
|
using dragonx::rpc::Connection;
|
||||||
|
// Genuine loopback / local hosts.
|
||||||
|
EXPECT_TRUE(Connection::isLocalHost("127.0.0.1"));
|
||||||
|
EXPECT_TRUE(Connection::isLocalHost("127.1.2.3"));
|
||||||
|
EXPECT_TRUE(Connection::isLocalHost("localhost"));
|
||||||
|
EXPECT_TRUE(Connection::isLocalHost("LocalHost"));
|
||||||
|
EXPECT_TRUE(Connection::isLocalHost("::1"));
|
||||||
|
EXPECT_TRUE(Connection::isLocalHost("[::1]"));
|
||||||
|
// The regression this fix targets: a hostname merely starting "127." is NOT loopback.
|
||||||
|
EXPECT_TRUE(!Connection::isLocalHost("127.evil.com"));
|
||||||
|
EXPECT_TRUE(!Connection::isLocalHost("127.0.0.1.attacker.example"));
|
||||||
|
EXPECT_TRUE(!Connection::isLocalHost("127.300.0.1"));
|
||||||
|
EXPECT_TRUE(!Connection::isLocalHost("1270.0.0.1"));
|
||||||
|
EXPECT_TRUE(!Connection::isLocalHost("10.0.0.5"));
|
||||||
|
EXPECT_TRUE(!Connection::isLocalHost("example.com"));
|
||||||
|
}
|
||||||
|
|
||||||
|
void testAllowsPlaintextRemote()
|
||||||
|
{
|
||||||
|
using dragonx::rpc::Connection;
|
||||||
|
using dragonx::rpc::ConnectionConfig;
|
||||||
|
|
||||||
|
ConnectionConfig local;
|
||||||
|
local.host = "127.0.0.1";
|
||||||
|
local.use_tls = false;
|
||||||
|
EXPECT_TRUE(!Connection::usesPlaintextRemote(local)); // local is never "plaintext remote"
|
||||||
|
|
||||||
|
ConnectionConfig remote;
|
||||||
|
remote.host = "10.0.0.5";
|
||||||
|
remote.use_tls = false;
|
||||||
|
EXPECT_TRUE(Connection::usesPlaintextRemote(remote)); // remote + no TLS
|
||||||
|
EXPECT_TRUE(!Connection::allowsPlaintextRemote(remote)); // blocked by default → connect refused
|
||||||
|
|
||||||
|
remote.allow_plaintext_remote = true;
|
||||||
|
EXPECT_TRUE(Connection::allowsPlaintextRemote(remote)); // explicit opt-in
|
||||||
|
|
||||||
|
ConnectionConfig remoteTls;
|
||||||
|
remoteTls.host = "10.0.0.5";
|
||||||
|
remoteTls.use_tls = true;
|
||||||
|
EXPECT_TRUE(!Connection::usesPlaintextRemote(remoteTls)); // TLS → not plaintext, never refused
|
||||||
|
}
|
||||||
|
|
||||||
|
void testConsoleSecretRedaction()
|
||||||
|
{
|
||||||
|
using dragonx::ui::RedactConsoleCommand;
|
||||||
|
using dragonx::ui::ConsoleCommandCarriesSecret;
|
||||||
|
|
||||||
|
// Secret-bearing commands are recognized (case- and whitespace-insensitive on the name).
|
||||||
|
EXPECT_TRUE(ConsoleCommandCarriesSecret("walletpassphrase myPass 60"));
|
||||||
|
EXPECT_TRUE(ConsoleCommandCarriesSecret("z_importkey SK-secret"));
|
||||||
|
EXPECT_TRUE(ConsoleCommandCarriesSecret(" ENCRYPTWALLET topsecret"));
|
||||||
|
EXPECT_TRUE(!ConsoleCommandCarriesSecret("getinfo"));
|
||||||
|
EXPECT_TRUE(!ConsoleCommandCarriesSecret("getwalletinfo")); // not a false-positive substring match
|
||||||
|
|
||||||
|
// Redaction replaces the arguments with **** but preserves the (original-case) command name.
|
||||||
|
EXPECT_EQ(RedactConsoleCommand("walletpassphrase myPass 60"), std::string("walletpassphrase ****"));
|
||||||
|
EXPECT_EQ(RedactConsoleCommand("z_importkey SK-secret-key"), std::string("z_importkey ****"));
|
||||||
|
EXPECT_EQ(RedactConsoleCommand("ENCRYPTWALLET topsecret"), std::string("ENCRYPTWALLET ****"));
|
||||||
|
// A bare secret command with no argument is left unchanged (nothing to hide).
|
||||||
|
EXPECT_EQ(RedactConsoleCommand("walletpassphrase"), std::string("walletpassphrase"));
|
||||||
|
// Non-secret commands pass through untouched.
|
||||||
|
EXPECT_EQ(RedactConsoleCommand("sendtoaddress addr 1.0"), std::string("sendtoaddress addr 1.0"));
|
||||||
|
EXPECT_EQ(RedactConsoleCommand("getwalletinfo"), std::string("getwalletinfo"));
|
||||||
|
}
|
||||||
|
|
||||||
|
void testNodeStatusBanner()
|
||||||
|
{
|
||||||
|
using namespace dragonx::ui;
|
||||||
|
|
||||||
|
// Connected full node → no banner.
|
||||||
|
{
|
||||||
|
NodeBannerInputs in; in.connected = true;
|
||||||
|
EXPECT_TRUE(!evaluateNodeStatusBanner(in).show);
|
||||||
|
}
|
||||||
|
// Expected startup phases own the screen (loading/warmup overlay) → no banner.
|
||||||
|
{
|
||||||
|
NodeBannerInputs in; in.warming_up = true;
|
||||||
|
EXPECT_TRUE(!evaluateNodeStatusBanner(in).show);
|
||||||
|
NodeBannerInputs in2; in2.daemon_initializing = true;
|
||||||
|
EXPECT_TRUE(!evaluateNodeStatusBanner(in2).show);
|
||||||
|
NodeBannerInputs in3; in3.connection_in_progress = true;
|
||||||
|
EXPECT_TRUE(!evaluateNodeStatusBanner(in3).show);
|
||||||
|
}
|
||||||
|
// Genuinely offline full node → amber, reconnect offered, detail passed through.
|
||||||
|
{
|
||||||
|
NodeBannerInputs in;
|
||||||
|
in.connection_status = "Lost connection to daemon";
|
||||||
|
NodeBannerState s = evaluateNodeStatusBanner(in);
|
||||||
|
EXPECT_TRUE(s.show);
|
||||||
|
EXPECT_TRUE(s.severity == NodeBannerSeverity::Warning);
|
||||||
|
EXPECT_TRUE(s.reason == NodeBannerReason::FullNodeOffline);
|
||||||
|
EXPECT_TRUE(s.action == NodeBannerAction::Reconnect);
|
||||||
|
EXPECT_EQ(s.detail, std::string("Lost connection to daemon"));
|
||||||
|
}
|
||||||
|
// Embedded daemon crashed and auto-restart gave up → red, restart offered, lastError preferred.
|
||||||
|
{
|
||||||
|
NodeBannerInputs in;
|
||||||
|
in.using_embedded_daemon = true;
|
||||||
|
in.has_daemon_controller = true;
|
||||||
|
in.daemon_running = false;
|
||||||
|
in.daemon_crash_count = kNodeBannerCrashGiveUpCount;
|
||||||
|
in.daemon_last_error = "exit code 134";
|
||||||
|
in.connection_status = "Daemon crashed 3 times";
|
||||||
|
NodeBannerState s = evaluateNodeStatusBanner(in);
|
||||||
|
EXPECT_TRUE(s.show);
|
||||||
|
EXPECT_TRUE(s.severity == NodeBannerSeverity::Error);
|
||||||
|
EXPECT_TRUE(s.reason == NodeBannerReason::DaemonCrashed);
|
||||||
|
EXPECT_TRUE(s.action == NodeBannerAction::RestartNode);
|
||||||
|
EXPECT_EQ(s.detail, std::string("exit code 134"));
|
||||||
|
}
|
||||||
|
// Below the give-up threshold it's still just an offline/reconnect banner, not the crash one.
|
||||||
|
{
|
||||||
|
NodeBannerInputs in;
|
||||||
|
in.using_embedded_daemon = true;
|
||||||
|
in.has_daemon_controller = true;
|
||||||
|
in.daemon_running = false;
|
||||||
|
in.daemon_crash_count = kNodeBannerCrashGiveUpCount - 1;
|
||||||
|
NodeBannerState s = evaluateNodeStatusBanner(in);
|
||||||
|
EXPECT_TRUE(s.show);
|
||||||
|
EXPECT_TRUE(s.reason == NodeBannerReason::FullNodeOffline);
|
||||||
|
EXPECT_TRUE(s.action == NodeBannerAction::Reconnect);
|
||||||
|
}
|
||||||
|
// Lite: an open failure shows a red, action-less banner; no failure → nothing.
|
||||||
|
{
|
||||||
|
NodeBannerInputs in; in.lite = true; in.connected = false;
|
||||||
|
in.lite_open_error = "wallet.dat is corrupt";
|
||||||
|
NodeBannerState s = evaluateNodeStatusBanner(in);
|
||||||
|
EXPECT_TRUE(s.show);
|
||||||
|
EXPECT_TRUE(s.severity == NodeBannerSeverity::Error);
|
||||||
|
EXPECT_TRUE(s.reason == NodeBannerReason::LiteOpenFailed);
|
||||||
|
EXPECT_TRUE(s.action == NodeBannerAction::None);
|
||||||
|
EXPECT_EQ(s.detail, std::string("wallet.dat is corrupt"));
|
||||||
|
|
||||||
|
NodeBannerInputs clean; clean.lite = true; clean.connected = false; // no error yet
|
||||||
|
EXPECT_TRUE(!evaluateNodeStatusBanner(clean).show);
|
||||||
|
NodeBannerInputs open; open.lite = true; open.connected = true;
|
||||||
|
EXPECT_TRUE(!evaluateNodeStatusBanner(open).show);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void testStalenessBadge()
|
||||||
|
{
|
||||||
|
using namespace dragonx::ui;
|
||||||
|
const int64_t now = 1'000'000;
|
||||||
|
|
||||||
|
// Disconnected → banner's job, never a badge.
|
||||||
|
EXPECT_TRUE(!evaluateStalenessBadge(now - 999, now, /*connected=*/false).show);
|
||||||
|
// Never updated this session (0 stamp, e.g. fresh start / reset on disconnect) → nothing.
|
||||||
|
EXPECT_TRUE(!evaluateStalenessBadge(0, now, true).show);
|
||||||
|
// Fresh (just under the threshold) → no badge.
|
||||||
|
EXPECT_TRUE(!evaluateStalenessBadge(now - (kStaleAfterSeconds - 1), now, true).show);
|
||||||
|
// At the threshold → amber badge, age reported.
|
||||||
|
{
|
||||||
|
StalenessBadge b = evaluateStalenessBadge(now - kStaleAfterSeconds, now, true);
|
||||||
|
EXPECT_TRUE(b.show);
|
||||||
|
EXPECT_TRUE(b.severity == StalenessSeverity::Warning);
|
||||||
|
EXPECT_EQ(b.seconds_old, (int64_t)kStaleAfterSeconds);
|
||||||
|
}
|
||||||
|
// Past the very-stale threshold → red.
|
||||||
|
{
|
||||||
|
StalenessBadge b = evaluateStalenessBadge(now - kVeryStaleAfterSeconds, now, true);
|
||||||
|
EXPECT_TRUE(b.show);
|
||||||
|
EXPECT_TRUE(b.severity == StalenessSeverity::Error);
|
||||||
|
}
|
||||||
|
// Clock skew (future timestamp) is clamped to age 0 → no badge, no negative age.
|
||||||
|
{
|
||||||
|
StalenessBadge b = evaluateStalenessBadge(now + 100, now, true);
|
||||||
|
EXPECT_TRUE(!b.show);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void testNotificationHistory()
|
||||||
|
{
|
||||||
|
using dragonx::ui::Notifications;
|
||||||
|
using dragonx::ui::NotificationType;
|
||||||
|
auto& n = Notifications::instance();
|
||||||
|
n.clearHistory();
|
||||||
|
auto base = n.totalPushed(); // monotonic counter is NOT reset by clearHistory()
|
||||||
|
|
||||||
|
n.push("first", NotificationType::Info, 5.0f);
|
||||||
|
n.push("second", NotificationType::Error, 5.0f);
|
||||||
|
EXPECT_EQ((int)n.history().size(), 2);
|
||||||
|
EXPECT_TRUE(n.hasHistory());
|
||||||
|
// Oldest first, newest last; type + wall-clock stamp retained.
|
||||||
|
EXPECT_EQ(n.history().front().message, std::string("first"));
|
||||||
|
EXPECT_EQ(n.history().back().message, std::string("second"));
|
||||||
|
EXPECT_TRUE(n.history().back().type == NotificationType::Error);
|
||||||
|
EXPECT_TRUE(n.history().back().epoch > 0);
|
||||||
|
EXPECT_EQ((int)(n.totalPushed() - base), 2);
|
||||||
|
|
||||||
|
// Cap at 100: push past it → size caps, oldest entries drop, totalPushed keeps counting.
|
||||||
|
for (int i = 0; i < 150; ++i) n.push("bulk", NotificationType::Info, 5.0f);
|
||||||
|
EXPECT_EQ((int)n.history().size(), 100);
|
||||||
|
EXPECT_EQ((int)(n.totalPushed() - base), 152);
|
||||||
|
EXPECT_EQ(n.history().front().message, std::string("bulk")); // the two originals fell off
|
||||||
|
|
||||||
|
n.clearHistory();
|
||||||
|
EXPECT_TRUE(!n.hasHistory());
|
||||||
|
EXPECT_EQ((int)n.history().size(), 0);
|
||||||
|
EXPECT_EQ((int)(n.totalPushed() - base), 152); // clearing doesn't rewind the counter
|
||||||
|
}
|
||||||
|
|
||||||
|
void testSeedMigrationResume()
|
||||||
|
{
|
||||||
|
using dragonx::decideSeedMigrationResume;
|
||||||
|
using dragonx::MigrationResume;
|
||||||
|
|
||||||
|
// No pending migration (or missing dest) → start fresh at the intro.
|
||||||
|
EXPECT_TRUE(decideSeedMigrationResume(false, false, "", "", true) == MigrationResume::Intro);
|
||||||
|
EXPECT_TRUE(decideSeedMigrationResume(false, true, "tx", "op", true) == MigrationResume::Intro);
|
||||||
|
EXPECT_TRUE(decideSeedMigrationResume(true, false, "tx", "op", true) == MigrationResume::Intro);
|
||||||
|
|
||||||
|
// A persisted txid outranks everything → resume at the confirm/adopt gate (txid-first invariant).
|
||||||
|
EXPECT_TRUE(decideSeedMigrationResume(true, true, "tx", "", true) == MigrationResume::Confirming);
|
||||||
|
EXPECT_TRUE(decideSeedMigrationResume(true, true, "tx", "op", true) == MigrationResume::Confirming);
|
||||||
|
EXPECT_TRUE(decideSeedMigrationResume(true, true, "tx", "op", false) == MigrationResume::Confirming);
|
||||||
|
|
||||||
|
// Opid but no txid, AND connected → re-track the opid (recover the txid / detect stale).
|
||||||
|
EXPECT_TRUE(decideSeedMigrationResume(true, true, "", "op", true) == MigrationResume::RetrackOpid);
|
||||||
|
|
||||||
|
// W3-3 connectivity gate: opid but NOT connected → the dismissable Sweep gate, NOT the buttonless
|
||||||
|
// Sweeping spinner (whose only exit is the opid poller, which needs a connection). This is the
|
||||||
|
// trap the review caught.
|
||||||
|
EXPECT_TRUE(decideSeedMigrationResume(true, true, "", "op", false) == MigrationResume::SweepGate);
|
||||||
|
|
||||||
|
// No txid and no opid → the Sweep gate (whether or not connected).
|
||||||
|
EXPECT_TRUE(decideSeedMigrationResume(true, true, "", "", true) == MigrationResume::SweepGate);
|
||||||
|
EXPECT_TRUE(decideSeedMigrationResume(true, true, "", "", false) == MigrationResume::SweepGate);
|
||||||
|
}
|
||||||
|
|
||||||
|
void testLoggerFileSink()
|
||||||
|
{
|
||||||
|
using dragonx::util::Logger;
|
||||||
|
namespace fsn = std::filesystem;
|
||||||
|
fsn::path logPath = fsn::temp_directory_path() / "od_logger_test.log";
|
||||||
|
std::error_code ec;
|
||||||
|
fsn::remove(logPath, ec);
|
||||||
|
fsn::remove(logPath.string() + ".1", ec);
|
||||||
|
|
||||||
|
// W7-2: init() opens the file sink and must NOT deadlock — it writes the banner under the same
|
||||||
|
// non-recursive lock it holds (this test would hang if that regressed).
|
||||||
|
Logger& lg = Logger::instance();
|
||||||
|
EXPECT_TRUE(lg.init(logPath.string()));
|
||||||
|
lg.write("hello-w7-2-sink");
|
||||||
|
EXPECT_TRUE(fsn::exists(logPath));
|
||||||
|
|
||||||
|
std::ifstream f(logPath.string());
|
||||||
|
std::string all, line;
|
||||||
|
while (std::getline(f, line)) all += line + "\n";
|
||||||
|
f.close();
|
||||||
|
EXPECT_TRUE(all.find("hello-w7-2-sink") != std::string::npos);
|
||||||
|
EXPECT_TRUE(all.find("Logger initialized") != std::string::npos);
|
||||||
|
|
||||||
|
fsn::remove(logPath, ec);
|
||||||
|
fsn::remove(logPath.string() + ".1", ec);
|
||||||
|
}
|
||||||
|
|
||||||
|
void testConnectHasStalled()
|
||||||
|
{
|
||||||
|
using dragonx::util::connectHasStalled;
|
||||||
|
EXPECT_TRUE(connectHasStalled(100.0, 145.0, 45.0f)); // exactly at threshold
|
||||||
|
EXPECT_TRUE(connectHasStalled(100.0, 300.0, 45.0f)); // well over
|
||||||
|
EXPECT_TRUE(!connectHasStalled(100.0, 144.0, 45.0f)); // just under
|
||||||
|
EXPECT_TRUE(!connectHasStalled(0.0, 1000.0, 45.0f)); // sentinel: not stalling
|
||||||
|
EXPECT_TRUE(!connectHasStalled(-1.0, 1000.0, 45.0f)); // sentinel: not stalling
|
||||||
|
EXPECT_TRUE(!connectHasStalled(10.0, 20.0, 0.0f)); // disabled: threshold 0
|
||||||
|
EXPECT_TRUE(!connectHasStalled(10.0, 20.0, -5.0f)); // disabled: negative threshold
|
||||||
|
}
|
||||||
|
|
||||||
|
void testVerifySaplingParams()
|
||||||
|
{
|
||||||
|
using dragonx::rpc::Connection;
|
||||||
|
namespace fsn = std::filesystem;
|
||||||
|
|
||||||
|
fsn::path dir = fsn::temp_directory_path() / "od_sapling_test";
|
||||||
|
std::error_code rmec;
|
||||||
|
fsn::remove_all(dir, rmec);
|
||||||
|
fsn::create_directories(dir);
|
||||||
|
|
||||||
|
auto writeFile = [](const fsn::path& p, const std::string& content) {
|
||||||
|
std::ofstream(p.string(), std::ios::binary) << content;
|
||||||
|
};
|
||||||
|
const std::string spendContent = "fake-spend-params-contents";
|
||||||
|
const std::string outputContent = "fake-output-params-contents";
|
||||||
|
writeFile(dir / "sapling-spend.params", spendContent);
|
||||||
|
writeFile(dir / "sapling-output.params", outputContent);
|
||||||
|
|
||||||
|
const std::string spendHash = dragonx::util::sha256Hex(spendContent.data(), spendContent.size());
|
||||||
|
const std::string outputHash = dragonx::util::sha256Hex(outputContent.data(), outputContent.size());
|
||||||
|
const std::vector<std::pair<std::string, std::string>> good = {
|
||||||
|
{ "sapling-spend.params", spendHash },
|
||||||
|
{ "sapling-output.params", outputHash },
|
||||||
|
};
|
||||||
|
|
||||||
|
// Valid params → pass, and a verification marker is written.
|
||||||
|
EXPECT_TRUE(Connection::verifySaplingParamsIn(dir.string(), good));
|
||||||
|
EXPECT_TRUE(fsn::exists(dir / ".sapling_verified"));
|
||||||
|
|
||||||
|
// Second call → marker fast-path, still true (round-trips the cache).
|
||||||
|
EXPECT_TRUE(Connection::verifySaplingParamsIn(dir.string(), good));
|
||||||
|
|
||||||
|
// Wrong expected hash → integrity failure (fresh dir so no marker can short-circuit it).
|
||||||
|
fsn::path dir2 = fsn::temp_directory_path() / "od_sapling_test2";
|
||||||
|
fsn::remove_all(dir2, rmec);
|
||||||
|
fsn::create_directories(dir2);
|
||||||
|
writeFile(dir2 / "sapling-spend.params", spendContent);
|
||||||
|
writeFile(dir2 / "sapling-output.params", outputContent);
|
||||||
|
const std::vector<std::pair<std::string, std::string>> wrong = {
|
||||||
|
{ "sapling-spend.params", std::string(64, 'a') },
|
||||||
|
{ "sapling-output.params", outputHash },
|
||||||
|
};
|
||||||
|
EXPECT_TRUE(!Connection::verifySaplingParamsIn(dir2.string(), wrong));
|
||||||
|
|
||||||
|
// Truncated content (size change) invalidates the marker AND fails the hash.
|
||||||
|
writeFile(dir / "sapling-spend.params", std::string("x"));
|
||||||
|
EXPECT_TRUE(!Connection::verifySaplingParamsIn(dir.string(), good));
|
||||||
|
|
||||||
|
// A missing param → fail.
|
||||||
|
fsn::remove(dir / "sapling-output.params", rmec);
|
||||||
|
EXPECT_TRUE(!Connection::verifySaplingParamsIn(dir.string(), good));
|
||||||
|
|
||||||
|
fsn::remove_all(dir, rmec);
|
||||||
|
fsn::remove_all(dir2, rmec);
|
||||||
|
}
|
||||||
|
|
||||||
|
void testPlatformEnsureDirectory()
|
||||||
|
{
|
||||||
|
using dragonx::util::Platform;
|
||||||
|
|
||||||
|
// An existing directory → true (temp_directory_path always exists).
|
||||||
|
{
|
||||||
|
std::string err = "sentinel";
|
||||||
|
EXPECT_TRUE(Platform::ensureDirectory(std::filesystem::temp_directory_path().string(), &err));
|
||||||
|
}
|
||||||
|
|
||||||
|
// A fresh nested path → created, no error.
|
||||||
|
{
|
||||||
|
std::filesystem::path base = std::filesystem::temp_directory_path() / "od_ensuredir_test";
|
||||||
|
std::error_code rmec; std::filesystem::remove_all(base, rmec);
|
||||||
|
std::filesystem::path nested = base / "a" / "b" / "c";
|
||||||
|
std::string err;
|
||||||
|
EXPECT_TRUE(Platform::ensureDirectory(nested.string(), &err));
|
||||||
|
EXPECT_TRUE(std::filesystem::is_directory(nested));
|
||||||
|
EXPECT_TRUE(err.empty());
|
||||||
|
std::filesystem::remove_all(base, rmec);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Empty path → false with a message.
|
||||||
|
{
|
||||||
|
std::string err;
|
||||||
|
EXPECT_TRUE(!Platform::ensureDirectory("", &err));
|
||||||
|
EXPECT_TRUE(!err.empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
// A path whose parent component is a regular file cannot be created. This fails the
|
||||||
|
// same way for root and non-root, so it's a stable negative case across environments.
|
||||||
|
{
|
||||||
|
std::filesystem::path f = std::filesystem::temp_directory_path() / "od_ensuredir_file";
|
||||||
|
std::error_code rmec; std::filesystem::remove_all(f, rmec);
|
||||||
|
{ std::ofstream(f.string()) << "x"; }
|
||||||
|
std::string err;
|
||||||
|
bool ok = Platform::ensureDirectory((f / "child").string(), &err);
|
||||||
|
std::filesystem::remove_all(f, rmec);
|
||||||
|
EXPECT_TRUE(!ok);
|
||||||
|
EXPECT_TRUE(err.find("Cannot create") != std::string::npos);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#ifndef _WIN32
|
||||||
|
// Integration tests that drive the REAL EmbeddedDaemon fork/exec/waitpid paths (POSIX only).
|
||||||
|
void testExecFailureReported()
|
||||||
|
{
|
||||||
|
using dragonx::daemon::EmbeddedDaemon;
|
||||||
|
namespace fsn = std::filesystem;
|
||||||
|
|
||||||
|
// A present-but-non-executable file: execv() must fail, and the F2 self-pipe handshake
|
||||||
|
// must report it as a start FAILURE with a precise reason — not a transient "Running".
|
||||||
|
fsn::path bin = fsn::temp_directory_path() / "od_fake_daemon_bin";
|
||||||
|
{ std::ofstream(bin.string(), std::ios::binary) << "this is not an executable"; }
|
||||||
|
fsn::permissions(bin, fsn::perms::owner_read, fsn::perm_options::replace); // 0400, no +x
|
||||||
|
|
||||||
|
EmbeddedDaemon d;
|
||||||
|
d.setSkipPortCheck(true); // bypass the port + datadir-lock gates so we reach startProcess()
|
||||||
|
EXPECT_TRUE(!d.start(bin.string()));
|
||||||
|
EXPECT_TRUE(d.getLastError().find("not executable or wrong architecture") != std::string::npos);
|
||||||
|
EXPECT_TRUE(!d.isRunning());
|
||||||
|
|
||||||
|
std::error_code ec; fsn::remove(bin, ec);
|
||||||
|
}
|
||||||
|
|
||||||
|
void testDaemonCrashDetected()
|
||||||
|
{
|
||||||
|
using dragonx::daemon::EmbeddedDaemon;
|
||||||
|
namespace fsn = std::filesystem;
|
||||||
|
|
||||||
|
// A tiny script that ignores the injected daemon args, lives briefly, then exits abnormally
|
||||||
|
// — standing in for a daemon that crashes. is_script detection runs it via /bin/bash.
|
||||||
|
fsn::path script = fsn::temp_directory_path() / "od_fake_daemon.sh";
|
||||||
|
{ std::ofstream(script.string()) << "#!/bin/bash\nsleep 0.2\nexit 7\n"; }
|
||||||
|
fsn::permissions(script, fsn::perms::owner_all, fsn::perm_options::replace); // +x
|
||||||
|
|
||||||
|
EmbeddedDaemon d;
|
||||||
|
d.setSkipPortCheck(true);
|
||||||
|
EXPECT_TRUE(d.start(script.string()));
|
||||||
|
EXPECT_TRUE(d.isRunning()); // reads the atomic state_, not a racy waitpid()
|
||||||
|
|
||||||
|
// Hammer isRunning() the way the UI thread does while the child exits and monitorProcess()
|
||||||
|
// reaps it. Pre-fix (F1), isRunning()'s own waitpid() could steal the reap and hide the
|
||||||
|
// crash; with the fix the monitor is the sole reaper and always sees it.
|
||||||
|
for (int i = 0; i < 400 && d.getCrashCount() == 0; ++i) {
|
||||||
|
(void)d.isRunning();
|
||||||
|
std::this_thread::sleep_for(std::chrono::milliseconds(5));
|
||||||
|
}
|
||||||
|
EXPECT_TRUE(d.getCrashCount() >= 1); // the unexpected exit was detected and counted
|
||||||
|
EXPECT_TRUE(!d.isRunning()); // state_ flipped to Error
|
||||||
|
|
||||||
|
d.stop(); // join the monitor thread cleanly
|
||||||
|
std::error_code ec; fsn::remove(script, ec);
|
||||||
|
}
|
||||||
|
#endif // !_WIN32
|
||||||
|
|
||||||
|
void testDatadirLockGate()
|
||||||
|
{
|
||||||
|
using dragonx::daemon::EmbeddedDaemon;
|
||||||
|
|
||||||
|
// Normal start, no lingering daemon after the bounded wait → proceed.
|
||||||
|
auto clear = EmbeddedDaemon::evaluateDatadirLockGate(false, false, false);
|
||||||
|
EXPECT_TRUE(clear.proceed);
|
||||||
|
|
||||||
|
// A previous dragonxd still alive after the wait → bail with a distinct, non-crash msg.
|
||||||
|
auto locked = EmbeddedDaemon::evaluateDatadirLockGate(false, false, true);
|
||||||
|
EXPECT_TRUE(!locked.proceed);
|
||||||
|
EXPECT_TRUE(std::string(locked.errorMessage).find("data directory lock") != std::string::npos);
|
||||||
|
|
||||||
|
// Isolated instance via skip_port_check_ is exempt even if a sibling dragonxd is running.
|
||||||
|
auto skipPort = EmbeddedDaemon::evaluateDatadirLockGate(true, false, true);
|
||||||
|
EXPECT_TRUE(skipPort.proceed);
|
||||||
|
|
||||||
|
// Isolated instance via -datadir override is exempt even if a sibling is running.
|
||||||
|
auto isolated = EmbeddedDaemon::evaluateDatadirLockGate(false, true, true);
|
||||||
|
EXPECT_TRUE(isolated.proceed);
|
||||||
|
}
|
||||||
|
|
||||||
void testDaemonLifecycleExecution()
|
void testDaemonLifecycleExecution()
|
||||||
{
|
{
|
||||||
using dragonx::daemon::DaemonController;
|
using dragonx::daemon::DaemonController;
|
||||||
@@ -3231,6 +3735,19 @@ void testRendererHelpers()
|
|||||||
EXPECT_EQ(dragonx::ui::defaultPoolWorkerAddress(poolAddresses), std::string("zs-default-worker"));
|
EXPECT_EQ(dragonx::ui::defaultPoolWorkerAddress(poolAddresses), std::string("zs-default-worker"));
|
||||||
EXPECT_TRUE(dragonx::ui::miningValueAlreadySaved({"pool-a", "pool-b"}, "pool-b"));
|
EXPECT_TRUE(dragonx::ui::miningValueAlreadySaved({"pool-a", "pool-b"}, "pool-b"));
|
||||||
EXPECT_FALSE(dragonx::ui::miningValueAlreadySaved({"pool-a"}, ""));
|
EXPECT_FALSE(dragonx::ui::miningValueAlreadySaved({"pool-a"}, ""));
|
||||||
|
|
||||||
|
// resolveMiningUserAddress: the configured payout address is the xmrig "user"
|
||||||
|
// (where rewards go) and must win over the wallet's own addresses.
|
||||||
|
EXPECT_EQ(dragonx::ui::resolveMiningUserAddress("zs-payout", "zs-own", "R-own"),
|
||||||
|
std::string("zs-payout")); // explicit payout wins
|
||||||
|
EXPECT_EQ(dragonx::ui::resolveMiningUserAddress("", "zs-own", "R-own"),
|
||||||
|
std::string("zs-own")); // unset -> own shielded
|
||||||
|
EXPECT_EQ(dragonx::ui::resolveMiningUserAddress("x", "zs-own", "R-own"),
|
||||||
|
std::string("zs-own")); // "x" placeholder counts as unset
|
||||||
|
EXPECT_EQ(dragonx::ui::resolveMiningUserAddress("x", "", "R-own"),
|
||||||
|
std::string("R-own")); // no shielded -> transparent
|
||||||
|
EXPECT_EQ(dragonx::ui::resolveMiningUserAddress("", "", ""),
|
||||||
|
std::string("")); // nothing anywhere -> caller errors
|
||||||
EXPECT_EQ(std::string(dragonx::ui::defaultPoolUrl()), std::string("pool.dragonx.is:3433"));
|
EXPECT_EQ(std::string(dragonx::ui::defaultPoolUrl()), std::string("pool.dragonx.is:3433"));
|
||||||
|
|
||||||
dragonx::TransactionInfo tx;
|
dragonx::TransactionInfo tx;
|
||||||
@@ -4132,7 +4649,6 @@ void testLiteWalletControllerLifecycle()
|
|||||||
EXPECT_FALSE(controller.walletOpen());
|
EXPECT_FALSE(controller.walletOpen());
|
||||||
|
|
||||||
LiteWalletCreateRequest req;
|
LiteWalletCreateRequest req;
|
||||||
req.passphrase = "hunter2";
|
|
||||||
const auto result = controller.createWallet(req);
|
const auto result = controller.createWallet(req);
|
||||||
EXPECT_TRUE(result.ok);
|
EXPECT_TRUE(result.ok);
|
||||||
EXPECT_TRUE(result.walletReady);
|
EXPECT_TRUE(result.walletReady);
|
||||||
@@ -4149,7 +4665,6 @@ void testLiteWalletControllerLifecycle()
|
|||||||
dragonx::test::g_liteFakeWalletExists = true;
|
dragonx::test::g_liteFakeWalletExists = true;
|
||||||
LiteWalletController controller(liteCaps, conn, LiteClientBridge::fromApi(dragonx::test::makeFakeLiteApi()));
|
LiteWalletController controller(liteCaps, conn, LiteClientBridge::fromApi(dragonx::test::makeFakeLiteApi()));
|
||||||
LiteWalletOpenRequest req;
|
LiteWalletOpenRequest req;
|
||||||
req.passphrase = "hunter2";
|
|
||||||
const auto result = controller.openWallet(req);
|
const auto result = controller.openWallet(req);
|
||||||
EXPECT_TRUE(result.ok);
|
EXPECT_TRUE(result.ok);
|
||||||
EXPECT_TRUE(result.walletReady);
|
EXPECT_TRUE(result.walletReady);
|
||||||
@@ -4224,7 +4739,6 @@ void testLiteWalletControllerM4()
|
|||||||
auto c = std::make_unique<LiteWalletController>(
|
auto c = std::make_unique<LiteWalletController>(
|
||||||
liteCaps, conn, LiteClientBridge::fromApi(dragonx::test::makeFakeLiteApi()));
|
liteCaps, conn, LiteClientBridge::fromApi(dragonx::test::makeFakeLiteApi()));
|
||||||
LiteWalletCreateRequest req;
|
LiteWalletCreateRequest req;
|
||||||
req.passphrase = "hunter2";
|
|
||||||
(void)c->createWallet(req);
|
(void)c->createWallet(req);
|
||||||
return c;
|
return c;
|
||||||
};
|
};
|
||||||
@@ -4352,7 +4866,6 @@ void testLiteWalletControllerM5Persistence()
|
|||||||
auto c = std::make_unique<LiteWalletController>(
|
auto c = std::make_unique<LiteWalletController>(
|
||||||
liteCaps, conn, LiteClientBridge::fromApi(dragonx::test::makeFakeLiteApi()));
|
liteCaps, conn, LiteClientBridge::fromApi(dragonx::test::makeFakeLiteApi()));
|
||||||
LiteWalletCreateRequest req;
|
LiteWalletCreateRequest req;
|
||||||
req.passphrase = "hunter2";
|
|
||||||
(void)c->createWallet(req);
|
(void)c->createWallet(req);
|
||||||
return c;
|
return c;
|
||||||
};
|
};
|
||||||
@@ -4434,7 +4947,6 @@ void testLiteWalletControllerEncryption()
|
|||||||
auto c = std::make_unique<LiteWalletController>(
|
auto c = std::make_unique<LiteWalletController>(
|
||||||
liteCaps, conn, LiteClientBridge::fromApi(dragonx::test::makeFakeLiteApi()));
|
liteCaps, conn, LiteClientBridge::fromApi(dragonx::test::makeFakeLiteApi()));
|
||||||
LiteWalletCreateRequest req;
|
LiteWalletCreateRequest req;
|
||||||
req.passphrase = "hunter2";
|
|
||||||
(void)c->createWallet(req);
|
(void)c->createWallet(req);
|
||||||
return c;
|
return c;
|
||||||
};
|
};
|
||||||
@@ -4665,6 +5177,33 @@ void testLiteWalletControllerConsoleCommand()
|
|||||||
// Async FULL lifecycle (Settings-page create/open/restore WITH passphrase/restore params) also
|
// Async FULL lifecycle (Settings-page create/open/restore WITH passphrase/restore params) also
|
||||||
// fails over: the request runs off the UI thread against the preferred server, then the other
|
// fails over: the request runs off the UI thread against the preferred server, then the other
|
||||||
// usable defaults, finalized by pumpLifecycleResult() on the main thread.
|
// usable defaults, finalized by pumpLifecycleResult() on the main thread.
|
||||||
|
// W5-3: a create-time passphrase now actually encrypts (and locks) the new lite wallet, and it
|
||||||
|
// unlocks with the same passphrase — previously the field was collected but ignored.
|
||||||
|
void testLiteWalletControllerCreateEncryptsWithPassphrase()
|
||||||
|
{
|
||||||
|
using namespace dragonx::wallet;
|
||||||
|
const auto liteCaps = makeWalletCapabilities(WalletBuildKind::Lite, false, true);
|
||||||
|
const LiteConnectionSettings conn = defaultLiteConnectionSettings();
|
||||||
|
|
||||||
|
dragonx::test::g_liteFakeEncrypted = false;
|
||||||
|
dragonx::test::g_liteFakeLocked = false;
|
||||||
|
auto c = std::make_unique<LiteWalletController>(
|
||||||
|
liteCaps, conn, LiteClientBridge::fromApi(dragonx::test::makeFakeLiteApi()));
|
||||||
|
|
||||||
|
LiteWalletCreateRequest req;
|
||||||
|
req.passphrase = "hunter2";
|
||||||
|
(void)c->createWallet(req);
|
||||||
|
|
||||||
|
const auto s = c->encryptionStatus();
|
||||||
|
EXPECT_TRUE(s.ok);
|
||||||
|
EXPECT_TRUE(s.encrypted); // the create-time passphrase encrypted the new wallet
|
||||||
|
EXPECT_TRUE(s.locked); // encrypt locks immediately
|
||||||
|
|
||||||
|
EXPECT_TRUE(c->unlockWallet("hunter2"));
|
||||||
|
const auto s2 = c->encryptionStatus();
|
||||||
|
EXPECT_FALSE(s2.locked);
|
||||||
|
}
|
||||||
|
|
||||||
void testLiteWalletControllerAsyncLifecycleFailover()
|
void testLiteWalletControllerAsyncLifecycleFailover()
|
||||||
{
|
{
|
||||||
using namespace dragonx::wallet;
|
using namespace dragonx::wallet;
|
||||||
@@ -4693,7 +5232,6 @@ void testLiteWalletControllerAsyncLifecycleFailover()
|
|||||||
LiteWalletController controller(liteCaps, conn,
|
LiteWalletController controller(liteCaps, conn,
|
||||||
LiteClientBridge::fromApi(dragonx::test::makeFakeLiteApi()));
|
LiteClientBridge::fromApi(dragonx::test::makeFakeLiteApi()));
|
||||||
LiteWalletCreateRequest req;
|
LiteWalletCreateRequest req;
|
||||||
req.passphrase = "hunter2";
|
|
||||||
EXPECT_TRUE(controller.beginCreateWalletAsync(req));
|
EXPECT_TRUE(controller.beginCreateWalletAsync(req));
|
||||||
drain(controller);
|
drain(controller);
|
||||||
EXPECT_TRUE(controller.walletOpen());
|
EXPECT_TRUE(controller.walletOpen());
|
||||||
@@ -5466,6 +6004,188 @@ void testAddressChecksumValidation()
|
|||||||
EXPECT_FALSE(isValidBech32("abc1rzg")); // too short / bad checksum
|
EXPECT_FALSE(isValidBech32("abc1rzg")); // too short / bad checksum
|
||||||
EXPECT_FALSE(isValidBech32("Abcdef1qpzry9x8gf2tvdw0s3jn54khce6mua7lmqqqxw")); // mixed case
|
EXPECT_FALSE(isValidBech32("Abcdef1qpzry9x8gf2tvdw0s3jn54khce6mua7lmqqqxw")); // mixed case
|
||||||
EXPECT_FALSE(isValidBech32("nosalt")); // no separator
|
EXPECT_FALSE(isValidBech32("nosalt")); // no separator
|
||||||
|
|
||||||
|
// decodeBase58Check exposes the checksum-stripped payload so callers can inspect version/length.
|
||||||
|
using dragonx::util::decodeBase58Check;
|
||||||
|
std::vector<std::uint8_t> payload;
|
||||||
|
EXPECT_TRUE(decodeBase58Check("1A1zP1eP5QGefi2DMPTfTL5SLmv7DivfNa", payload));
|
||||||
|
EXPECT_EQ(payload.size(), (size_t)21); // version(1) + 20-byte hash160, checksum stripped
|
||||||
|
EXPECT_EQ((int)payload[0], 0); // mainnet P2PKH version byte
|
||||||
|
EXPECT_FALSE(decodeBase58Check("1A1zP1eP5QGefi2DMPTfTL5SLmv7Divfna", payload)); // bad checksum
|
||||||
|
|
||||||
|
// bech32Hrp returns the (lower-cased) HRP of a valid string, or "" when invalid.
|
||||||
|
using dragonx::util::bech32Hrp;
|
||||||
|
EXPECT_EQ(bech32Hrp("abcdef1qpzry9x8gf2tvdw0s3jn54khce6mua7lmqqqxw"), std::string("abcdef"));
|
||||||
|
EXPECT_EQ(bech32Hrp("A12UEL5L"), std::string("a")); // lower-cased
|
||||||
|
EXPECT_EQ(bech32Hrp("A12UEL5M"), std::string("")); // invalid → empty
|
||||||
|
|
||||||
|
// Address type recognizers: accept every real DragonX recipient form, reject non-addresses.
|
||||||
|
using dragonx::util::isTransparentAddress;
|
||||||
|
using dragonx::util::isShieldedAddress;
|
||||||
|
using dragonx::util::isValidRecipientAddress;
|
||||||
|
const std::string p2pkh = "R9NXAVJezHiBnT3ijTpg3JUZre7PxhJWti"; // v60
|
||||||
|
const std::string p2sh = "bCpbnCkrjoJ6EHXtLx9eASHEbFYyikt35C"; // v85 multisig — the regression
|
||||||
|
const std::string zaddr = "zs1qqqsyqcyq5rqwzqfpg9scrgwpugpzysnzs23v9ccrydpk8qarc0jqgfzyvjz2f389q5j5ctfvp5";
|
||||||
|
EXPECT_TRUE(isTransparentAddress(p2pkh));
|
||||||
|
EXPECT_TRUE(isTransparentAddress(p2sh)); // was silently dropped by the old 'R'-only filter
|
||||||
|
EXPECT_FALSE(isShieldedAddress(p2pkh));
|
||||||
|
EXPECT_TRUE(isShieldedAddress(zaddr));
|
||||||
|
EXPECT_FALSE(isTransparentAddress(zaddr));
|
||||||
|
EXPECT_TRUE(isValidRecipientAddress(p2pkh));
|
||||||
|
EXPECT_TRUE(isValidRecipientAddress(p2sh));
|
||||||
|
EXPECT_TRUE(isValidRecipientAddress(zaddr));
|
||||||
|
// A WIF spending key is NOT a recipient (33/34-byte payload, not 21); nor is a typo'd address.
|
||||||
|
EXPECT_FALSE(isTransparentAddress("Up3W7uVYkLxCfH91APxjSpkkGBWJyBrm3tt1bCz64V5fpZK9ef3C"));
|
||||||
|
EXPECT_FALSE(isValidRecipientAddress("R9NXAVJezHiBnT3ijTpg3JUZre7PxhJWtX")); // flipped checksum char
|
||||||
|
EXPECT_FALSE(isValidRecipientAddress(""));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Import-key recognition: the client gate must accept every real DragonX key form and reject
|
||||||
|
// non-keys, so it never blocks a valid import with "Unrecognized key format" (audit F1/F2/F3).
|
||||||
|
void testPrivateKeyImportRecognition()
|
||||||
|
{
|
||||||
|
using dragonx::services::WalletSecurityController;
|
||||||
|
using KeyKind = WalletSecurityController::KeyKind;
|
||||||
|
|
||||||
|
// --- Transparent WIF (DragonX SECRET_KEY version 188; testnet 128) ---
|
||||||
|
const std::string wifCompressed = "Up3W7uVYkLxCfH91APxjSpkkGBWJyBrm3tt1bCz64V5fpZK9ef3C"; // v188 compressed 'U'
|
||||||
|
const std::string wifUncompressed = "7JTPumX2kofLQdHKANy8MMLRkmXCmuJcosiv9f4RFqW9oCJXBHD"; // v188 uncompressed '7'
|
||||||
|
const std::string wifTestnet = "KwFfpDsaF7yxCELuyrH9gP5XL7TAt5b9HPWC1xCQbmrxvhJgMQHb"; // v128 compressed
|
||||||
|
|
||||||
|
EXPECT_TRUE(WalletSecurityController::isRecognizedPrivateKey(wifCompressed));
|
||||||
|
EXPECT_TRUE(WalletSecurityController::isRecognizedPrivateKey(wifUncompressed)); // F2 regression: '7' was rejected
|
||||||
|
EXPECT_TRUE(WalletSecurityController::isRecognizedPrivateKey(wifTestnet));
|
||||||
|
EXPECT_TRUE(WalletSecurityController::isRecognizedImportKey(wifUncompressed));
|
||||||
|
EXPECT_EQ(WalletSecurityController::classifyPrivateKey(wifUncompressed), KeyKind::Transparent);
|
||||||
|
EXPECT_FALSE(WalletSecurityController::isViewingKey(wifCompressed));
|
||||||
|
|
||||||
|
// A corrupted WIF (flipped last char) fails the checksum → caught locally, not at the daemon.
|
||||||
|
std::string wifBad = wifCompressed;
|
||||||
|
wifBad.back() = (wifBad.back() == 'C' ? 'D' : 'C');
|
||||||
|
EXPECT_FALSE(WalletSecurityController::isRecognizedPrivateKey(wifBad));
|
||||||
|
|
||||||
|
// A transparent R-address is Base58Check-valid but NOT a key (21-byte payload, not 33/34).
|
||||||
|
EXPECT_FALSE(WalletSecurityController::isRecognizedPrivateKey("R9NXAVJezHiBnT3ijTpg3JUZre7PxhJWti"));
|
||||||
|
|
||||||
|
// --- Sapling incoming viewing key (mainnet HRP "zivks") — the F1 regression ---
|
||||||
|
const std::string ivk = "zivks1qypqxpq9qcrsszg2pvxq6rs0zqg3yyc5z5tpwxqergd3c8g7rusq45amw7";
|
||||||
|
EXPECT_TRUE(WalletSecurityController::isViewingKey(ivk));
|
||||||
|
EXPECT_TRUE(WalletSecurityController::isRecognizedImportKey(ivk));
|
||||||
|
EXPECT_FALSE(WalletSecurityController::isRecognizedPrivateKey(ivk)); // a viewing key can't spend
|
||||||
|
// The stale Zcash "zxview…" prefix is NOT a DragonX viewing key (and not valid Bech32 here).
|
||||||
|
EXPECT_FALSE(WalletSecurityController::isViewingKey("zxviews1abcdef"));
|
||||||
|
|
||||||
|
// --- Sapling z spending key (recognized by HRP prefix; daemon vets the long payload) ---
|
||||||
|
const std::string zspend = "secret-extended-key-main1qxxxxxxxxxxxxxxxxxxxx";
|
||||||
|
EXPECT_TRUE(WalletSecurityController::isRecognizedPrivateKey(zspend));
|
||||||
|
EXPECT_TRUE(WalletSecurityController::isRecognizedImportKey(zspend));
|
||||||
|
EXPECT_EQ(WalletSecurityController::classifyPrivateKey(zspend), KeyKind::Shielded);
|
||||||
|
|
||||||
|
// --- Garbage / empty ---
|
||||||
|
EXPECT_FALSE(WalletSecurityController::isRecognizedImportKey(""));
|
||||||
|
EXPECT_FALSE(WalletSecurityController::isRecognizedImportKey("hello world"));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Seed-phrase normalization + word count + the 24-word completeness gate. Guards the lite-restore
|
||||||
|
// crash fix (only 24-word/32-byte-entropy seeds are safe for the SDXL backend) and the NBSP-paste
|
||||||
|
// recovery fix. Both restore gates (first-run wizard + Settings) route through these.
|
||||||
|
void testSeedPhraseHelpers()
|
||||||
|
{
|
||||||
|
using dragonx::util::normalizeSeedPhrase;
|
||||||
|
using dragonx::util::seedPhraseWordCount;
|
||||||
|
using dragonx::util::isCompleteRecoveryPhrase;
|
||||||
|
|
||||||
|
// --- completeness gate: 24 words only (12/15/18/21 valid-BIP39 lengths crash the backend) ---
|
||||||
|
EXPECT_TRUE(isCompleteRecoveryPhrase(24));
|
||||||
|
EXPECT_FALSE(isCompleteRecoveryPhrase(12));
|
||||||
|
EXPECT_FALSE(isCompleteRecoveryPhrase(15));
|
||||||
|
EXPECT_FALSE(isCompleteRecoveryPhrase(21));
|
||||||
|
EXPECT_FALSE(isCompleteRecoveryPhrase(23));
|
||||||
|
EXPECT_FALSE(isCompleteRecoveryPhrase(25));
|
||||||
|
EXPECT_FALSE(isCompleteRecoveryPhrase(0));
|
||||||
|
|
||||||
|
// --- plain ASCII: trim, collapse runs, count exactly; the common case must be untouched otherwise ---
|
||||||
|
EXPECT_EQ(normalizeSeedPhrase(" alpha beta\tgamma\ndelta "), std::string("alpha beta gamma delta"));
|
||||||
|
EXPECT_EQ(seedPhraseWordCount(normalizeSeedPhrase("alpha beta gamma")), 3);
|
||||||
|
EXPECT_EQ(seedPhraseWordCount(""), 0);
|
||||||
|
EXPECT_EQ(seedPhraseWordCount(normalizeSeedPhrase(" \t \n ")), 0); // whitespace-only
|
||||||
|
|
||||||
|
// --- NBSP (U+00A0 = 0xC2 0xA0) between words must fold to a real space, not glue the words ---
|
||||||
|
EXPECT_EQ(normalizeSeedPhrase("alpha\xC2\xA0" "beta"), std::string("alpha beta"));
|
||||||
|
EXPECT_EQ(seedPhraseWordCount(normalizeSeedPhrase("alpha\xC2\xA0" "beta")), 2);
|
||||||
|
// Other Unicode spaces: en space U+2002, ideographic U+3000, narrow NBSP U+202F.
|
||||||
|
EXPECT_EQ(normalizeSeedPhrase("a\xE2\x80\x82" "b\xE3\x80\x80" "c\xE2\x80\xAF" "d"), std::string("a b c d"));
|
||||||
|
// Zero-width chars (U+200B, U+FEFF BOM) are stripped, not treated as separators.
|
||||||
|
EXPECT_EQ(normalizeSeedPhrase("\xEF\xBB\xBF" "alpha\xE2\x80\x8B beta"), std::string("alpha beta"));
|
||||||
|
|
||||||
|
// --- a full 24-word phrase pasted with NBSP separators counts as 24 (regression for the fix) ---
|
||||||
|
std::string words24;
|
||||||
|
for (int i = 0; i < 24; ++i) { if (i) words24 += "\xC2\xA0"; words24 += "word"; }
|
||||||
|
EXPECT_EQ(seedPhraseWordCount(normalizeSeedPhrase(words24)), 24);
|
||||||
|
EXPECT_TRUE(isCompleteRecoveryPhrase(seedPhraseWordCount(normalizeSeedPhrase(words24))));
|
||||||
|
// The normalized form is plain single-space separated (what the backend's split(" ") needs).
|
||||||
|
EXPECT_EQ(normalizeSeedPhrase(words24).find("\xC2\xA0"), std::string::npos);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Block-DB abort detection: classify a crashed daemon's console output so the app can offer a
|
||||||
|
// one-click reindex instead of silently showing a zero balance (a daemon-vs-chaindata format break).
|
||||||
|
void testBlockDbOutputDiagnosis()
|
||||||
|
{
|
||||||
|
using dragonx::daemon::blockDbOutputLooksBroken;
|
||||||
|
|
||||||
|
// The exact abort sequence we observed on a format-mismatched datadir.
|
||||||
|
EXPECT_TRUE(blockDbOutputLooksBroken(
|
||||||
|
"Opened LevelDB successfully\n"
|
||||||
|
"GetValue: CDataStream error - non-canonical optional discriminant: iostream error\n"
|
||||||
|
"ERROR: LoadBlockIndex() : failed to read value\n"
|
||||||
|
": Error loading block database.\n"
|
||||||
|
"Aborted block database rebuild. Exiting.\n"));
|
||||||
|
// Each individual fatal marker also trips it (partial capture / different phrasing).
|
||||||
|
EXPECT_TRUE(blockDbOutputLooksBroken("... : Error loading block database."));
|
||||||
|
EXPECT_TRUE(blockDbOutputLooksBroken("Aborted block database rebuild. Exiting."));
|
||||||
|
EXPECT_TRUE(blockDbOutputLooksBroken("ERROR: LoadBlockIndex() : failed to read value"));
|
||||||
|
|
||||||
|
// Normal startup / other failures must NOT be misread as a block-DB problem (no false reindex offer).
|
||||||
|
EXPECT_FALSE(blockDbOutputLooksBroken(
|
||||||
|
"Loading block index...\nVerifying blocks...\nLoading wallet...\nRescanning...\nDone loading\n"));
|
||||||
|
EXPECT_FALSE(blockDbOutputLooksBroken("Error loading wallet")); // wallet corruption → salvage, not reindex
|
||||||
|
EXPECT_FALSE(blockDbOutputLooksBroken("Error: Could not find any asmap file!"));
|
||||||
|
EXPECT_FALSE(blockDbOutputLooksBroken(""));
|
||||||
|
|
||||||
|
// Wallet auto-recovery detection: the node salvaged wallet.dat (moved the original to a .bak).
|
||||||
|
using dragonx::daemon::walletAutoRecovered;
|
||||||
|
EXPECT_TRUE(walletAutoRecovered(
|
||||||
|
"Warning: wallet.dat corrupt, data salvaged! Original wallet.dat saved as wallet.1786300000.bak in ..."));
|
||||||
|
EXPECT_TRUE(walletAutoRecovered("wallet.dat corrupt, salvage failed"));
|
||||||
|
// The FAILED-salvage sequence a BDB-inconsistent wallet actually produces (must also be detected —
|
||||||
|
// this is the case that previously slipped through and silently emptied the wallet).
|
||||||
|
EXPECT_TRUE(walletAutoRecovered(
|
||||||
|
"Renamed wallet.dat to wallet.1786375505.bak\n"
|
||||||
|
"CDBEnv::Salvage: Database salvage found errors, all data may not be recoverable.\n"
|
||||||
|
"Salvage(aggressive) found no records in wallet.1786375505.bak.\n"));
|
||||||
|
EXPECT_FALSE(walletAutoRecovered("Loading wallet...\nWallet completed loading\n")); // normal load
|
||||||
|
EXPECT_FALSE(walletAutoRecovered(": Error loading block database.")); // block-DB abort != salvage
|
||||||
|
EXPECT_FALSE(walletAutoRecovered(""));
|
||||||
|
|
||||||
|
// Newest salvage backup picker (the "wallet.<unixtime>.bak" the recovery just made).
|
||||||
|
using dragonx::daemon::newestWalletSalvageBak;
|
||||||
|
EXPECT_EQ(newestWalletSalvageBak({"wallet.dat", "wallet.1786200000.bak", "wallet.1786300000.bak", "peers.dat"}),
|
||||||
|
std::string("wallet.1786300000.bak")); // highest timestamp wins
|
||||||
|
EXPECT_EQ(newestWalletSalvageBak({"wallet.dat", "wallet.dat.encrypted.bak", "notes.txt"}),
|
||||||
|
std::string("")); // no wallet.<digits>.bak present
|
||||||
|
EXPECT_EQ(newestWalletSalvageBak({}), std::string(""));
|
||||||
|
|
||||||
|
// Restore must pick the LARGEST (least-salvaged) backup, NOT the newest — a salvage cascade shrinks
|
||||||
|
// the wallet each round, so the newest .bak can be an emptied 40KB copy while the original is huge.
|
||||||
|
using dragonx::daemon::largestWalletSalvageBak;
|
||||||
|
EXPECT_EQ(largestWalletSalvageBak({
|
||||||
|
{"wallet.dat", 40000ull}, // current (salvaged, tiny) — not a .bak
|
||||||
|
{"wallet.1786200000.bak", 194174976ull}, // ORIGINAL — oldest ts, biggest
|
||||||
|
{"wallet.1786340620.bak", 40960ull}, // latest salvage — newest ts, empty
|
||||||
|
{"peers.dat", 999ull}}),
|
||||||
|
std::string("wallet.1786200000.bak")); // largest wins over newest
|
||||||
|
EXPECT_EQ(largestWalletSalvageBak({{"wallet.dat", 100ull}, {"notes.txt", 5ull}}), std::string(""));
|
||||||
|
EXPECT_EQ(largestWalletSalvageBak({}), std::string(""));
|
||||||
}
|
}
|
||||||
|
|
||||||
// Live probe of a real lite server (env-gated). Validates CONNECT_ONLY latency + IP capture.
|
// Live probe of a real lite server (env-gated). Validates CONNECT_ONLY latency + IP capture.
|
||||||
@@ -5642,6 +6362,12 @@ void testDaemonChecksumParsing()
|
|||||||
"| DragonX-1.0.2-Win64.ZIP | `dd6a554ac05c834da9910ae796215567e97c426f3aed15b54af1f7b90d48c43a` |");
|
"| DragonX-1.0.2-Win64.ZIP | `dd6a554ac05c834da9910ae796215567e97c426f3aed15b54af1f7b90d48c43a` |");
|
||||||
EXPECT_EQ(mixed.at("dragonx-1.0.2-win64.zip"),
|
EXPECT_EQ(mixed.at("dragonx-1.0.2-win64.zip"),
|
||||||
std::string("dd6a554ac05c834da9910ae796215567e97c426f3aed15b54af1f7b90d48c43a"));
|
std::string("dd6a554ac05c834da9910ae796215567e97c426f3aed15b54af1f7b90d48c43a"));
|
||||||
|
// Regression: a markdown-bolded filename (**archive.zip**) must still parse — otherwise a valid,
|
||||||
|
// correctly-signed release whose body bolds the name would fail checksum lookup and be refused.
|
||||||
|
const auto bold = parseDaemonChecksums(
|
||||||
|
"| **dragonx-1.0.2-win64.zip** | `dd6a554ac05c834da9910ae796215567e97c426f3aed15b54af1f7b90d48c43a` |");
|
||||||
|
EXPECT_EQ(bold.at("dragonx-1.0.2-win64.zip"),
|
||||||
|
std::string("dd6a554ac05c834da9910ae796215567e97c426f3aed15b54af1f7b90d48c43a"));
|
||||||
}
|
}
|
||||||
|
|
||||||
void testDaemonBasenamesAndVersionCore()
|
void testDaemonBasenamesAndVersionCore()
|
||||||
@@ -5828,6 +6554,94 @@ void testPoolHashrateParsing()
|
|||||||
EXPECT_FALSE(ok);
|
EXPECT_FALSE(ok);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Schema-aware pool fee parsing (fed to the mining-tab "N% fee" display).
|
||||||
|
void testPoolFeeParsing()
|
||||||
|
{
|
||||||
|
using namespace dragonx::util;
|
||||||
|
bool ok = false;
|
||||||
|
|
||||||
|
// pool.dragonx.is custom schema: pools.dragonx.poolFee (a whole-percent number).
|
||||||
|
const std::string isJson =
|
||||||
|
R"({"pools":{"dragonx":{"hashrate":27670.14,"poolFee":1,"soloFee":3}}})";
|
||||||
|
double fee = parsePoolFee(PoolStatsSchema::DragonXIs, isJson, "", ok);
|
||||||
|
EXPECT_TRUE(ok);
|
||||||
|
EXPECT_NEAR(fee, 1.0, 0.001);
|
||||||
|
|
||||||
|
// Fractional fees survive (display rounds, but the parse must not).
|
||||||
|
const std::string isFrac = R"({"pools":{"dragonx":{"poolFee":1.5}}})";
|
||||||
|
fee = parsePoolFee(PoolStatsSchema::DragonXIs, isFrac, "", ok);
|
||||||
|
EXPECT_TRUE(ok);
|
||||||
|
EXPECT_NEAR(fee, 1.5, 0.001);
|
||||||
|
|
||||||
|
// Miningcore schema: the requested pool id's poolFeePercent.
|
||||||
|
const std::string ccJson =
|
||||||
|
R"({"pools":[)"
|
||||||
|
R"({"id":"dragonx-solo","poolFeePercent":2.0,"poolStats":{"poolHashrate":88780.0}},)"
|
||||||
|
R"({"id":"dragonx-pplns","poolFeePercent":0.9,"poolStats":{"poolHashrate":1585.9}}]})";
|
||||||
|
fee = parsePoolFee(PoolStatsSchema::Miningcore, ccJson, "dragonx-pplns", ok);
|
||||||
|
EXPECT_TRUE(ok);
|
||||||
|
EXPECT_NEAR(fee, 0.9, 0.001);
|
||||||
|
|
||||||
|
// Missing field / malformed / wrong-schema input all fail closed (caller keeps
|
||||||
|
// the compile-time fallback rather than showing a bogus 0%).
|
||||||
|
parsePoolFee(PoolStatsSchema::DragonXIs, R"({"pools":{"dragonx":{"hashrate":1.0}}})", "", ok);
|
||||||
|
EXPECT_FALSE(ok); // no poolFee key
|
||||||
|
parsePoolFee(PoolStatsSchema::DragonXIs, "not json", "", ok);
|
||||||
|
EXPECT_FALSE(ok);
|
||||||
|
parsePoolFee(PoolStatsSchema::Miningcore, ccJson, "does-not-exist", ok);
|
||||||
|
EXPECT_FALSE(ok);
|
||||||
|
parsePoolFee(PoolStatsSchema::DragonXIs, R"({"pools":{"dragonx":{"poolFee":"1"}}})", "", ok);
|
||||||
|
EXPECT_FALSE(ok); // string, not number
|
||||||
|
}
|
||||||
|
|
||||||
|
// The effective pool list = official pools ∪ saved favorites ∪ current custom pool,
|
||||||
|
// endpoint-deduped, with synthetic user rows flagged official=false.
|
||||||
|
void testEffectivePools()
|
||||||
|
{
|
||||||
|
using namespace dragonx::util;
|
||||||
|
const int base = (int)knownPools().size();
|
||||||
|
|
||||||
|
// Current pool is the official one, nothing saved -> just the official pools.
|
||||||
|
auto a = effectivePools("pool.dragonx.is:3433", {});
|
||||||
|
EXPECT_EQ((int)a.size(), base);
|
||||||
|
|
||||||
|
// A custom current pool (neither official nor saved) appears as an extra row.
|
||||||
|
auto b = effectivePools("my.pool.example:3333", {});
|
||||||
|
EXPECT_EQ((int)b.size(), base + 1);
|
||||||
|
const KnownPool* custom = findPoolByUrl(b, "my.pool.example:3333");
|
||||||
|
EXPECT_TRUE(custom != nullptr);
|
||||||
|
EXPECT_FALSE(custom->official);
|
||||||
|
EXPECT_TRUE(custom->feePercent < 0.0); // unknown fee
|
||||||
|
|
||||||
|
// Saved pools are appended; an official one among them and a duplicate collapse.
|
||||||
|
auto c = effectivePools("pool.dragonx.is:3433",
|
||||||
|
{"pool.dragonx.is:3433", "alt.pool:1", "alt.pool:1"});
|
||||||
|
EXPECT_EQ((int)c.size(), base + 1);
|
||||||
|
EXPECT_TRUE(findPoolByUrl(c, "alt.pool:1") != nullptr);
|
||||||
|
|
||||||
|
// Current pool equal to a saved one is not listed twice.
|
||||||
|
auto d = effectivePools("alt.pool:1", {"alt.pool:1"});
|
||||||
|
EXPECT_EQ((int)d.size(), base + 1);
|
||||||
|
|
||||||
|
// Blank/whitespace URLs are ignored (no phantom rows).
|
||||||
|
auto e = effectivePools(" ", {"", " "});
|
||||||
|
EXPECT_EQ((int)e.size(), base);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fee formatting: whole numbers stay clean, fractional fees keep their decimals.
|
||||||
|
void testFormatFeePercent()
|
||||||
|
{
|
||||||
|
using dragonx::ui::FormatFeePercent;
|
||||||
|
EXPECT_TRUE(FormatFeePercent(1.0) == "1");
|
||||||
|
EXPECT_TRUE(FormatFeePercent(0.0) == "0");
|
||||||
|
EXPECT_TRUE(FormatFeePercent(3.0) == "3");
|
||||||
|
EXPECT_TRUE(FormatFeePercent(1.5) == "1.5");
|
||||||
|
EXPECT_TRUE(FormatFeePercent(0.9) == "0.9");
|
||||||
|
EXPECT_TRUE(FormatFeePercent(1.25) == "1.25");
|
||||||
|
EXPECT_TRUE(FormatFeePercent(2.50) == "2.5"); // trailing zero trimmed
|
||||||
|
EXPECT_TRUE(FormatFeePercent(100.0) == "100");
|
||||||
|
}
|
||||||
|
|
||||||
// Weighted-random pool selection: smaller pools favored, incumbent sticky, fails safe.
|
// Weighted-random pool selection: smaller pools favored, incumbent sticky, fails safe.
|
||||||
void testPoolWeightedSelection()
|
void testPoolWeightedSelection()
|
||||||
{
|
{
|
||||||
@@ -6518,6 +7332,22 @@ int main()
|
|||||||
testWalletSecurityWorkflow();
|
testWalletSecurityWorkflow();
|
||||||
testWalletSecurityWorkflowExecutor();
|
testWalletSecurityWorkflowExecutor();
|
||||||
testDaemonShutdownPolicy();
|
testDaemonShutdownPolicy();
|
||||||
|
testDatadirLockGate();
|
||||||
|
#ifndef _WIN32
|
||||||
|
testExecFailureReported();
|
||||||
|
testDaemonCrashDetected();
|
||||||
|
#endif
|
||||||
|
testPlatformEnsureDirectory();
|
||||||
|
testVerifySaplingParams();
|
||||||
|
testConnectHasStalled();
|
||||||
|
testIsLocalHost();
|
||||||
|
testAllowsPlaintextRemote();
|
||||||
|
testConsoleSecretRedaction();
|
||||||
|
testNodeStatusBanner();
|
||||||
|
testStalenessBadge();
|
||||||
|
testNotificationHistory();
|
||||||
|
testSeedMigrationResume();
|
||||||
|
testLoggerFileSink();
|
||||||
testDaemonLifecycleExecution();
|
testDaemonLifecycleExecution();
|
||||||
testDaemonLifecycleAdapters();
|
testDaemonLifecycleAdapters();
|
||||||
testConsoleTextLayout();
|
testConsoleTextLayout();
|
||||||
@@ -6552,6 +7382,7 @@ int main()
|
|||||||
testLiteWalletControllerM4();
|
testLiteWalletControllerM4();
|
||||||
testLiteWalletControllerM5Persistence();
|
testLiteWalletControllerM5Persistence();
|
||||||
testLiteWalletControllerEncryption();
|
testLiteWalletControllerEncryption();
|
||||||
|
testLiteWalletControllerCreateEncryptsWithPassphrase();
|
||||||
testLiteChainNameMigration();
|
testLiteChainNameMigration();
|
||||||
testLiteRefreshModelAppliesToWalletState();
|
testLiteRefreshModelAppliesToWalletState();
|
||||||
testLiteSendShowsRecipientFromOutgoing();
|
testLiteSendShowsRecipientFromOutgoing();
|
||||||
@@ -6590,6 +7421,9 @@ int main()
|
|||||||
testLiteOfficialServerDetection();
|
testLiteOfficialServerDetection();
|
||||||
testPoolRegistryLookup();
|
testPoolRegistryLookup();
|
||||||
testPoolHashrateParsing();
|
testPoolHashrateParsing();
|
||||||
|
testPoolFeeParsing();
|
||||||
|
testEffectivePools();
|
||||||
|
testFormatFeePercent();
|
||||||
testPoolWeightedSelection();
|
testPoolWeightedSelection();
|
||||||
testAtomicFileWrite();
|
testAtomicFileWrite();
|
||||||
testHushChatCrypto();
|
testHushChatCrypto();
|
||||||
@@ -6601,6 +7435,9 @@ int main()
|
|||||||
testHushChatShuffledReceive();
|
testHushChatShuffledReceive();
|
||||||
testWalletFileProbe();
|
testWalletFileProbe();
|
||||||
testAddressChecksumValidation();
|
testAddressChecksumValidation();
|
||||||
|
testPrivateKeyImportRecognition();
|
||||||
|
testSeedPhraseHelpers();
|
||||||
|
testBlockDbOutputDiagnosis();
|
||||||
testLiteServerProbeLive();
|
testLiteServerProbeLive();
|
||||||
testXmrigLiveInstall();
|
testXmrigLiveInstall();
|
||||||
testGeneratedResourceBehavior();
|
testGeneratedResourceBehavior();
|
||||||
|
|||||||
12
third_party/silentdragonxlite/lib/rust-toolchain.toml
vendored
Normal file
12
third_party/silentdragonxlite/lib/rust-toolchain.toml
vendored
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
# Rust toolchain pin for the vendored SilentDragonXLite (SDXL) backend.
|
||||||
|
#
|
||||||
|
# The pinned librustzcash + transitive crates (notably traitobject 0.1.0) rely on
|
||||||
|
# pre-1.70 trait-coherence rules and fail to compile on newer rustc with
|
||||||
|
# error[E0119]: conflicting implementations of trait `Trait` for type `(dyn Send + Sync)`
|
||||||
|
# so the backend must be built with 1.63 (the toolchain scripts/build-lite-backend-artifact.sh
|
||||||
|
# and CLAUDE.md target). rustup auto-selects this when cargo runs in this tree, so no
|
||||||
|
# RUSTUP_TOOLCHAIN / `cargo +1.63.0` is needed.
|
||||||
|
#
|
||||||
|
# Install it once with: rustup toolchain install 1.63.0
|
||||||
|
[toolchain]
|
||||||
|
channel = "1.63.0"
|
||||||
90
tools/wallet_rebuild/main.cpp
Normal file
90
tools/wallet_rebuild/main.cpp
Normal file
@@ -0,0 +1,90 @@
|
|||||||
|
// dragonx-wallet-rebuild — offline recovery helper for a BDB-inconsistent wallet.dat.
|
||||||
|
//
|
||||||
|
// Some wallet.dat files are valid Berkeley DB btrees whose EXTENT metadata is stale (the "main"
|
||||||
|
// subdatabase metapage records a low last_pgno while its live data spans thousands of pages further
|
||||||
|
// in the file). A tolerant page-walk reads every record, but the daemon's Berkeley DB `verify`
|
||||||
|
// rejects the file and auto-salvages it — which finds nothing and, on each restart, shrinks the
|
||||||
|
// wallet to empty (the "salvage cascade" that looks like fund loss). The keys are intact; only the
|
||||||
|
// DB envelope is broken.
|
||||||
|
//
|
||||||
|
// This tool fixes it the way it must be fixed — offline, on the file, before any daemon touches it:
|
||||||
|
// 1) read all (key,value) records with the tolerant walker (util/wallet_file_probe.h), then
|
||||||
|
// 2) write them VERBATIM into a fresh, consistent Berkeley DB "main" btree via real libdb put()s,
|
||||||
|
// so libdb computes correct extent/metapage bookkeeping itself (sidestepping the whole defect).
|
||||||
|
// Records are copied byte-for-byte: encrypted key material (ckey/csapzkey/mkey) passes through as
|
||||||
|
// opaque ciphertext — no passphrase, no decryption, no key material ever interpreted. Only large `tx`
|
||||||
|
// history values (which live in BDB overflow pages) are skipped; a wallet rescan rebuilds those.
|
||||||
|
//
|
||||||
|
// Usage: dragonx-wallet-rebuild <source-wallet.dat> <output-wallet.dat>
|
||||||
|
// Output: a single JSON line on stdout; exit 0 on success, non-zero on failure. Never touches the
|
||||||
|
// source (opens it read-only); refuses to overwrite an existing output (DB_EXCL).
|
||||||
|
|
||||||
|
#include "util/wallet_file_probe.h"
|
||||||
|
|
||||||
|
#include <db.h>
|
||||||
|
|
||||||
|
#include <cstdio>
|
||||||
|
#include <cstring>
|
||||||
|
#include <string>
|
||||||
|
|
||||||
|
int main(int argc, char** argv)
|
||||||
|
{
|
||||||
|
if (argc < 3) {
|
||||||
|
std::fprintf(stderr, "usage: dragonx-wallet-rebuild <source-wallet.dat> <output-wallet.dat>\n");
|
||||||
|
return 2;
|
||||||
|
}
|
||||||
|
const char* src = argv[1];
|
||||||
|
const char* dst = argv[2];
|
||||||
|
|
||||||
|
// --- 1) tolerant read (no libdb; reads records the daemon's BDB can't) ---
|
||||||
|
const auto rec = dragonx::util::extractWalletBtreeRecords(src);
|
||||||
|
if (!rec.parsed) {
|
||||||
|
std::printf("{\"ok\":false,\"error\":\"source is not a readable Berkeley DB btree wallet\"}\n");
|
||||||
|
return 3;
|
||||||
|
}
|
||||||
|
if (rec.keyRecords == 0) {
|
||||||
|
// Refuse to produce a keyless wallet — nothing to recover, and installing it would look like loss.
|
||||||
|
std::printf("{\"ok\":false,\"error\":\"no key records found in source\",\"read\":%zu}\n",
|
||||||
|
rec.records.size());
|
||||||
|
return 4;
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- 2) write a fresh, consistent BDB "main" btree (what CWalletDB expects) ---
|
||||||
|
DB* db = nullptr;
|
||||||
|
int r = db_create(&db, nullptr, 0);
|
||||||
|
if (r != 0) {
|
||||||
|
std::printf("{\"ok\":false,\"error\":\"db_create: %s\"}\n", db_strerror(r));
|
||||||
|
return 5;
|
||||||
|
}
|
||||||
|
// DB_EXCL: never clobber an existing file — the caller passes a fresh path.
|
||||||
|
r = db->open(db, nullptr, dst, "main", DB_BTREE, DB_CREATE | DB_EXCL, 0600);
|
||||||
|
if (r != 0) {
|
||||||
|
std::printf("{\"ok\":false,\"error\":\"open output: %s\"}\n", db_strerror(r));
|
||||||
|
db->close(db, 0);
|
||||||
|
return 6;
|
||||||
|
}
|
||||||
|
long wrote = 0;
|
||||||
|
for (const auto& kv : rec.records) {
|
||||||
|
DBT k, v;
|
||||||
|
std::memset(&k, 0, sizeof k);
|
||||||
|
std::memset(&v, 0, sizeof v);
|
||||||
|
k.data = const_cast<char*>(kv.first.data()); k.size = static_cast<u_int32_t>(kv.first.size());
|
||||||
|
v.data = const_cast<char*>(kv.second.data()); v.size = static_cast<u_int32_t>(kv.second.size());
|
||||||
|
r = db->put(db, nullptr, &k, &v, 0);
|
||||||
|
if (r != 0) {
|
||||||
|
std::printf("{\"ok\":false,\"error\":\"put failed: %s\",\"wrote\":%ld}\n", db_strerror(r), wrote);
|
||||||
|
db->close(db, 0);
|
||||||
|
return 7;
|
||||||
|
}
|
||||||
|
++wrote;
|
||||||
|
}
|
||||||
|
r = db->close(db, 0); // close flushes correct metadata
|
||||||
|
if (r != 0) {
|
||||||
|
std::printf("{\"ok\":false,\"error\":\"close: %s\",\"wrote\":%ld}\n", db_strerror(r), wrote);
|
||||||
|
return 8;
|
||||||
|
}
|
||||||
|
|
||||||
|
std::printf("{\"ok\":true,\"read\":%zu,\"keyRecords\":%d,\"skippedOverflow\":%d,\"wrote\":%ld,\"complete\":%s}\n",
|
||||||
|
rec.records.size(), rec.keyRecords, rec.skippedOverflow, wrote, rec.complete ? "true" : "false");
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user