Compare commits
298
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a30a083df0 | ||
|
|
89d3975eb3 | ||
|
|
3f1838cfdd | ||
|
|
dcb8e6eb25 | ||
|
|
3f127f9b62 | ||
|
|
f2d9590058 | ||
|
|
ee6e99965c | ||
|
|
b3e09a3314 | ||
|
|
2fdf33f99b | ||
|
|
092a802959 | ||
|
|
e9eeaf00c3 | ||
|
|
572b98cbcf | ||
|
|
bcfc324426 | ||
|
|
7cea9b7339 | ||
|
|
f3f0b32a48 | ||
|
|
8fe20f6385 | ||
|
|
15c1181aef | ||
|
|
0101cc707c | ||
|
|
adf61e43d6 | ||
|
|
fb281714f9 | ||
|
|
74c497922e | ||
|
|
ae16c06d24 | ||
|
|
408393a547 | ||
|
|
a80be443d1 | ||
|
|
a792a1ec0c | ||
|
|
a11130769d | ||
|
|
4c6a78b3b0 | ||
|
|
7d2972f315 | ||
|
|
8eee351faa | ||
|
|
09f39f9183 | ||
|
|
5cfa713be1 | ||
|
|
cd9ca4cd77 | ||
|
|
9db73323b6 | ||
|
|
4a8d07edce | ||
|
|
643ac7daef | ||
|
|
e27d88eca4 | ||
|
|
6efe3c0f5c | ||
|
|
750771ebfb | ||
|
|
a99f71a0cd | ||
|
|
9ff64b1846 | ||
|
|
b47d55a3d0 | ||
|
|
07437fb447 | ||
|
|
e28cacbe8e | ||
|
|
46371c69df | ||
|
|
3cda8c9e31 | ||
|
|
064c9d1b61 | ||
|
|
2ef91ea7d4 | ||
|
|
19da7d1474 | ||
|
|
faf45e60e4 | ||
|
|
2282f332a6 | ||
|
|
5fcab14e4e | ||
|
|
e40c6c0a7c | ||
|
|
4d95ef68f6 | ||
|
|
3f85953452 | ||
|
|
5a3d36904c | ||
|
|
ef14518089 | ||
|
|
39292b1b1f | ||
|
|
6d4ed96f6d | ||
|
|
b49f85bcbe | ||
|
|
0bdea68337 | ||
|
|
c045e56d4e | ||
|
|
be69c9bafe | ||
|
|
d9dae45c39 | ||
|
|
e2210cf5d3 | ||
|
|
25ad23aee4 | ||
|
|
6a6ac8cf99 | ||
|
|
998788d351 | ||
|
|
16371c5879 | ||
|
|
d9143f0e18 | ||
|
|
7e7804e577 | ||
|
|
8df0fe1734 | ||
|
|
30f5243dd1 | ||
|
|
b407b8927a | ||
|
|
60b3ad5d5a | ||
|
|
614dfbbc5b | ||
|
|
a6b7b966b0 | ||
|
|
e9042f091e | ||
|
|
e77b960cc5 | ||
|
|
3fd52fbdaf | ||
|
|
1416654385 | ||
|
|
3554ba1e2d | ||
|
|
aa486266c6 | ||
|
|
8c5bd485dd | ||
|
|
8168e26d93 | ||
|
|
e685d425ed | ||
|
|
2396ed33db | ||
|
|
f47528de53 | ||
|
|
bf5cb39566 | ||
|
|
667c89ba51 | ||
|
|
4d9d528566 | ||
|
|
b3a1a9e654 | ||
|
|
e1003cba43 | ||
|
|
f1b0ae5ce7 | ||
|
|
edbb98d9ec | ||
|
|
2a535c844d | ||
|
|
63a422a615 | ||
|
|
100d76b88f | ||
|
|
fa1ba97286 | ||
|
|
53da160546 | ||
|
|
49240facff | ||
|
|
13059412ce | ||
|
|
45d8bdccf0 | ||
|
|
a24ffea86e | ||
|
|
5d9bd1688c | ||
|
|
f3f85d602d | ||
|
|
2c9e99c022 | ||
|
|
1cd4ea4026 | ||
|
|
d601f786e2 | ||
|
|
29c1674094 | ||
|
|
95605dd7e9 | ||
|
|
96352ce9ad | ||
|
|
bbe2bf94dc | ||
|
|
f76d71fede | ||
|
|
2a3e88c8dc | ||
|
|
b4ff9a10a2 | ||
|
|
76daf9619d | ||
|
|
3f82af99c4 | ||
|
|
ce00621a4a | ||
|
|
a631e4421a | ||
|
|
32ea4d0524 | ||
|
|
fae5ef05b9 | ||
|
|
13989cf268 | ||
|
|
c5b325a752 | ||
|
|
969718e03a | ||
|
|
7a9b29f358 | ||
|
|
968d6d3875 | ||
|
|
fdd95aa457 | ||
|
|
75e0c5048e | ||
|
|
cd83038de5 | ||
|
|
fbc429f914 | ||
|
|
944fafa1c7 | ||
|
|
ebbeb846e8 | ||
|
|
c3f981b52e | ||
|
|
a5e4fd5c02 | ||
|
|
01b59d24db | ||
|
|
3ca7a5c90e | ||
|
|
b8a4a11f90 | ||
|
|
c1b289cc27 | ||
|
|
f7ed1ae115 | ||
|
|
116a997a3a | ||
|
|
70e96f75f0 | ||
|
|
6a24355c79 | ||
|
|
5376681af5 | ||
|
|
8255923f33 | ||
|
|
c3ffc97b31 | ||
|
|
d4339be94b | ||
|
|
579accd0c6 | ||
|
|
abc82136c4 | ||
|
|
21dc874988 | ||
|
|
fbe09c6a4c | ||
|
|
2fce9a7860 | ||
|
|
0aaf33ecf0 | ||
|
|
1ea584311e | ||
|
|
3f7af2d028 | ||
|
|
5ae5e5213b | ||
|
|
e7f084e3eb | ||
|
|
a5185737e6 | ||
|
|
a581f42a33 | ||
|
|
ae66b54694 | ||
|
|
81733f5603 | ||
|
|
b3a57b07ad | ||
|
|
78e38570a8 | ||
|
|
70d8b0f687 | ||
|
|
f6e5038043 | ||
|
|
62a5577491 | ||
|
|
3ac46abe1a | ||
|
|
eac0993512 | ||
|
|
e52c9e1b92 | ||
|
|
b6cffdf942 | ||
|
|
31304a1a63 | ||
|
|
39333f80b7 | ||
|
|
a5db2e9a3d | ||
|
|
55431cd606 | ||
|
|
20b63bf45d | ||
|
|
026d0e92a6 | ||
|
|
0a72b47ca6 | ||
|
|
1848025a95 | ||
|
|
1bf803f107 | ||
|
|
261883ca21 | ||
|
|
f970cae193 | ||
|
|
73f7f33a4b | ||
|
|
b50d5ac31f | ||
|
|
e0f9d36780 | ||
|
|
1f162ec830 | ||
|
|
04ad1462d5 | ||
|
|
abb63d9441 | ||
|
|
70eacd90ad | ||
|
|
8b094595b1 | ||
|
|
e65be1d24b | ||
|
|
25ceb2cfe1 | ||
|
|
22e3f104d6 | ||
|
|
7c0afd06a8 | ||
|
|
24a90ae532 | ||
|
|
db8d5359a4 | ||
|
|
c233d4d503 | ||
|
|
ba08cab95a | ||
|
|
6f1ae874fb | ||
|
|
965f36cecb | ||
|
|
e7ebc568b0 | ||
|
|
7f99fbb9c6 | ||
|
|
6de9b5cc90 | ||
|
|
bd4c06dfd0 | ||
|
|
d28cc0da31 | ||
|
|
8895038c40 | ||
|
|
3f13d79ccb | ||
|
|
8c0a22b7bc | ||
|
|
e025a314d1 | ||
|
|
1ced49d622 | ||
|
|
ee0a486676 | ||
|
|
c2c2a8325b | ||
|
|
bb973a1007 | ||
|
|
88fa13dd1a | ||
|
|
888a41ddc2 | ||
|
|
3069151e7f | ||
|
|
4a95d9ecb7 | ||
|
|
a69380817e | ||
|
|
e45f4c7d90 | ||
|
|
d3fa09128e | ||
|
|
860916d203 | ||
|
|
4a2a1bdd54 | ||
|
|
3af7b0ef44 | ||
|
|
c6fc1ac168 | ||
|
|
971f2abae5 | ||
|
|
47180ff840 | ||
|
|
a700d2c7ee | ||
|
|
1658950386 | ||
|
|
bb84243114 | ||
|
|
7c24400c83 | ||
|
|
55975901a2 | ||
|
|
b2a57a61a0 | ||
|
|
296c04386c | ||
|
|
2618744e59 | ||
|
|
ad0f60a747 | ||
|
|
93b6871511 | ||
|
|
c0568eb930 | ||
|
|
67260cc6f0 | ||
|
|
6c02e67c6d | ||
|
|
c9cb50f4dd | ||
|
|
7649b1f8a3 | ||
|
|
72054fe17f | ||
|
|
8f8dc4c922 | ||
|
|
0e2f853025 | ||
|
|
d5202f76ca | ||
|
|
ed067f4a91 | ||
|
|
3f1194a84b | ||
|
|
cee8b9d7f6 | ||
|
|
b29a09eaa0 | ||
|
|
f86cc4d6b7 | ||
|
|
323143e111 | ||
|
|
879de37d18 | ||
|
|
8b9a498eee | ||
|
|
4eed4283d6 | ||
|
|
7fa7c3d6da | ||
|
|
2cf183ddd9 | ||
|
|
7ceff1eaaa | ||
|
|
16e0ee9947 | ||
|
|
2d29a04981 | ||
|
|
43fc0090d4 | ||
|
|
1cb03332e7 | ||
|
|
575abd4bcc | ||
|
|
fffab228d1 | ||
|
|
402a3053dc | ||
|
|
ffce7cad24 | ||
|
|
3961f84d16 | ||
|
|
71ed92b9f1 | ||
|
|
e367eb47ff | ||
|
|
31d8742ce7 | ||
|
|
5246898968 | ||
|
|
a3361020d1 | ||
|
|
e20b70d73f | ||
|
|
1215ded1a0 | ||
|
|
0f25c67600 | ||
|
|
0c821333c8 | ||
|
|
98fa2154b4 | ||
|
|
6b14098bb2 | ||
|
|
821bebd13e | ||
|
|
5993488176 | ||
|
|
b1d5806f9e | ||
|
|
a091bbc52f | ||
|
|
d8175b7dc1 | ||
|
|
2c04b88e19 | ||
|
|
bade958849 | ||
|
|
9339bb0663 | ||
|
|
7b6e8a8a8e | ||
|
|
46189da0da | ||
|
|
bc6c1de7d6 | ||
|
|
ec88dac459 | ||
|
|
d058e5fc2a | ||
|
|
86b9d2e00d | ||
|
|
0056c46929 | ||
|
|
1151141271 | ||
|
|
fae8b20fb7 | ||
|
|
424954ff4f | ||
|
|
0e5533f6b9 | ||
|
|
080f3d9668 | ||
|
|
c6bd4eb733 | ||
|
|
13788b5dd0 | ||
|
|
5554016029 |
+145
-22
@@ -15,7 +15,7 @@ if(APPLE)
|
||||
endif()
|
||||
|
||||
project(ObsidianDragon
|
||||
VERSION 2.0.1
|
||||
VERSION 2.0.2
|
||||
LANGUAGES C CXX
|
||||
DESCRIPTION "DragonX Cryptocurrency Wallet"
|
||||
)
|
||||
@@ -26,7 +26,7 @@ set(DRAGONX_VERSION_SUFFIX "")
|
||||
# ObsidianDragonLite is versioned INDEPENDENTLY of the full-node app above. The active variant's
|
||||
# version flows to the generated header, the Windows .rc/manifest, and build.sh's release names via
|
||||
# DRAGONX_APP_VERSION* (resolved in the lite/full block below).
|
||||
set(DRAGONX_LITE_VERSION "1.1.0")
|
||||
set(DRAGONX_LITE_VERSION "1.1.1")
|
||||
set(DRAGONX_LITE_VERSION_SUFFIX "")
|
||||
|
||||
# C++17 standard
|
||||
@@ -45,6 +45,19 @@ option(DRAGONX_ENABLE_EMBEDDED_DAEMON "Enable embedded dragonxd support" ON)
|
||||
option(DRAGONX_BUILD_LITE "Build ObsidianDragonLite variant without full-node features" OFF)
|
||||
option(DRAGONX_ENABLE_LITE_BACKEND "Enable real lite wallet backend integration" OFF)
|
||||
option(DRAGONX_ENABLE_CHAT "Enable the HushChat protocol/UI integration" ON)
|
||||
option(DRAGONX_BUILD_FAKE_DAEMON "Build the fake slow dragonxd test stub (tools/fake_dragonxd); never shipped" OFF)
|
||||
# The screenshot / full UI sweep injects demo data and touches the wallet's data and config dirs, so it is a
|
||||
# dev tool only. build.sh passes it EXPLICITLY (ON for dev, OFF for every release configure) because build/linux
|
||||
# and build/mac are shared between dev and release builds; dragonx_release refuses to package while it is ON.
|
||||
option(DRAGONX_ENABLE_UI_SWEEP "Compile the UI sweep dev tool (never for shipped builds)" OFF)
|
||||
# Embedded fallback copy of res/lang/*.json (xxd -i -> lang_*.h). Needed only by the Windows single-file exe (no res/ beside it);
|
||||
# Linux zip/AppImage and the macOS .app always ship res/lang, which loadLanguage() reads first.
|
||||
if(WIN32)
|
||||
set(_DRAGONX_EMBED_LANG_DEFAULT ON)
|
||||
else()
|
||||
set(_DRAGONX_EMBED_LANG_DEFAULT OFF)
|
||||
endif()
|
||||
option(DRAGONX_EMBED_LANG "Embed res/lang/*.json in the binary as a fallback" ${_DRAGONX_EMBED_LANG_DEFAULT})
|
||||
set(DRAGONX_LITE_BACKEND_LIBRARY "" CACHE FILEPATH "Path to a prebuilt SDXL-compatible lite backend library")
|
||||
set(DRAGONX_LITE_BACKEND_INCLUDE_DIR "" CACHE PATH "Optional include directory for SDXL-compatible lite backend headers")
|
||||
set(DRAGONX_LITE_BACKEND_EXTRA_LIBS "" CACHE STRING "Additional libraries needed by the SDXL-compatible lite backend")
|
||||
@@ -154,6 +167,11 @@ if(DRAGONX_ENABLE_LITE_BACKEND)
|
||||
set(DRAGONX_LITE_BACKEND_READY ON)
|
||||
endif()
|
||||
|
||||
# Windows (mingw cross) builds skip the large test target unless asked for with -DBUILD_TESTING=ON
|
||||
# (scripts/test-windows.sh does that in its own build/windows-tests tree), so release builds are unchanged.
|
||||
if(WIN32 AND NOT DEFINED BUILD_TESTING)
|
||||
set(BUILD_TESTING OFF CACHE BOOL "Build the testing tree.")
|
||||
endif()
|
||||
include(CTest)
|
||||
|
||||
# Output directories
|
||||
@@ -265,7 +283,11 @@ if(WIN32)
|
||||
set(BUILD_CURL_EXE OFF CACHE BOOL "" FORCE)
|
||||
set(BUILD_SHARED_LIBS OFF CACHE BOOL "" FORCE)
|
||||
set(CURL_STATICLIB ON CACHE BOOL "" FORCE)
|
||||
set(BUILD_TESTING OFF CACHE BOOL "" FORCE)
|
||||
# Keep curl's own tests out, without switching off ObsidianDragonTests for every Windows build:
|
||||
# hide BUILD_TESTING behind a normal variable while curl configures, then restore our cache value
|
||||
# (curl's cmake_dependent_option(BUILD_TESTING ...) FORCE-writes the OFF it saw into the cache).
|
||||
set(_dragonx_build_testing "${BUILD_TESTING}")
|
||||
set(BUILD_TESTING OFF)
|
||||
set(CURL_DISABLE_LDAP ON CACHE BOOL "" FORCE)
|
||||
set(CURL_DISABLE_LDAPS ON CACHE BOOL "" FORCE)
|
||||
set(CURL_USE_SCHANNEL ON CACHE BOOL "" FORCE) # Use Windows native SSL
|
||||
@@ -274,6 +296,8 @@ if(WIN32)
|
||||
set(CURL_ZLIB OFF CACHE BOOL "" FORCE)
|
||||
set(HTTP_ONLY ON CACHE BOOL "" FORCE)
|
||||
FetchContent_MakeAvailable(curl)
|
||||
unset(BUILD_TESTING)
|
||||
set(BUILD_TESTING "${_dragonx_build_testing}" CACHE BOOL "Build the testing tree." FORCE)
|
||||
set(CURL_LIBRARIES libcurl_static)
|
||||
set(CURL_INCLUDE_DIRS ${curl_SOURCE_DIR}/include ${curl_BINARY_DIR}/lib/curl)
|
||||
else()
|
||||
@@ -386,6 +410,12 @@ set(IMGUI_SOURCES
|
||||
${IMGUI_DIR}/backends/imgui_impl_sdl3.cpp
|
||||
)
|
||||
|
||||
# The ImGui demo window is unreachable in the wallet, so compile imgui_demo.cpp to its empty stubs
|
||||
# (ShowDemoWindow/ShowStyleEditor/... stay defined; imgui.h declares them unconditionally, so this is ODR-safe).
|
||||
# Do NOT use IMGUI_DISABLE_DEBUG_TOOLS: it changes imgui.h declarations and removes duplicate-ID highlighting.
|
||||
# (Makefiles: a per-source define lands in the target's flags.make, so existing trees do one full app rebuild.)
|
||||
set_source_files_properties(${IMGUI_DIR}/imgui_demo.cpp PROPERTIES COMPILE_DEFINITIONS IMGUI_DISABLE_DEMO_WINDOWS)
|
||||
|
||||
set(IMGUI_HEADERS
|
||||
${IMGUI_DIR}/imgui.h
|
||||
${IMGUI_DIR}/imconfig.h
|
||||
@@ -454,8 +484,11 @@ set(APP_SOURCES
|
||||
src/app_security.cpp
|
||||
src/app_sweep.cpp
|
||||
src/app_wizard.cpp
|
||||
src/services/history_scan_policy.cpp
|
||||
src/services/network_refresh_service.cpp
|
||||
src/services/note_consolidation.cpp
|
||||
src/services/refresh_scheduler.cpp
|
||||
src/services/wallet_scan_governor.cpp
|
||||
src/services/wallet_security_controller.cpp
|
||||
src/services/wallet_security_workflow.cpp
|
||||
src/services/wallet_security_workflow_executor.cpp
|
||||
@@ -480,7 +513,9 @@ set(APP_SOURCES
|
||||
src/wallet/lite_wallet_server_selection_adapter.cpp
|
||||
src/wallet/lite_wallet_lifecycle_service.cpp
|
||||
src/data/wallet_state.cpp
|
||||
src/data/transaction_csv.cpp
|
||||
src/data/transaction_history_cache.cpp
|
||||
src/data/transaction_history_store_policy.cpp
|
||||
src/ui/theme.cpp
|
||||
src/ui/theme_loader.cpp
|
||||
src/ui/explorer/explorer_block_cache.cpp
|
||||
@@ -544,6 +579,7 @@ set(APP_SOURCES
|
||||
src/data/exchange_info.cpp
|
||||
src/util/logger.cpp
|
||||
src/util/async_task_manager.cpp
|
||||
src/util/coalescing_writer.cpp
|
||||
src/util/amount_format.cpp
|
||||
src/util/address_validation.cpp
|
||||
src/util/seed_phrase.cpp
|
||||
@@ -554,6 +590,7 @@ set(APP_SOURCES
|
||||
src/util/platform.cpp
|
||||
src/util/payment_uri.cpp
|
||||
src/util/texture_loader.cpp
|
||||
src/util/image_decode_core.cpp
|
||||
src/util/svg_texture.cpp
|
||||
src/util/noise_texture.cpp
|
||||
src/daemon/embedded_daemon.cpp
|
||||
@@ -600,8 +637,15 @@ endif()
|
||||
|
||||
set(APP_HEADERS
|
||||
src/app.h
|
||||
src/services/history_scan_policy.h
|
||||
src/services/network_refresh_service.h
|
||||
src/services/refresh_scheduler.h
|
||||
src/services/wallet_scan_governor.h
|
||||
src/services/connect_scan_gate.h
|
||||
src/services/scan_policy.h
|
||||
src/services/chat_scan_policy.h
|
||||
src/services/mining_thread_commit.h
|
||||
src/services/lowest_block_search.h
|
||||
src/services/wallet_security_controller.h
|
||||
src/services/wallet_security_workflow.h
|
||||
src/services/wallet_security_workflow_executor.h
|
||||
@@ -629,6 +673,7 @@ set(APP_HEADERS
|
||||
src/config/version.h
|
||||
src/data/wallet_state.h
|
||||
src/data/transaction_history_cache.h
|
||||
src/data/transaction_history_store_policy.h
|
||||
src/ui/theme.h
|
||||
src/ui/theme_loader.h
|
||||
src/ui/explorer/explorer_block_cache.h
|
||||
@@ -637,6 +682,8 @@ set(APP_HEADERS
|
||||
src/ui/windows/balance_tab.h
|
||||
src/ui/windows/balance_address_list.h
|
||||
src/ui/windows/balance_recent_tx.h
|
||||
src/ui/windows/balance_lerp.h
|
||||
src/ui/windows/chat_thread_layout.h
|
||||
src/ui/windows/balance_tab_helpers.h
|
||||
src/ui/windows/send_tab.h
|
||||
src/ui/windows/receive_tab.h
|
||||
@@ -677,12 +724,19 @@ set(APP_HEADERS
|
||||
src/rpc/rpc_client.h
|
||||
src/rpc/rpc_worker.h
|
||||
src/rpc/connection.h
|
||||
src/rpc/long_call_lane.h
|
||||
src/rpc/types.h
|
||||
src/config/settings.h
|
||||
src/data/address_book.h
|
||||
src/data/exchange_info.h
|
||||
src/util/logger.h
|
||||
src/util/async_task_manager.h
|
||||
src/util/serial_job_lane.h
|
||||
src/util/fetch_backoff.h
|
||||
src/util/coalescing_writer.h
|
||||
src/util/local_time.h
|
||||
src/util/background_sampler.h
|
||||
src/util/text_fit.h
|
||||
src/util/amount_format.h
|
||||
src/util/base64.h
|
||||
src/util/single_instance.h
|
||||
@@ -766,9 +820,21 @@ ${CMAKE_SOURCE_DIR}/res/fonts/NotoEmoji-Subset.ttf;\
|
||||
${CMAKE_SOURCE_DIR}/res/fonts/TwemojiMozilla-Color.ttf"
|
||||
)
|
||||
|
||||
# Embed ui.toml + default_banlist via INCBIN (same pattern as fonts) - zero configure cost, no
|
||||
# generated header, so editing them re-assembles one tiny TU instead of recompiling every includer.
|
||||
configure_file(
|
||||
${CMAKE_SOURCE_DIR}/src/embedded/embedded_text.cpp.in
|
||||
${CMAKE_BINARY_DIR}/generated/embedded_text.cpp
|
||||
@ONLY)
|
||||
set_source_files_properties(
|
||||
${CMAKE_BINARY_DIR}/generated/embedded_text.cpp
|
||||
PROPERTIES OBJECT_DEPENDS
|
||||
"${CMAKE_SOURCE_DIR}/res/themes/ui.toml;${CMAKE_SOURCE_DIR}/res/default_banlist.txt")
|
||||
|
||||
add_executable(ObsidianDragon
|
||||
${APP_SOURCES}
|
||||
${CMAKE_BINARY_DIR}/generated/embedded_fonts.cpp
|
||||
${CMAKE_BINARY_DIR}/generated/embedded_text.cpp
|
||||
${IMGUI_SOURCES}
|
||||
${QRCODE_SOURCES}
|
||||
${GLAD_SOURCES}
|
||||
@@ -889,6 +955,8 @@ target_compile_definitions(ObsidianDragon PRIVATE
|
||||
DRAGONX_ENABLE_EMBEDDED_DAEMON=$<BOOL:${DRAGONX_ENABLE_EMBEDDED_DAEMON}>
|
||||
DRAGONX_ENABLE_LITE_BACKEND=$<BOOL:${DRAGONX_LITE_BACKEND_READY}>
|
||||
DRAGONX_ENABLE_CHAT=$<BOOL:${DRAGONX_ENABLE_CHAT}>
|
||||
DRAGONX_EMBED_LANG=$<BOOL:${DRAGONX_EMBED_LANG}>
|
||||
DRAGONX_ENABLE_UI_SWEEP=$<BOOL:${DRAGONX_ENABLE_UI_SWEEP}>
|
||||
)
|
||||
if(WIN32)
|
||||
target_compile_definitions(ObsidianDragon PRIVATE DRAGONX_USE_DX11)
|
||||
@@ -905,7 +973,9 @@ if(DRAGONX_FREETYPE)
|
||||
target_compile_definitions(ObsidianDragon PRIVATE DRAGONX_HAVE_FREETYPE)
|
||||
endif()
|
||||
|
||||
add_executable(HushChatFixtureCheck
|
||||
# Dev-only HushChat fixture CLI (nothing runs it; not linked into the app or tests). Not part of `all`:
|
||||
# cmake --build build/linux --target HushChatFixtureCheck (output: build/linux/bin/HushChatFixtureCheck)
|
||||
add_executable(HushChatFixtureCheck EXCLUDE_FROM_ALL
|
||||
tools/hushchat_fixture_check.cpp
|
||||
src/chat/chat_protocol.cpp
|
||||
src/chat/chat_fixture_tooling.cpp
|
||||
@@ -948,9 +1018,11 @@ endif()
|
||||
# so edits to res/lang/*.json are picked up by 'make' without re-running cmake.
|
||||
file(GLOB LANG_FILES ${CMAKE_SOURCE_DIR}/res/lang/*.json)
|
||||
if(LANG_FILES)
|
||||
find_program(XXD_EXECUTABLE NAMES xxd)
|
||||
if(NOT XXD_EXECUTABLE)
|
||||
message(WARNING "xxd not found; runtime language JSON files will be copied, but embedded build/generated/embedded/lang_*.h files will not be regenerated")
|
||||
if(DRAGONX_EMBED_LANG)
|
||||
find_program(XXD_EXECUTABLE NAMES xxd)
|
||||
endif()
|
||||
if(DRAGONX_EMBED_LANG AND NOT XXD_EXECUTABLE)
|
||||
message(FATAL_ERROR "DRAGONX_EMBED_LANG is ON but xxd was not found; it generates build/generated/embedded/lang_*.h. Install xxd or configure with -DDRAGONX_EMBED_LANG=OFF")
|
||||
endif()
|
||||
file(MAKE_DIRECTORY ${CMAKE_BINARY_DIR}/generated/embedded)
|
||||
|
||||
@@ -967,7 +1039,7 @@ if(LANG_FILES)
|
||||
list(APPEND LANG_OUTPUTS ${CMAKE_RUNTIME_OUTPUT_DIRECTORY}/res/lang/${LANG_FILENAME})
|
||||
|
||||
# Also regenerate the embedded header so the binary always has fresh translations
|
||||
if(XXD_EXECUTABLE)
|
||||
if(DRAGONX_EMBED_LANG AND XXD_EXECUTABLE)
|
||||
get_filename_component(LANG_CODE ${LANG_FILENAME} NAME_WE)
|
||||
set(LANG_HEADER ${CMAKE_BINARY_DIR}/generated/embedded/lang_${LANG_CODE}.h)
|
||||
add_custom_command(
|
||||
@@ -985,20 +1057,6 @@ if(LANG_FILES)
|
||||
message(STATUS " Language files: ${LANG_FILES}")
|
||||
endif()
|
||||
|
||||
# Embed ui.toml into the binary so it's always available at runtime
|
||||
include(${CMAKE_SOURCE_DIR}/cmake/EmbedResources.cmake)
|
||||
file(MAKE_DIRECTORY ${CMAKE_BINARY_DIR}/generated)
|
||||
embed_resource(
|
||||
${CMAKE_SOURCE_DIR}/res/themes/ui.toml
|
||||
${CMAKE_BINARY_DIR}/generated/ui_toml_embedded.h
|
||||
ui_toml
|
||||
)
|
||||
embed_resource(
|
||||
${CMAKE_SOURCE_DIR}/res/default_banlist.txt
|
||||
${CMAKE_BINARY_DIR}/generated/default_banlist_embedded.h
|
||||
default_banlist
|
||||
)
|
||||
|
||||
# Note: xmrig is embedded via build.sh (embedded_data.h) for Windows builds,
|
||||
# following the same pattern as daemon embedding.
|
||||
|
||||
@@ -1105,6 +1163,47 @@ else()
|
||||
message(STATUS "wallet-rebuild helper: OFF (no Berkeley DB found; set DRAGONX_BDB_ROOT for release builds)")
|
||||
endif()
|
||||
|
||||
# Release packaging (build.sh --linux-release / --win-release / --mac-release) builds ONLY what ships:
|
||||
# cmake --build . --target dragonx_release
|
||||
# The test binary, the lite smoke tools and the dev CLIs are skipped WITHOUT writing BUILD_TESTING=OFF into the
|
||||
# build/<platform> cache that dev builds share. A stale OFF would leave ctest running an old test binary.
|
||||
# If a release path starts copying a new CMake-built file out of bin/, add its target here.
|
||||
add_custom_target(dragonx_release)
|
||||
add_dependencies(dragonx_release ObsidianDragon)
|
||||
foreach(_dragonx_release_dep dragonx-wallet-rebuild copy_langs copy_themes)
|
||||
if(TARGET ${_dragonx_release_dep})
|
||||
add_dependencies(dragonx_release ${_dragonx_release_dep})
|
||||
endif()
|
||||
endforeach()
|
||||
# Backstop for the UI sweep dev tool: a tree whose cache says ON (e.g. hand-configured) cannot package a release.
|
||||
if(DRAGONX_ENABLE_UI_SWEEP)
|
||||
add_custom_target(dragonx_release_sweep_guard
|
||||
COMMAND ${CMAKE_COMMAND} -E echo "DRAGONX_ENABLE_UI_SWEEP=ON: refusing to package a release"
|
||||
COMMAND ${CMAKE_COMMAND} -E false)
|
||||
add_dependencies(dragonx_release dragonx_release_sweep_guard)
|
||||
endif()
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# fake_dragonxd — a deliberately slow, scriptable dragonxd stand-in that reproduces the daemon-stop
|
||||
# incident (usage and the incident it models: tools/fake_dragonxd/README.md).
|
||||
# Test tooling only: OFF by default, never packaged, and written to <build>/fake_dragonxd/ so its
|
||||
# "dragonxd" name can't shadow the real bin/dragonxd.
|
||||
# -----------------------------------------------------------------------------
|
||||
if(DRAGONX_BUILD_FAKE_DAEMON)
|
||||
add_executable(fake_dragonxd tools/fake_dragonxd/fake_dragonxd.cpp)
|
||||
target_link_libraries(fake_dragonxd PRIVATE nlohmann_json::nlohmann_json)
|
||||
if(WIN32)
|
||||
target_link_libraries(fake_dragonxd PRIVATE ws2_32)
|
||||
else()
|
||||
find_package(Threads REQUIRED)
|
||||
target_link_libraries(fake_dragonxd PRIVATE Threads::Threads)
|
||||
endif()
|
||||
set_target_properties(fake_dragonxd PROPERTIES
|
||||
OUTPUT_NAME dragonxd
|
||||
RUNTIME_OUTPUT_DIRECTORY ${CMAKE_BINARY_DIR}/fake_dragonxd)
|
||||
message(STATUS "fake_dragonxd test stub: ON (${CMAKE_BINARY_DIR}/fake_dragonxd/)")
|
||||
endif()
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Tests
|
||||
# -----------------------------------------------------------------------------
|
||||
@@ -1112,8 +1211,11 @@ endif()
|
||||
if(BUILD_TESTING)
|
||||
add_executable(ObsidianDragonTests
|
||||
tests/test_phase4.cpp
|
||||
src/services/history_scan_policy.cpp
|
||||
src/services/network_refresh_service.cpp
|
||||
src/services/note_consolidation.cpp
|
||||
src/services/refresh_scheduler.cpp
|
||||
src/services/wallet_scan_governor.cpp
|
||||
src/services/wallet_security_controller.cpp
|
||||
src/services/wallet_security_workflow.cpp
|
||||
src/services/wallet_security_workflow_executor.cpp
|
||||
@@ -1156,17 +1258,24 @@ if(BUILD_TESTING)
|
||||
src/util/seed_phrase.cpp
|
||||
src/util/i18n.cpp
|
||||
src/util/text_format.cpp
|
||||
src/util/image_decode_core.cpp
|
||||
src/data/wallet_state.cpp
|
||||
src/data/transaction_csv.cpp
|
||||
src/data/transaction_history_cache.cpp
|
||||
src/data/transaction_history_store_policy.cpp
|
||||
src/data/address_book.cpp
|
||||
src/data/wallet_index.cpp
|
||||
src/daemon/lifecycle_adapters.cpp
|
||||
src/daemon/embedded_daemon.cpp
|
||||
src/rpc/connection.cpp
|
||||
src/rpc/rpc_client.cpp
|
||||
src/util/base64.cpp
|
||||
src/config/settings.cpp
|
||||
src/resources/embedded_resources.cpp
|
||||
src/util/secure_vault.cpp
|
||||
src/util/platform.cpp
|
||||
${CMAKE_BINARY_DIR}/generated/embedded_text.cpp
|
||||
src/util/coalescing_writer.cpp
|
||||
src/util/logger.cpp
|
||||
src/util/lite_server_probe.cpp
|
||||
src/util/pool_registry_core.cpp
|
||||
@@ -1175,6 +1284,8 @@ if(BUILD_TESTING)
|
||||
src/util/xmrig_updater_core.cpp
|
||||
src/util/daemon_updater.cpp
|
||||
src/util/daemon_updater_core.cpp
|
||||
src/util/bootstrap.cpp
|
||||
src/util/single_instance.cpp
|
||||
${MINIZ_SOURCES}
|
||||
)
|
||||
|
||||
@@ -1187,6 +1298,7 @@ if(BUILD_TESTING)
|
||||
${SODIUM_INCLUDE_DIR}
|
||||
${CURL_INCLUDE_DIRS}
|
||||
${MINIZ_DIR}
|
||||
${libwebp_SOURCE_DIR}/src # <webp/decode.h>, <webp/demux.h> for image_decode_core.cpp
|
||||
)
|
||||
|
||||
target_link_libraries(ObsidianDragonTests PRIVATE
|
||||
@@ -1194,6 +1306,8 @@ if(BUILD_TESTING)
|
||||
sqlite3_amalgamation
|
||||
${SODIUM_LIBRARY}
|
||||
${CURL_LIBRARIES}
|
||||
webp
|
||||
webpdemux
|
||||
)
|
||||
|
||||
target_compile_definitions(ObsidianDragonTests PRIVATE
|
||||
@@ -1201,6 +1315,8 @@ if(BUILD_TESTING)
|
||||
DRAGONX_LITE_BUILD=$<BOOL:${DRAGONX_BUILD_LITE}>
|
||||
DRAGONX_ENABLE_EMBEDDED_DAEMON=$<BOOL:${DRAGONX_ENABLE_EMBEDDED_DAEMON}>
|
||||
DRAGONX_ENABLE_LITE_BACKEND=$<BOOL:${DRAGONX_LITE_BACKEND_READY}>
|
||||
DRAGONX_EMBED_LANG=$<BOOL:${DRAGONX_EMBED_LANG}>
|
||||
DRAGONX_ENABLE_UI_SWEEP=$<BOOL:${DRAGONX_ENABLE_UI_SWEEP}>
|
||||
DRAGONX_TEST_FIXTURE_DIR="${CMAKE_SOURCE_DIR}/tests/fixtures"
|
||||
)
|
||||
|
||||
@@ -1211,6 +1327,13 @@ if(BUILD_TESTING)
|
||||
if(UNIX)
|
||||
target_link_libraries(ObsidianDragonTests PRIVATE ${CMAKE_DL_LIBS})
|
||||
endif()
|
||||
if(WIN32)
|
||||
target_link_libraries(ObsidianDragonTests PRIVATE ws2_32 crypt32 wldap32 psapi iphlpapi shell32 ole32 advapi32 bcrypt)
|
||||
endif()
|
||||
# i18n.cpp includes the generated embedded/lang_*.h headers; a tree that builds only this target needs them too.
|
||||
if(TARGET copy_langs)
|
||||
add_dependencies(ObsidianDragonTests copy_langs)
|
||||
endif()
|
||||
|
||||
add_test(NAME ObsidianDragonPhase4Tests COMMAND ObsidianDragonTests)
|
||||
endif()
|
||||
|
||||
@@ -0,0 +1,432 @@
|
||||
# Sent-history sweep: GUI watermark fix, then daemon follow-up
|
||||
|
||||
**Audience:** ObsidianDragon (GUI) team and the DragonX daemon maintainer.
|
||||
**Date:** 2026-10-16. **Status:** proposal.
|
||||
|
||||
**Code cited**
|
||||
- GUI: `ObsidianDragon` @ `064c9d1` (unprefixed paths).
|
||||
- Daemon: `dragonx` @ `e9247f5c6` (`perf/history-rpc-copies`), paths prefixed `dx:`. `test/v1.3.1-candidate` @ `ff28e34c8` is the same plus a crash fix; on that branch, `dx:wallet.cpp` lines past ~1263 shift by +10.
|
||||
|
||||
Anything not marked *(inference)* or *(estimate)* was verified in the code or measured.
|
||||
|
||||
---
|
||||
|
||||
## 1. Problem
|
||||
|
||||
**The wallet measured:** an isolated copy of the owner's mainnet wallet, run in a network namespace with no peers.
|
||||
- 16,206 Sapling notes, all unspent.
|
||||
- 5 z-addresses and 11,889 wallet txs.
|
||||
- Unencrypted; `wallet.dat` is about 2.1 GB.
|
||||
|
||||
| Call | v1.3.0 | After daemon PR-A+B+C |
|
||||
|---|---|---|
|
||||
| Balance RPCs | ~14 s | ~50 ms |
|
||||
| Windowed `z_listsentbyaddress` (filter type 2) | 19.5 s | 128 ms |
|
||||
| **Full `z_listsentbyaddress` (no watermark)** | **424 s**; RSS 8 → 19.4 GB, never released | **113 s** |
|
||||
| Full-sweep reply | 26.2 MB (all 11,889 txs) | same |
|
||||
|
||||
### Why every launch pays for the full sweep
|
||||
|
||||
- **The watermark lives only in memory.** `history_sent_scan_height_` starts at `-1` (`src/app.h:1764`).
|
||||
- **Every disconnect resets it to `-1`.** That covers daemon restarts, reconnects and wallet switches. The path is `onDisconnected` → `resetTransactionHistoryCacheSession` → `invalidateShieldedHistoryScanProgress(false)` (`src/app_network.cpp:936`, `:1847`, `:1889-1895`).
|
||||
- **Other resets:**
|
||||
- a locked-wallet connect;
|
||||
- imports (`:4013`);
|
||||
- rescans (`:7279`, `:7302`; `src/app.cpp:6298`) and zap (`app.cpp:6352`);
|
||||
- a new, never-scanned z-address (`app_network.cpp:2593-2597`);
|
||||
- the end of a warmup during which a rescan was observed (`:2186-2195`).
|
||||
- **No watermark means a full sweep.** `sentSweepIsFull` is then true (`src/services/history_scan_policy.cpp:35-38`). The GUI sends `[z,0]` via `callLong(..., kFullSentSweepTimeoutSec = 120)` (`src/services/network_refresh_service.cpp:1324-1325`, `history_scan_policy.h:31`).
|
||||
- **The daemon ignores the address argument.** It walks every wallet tx under `LOCK2(cs_main, cs_wallet)` and calls `zsWalletTxJSON(wtx, ret, "*", false, 0, …)` for each one (`dx:src/wallet/rpcwallet.cpp:3719-3746`; the call is at `:3743`).
|
||||
- **The full sweep barely fits, or never fits, the timeout.**
|
||||
- On the fixed daemon the sweep finishes only **7 s inside** the 120 s timeout.
|
||||
- On v1.3.0 it never finishes. Failures back off 10 min → 1 h → 6 h (`fullSentSweepRetryDelaySec`). That backoff is also kept only in memory (`app.h:1765-1766`).
|
||||
|
||||
### What the GUI uses from the reply
|
||||
|
||||
- **Sweep consumer** (`network_refresh_service.cpp:1328-1332`, `appendSentTransparentOutputs` ~`:1076-1123`):
|
||||
- It reads only `txid`, `confirmations`, `blocktime`/`time` and `sends.transparentSends`.
|
||||
- It never reads `received` (20.8 MB of the 26.2 MB) or `spends`.
|
||||
- **Shielded sends come from enrichment.** Every txid goes into `knownTxids` for `z_viewtransaction` enrichment, capped at 25 per cycle (`app.h:1753`). The rest wait in the in-memory `history_enrich_backlog_`.
|
||||
- **One exception:** `locateSweepTxid` (seed-migration resume) does read `sends.saplingSends` (`app_network.cpp:6394`).
|
||||
|
||||
---
|
||||
|
||||
## 2. Part 1: GUI fix (persist the watermark)
|
||||
|
||||
**Goal:** a relaunch, reconnect or daemon restart runs only the windowed sweep, `[z,0,2,(tip-W)+margin]`. A full sweep then happens only in three cases:
|
||||
- there is no persisted state;
|
||||
- the persisted state fails verification;
|
||||
- the periodic safety-net re-sweep is due.
|
||||
|
||||
### 2.1 Where to store it
|
||||
|
||||
**Use the existing encrypted history snapshot.** Store an optional `sent_sweep` object in its JSON payload, not in `settings.json` or `wallets.json`. Why:
|
||||
- The watermark is only valid together with the rows it produced. Both are written in one `INSERT OR REPLACE` in WAL mode (`src/data/transaction_history_cache.cpp:438-457`), so a crash leaves the previous pair intact.
|
||||
- The snapshot already provides what the watermark needs:
|
||||
- per-wallet keying (BLAKE2b of the sorted z+t address set, `:157-186`);
|
||||
- encryption (Argon2id from the passphrase, or salted BLAKE2b when the wallet is unencrypted);
|
||||
- a tip check on load (`:338-344`);
|
||||
- one-wallet pruning (`pruneOtherWallets`, `:655-672`).
|
||||
- **Keep `kSchemaVersion = 1`.** `load()` rejects any other version (`:351`), so a bump would throw away every user's cache.
|
||||
- Unknown keys are ignored, so older builds read new payloads safely.
|
||||
- A missing `sent_sweep` means `W = -1`, which is today's behaviour.
|
||||
|
||||
| Field | Type | Meaning |
|
||||
|---|---|---|
|
||||
| `height` | int | `W`: the tip used for the sweep's `maxDepth`. Read in the same worker job, right before the call |
|
||||
| `anchor_height` | int | `A = W - margin`: the bottom edge of the window the sweep covered |
|
||||
| `anchor_hash` | hex | `H_A = getblockhash(A)`, fetched by the worker after the sweep succeeds |
|
||||
| `backlog` | [txid] | `history_enrich_backlog_`. **Required**, see 2.4 |
|
||||
| `full_failures`, `full_retry_delay_s` | int | Full-sweep backoff. The delay is relative to save time, not absolute |
|
||||
| `daemon_id` | string | Daemon version + start time, from `getinfo` / uptime. If it changes, the restored backoff resets (2.5) |
|
||||
| `last_full_height`, `last_full_time` | int | Last successful full sweep; drives the safety net (2.6) |
|
||||
| `txcount`, `k` | int | Optional tripwire (2.3). Both captured **in the sweep's worker job** |
|
||||
|
||||
Do **not** reuse the snapshot's `tip_height`/`tip_hash` columns. They record the tip at store time, not at sweep time (`app_network.cpp:2069-2070`).
|
||||
|
||||
### 2.2 Code changes
|
||||
|
||||
| File | Change |
|
||||
|---|---|
|
||||
| `src/data/transaction_history_cache.h:20-28`, `:33-48`, `:79-84` | Add a `SentSweepState` struct to `LoadResult`, `SnapshotJob` and `replace()` |
|
||||
| `src/data/transaction_history_cache.cpp:412-426` (`writeSnapshot`) | Emit `sent_sweep` only if `0 < A <= W <= job.tipHeight` |
|
||||
| `src/data/transaction_history_cache.cpp:351-370` (`load`) | Parse with defaults (`W=-1`, empty backlog). Drop the object if it has the wrong type or `W` is above the stored `tip_height` |
|
||||
| `src/data/transaction_history_store_policy.h:35-41` / `.cpp:62-113` | Add `W`/`A`/`H_A` to the `slow` signature. They move every block, so this means a write every 600 s and always on disconnect, lock and exit. Add the backlog to `content` (written within about 20 s). Otherwise an invalidation that only changes the watermark is skipped as "unchanged". Update both callers (`app_network.cpp:2012-2014`, `:2041-2045`). The epoch counter is never persisted, so it doesn't belong in a signature |
|
||||
| `src/app_network.cpp:2066-2079` (`storeTransactionHistoryCacheIfAvailable`) | Fill `job->sentSweep` from the `history_sent_*` and backlog members |
|
||||
| `src/app_network.cpp:1998-2017` (`loadTransactionHistoryCacheIfAvailable`) | Restore the members. `++history_sent_epoch_`. Set `history_sent_verify_pending_ = true`. Apply any **pending rescan action** (2.3) |
|
||||
| `src/app_network.cpp:2028` | The store returns early when there are no rows. On a `persistCache=true` invalidation with no rows, call `clearWallet()` so a stale W can't survive on disk |
|
||||
| `src/services/network_refresh_service.h:256-266`, `:299` | New snapshot/result fields: `anchorHeight`/`anchorHash` in, the verify result and `H_A` out, and the band cursor |
|
||||
| `src/services/network_refresh_service.cpp:1314-1345` | Read the tip immediately before the call. After success, fetch `getblockhash(A)`. Add the verify step (2.3) and the large-window path (2.5) |
|
||||
| `src/app_network.cpp:2784-2792` | Gate both the end-of-pass sweep and the **mid-pass** windowed sweep (`allowMidPassSentSweep`) on the pending verify. Extend the retry gate at `:2792`, which today applies only when `W < 0`, to large windows |
|
||||
| `src/app_network.cpp:2843-2854` | Count large-window failures toward the backoff; today only `sentSweepFull` failures count (`:2849`). Store the band cursor in `App` and reset it with `history_sent_epoch_`, like the watermark |
|
||||
| `src/app.cpp:restoreOriginalWallet` | Call `invalidateShieldedHistoryScanProgress(true)` before `onDisconnected`, as `rescanBlockchain` and zap already do (`app.cpp:6298`, `:6352`) |
|
||||
| `src/services/history_scan_policy.{h,cpp}` | Pure helpers: `sentSweepAnchor(W)`, `sentSweepNeedsLongCall(tip, W)`, `sentSweepMargin(firstAfterLoad)`, `nextSentSweepBand(...)` |
|
||||
|
||||
### 2.3 Invalidation and safety rules
|
||||
|
||||
**Verify at the bottom edge of the window, not at its tip.**
|
||||
- **The check:** before the first windowed sweep after a load, call `getblockhash(A)` and compare it with `H_A`.
|
||||
- **Match:** any fork point lies above `A`, so it is inside the window. Run the windowed sweep.
|
||||
- **Mismatch:** a reorg went deeper than the margin, or this is a different chain or datadir. Drop W and run the banded full sweep (2.5).
|
||||
- **Why not the tip:** checking `getblockhash(W)` would trigger a full sweep on an ordinary one-block reorg of the tip, even though the window already covers that reorg.
|
||||
|
||||
| Event | Action | Mechanism |
|
||||
|---|---|---|
|
||||
| First windowed sweep after a load | Anchor verify (above) | `getblockhash`: O(1), under `cs_main` |
|
||||
| Stored `tip_height` > current tip at load (replay after an unclean stop, older datadir) | **Today:** `load()` deletes the whole snapshot (`cache.cpp:338-344`, `clearWalletByHash` at `:341`). Rows and W are lost, and a full sweep runs | Keep as the default. **Optional improvement:** have `load()` keep the rows and `sent_sweep`, flag "ahead", and defer the sent sweep until `tip >= A`, then verify `H_A`. That requires changes in `sentSweepIsFull` (`history_scan_policy.cpp:37`: `W > tip` means full today), the gate at `app_network.cpp:2789-2792`, and the test at `tests/test_phase4.cpp:15831` |
|
||||
| Wallet switch, or an address added or imported while the GUI was closed | Nothing new | Different address set → different snapshot key → full sweep |
|
||||
| GUI-initiated rescan, zap, import, runtime rescan | Already `invalidate(true)`; with 2.2 it now reaches disk | `app.cpp:6298`, `:6352`; `app_network.cpp:4013`, `:7279`, `:7302` |
|
||||
| New never-scanned z-address during a session | Already resets W (`:2593-2597`); the reset must be persisted | Signature change in 2.2 |
|
||||
| Disconnect, daemon restart, same-wallet reconnect | Keep today's in-memory reset | The disk copy is written first (`:857`) and reloaded after connect |
|
||||
| **Rescan observed in the daemon log** (the daemon's catch-up "Rescanning last N blocks (from block H)", or an external `-rescan`) | **Sticky pending action** (see below) | The warmup-end invalidation at `:2186-2195` runs *before* the snapshot loads, so today it can't reach a persisted W |
|
||||
| Wallet changed outside the GUI with the same address set (Console `z_importviewingkey`, external `-rescan`, same-seed restore) | Optional tripwire: after the first windowed sweep returns R rows, run the banded full sweep if `txcount − txcount_saved > \|R\| − k + slack`. `k` is the rows of the saved sweep within the same `100 + E` depth band the first window uses | `getwalletinfo.txcount` = `mapWallet.size()` (`dx:rpcwallet.cpp:2791`). The GUI already fetches it on every connect (`network_refresh_service.cpp:251-252`, `:295-298`; `app_network.cpp:2140-2143`). Measured cost: ~8-20 ms warm, ~180 ms on the first call after a start |
|
||||
| Daemon version change | Keep W. Reset the persisted backoff | Same filter semantics since 2020; v1.3.0 and the candidate both report 1.3.0.50 |
|
||||
|
||||
**The sticky rescan signal.** Why it's needed:
|
||||
- During warmup no address list is fetched (`scan_policy.h:42-44`, `:64-68`; `app_network.cpp:2492`), and `state_.clear()` has emptied the rows (`wallet_state.h:406-409`).
|
||||
- So when `invalidate(true)` runs at `app_network.cpp:2195`, it finds an empty identity and returns (`:2028`, `:2032`).
|
||||
- The cache then loads afterwards (`:2608`) and would restore the old W unchanged.
|
||||
|
||||
The fix:
|
||||
1. Whenever `rescan_confirmed_active_` is set (`app.cpp:1193`, `:1324`, `:1397`), record a pending action that survives `resetTransactionHistoryCacheSession`:
|
||||
- **clamp** to `H`, parsed from "Rescanning last N blocks (from block H)" or "Rescanning from height H";
|
||||
- **full reset** if `H` is unknown or near genesis.
|
||||
2. `loadTransactionHistoryCacheIfAvailable` applies it to the loaded W (`W = min(W, H)`, and `A` follows) and to the receive-scan heights, then clears it.
|
||||
|
||||
With daemon item D4 (wallet locator on clean shutdown, 3.2) shipped, the daemon's catch-up rescan line disappears from normal starts.
|
||||
|
||||
**Reorg overlap.**
|
||||
- **Window extent.** Filter type 2 drops only txs deeper than N (`dx:rpcwallet.cpp:3740`), and `maxDepth = tip − W + margin`. So the window covers heights ≥ `W − margin + 1`, plus the mempool (depth 0 always passes).
|
||||
- **Margin.**
|
||||
- `MAX_REORG_LENGTH` defaults to 99 (`dx:src/main.h:79`), and a longer reorg shuts the node down (`dx:main.cpp:4314-4333`).
|
||||
- A margin of 100 therefore leaves 1 block of slack, which tip drift between reading the tip and making the call can use up.
|
||||
- Use **margin ≈ 120** in steady state *(recommendation)*, and read the tip in the same worker job as the call.
|
||||
- **First sweep after a load.** Use margin `120 + E` with E ≈ 1000 blocks (~10 h).
|
||||
- This absorbs wallet-notifier lag at save time: `ThreadNotifyWallets` applies blocks asynchronously (`dx:src/validationinterface.cpp:89-262`), and no RPC exposes the wallet's processed tip.
|
||||
- E is an inferred bound, not a measurement.
|
||||
- **Large `-maxreorg`.** A `-maxreorg` set above the margin is caught by the anchor check.
|
||||
- **Conflicted txs.** Conflicted txs (depth −1) are dropped by both the full and the windowed sweep (`dx:rpcwallet.cpp:3725`), so nothing changes there.
|
||||
|
||||
**Locked-wallet rule.**
|
||||
- **The rule:** never advance or persist W from a sweep dispatched or completed while the wallet is locked, and never load or store the snapshot while it is locked.
|
||||
- **What already protects this today:**
|
||||
- History scans don't run while locked (`src/services/scan_policy.h:48-51`).
|
||||
- An encrypted wallet's snapshot opens only after unlock (`app_network.cpp:797-798`, `:1944-1962`).
|
||||
- Locking keeps W and stores immediately (`:1851-1859`).
|
||||
- **What breaks if it's ever violated:** a locked daemon wallet returns empty `saplingReceived` and `saplingSends`, because `GetSaplingHistoryKeys` needs spending keys (`dx:rpchushwallet.cpp:19-39`).
|
||||
- The fields the main consumer reads (txid, `transparentSends`) don't depend on the lock.
|
||||
- `locateSweepTxid` reads `saplingSends`, so it must never run on a locked wallet.
|
||||
|
||||
### 2.4 Backlog rule (required)
|
||||
|
||||
- **The risk.** Enrichment is capped at 25 per cycle, and the txids that miss the cap go to `history_enrich_backlog_` (`app_network.cpp:2843`).
|
||||
- A pure z→z send leaves only a change note, which produces no receive row (`network_refresh_service.cpp:972`).
|
||||
- So its txid exists **only** in a sweep result.
|
||||
- If W is persisted while those txids sit in the backlog, the sends are lost for good once the window moves past them.
|
||||
- **The rule:** persist the backlog together with W.
|
||||
- **Rejected fallback:** "persist `W = -1` while the backlog is non-empty" isn't enough on its own. Enrichment retries are unbounded (`network_refresh_service.cpp:1521`), so one txid that keeps failing would stop W from ever being persisted. If that fallback is wanted as well, cap the retries per txid.
|
||||
|
||||
### 2.5 Timeouts, large windows, banded full sweep
|
||||
|
||||
**Large windows.**
|
||||
- The windowed sweep uses `rpc.call`, which has curl's 30 s timeout (`src/rpc/rpc_client.cpp:223`). Only full-sweep failures count toward the backoff (`app_network.cpp:2846-2853`).
|
||||
- So a watermark saved days ago produces a huge "windowed" call that times out on every block, while the daemon keeps working on each abandoned call.
|
||||
- Rule: if `tip − W > 2400` (about 1 day of 36 s blocks), use `callLong` and count failures the same way as for a full sweep.
|
||||
|
||||
**Banded full sweep.**
|
||||
- **The call:** replace the single 113 s (fixed daemon) or 424 s (v1.3.0) call with depth bands, `z_listsentbyaddress(z, minconf=a, 2, maxDepth=b)`. `minconf` (`dx:rpcwallet.cpp:3732`) and filter type 2 (`:3740`) are independent filters, so each call returns exactly the depth band `[a, b]`.
|
||||
- **Order:**
|
||||
- Go newest band first; the first band uses `minconf=0` so it includes the mempool.
|
||||
- Overlap bands by at least 10 blocks, plus the blocks that arrive between calls, and dedupe by txid.
|
||||
- Size bands adaptively, aiming for about 5 s each.
|
||||
- **Scheduling:** run **one band per history cycle**, using the epoch-guarded band cursor from 2.2. All bands in one job would tie up the shared RPC worker for about 113 s, exactly as today.
|
||||
- **Watermark:** take `W` from the tip at the first band, and set it only after every band has succeeded.
|
||||
- **Don't page with `nCount`.** The iteration order is `nOrderPos`, not height (`:3709`, `:3746`).
|
||||
- **Limits:**
|
||||
- Banding shortens each `LOCK2` hold but not the total CPU.
|
||||
- On v1.3.0 every call has a ~19.5 s floor, because every `CWalletTx` is deep-copied *(inference from the windowed measurement)*. There the bands need `callLong`.
|
||||
|
||||
**Backoff persistence.**
|
||||
- Store the failure count and the relative delay (2.1), so a relaunch doesn't immediately re-run a sweep that keeps failing.
|
||||
- Reset both when `daemon_id` changes. Otherwise a user who installs the fixed daemon could wait up to 6 h for a full sweep that would now succeed.
|
||||
|
||||
### 2.6 Safety net
|
||||
|
||||
- **When:** about every 7 days, run one banded full re-sweep.
|
||||
- **Conditions:** only when synced, unlocked, idle (not on the Mining tab) and admitted by the governor.
|
||||
- **On success**, update `last_full_*`. On failure, back off.
|
||||
- **Why:** it catches what the guards can't see, such as a Console `z_importviewingkey`. That case is also missed within a session today.
|
||||
|
||||
### 2.7 Edge cases
|
||||
|
||||
| Case | Handling |
|
||||
|---|---|
|
||||
| Stored tip < current tip: `load()` doesn't check the hash (`cache.cpp:338-344`) | The anchor verify covers the watermark. Optionally extend `load()` too |
|
||||
| Transparent send rows from `appendSentTransparentOutputs` aren't reproduced by windowed sweeps | Probably dropped on the next clean cycle (`network_refresh_service.cpp:1356-1368`, `:1528-1539`) *(inference)*. Negligible on DragonX, where consensus bans non-notary/burn t-outputs (`dx:main.cpp:1606`, `:1633`, `:1657`). Optional fix: carry over `send` rows that `listtransactions` didn't produce |
|
||||
| `locateSweepTxid` runs a full sweep under the 30 s timeout (`app_network.cpp:6388`) | That times out on this wallet. Switch it to a windowed or banded call |
|
||||
| Watch-only addresses aren't part of the snapshot key | Covered by the tripwire and the safety net |
|
||||
| Switching between two wallets | `pruneOtherWallets` keeps one snapshot, so a switch still costs a full sweep (open question 4) |
|
||||
| GUI crash mid-sweep | W advances only on `sentSweepOk`, and the write is atomic |
|
||||
| Regtest or testnet | Different z-address prefixes, so a different snapshot key |
|
||||
|
||||
### 2.8 Test plan
|
||||
|
||||
**Unit tests** (`tests/test_phase4.cpp`):
|
||||
- In `testTransactionHistoryCache` (`:9057`):
|
||||
- every `sent_sweep` field round-trips;
|
||||
- a missing key reads as −1;
|
||||
- a wrong type, `W > tip_height` or `A > W` reads as −1;
|
||||
- unknown keys are ignored.
|
||||
- Signatures (~`:9244`): a change to W, A or `H_A` changes `slow`; a change to the backlog changes `content`.
|
||||
- Policy (~`:15829`, `:16098-16213`):
|
||||
- the anchor is `W − margin`;
|
||||
- the first window after a load uses `120 + E`;
|
||||
- `tip − W > 2400` uses `callLong` and counts failures;
|
||||
- band planning: boundaries, overlap, dedupe, and cursor reset on an epoch change;
|
||||
- an anchor mismatch means a full sweep;
|
||||
- the pending rescan action clamps W;
|
||||
- `daemon_id` changes reset the backoff.
|
||||
|
||||
**Integration tests** (regtest or `-testnode`):
|
||||
1. Full sweep, quit, relaunch: the log shows only `[z,0,2,N]` calls.
|
||||
2. `invalidateblock` one block above A while the GUI is closed: the windowed sweep runs and returns the right rows.
|
||||
3. `invalidateblock` below A: the verify fails and a banded full sweep runs.
|
||||
4. More than 25 z→z sends, then quit before enrichment finishes: after relaunch every send appears.
|
||||
5. A daemon started with `-rescan` while the GUI was closed: the sticky action clamps or resets W.
|
||||
6. `restoreOriginalWallet`: W is invalidated.
|
||||
7. Locked encrypted wallet: W doesn't advance and the snapshot isn't loaded.
|
||||
|
||||
**Mainnet copy:** relaunch against the isolated owner copy. Expect no `[z,0]` call and a sent sweep well under 1 s.
|
||||
|
||||
### 2.9 Expected effect
|
||||
|
||||
| Scenario | Today | With Part 1 |
|
||||
|---|---|---|
|
||||
| Relaunch or reconnect, fixed daemon | 113 s full sweep under `LOCK2`, 7 s from the timeout | `getblockhash` plus one windowed sweep over `120 + E` + the closure gap. Steady-state windows measure ~128 ms; the first window after a load is larger *(unmeasured)* |
|
||||
| Relaunch, v1.3.0 daemon | 424 s; never fits in 120 s, so it loops in backoff with RSS at 19.4 GB | ~19.5 s+ windowed once a W exists. Seeding the first W needs the banded sweep |
|
||||
| Safety net | — | One banded full sweep a week, each `LOCK2` hold about 5 s |
|
||||
|
||||
---
|
||||
|
||||
## 3. Part 2: daemon follow-up
|
||||
|
||||
### Where the 113 s goes (owner wallet, full sweep)
|
||||
|
||||
| Component | Cost | Basis |
|
||||
|---|---|---|
|
||||
| Trial decryption of outputs that aren't wallet notes: ~172,800 outputs × 5 ivks ≈ 864k failed tries × ~0.13 ms | **~112 s (~99%)** | Output count derived from tx sizes; per-try cost fitted to the full (112.7 s) and windowed (126 ms / 740 tries) measurements *(estimate)*. Code: `OutputDecryptions::Get` (`dx:rpchushwallet.cpp:87-100`), loop at `:303-346` |
|
||||
| `DecryptSent` esk-path decrypts | **0 on this wallet** | No tx spends a wallet note, so the sends helper is gated off (`:586`) |
|
||||
| UniValue build + copy under `LOCK2` | ~0.28 s | Synthetic benchmark on the repo's UniValue |
|
||||
| Reply serialization (after the lock is released) | ~0.31-0.34 s | Same benchmark |
|
||||
|
||||
### Where the foreign outputs come from
|
||||
|
||||
**Sietch padding.** `z_sendmany` pads its z-recipients up to `-sietch-min-zouts` (default 7, clamped to 3-50; `dx:rpcwallet.cpp:5424-5434`), with change on top.
|
||||
- Recipient and dummy outputs take the sender's ovk: the from-address's `fvk.ovk`, or the seed ovk for t-address sources (`dx:asyncrpcoperation_sendmany.cpp:352-368`, applied at `:437`).
|
||||
- Consolidation (`saplingconsolidation.cpp:244`), sweep (`sweep.cpp:299`), shieldcoinbase (`:239-254`) and autoshield (`:435`) also add dummies with the sender's ovk.
|
||||
- So a **sending** wallet's `DecryptSent` opens every one of its own dummies. That is the ~0.36 ms esk-path decrypt each, which is D2.
|
||||
- Every other wallet sees them as foreign outputs and tries them under every ivk, which is D1.
|
||||
|
||||
**On this wallet** most foreign outputs are **not** sietch dummies. About 158.6k of the ~174k sit in the 9,597 txs with 10 or more outputs, which are mostly multi-recipient payouts *(inference from tx sizes)*. That doesn't change D1.
|
||||
|
||||
### Items
|
||||
|
||||
| # | Item | Output | Gain (owner wallet) | Risk | GUI change |
|
||||
|---|---|---|---|---|---|
|
||||
| D1 | Per-output negative ivk cache, plus parallel pre-fill of the txs a call selects | Byte-identical | Warm 113 s → 0.4-1 s. First full sweep after a restart ~20-25 s (pre-fill at the measured 4.75× warm-up scaling). A windowed call stays ~100 ms *(estimate)* | Low-med | None |
|
||||
| D2 | `DecryptSent` cache per (output, ovk) | Byte-identical | 0 here; ~0.36 ms per send output on sending wallets | Low | None |
|
||||
| D3 | `getalldata` balance loop | Byte-identical | ~20-35 s → 10-30 ms *(estimate)* | Low | None (the GUI never calls it) |
|
||||
| D4 | Write the wallet locator on clean shutdown | n/a | Start: −49-62 s (post-rescan locator write, *inferred from the log gap*) and −5-10 s of block rescan; the ~25 s witness-root pass moves to the first live block. Shutdown: +3-60 s *(inferred)* | Medium | Check the stop policy |
|
||||
| D5 | JSON trims | Byte-identical | 0.2-0.4 s | Low | None |
|
||||
| D6 | Sends-only `returnType` (6th parameter) | **Changes output; opt-in** | 113 s → 0.1-0.3 s; 26.2 → ~4.1 MB *(estimate)* | Low | Probe + opt-in |
|
||||
| D7 | Honour the address argument | **Changes output** | None (slower) | Breaks the GUI | Not recommended |
|
||||
|
||||
- **D6 is the single largest win**, but it needs a GUI release.
|
||||
- **D1-D5 are invisible to the GUI.**
|
||||
- **With Part 1 shipped, full sweeps become rare**, so D1 mostly helps older GUIs, other clients and the safety net.
|
||||
|
||||
### 3.1 D1: per-output decryption cache (byte-identical)
|
||||
|
||||
**Shape**
|
||||
- A `mutable std::vector<uint64_t>` on `CWalletTx`, with one mask per `vShieldedOutput` index.
|
||||
- It is sized once, never serialized, and cleared in `Init()`.
|
||||
- Precedents: `cachedPlaintext` (`dx:src/wallet/wallet.h:340-348`) and the mutable caches at `:472-490`.
|
||||
- Bit k means "history key k does not decrypt this output".
|
||||
- An append-only ivk → index registry on `CWallet`, under `cs_wallet`. It is resolved once per RPC into `SaplingHistoryKeys::SpendableAddress` (`dx:rpchushwallet.cpp:19-39`).
|
||||
|
||||
**Logic in `OutputDecryptions::Get`**
|
||||
- If the bit is set, return null without decrypting.
|
||||
- After a failed `Decrypt`, set the bit.
|
||||
- Never cache a success: wallet notes already carry `cachedPlaintext`.
|
||||
- Keys with index ≥ 64 aren't cached.
|
||||
|
||||
**Invariant: the result is a pure function of (txid, n, ivk).** A v4 txid commits to every output byte, so:
|
||||
- nothing ever goes stale;
|
||||
- a newly added key has a clear bit and is still tried;
|
||||
- locking, or losing keys, only means fewer keys are consulted;
|
||||
- reorgs, deletions and reloads are harmless;
|
||||
- output stays byte-identical.
|
||||
|
||||
**Thread safety**
|
||||
- The mask is mutable behind a `const CWalletTx&`, so the compiler won't enforce locking. Every mask read and write therefore happens on the RPC thread under `cs_wallet`, with an `AssertLockHeld` in the accessor.
|
||||
- Pre-fill workers follow `FillSaplingNoteCache`'s rule that workers must not lock, or even assert a lock (`dx:wallet.cpp:5745-5746`). They call only `decrypt_with_note` on pre-resolved (output, ivk) pairs and write private masks, which are merged serially after the join.
|
||||
- Workers must not call `GetSaplingHistoryKeys` or `IsNullifierOfAddress`, because both assert locks.
|
||||
|
||||
**Fill**
|
||||
- Lazily, as the helpers run.
|
||||
- In addition, pre-fill in parallel, but **only the txs the call has already selected**:
|
||||
1. In `z_listsentbyaddress`, first collect the `wtx` pointers that pass the filters at `dx:rpcwallet.cpp:3722-3741`, honouring `nCount`.
|
||||
2. Pre-fill their outputs, using the 128-item threshold and `-witnessbuildthreads` from `dx:wallet.cpp:5739-5797`.
|
||||
3. Then emit.
|
||||
- Pre-fill time is spent under `LOCK2`. For a full sweep that's ~20-25 s once; a windowed call stays small.
|
||||
- Optionally, also record the misses `FindMySaplingNotes` already computes (`:2282-2322`).
|
||||
- A startup precompute is opt-in only: ~112 CPU-s, about 24 s of wall time per start *(estimate)*.
|
||||
|
||||
**Also benefits:** `getalldata`'s tx list (`dx:rpcwallet.cpp:3254`) and `z_listreceivedaddress` (`:3555`).
|
||||
|
||||
**Tests**
|
||||
- Extend the verbatim oracle `dx:src/gtest/test_wallet_history.cpp`: cold, warm and pre-filled runs must match byte for byte.
|
||||
- A key added after the mask was filled, which opens a foreign output, must appear in the output.
|
||||
- More than 64 keys; locked and encrypted wallets; `-witnessbuildthreads=1` vs 24; a `DEBUG_LOCKORDER` build.
|
||||
- The mainnet A/B harness: `cmp` cold and warm replies against the captured 26,233,144-byte reply (`/home/d/dragonx-ab/mainnet-ab/out/B/cap/023_z_listsentbyaddress_addr0_full.json`).
|
||||
|
||||
**D2.** Cache `DecryptSent` results per (n, ovk index): the plaintext and address on success, or "no". That's about 650 B per entry. Key the seed ovk by value, so lock state still decides whether the cache is consulted. Use the same oracle tests as D1.
|
||||
|
||||
### 3.2 Smaller items
|
||||
|
||||
**D3: the `getalldata` balance loop** (`dx:rpcwallet.cpp:3108-3151`, under `LOCK2` at `:2998`).
|
||||
- Today it copies each `SaplingNoteData` by value, witnesses included (`:3110`). For every unspent note and every address it re-derives the spending key and trial-decrypts (`:3117-3121`).
|
||||
- Fix:
|
||||
- build `SaplingHistoryKeys` once, and reuse it at `:3233`;
|
||||
- take note data by `const&`;
|
||||
- use `OutputDecryptions(wtx, op.n).Get(a.ivk)` over `keys.spendable`, moving `OutputDecryptions` out of its anonymous namespace (`rpchushwallet.cpp:56-120`) into `rpchushwallet.h`.
|
||||
- Keep two quirks for byte-identity, and fix them later as a separate change with a release note:
|
||||
- The no-op `txType == 3;` at `:3124` means locked notes are never counted as locked. When fixed, use a per-note local, `noteType = (txType == 0 && IsLockedNote(op)) ? 3 : txType`. `txType` is per tx (`:3043`), so assigning to it would mark every later note of the tx as locked.
|
||||
- Every address whose ivk opens the note is credited, because the `continue` doesn't exit the address loop.
|
||||
- Don't switch to `GetFilteredNotes`: that changes the output.
|
||||
- Test: an oracle gtest with a verbatim copy of the old loop (shared-ivk, locked-note, watch-only and encrypted cases).
|
||||
|
||||
**D4: write the wallet locator on clean shutdown.**
|
||||
- **Evidence:** the copy, taken from a clean Windows shutdown, was 12 blocks behind its chainstate. So every start rescans those blocks.
|
||||
- **Cause:**
|
||||
- `FlushStateToDisk` writes the locator only if `DATABASE_WRITE_INTERVAL` (3600 s, `dx:main.h:145`) has passed, even for `FLUSH_STATE_ALWAYS` (`dx:main.cpp:3838-3841`).
|
||||
- `Shutdown()` calls it at `dx:init.cpp:259`.
|
||||
- `pwalletMain->Flush(true)` at `:280` only flushes BDB.
|
||||
- **Hook:** in `Shutdown()`, between `:259` and `:280`:
|
||||
|
||||
```cpp
|
||||
LOCK2(cs_main, pwalletMain->cs_wallet);
|
||||
const CBlockIndex* tip = pwalletMain->GetWalletTip(); // new accessor: pindexWalletTip is private (wallet.h:988-994)
|
||||
if (tip && tip != lastStamped)
|
||||
pwalletMain->SetBestChainINTERNAL(walletdb, chainActive.GetLocator(tip));
|
||||
```
|
||||
|
||||
- **Don't route it through `CWallet::SetBestChain`.** That swaps in `pindexWalletTip` when set but falls back to the passed locator when it's null (`dx:wallet.cpp:829-834`). The null case is exactly "stamp the tip before the init rescan ran", which would skip the unscanned blocks for good.
|
||||
- **The failure path needs the lock too.** AppInit2's failure path calls `Shutdown()` without `join_all` (`dx:bitcoind.cpp:232-243`), so the notifier can still be running. Reading the tip under `LOCK2` keeps that safe.
|
||||
- **Don't drop the interval check globally.** `gettxoutsetinfo` and init also flush with ALWAYS, and each would then rewrite ~2 GB under `cs_main`.
|
||||
- **Trade-off:** one locator write rewrites every note-bearing `CWalletTx`, ~2 GB here (`SetBestChainINTERNAL`, `dx:wallet.h:939-986`, WriteTx loop `:947-962`). The cost moves to shutdown rather than disappearing; see the D4 row in the table above.
|
||||
- **Ship it together with a parallel witness-root check at init.**
|
||||
- **Risk:** the GUI's stop policy (`src/daemon/daemon_controller.h:360`, `evaluateStopWait`) must allow about 16 s plus 3-60 s. Context: the 2026-09-30 Windows kill loop.
|
||||
- **Tests:**
|
||||
- Stop within the hour and restart: no "Rescanning last" line, and identical balances.
|
||||
- Stop during the startup rescan: the locator does not advance.
|
||||
|
||||
**D5: JSON trims.**
|
||||
- Use `static_cast<const CTransaction&>` at `dx:rpchushwallet.cpp:553`. Today it copies 190.6 MB of tx data per full sweep.
|
||||
- Drop the `getValues` / reverse / `push_backV` copy (`dx:rpcwallet.cpp:3749-3755`). Collect the selected `wtx` pointers in the existing reverse loop, which keeps `nCount` picking the newest txs, then build `ret` oldest to newest. The same pattern is at `getalldata` `:3259-3265`.
|
||||
|
||||
**Also found; each needs its own design.**
|
||||
- The hourly `SetBestChain` rewrites ~2 GB while `cs_main` is held (3-47 s gaps in the live log, *inferred from log gaps*).
|
||||
- `FindMySaplingNotes` tries foreign outputs once per viewing key and again per address (`dx:wallet.cpp:2282-2322`). De-duplicating ivks would probably halve the per-block tries *(inference)*.
|
||||
|
||||
### 3.3 Options that change RPC output (need a GUI opt-in)
|
||||
|
||||
**D6: sends-only mode.**
|
||||
- **The parameter:** add an optional 6th parameter `returnType` to `z_listsentbyaddress`, default 0 (unchanged output).
|
||||
- The code's meanings are 1 = spends, 2 = received, 3 = sends. The comment at `dx:rpchushwallet.cpp:561-566` swaps 1 and 3; fix it in the same change.
|
||||
- **With `3` and no address filter:**
|
||||
- the spends helper still runs (`:569`), but its output isn't emitted;
|
||||
- `received`, where all the trial decryption happens, is skipped (`:576-581`);
|
||||
- `sends` is emitted (`:584-592`), and every tx is still pushed (`:594-598`).
|
||||
- The GUI gets the same txids and the same `sends.transparentSends`.
|
||||
- **Daemon changes:**
|
||||
- Raise the check at `dx:rpcwallet.cpp:3606` from `> 5` to `> 6`, keeping the `== 3` rejection.
|
||||
- Change `:3693` to `params.size() >= 5`. Today it reads `nCount` only when `size() == 5`, so every 6-parameter call would silently drop it.
|
||||
- Read `params[5]`, reject anything outside 0-3, and document it in the help text. `dx:src/rpc/client.cpp:165` already converts index 5.
|
||||
- **GUI side:** send 6 parameters. An old daemon throws its help text for more than 5, so fall back to 5 parameters and remember that per connection.
|
||||
- Alternative: a new RPC name, probed through "Method not found" the same way as `z_autoshieldstatus` (`app.h:1744-1747`).
|
||||
- **Owner decision:** a variant that returns only txs with wallet spends would return 0 rows here, but it changes the `knownTxids` set.
|
||||
|
||||
**D7: honour the address argument.**
|
||||
- The GUI passes `shieldedAddresses.front()` (`network_refresh_service.cpp:1319`) and relies on the daemon using `"*"` (`dx:rpcwallet.cpp:3743`). Honouring the argument by default would silently shrink GUI sweeps to one address.
|
||||
- It isn't faster either: with a filter set, `fBool=true` runs `zsTxSendsToJSON` for every tx (`:586`).
|
||||
- Recommendation: document `"*"` as a compatibility contract. If honouring the argument is ever wanted, put it behind a flag.
|
||||
|
||||
**Additive and safe for the GUI: a wallet-tip / history fingerprint.**
|
||||
- Expose `pindexWalletTip`, for example in `getwalletinfo` or a new `z_getwallethistorystate(maxheight)` returning `{count, txid-hash, wallet_tip}`.
|
||||
- The GUI ignores unknown JSON fields.
|
||||
- This would make Part 1's invalidation exact, replacing the E-margin guess and the `txcount` tripwire.
|
||||
|
||||
---
|
||||
|
||||
## 4. Rollout and compatibility
|
||||
|
||||
| Step | Ships in | Works with | Notes |
|
||||
|---|---|---|---|
|
||||
| Part 1: watermark + anchor verify + backlog + sticky rescan + large-window path + banding + safety net | Next GUI release | Every daemon version; it uses only existing `z_listsentbyaddress` parameters, `getblockhash`, `getwalletinfo` and `getinfo` | Old GUI with a new snapshot: the key is ignored. New GUI with an old snapshot: one full sweep, which is today's behaviour. On v1.3.0, seeding the first W needs the banded sweep |
|
||||
| D1, D2, D3, D5 | Daemon v1.3.1 / v1.3.2 | Invisible to the GUI | Gate each on the byte-identical oracle and the mainnet `cmp` |
|
||||
| D6 + GUI probe | Daemon first, then GUI | The GUI falls back on old daemons | Makes the safety net and any full sweep cheap |
|
||||
| D4 | Together with a parallel witness-root check | Invisible apart from shutdown time | Confirm the GUI stop policy first |
|
||||
|
||||
---
|
||||
|
||||
## 5. Open questions
|
||||
|
||||
1. How big is the backlog after the first full sweep on the owner's wallet? Unmeasured; it sizes the persisted `backlog` field.
|
||||
2. What is the full-sweep time on the owner's 24-core Windows node? The 113 s was measured on Linux/WSL, 7 s inside the 120 s timeout.
|
||||
3. How long does the wallet notifier lag after a catch-up on this wallet? This sets E.
|
||||
4. Should the snapshot store keep more than one wallet, so that switching wallets also benefits?
|
||||
5. D6 shape: a 6th parameter or a new RPC? Should a "wallet-spends-only" variant exist?
|
||||
6. Does any client besides ObsidianDragon depend on `returnType 0` output? This sets D1's priority once D6 exists.
|
||||
7. Are the 5 ivks distinct? Assumed. The per-address note counts sum to exactly 16,206, which fits but doesn't prove it.
|
||||
8. Does the owner want the additive wallet-tip / fingerprint RPC, which makes Part 1's invalidation exact?
|
||||
@@ -349,6 +349,7 @@ build_dev() {
|
||||
-DCMAKE_BUILD_TYPE="$BUILD_TYPE" \
|
||||
-DCMAKE_CXX_FLAGS_RELEASE="-O3 -DNDEBUG" \
|
||||
-DDRAGONX_USE_SYSTEM_SDL3=ON \
|
||||
-DDRAGONX_ENABLE_UI_SWEEP=ON \
|
||||
"${CMAKE_LITE_ARGS[@]}"
|
||||
|
||||
info "Building with $JOBS jobs ..."
|
||||
@@ -363,8 +364,8 @@ build_dev() {
|
||||
# ═══════════════════════════════════════════════════════════════════════════════
|
||||
build_release_linux() {
|
||||
header "Release: Linux x86_64"
|
||||
local bd="$SCRIPT_DIR/build/linux"
|
||||
local out="$SCRIPT_DIR/release/linux"
|
||||
local bd="${DRAGONX_BUILD_ROOT:-$SCRIPT_DIR/build}/linux"
|
||||
local out="${DRAGONX_RELEASE_ROOT:-$SCRIPT_DIR/release}/linux"
|
||||
|
||||
if $CLEAN; then
|
||||
info "Cleaning $bd ..."; rm -rf "$bd"
|
||||
@@ -385,15 +386,17 @@ build_release_linux() {
|
||||
exit 1
|
||||
fi
|
||||
info "Configuring ..."
|
||||
# UI sweep OFF explicitly: build/linux is shared with dev builds, which cache it ON.
|
||||
cmake "$SCRIPT_DIR" \
|
||||
-DCMAKE_BUILD_TYPE=Release \
|
||||
-DCMAKE_CXX_FLAGS_RELEASE="-O3 -DNDEBUG" \
|
||||
-DDRAGONX_USE_SYSTEM_SDL3=ON \
|
||||
-DDRAGONX_ENABLE_UI_SWEEP=OFF \
|
||||
"${BDB_ARGS[@]}" \
|
||||
"${CMAKE_LITE_ARGS[@]}"
|
||||
|
||||
info "Building with $JOBS jobs ..."
|
||||
cmake --build . -j "$JOBS"
|
||||
cmake --build . -j "$JOBS" --target dragonx_release
|
||||
|
||||
[[ -f "bin/${APP_BASENAME}" ]] || { err "Linux build failed"; exit 1; }
|
||||
|
||||
@@ -577,8 +580,8 @@ APPRUN
|
||||
# ═══════════════════════════════════════════════════════════════════════════════
|
||||
build_release_win() {
|
||||
header "Release: Windows x86_64 (cross-compile)"
|
||||
local bd="$SCRIPT_DIR/build/windows"
|
||||
local out="$SCRIPT_DIR/release/windows"
|
||||
local bd="${DRAGONX_BUILD_ROOT:-$SCRIPT_DIR/build}/windows"
|
||||
local out="${DRAGONX_RELEASE_ROOT:-$SCRIPT_DIR/release}/windows"
|
||||
|
||||
if $CLEAN; then
|
||||
info "Cleaning $bd ..."; rm -rf "$bd"
|
||||
@@ -646,13 +649,16 @@ TOOLCHAIN
|
||||
mkdir -p "$RES"
|
||||
|
||||
SAPLING_SPEND="" SAPLING_OUTPUT="" ASMAP_DAT=""
|
||||
find_sapling_params || true
|
||||
find_asmap || true
|
||||
|
||||
if [[ -n "$SAPLING_SPEND" && -n "$SAPLING_OUTPUT" ]]; then
|
||||
cp -f "$SAPLING_SPEND" "$RES/sapling-spend.params"
|
||||
cp -f "$SAPLING_OUTPUT" "$RES/sapling-output.params"
|
||||
local EMBED_NODE_DATA=false
|
||||
should_bundle_full_node_assets && EMBED_NODE_DATA=true
|
||||
if $EMBED_NODE_DATA; then
|
||||
find_sapling_params || true
|
||||
find_asmap || true
|
||||
fi
|
||||
|
||||
# Lite needs no Sapling params/asmap (its backend embeds its own), so it always gets a header for
|
||||
# images/themes/xmrig; the full node still requires the params to be found.
|
||||
if ! $EMBED_NODE_DATA || [[ -n "$SAPLING_SPEND" && -n "$SAPLING_OUTPUT" ]]; then
|
||||
cat > "$GEN/embedded_data.h" <<HDR
|
||||
// Auto-generated — DO NOT EDIT (build.sh --win-release)
|
||||
#pragma once
|
||||
@@ -660,16 +666,27 @@ TOOLCHAIN
|
||||
#include <cstddef>
|
||||
#include "incbin.h"
|
||||
|
||||
HDR
|
||||
if $EMBED_NODE_DATA; then
|
||||
cp -f "$SAPLING_SPEND" "$RES/sapling-spend.params"
|
||||
cp -f "$SAPLING_OUTPUT" "$RES/sapling-output.params"
|
||||
cat >> "$GEN/embedded_data.h" <<HDR
|
||||
INCBIN(sapling_spend_params, "$RES/sapling-spend.params");
|
||||
INCBIN(sapling_output_params, "$RES/sapling-output.params");
|
||||
HDR
|
||||
|
||||
if [[ -n "$ASMAP_DAT" ]]; then
|
||||
cp -f "$ASMAP_DAT" "$RES/asmap.dat"
|
||||
echo "INCBIN(asmap_dat, \"$RES/asmap.dat\");" >> "$GEN/embedded_data.h"
|
||||
if [[ -n "$ASMAP_DAT" ]]; then
|
||||
cp -f "$ASMAP_DAT" "$RES/asmap.dat"
|
||||
echo "INCBIN(asmap_dat, \"$RES/asmap.dat\");" >> "$GEN/embedded_data.h"
|
||||
else
|
||||
echo 'extern "C" { static const uint8_t* g_asmap_dat_data = nullptr; }' >> "$GEN/embedded_data.h"
|
||||
echo 'static const unsigned int g_asmap_dat_size = 0;' >> "$GEN/embedded_data.h"
|
||||
fi
|
||||
else
|
||||
echo 'extern "C" { static const uint8_t* g_asmap_dat_data = nullptr; }' >> "$GEN/embedded_data.h"
|
||||
echo 'static const unsigned int g_asmap_dat_size = 0;' >> "$GEN/embedded_data.h"
|
||||
info "Lite mode: not embedding Sapling params/asmap (the SDXL backend embeds its own)"
|
||||
for sym in sapling_spend_params sapling_output_params asmap_dat; do
|
||||
echo "extern \"C\" { static const uint8_t* g_${sym}_data = nullptr; }" >> "$GEN/embedded_data.h"
|
||||
echo "static const unsigned int g_${sym}_size = 0;" >> "$GEN/embedded_data.h"
|
||||
done
|
||||
fi
|
||||
|
||||
# ── Daemon binaries ──────────────────────────────────────────────
|
||||
@@ -809,6 +826,7 @@ HDR
|
||||
info "Embedded resources header generated"
|
||||
else
|
||||
warn "Building WITHOUT embedded resources (Sapling params not found)"
|
||||
rm -f "$GEN/embedded_data.h" # never compile a stale header left by the other variant in this shared build dir
|
||||
fi
|
||||
|
||||
# ── Fetch libsodium for Windows if needed ──────────────────────────────
|
||||
@@ -849,12 +867,13 @@ HDR
|
||||
-DCMAKE_TOOLCHAIN_FILE="$bd/mingw-toolchain.cmake" \
|
||||
-DCMAKE_BUILD_TYPE=Release \
|
||||
-DDRAGONX_USE_SYSTEM_SDL3=OFF \
|
||||
-DDRAGONX_ENABLE_UI_SWEEP=OFF \
|
||||
"${BDB_ARGS[@]}" \
|
||||
"${FT_CMAKE_ARG[@]}" \
|
||||
"${CMAKE_LITE_ARGS[@]}"
|
||||
|
||||
info "Building with $JOBS jobs ..."
|
||||
cmake --build . -j "$JOBS"
|
||||
cmake --build . -j "$JOBS" --target dragonx_release
|
||||
|
||||
[[ -f "bin/${APP_BASENAME}.exe" ]] || { err "Windows build failed"; exit 1; }
|
||||
# Strip the app exe — the Linux and macOS release paths already strip theirs, and even the Windows
|
||||
@@ -896,9 +915,14 @@ HDR
|
||||
info "Lite mode: skipping daemon and Sapling/asmap assets in Windows zip"
|
||||
fi
|
||||
|
||||
# Bundle xmrig for mining support
|
||||
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"
|
||||
# The exe embeds xmrig (HAS_EMBEDDED_XMRIG) and getXmrigPath() always prefers / re-extracts the copy in
|
||||
# %APPDATA%\<app>\dragonx, so a loose exe-dir xmrig.exe is never used. Ship it loose only when not embedded.
|
||||
if grep -q '^#define HAS_EMBEDDED_XMRIG 1' "$GEN/embedded_data.h" 2>/dev/null; then
|
||||
info "xmrig.exe is embedded in the exe — not copied loose into the zip"
|
||||
else
|
||||
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"
|
||||
fi
|
||||
|
||||
cp -r bin/res "$dist_dir/" 2>/dev/null || true
|
||||
|
||||
@@ -1031,8 +1055,8 @@ build_release_mac() {
|
||||
fi
|
||||
|
||||
header "Release: macOS ($MAC_ARCH$(${IS_CROSS} && echo ' — cross-compile'))"
|
||||
local bd="$SCRIPT_DIR/build/mac"
|
||||
local out="$SCRIPT_DIR/release/mac"
|
||||
local bd="${DRAGONX_BUILD_ROOT:-$SCRIPT_DIR/build}/mac"
|
||||
local out="${DRAGONX_RELEASE_ROOT:-$SCRIPT_DIR/release}/mac"
|
||||
|
||||
if $CLEAN; then
|
||||
info "Cleaning $bd ..."; rm -rf "$bd"
|
||||
@@ -1109,6 +1133,7 @@ TOOLCHAIN
|
||||
-DCMAKE_BUILD_TYPE=Release \
|
||||
-DCMAKE_CXX_FLAGS_RELEASE="-O3 -DNDEBUG" \
|
||||
-DDRAGONX_USE_SYSTEM_SDL3=OFF \
|
||||
-DDRAGONX_ENABLE_UI_SWEEP=OFF \
|
||||
-DCMAKE_OSX_DEPLOYMENT_TARGET=11.0 \
|
||||
${COMPILER_RT:+-DOSXCROSS_COMPILER_RT="$COMPILER_RT"} \
|
||||
"${BDB_ARGS[@]}" \
|
||||
@@ -1143,6 +1168,7 @@ TOOLCHAIN
|
||||
-DCMAKE_BUILD_TYPE=Release \
|
||||
-DCMAKE_CXX_FLAGS_RELEASE="-O3 -DNDEBUG" \
|
||||
-DDRAGONX_USE_SYSTEM_SDL3=OFF \
|
||||
-DDRAGONX_ENABLE_UI_SWEEP=OFF \
|
||||
-DCMAKE_OSX_DEPLOYMENT_TARGET=11.0 \
|
||||
-DCMAKE_OSX_ARCHITECTURES="$MAC_ARCHS" \
|
||||
"${BDB_ARGS[@]}" \
|
||||
@@ -1150,7 +1176,7 @@ TOOLCHAIN
|
||||
fi
|
||||
|
||||
info "Building with $JOBS jobs ..."
|
||||
cmake --build . -j "$JOBS"
|
||||
cmake --build . -j "$JOBS" --target dragonx_release
|
||||
|
||||
[[ -f "bin/${APP_BASENAME}" ]] || { err "macOS build failed"; exit 1; }
|
||||
|
||||
|
||||
@@ -1,42 +0,0 @@
|
||||
# CMake script to embed binary files as C arrays
|
||||
|
||||
function(embed_resource RESOURCE_FILE OUTPUT_FILE VAR_NAME)
|
||||
file(READ "${RESOURCE_FILE}" HEX_CONTENT HEX)
|
||||
|
||||
# Convert hex to C array format
|
||||
string(LENGTH "${HEX_CONTENT}" HEX_LENGTH)
|
||||
math(EXPR BYTE_COUNT "${HEX_LENGTH} / 2")
|
||||
|
||||
set(ARRAY_CONTENT "")
|
||||
set(INDEX 0)
|
||||
while(INDEX LESS HEX_LENGTH)
|
||||
string(SUBSTRING "${HEX_CONTENT}" ${INDEX} 2 BYTE)
|
||||
string(APPEND ARRAY_CONTENT "0x${BYTE},")
|
||||
math(EXPR INDEX "${INDEX} + 2")
|
||||
math(EXPR BYTE_NUM "${INDEX} / 2")
|
||||
math(EXPR MOD "${BYTE_NUM} % 16")
|
||||
if(MOD EQUAL 0)
|
||||
string(APPEND ARRAY_CONTENT "\n ")
|
||||
endif()
|
||||
endwhile()
|
||||
|
||||
# Generate header file
|
||||
set(HEADER_CONTENT "// Auto-generated embedded resource - DO NOT EDIT
|
||||
#pragma once
|
||||
#include <cstddef>
|
||||
#include <cstdint>
|
||||
|
||||
namespace embedded {
|
||||
|
||||
inline constexpr unsigned char ${VAR_NAME}_data[] = {
|
||||
${ARRAY_CONTENT}
|
||||
};
|
||||
|
||||
inline constexpr size_t ${VAR_NAME}_size = ${BYTE_COUNT};
|
||||
|
||||
} // namespace embedded
|
||||
")
|
||||
|
||||
file(WRITE "${OUTPUT_FILE}" "${HEADER_CONTENT}")
|
||||
message(STATUS "Embedded ${RESOURCE_FILE} -> ${OUTPUT_FILE} (${BYTE_COUNT} bytes)")
|
||||
endfunction()
|
||||
@@ -1,549 +0,0 @@
|
||||
# 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.
|
||||
@@ -1,61 +0,0 @@
|
||||
**DragonX (DRGX) Lite Desktop Wallet — v1.0.0**
|
||||
|
||||
ObsidianDragonLite is a lightweight companion to the ObsidianDragon full-node wallet. It skips the embedded full node entirely — **no multi-gigabyte blockchain download and near-instant startup** — by connecting to a DragonX lite-wallet (lightwalletd-style) server, while keeping the same native ImGui interface and shielded-first workflow.
|
||||
|
||||
This is the **first release** of the Lite variant.
|
||||
|
||||
---
|
||||
|
||||
## Why Lite?
|
||||
|
||||
- **No blockchain to download.** Sync in seconds instead of hours; a few MB of state instead of many GB.
|
||||
- **Same wallet, lighter footprint.** The familiar ObsidianDragon UI, shielded and transparent addresses, and address book — without running a node or miner.
|
||||
- **Portable.** A single self-contained binary that stores its data in its own `ObsidianDragonLite` folder, so it coexists cleanly alongside the full-node wallet.
|
||||
|
||||
---
|
||||
|
||||
## Features
|
||||
|
||||
- **Fast, node-free operation** — connect to a DragonX lite-wallet server and sync in seconds; minimal disk and RAM.
|
||||
- **Wallet lifecycle** — create a new wallet, restore from a seed phrase, or open an existing one; wallets auto-open on startup, with a first-run welcome prompt and **guided seed backup** on creation.
|
||||
- **Shielded + transparent** — send, shield, and receive DRGX; per-address balances computed from unspent notes and UTXOs.
|
||||
- **Keys & seed** — export your seed phrase and keys, and import keys, from Settings.
|
||||
- **Passphrase encryption** — encrypt, unlock, lock, and decrypt the wallet; you're prompted to unlock at send time and on startup when the wallet is locked.
|
||||
- **Encrypted messaging (HushChat)** — built-in **Contacts** and **Chat** with a seed-derived, **SilentDragonXLite-compatible** identity and a seed-encrypted local message store, so you can message other DragonX users end-to-end.
|
||||
- **Server management** — a built-in **server browser with automatic failover**; switch servers with live reconnect/recovery, and a **"Redownload blocks"** action to rescan from the server.
|
||||
- **Status & diagnostics** — a **Network tab** showing connection and sync status, plus an **interactive Console** for running backend commands and copyable error output.
|
||||
- **Multi-language UI** — full internationalization covering 8 languages: German, Spanish, French, Japanese, Korean, Portuguese, Russian, and Chinese.
|
||||
- **Cross-platform** — native builds for Linux (AppImage + zip), Windows (portable exe + zip), and macOS (DMG + .app), x86-64.
|
||||
- QR code generation for receiving addresses
|
||||
- Extensive theme and appearance options
|
||||
|
||||
---
|
||||
|
||||
## How it differs from the full-node wallet
|
||||
|
||||
- **No embedded `dragonxd`, no mining, no local blockchain explorer.** Chain data comes from the lite-wallet server rather than being verified locally by a full node — a lighter footprint in exchange for trusting the server for chain state.
|
||||
- Everything key-related stays **on your device**: seed, keys, and (when enabled) the encryption passphrase never leave the machine.
|
||||
|
||||
---
|
||||
|
||||
## Downloads
|
||||
|
||||
| File | SHA-256 |
|
||||
|------|---------|
|
||||
| ObsidianDragonLite-1.0.0.AppImage | `a1459d6081124a6b8df47aa898b60c0237875e8cbb132e4fd8ae637673055ed4` |
|
||||
| ObsidianDragonLite-1.0.0-Linux-x64.zip | `573af502a6372334572e4dc88bd26e0cf36ed543b9df1e835fd8e4abd295108d` |
|
||||
| ObsidianDragonLite-1.0.0.exe | `28659efb770fa573bd1b1d0fcab1c1a3f9c7e46574185c3916cc9f669684852c` |
|
||||
| ObsidianDragonLite-1.0.0-Windows-x64.zip | `f6434a931e873e5936ebe249b044d6c7a413694767c667374c91ee438259b50c` |
|
||||
| ObsidianDragonLite-1.0.0-macOS-x86_64.dmg | `10f976f1453e6b1978cb7a85026fd033c85a10c1e66f436475aa628379a298c7` |
|
||||
| ObsidianDragonLite-1.0.0-macOS-x86_64.app.zip | `3e7259c363f7be2e9027c5282c94b742f4be280c998203752dc0a0551f6ab68b` |
|
||||
|
||||
## System Requirements
|
||||
|
||||
- **Linux:** x86-64, glibc 2.31+ (Ubuntu 20.04+, Fedora 33+, etc.)
|
||||
- **Windows:** x86-64, Windows 10 or later
|
||||
- **macOS:** x86-64 (Intel; runs on Apple Silicon via Rosetta 2), macOS 10.15 Catalina or later
|
||||
- Network access to a DragonX lite-wallet server.
|
||||
|
||||
## License
|
||||
|
||||
Released under the **GPLv3**. See [LICENSE](https://git.dragonx.is/DragonX/ObsidianDragon/src/branch/master/LICENSE) for details.
|
||||
@@ -0,0 +1,43 @@
|
||||
**DragonX (DRGX) Lite Desktop Wallet — v1.1.1**
|
||||
|
||||
ObsidianDragonLite is the lightweight, node-free companion to ObsidianDragon — it connects to a DragonX lite-wallet server for near-instant startup, with the same native ImGui interface and shielded-first workflow. **v1.1.1 is a maintenance release** centered on an important wallet-encryption fix.
|
||||
|
||||
---
|
||||
|
||||
## ⚠️ Security fix — please read
|
||||
|
||||
**Creating or restoring a Lite wallet with a passphrase now actually encrypts it.**
|
||||
|
||||
In prior versions, the create / restore / open flow used by the Settings UI **silently discarded the passphrase you entered**:
|
||||
|
||||
- A **newly created or restored** wallet was saved to disk **unencrypted** — the seed and spending keys were stored in the clear even though you set a passphrase.
|
||||
- **Opening** an already-encrypted wallet did not unlock it with the supplied passphrase (it opened locked and re-prompted).
|
||||
|
||||
v1.1.1 fixes this: create/restore now **encrypt** the wallet with your passphrase (the backend encrypts and locks it), and open now **unlocks** it.
|
||||
|
||||
**What you should do:** if you created or restored a Lite wallet **with a passphrase** on an earlier version, treat that wallet's on-disk data as unencrypted. Open it in v1.1.1 and re-apply encryption from **Settings → encrypt** (and, if you're concerned the plaintext wallet file may have been exposed, move the funds to a freshly created encrypted wallet). Wallets you never set a passphrase on are unaffected.
|
||||
|
||||
---
|
||||
|
||||
## Also fixed
|
||||
|
||||
- **Chinese, Japanese, and Korean FAQ text renders correctly.** Several Help/FAQ titles showed `??` instead of characters (e.g. 什么, 门) after the previous translation back-fill; the bundled CJK font subset now includes those glyphs.
|
||||
|
||||
---
|
||||
|
||||
## Downloads
|
||||
|
||||
macOS builds are x86-64 (Intel; run on Apple Silicon via Rosetta 2).
|
||||
|
||||
| File | SHA-256 |
|
||||
|------|---------|
|
||||
| ObsidianDragonLite-1.1.1.AppImage | `323ff5f8658a5550aa12678ce4be37e32a2bfd3f3b0543074cc86a4267895dcf` |
|
||||
| ObsidianDragonLite-1.1.1-Linux-x64.zip | `e8604e479aed7e020946df45bf0d9373401354907e10565011694efa9978e631` |
|
||||
| ObsidianDragonLite-1.1.1.exe | `4cdcf291fe43c4fed04258aa55bb4e9b591f52780f9db5fb8f8bef522c43a351` |
|
||||
| ObsidianDragonLite-1.1.1-Windows-x64.zip | `c387bfcda508f32195c41fabec9f23d8b094d693a5dba41fbf8ec4abab6f2f6e` |
|
||||
| ObsidianDragonLite-1.1.1-macOS-x86_64.dmg | `bb0c491cb8f90ae55d162ec7c04c8f30988fa2f79c7911471220cddab44f99a5` |
|
||||
| ObsidianDragonLite-1.1.1-macOS-x86_64.app.zip | `a2a75fe40bf1f4db3abed8206dcc40d9fc74d8db2df7704afc9c834106b1c247` |
|
||||
|
||||
---
|
||||
|
||||
For the full feature set (node-free operation, wallet lifecycle, shielded + transparent send/shield/receive, HushChat, server browser with failover, 8-language UI), see the **lite-v1.0.0** release notes — v1.1.1 changes only the items listed above.
|
||||
@@ -1,86 +0,0 @@
|
||||
**DragonX (DRGX) Full-Node Desktop Wallet — v2.0.0**
|
||||
|
||||
This is ObsidianDragon, a native ImGui-based wallet for the DragonX network. It ships with an embedded full-node daemon (`dragonxd`) and an integrated CPU miner (DRG-XMRig), giving users a complete, self-contained experience on Linux, Windows, and macOS.
|
||||
|
||||
---
|
||||
|
||||
## What's New in v2.0.0
|
||||
|
||||
v2.0.0 is a major release. It adds **end-to-end encrypted HushChat messaging** and **BIP39 seed-phrase wallets** (with migrate-to-seed for legacy wallets), lets the wallet **update its own node and miner** with each install cryptographically verified, and ships a **security-hardening pass** across the updater, RPC, and secret-storage surfaces — plus full support for the **new multi-threaded `dragonxd`** and a large stability and UX overhaul.
|
||||
|
||||
### New Features
|
||||
|
||||
- **Encrypted messaging (HushChat)** — new **Contacts** and **Chat** tabs bring DRGX-native, end-to-end encrypted messaging. Your chat identity is derived from your wallet seed, messages are encrypted with libsodium (XChaCha20-Poly1305 / secretstream) and stored in a **seed-encrypted local database**, and the wire format is interoperable with SilentDragonX / SilentDragonXLite.
|
||||
- **BIP39 seed-phrase wallets & migrate-to-seed** — new wallets are backed by a mnemonic **seed phrase** you can back up from Settings. Existing **legacy wallets can be migrated into a seed wallet** — the wallet mints a new mnemonic wallet, sweeps your funds to it, and adopts it only once the sweep is mined, keeping a timestamped `wallet.dat` backup throughout. (The bundled `dragonxd` supports the required mnemonic RPCs; older nodes degrade gracefully.)
|
||||
- **In-app daemon updater** — Settings → **Node & Security → Daemon binary → "Check for updates…"** downloads the latest `dragonxd` from the project Gitea, verifies its **SHA-256 and a detached ed25519 signature** before installing, and replaces the binary **atomically while the node keeps running** (the new build takes effect on the next daemon start). A two-pane version picker lets you pin, downgrade, or install any published/pre-release build.
|
||||
- **In-app miner updater** — an **"Update miner…"** action in the Mining tab fetches, verifies (SHA-256 + ed25519), and installs the latest DRG-XMRig, with the same version picker and a display of current vs. latest.
|
||||
- **Daemon binary management** — the wallet no longer auto-overwrites a node you've dropped in; a dedicated Settings panel shows the managed binary and gathers all node actions (restart, rescan, repair) onto one toolbar.
|
||||
- **Repair Wallet** — a one-click `-zapwallettxes=2` recovery in Settings, plus automatic wallet reconciliation after a bootstrap and runtime rescan support for pruned nodes.
|
||||
- **Explorer search** — fuzzy, live (debounced) filtering of the block list by partial hash or height.
|
||||
- **History sorting & fast load** — sort transactions by date or amount, a "Loading older history (N%)" indicator during the initial bulk load, and persisted history that surfaces pending sends immediately.
|
||||
- **Smarter mining** — the thread benchmark now measures **sustained (thermally-throttled) hashrate** instead of an inflated initial burst, with GPU-aware idle mining and corrected idle thread scaling.
|
||||
|
||||
### Security & Integrity
|
||||
|
||||
- **Mandatory signature verification** — both the daemon and miner updaters **require** a valid ed25519 signature (checked against a key pinned in the wallet) in addition to the SHA-256; an install is refused if the signature is missing or invalid.
|
||||
- **Hardened secret handling** — RPC credentials are wiped from memory after use, RPC responses are size-capped and scrubbed of secrets in logs, generated node config is written **owner-only (0600)**, and secrets copied to the clipboard **auto-clear**.
|
||||
- **Safe on-disk writes** — settings, address book, and secret files are written **atomically with owner-only permissions**; the PIN vault fsyncs its secure-delete overwrite; SQLite cache growth is bounded.
|
||||
- **Recipient validation** — sends validate recipient address checksums (Base58Check + Bech32) before building a transaction.
|
||||
- **Path-traversal protection** — archive extraction (chain bootstrap and the updaters) rejects any entry that would escape the target directory (zip-slip), and the bootstrap fails closed on a missing checksum rather than trusting an unverified archive.
|
||||
- **Robustness** — malformed RPC error JSON is guarded and sends are single-flight, preventing duplicate/ill-formed submissions.
|
||||
|
||||
### Improvements
|
||||
|
||||
- **New multi-threaded daemon support** — live Sapling note-witness rebuild progress, accurate sync-speed display, reliable rescan-completion detection, and RPC polling throttled during sync so block download isn't slowed.
|
||||
- **Networking** — DragonX DNS seed nodes, `-maxconnections` passed to the daemon, and a peer count that stays current on every tab.
|
||||
- **Node resilience** — non-blocking warmup so you can connect while the daemon is still initializing, a live daemon-console tail on the startup overlay, fast-failing connect probes, and mid-session disconnect detection that keeps the UI responsive (in-flight RPC calls abort cleanly on disconnect/shutdown).
|
||||
- **Address list** — modernized with drag-to-transfer, labels, and view-only handling.
|
||||
- **UI / DPI** — overlay dialogs scale correctly with the font/DPI setting, improved CJK font rendering, native language names in the language picker, and format-incompatible translations are rejected.
|
||||
- **Settings** — an "Open data folder" button and confirmation modals for rescan and restart-daemon.
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
Extensive fixes across history (shielded-tx ordering, stuck "refreshing history" banners, unconfirmed-badge stickiness), sends (correct fee passed to `z_sendmany`, fast-lane worker restart on reconnect, note-selection fee-gap workaround), rescan (accurate completion detection, no false "complete", no per-second error flood), RPC (mid-session disconnect handling, stale-refresh invalidation), and UI layout/i18n. See the commit history for the full list.
|
||||
|
||||
---
|
||||
|
||||
## Features
|
||||
|
||||
- **Full-node wallet** — send, receive, and verify DRGX transactions with a bundled `dragonxd` daemon; no external setup required.
|
||||
- **Self-updating** — verify-and-install the latest node and miner from within the app (SHA-256 + ed25519 signature enforced).
|
||||
- **Encrypted messaging** — built-in HushChat with a seed-derived identity, end-to-end encryption, and a seed-encrypted local message store, interoperable with SilentDragonX / SilentDragonXLite.
|
||||
- **Seed-phrase wallets** — BIP39 mnemonic backup, and migrate-to-seed to upgrade a legacy wallet into a seed wallet.
|
||||
- **Built-in CPU mining** — start/stop DRG-XMRig from the Mining tab with real-time hashrate and pool statistics, mine-when-idle support with configurable delay, and sustained-hashrate benchmarking.
|
||||
- **Multi-language UI** — full internationalization covering 8 languages: German, Spanish, French, Japanese, Korean, Portuguese, Russian, and Chinese.
|
||||
- **Cross-platform** — native builds for Linux (AppImage + zip), Windows (portable exe + zip), and macOS (**universal** DMG + .app); Linux/Windows x86-64, macOS Intel + Apple Silicon.
|
||||
- Shielded (z-address) and transparent (t-address) send/receive
|
||||
- QR code generation for receiving addresses
|
||||
- Transaction history with memo support, date/amount sorting, and pending-send tracking
|
||||
- Blockchain explorer with live block/transaction search
|
||||
- Blockchain rescan and Sapling witness-rebuild with status-bar progress
|
||||
- Repair Wallet and daemon-binary management in Settings
|
||||
- Built-in console
|
||||
- Extensive theme and appearance options
|
||||
|
||||
---
|
||||
|
||||
## Downloads
|
||||
|
||||
| File | SHA-256 |
|
||||
|------|---------|
|
||||
| ObsidianDragon-2.0.0.AppImage | `34c9ea57ec27bf415e59d2890d995ed9069bfce04d979d7d8d58aa18f4570aa1` |
|
||||
| ObsidianDragon-2.0.0-Linux-x64.zip | `563004b4e45650dbfa9411d62fa1c59a3ca2199bd43a647b1614f2683bbdb5fa` |
|
||||
| ObsidianDragon-2.0.0.exe | `56100ae59dc3c67445d015cac5412ca40465bf459ba182c5ad2477a3b95ff548` |
|
||||
| ObsidianDragon-2.0.0-Windows-x64.zip | `ce9d067f36c5296288a473d3c2cceca7fcf807672a93a2084afc53c3b6e780d9` |
|
||||
| ObsidianDragon-2.0.0-macOS-universal.dmg | `8a01b6c0c2b5bebd64a222ba4a5ad04a1b8851138f28458b5c7f767fbb65db66` |
|
||||
| ObsidianDragon-2.0.0-macOS-universal.app.zip | `1bce1e5be15d9a2f3c6f42c95b23a0e89df142a73d65895252e4d8f50d19f541` |
|
||||
|
||||
## System Requirements
|
||||
|
||||
- **Linux:** x86-64, glibc 2.31+ (Ubuntu 20.04+, Fedora 33+, etc.)
|
||||
- **Windows:** x86-64, Windows 10 or later
|
||||
- **macOS:** Intel & Apple Silicon (universal binary), macOS 10.15 Catalina or later
|
||||
|
||||
## License
|
||||
|
||||
Released under the **GPLv3**. See [LICENSE](https://git.dragonx.is/DragonX/ObsidianDragon/src/branch/master/LICENSE) for details.
|
||||
@@ -0,0 +1,41 @@
|
||||
**DragonX (DRGX) Full-Node Desktop Wallet — v2.0.2**
|
||||
|
||||
ObsidianDragon is a native ImGui-based full-node wallet for the DragonX network, with an embedded `dragonxd` daemon and the integrated DRG-XMRig CPU miner. **v2.0.2 is a maintenance release** — a focused set of transaction-history, sync-safety, and correctness fixes on top of v2.0.x. No configuration changes are required; just replace the binary.
|
||||
|
||||
---
|
||||
|
||||
## What's Fixed in v2.0.2
|
||||
|
||||
### Transaction history
|
||||
|
||||
- **Outgoing shielded sends now appear in History.** Sends *from* a shielded (z-)address — both **z→z** (shielded-to-shielded) and **z→t** (deshield to a transparent address) — were missing from the History tab, because the daemon's `listtransactions` doesn't report them and `z_viewtransaction` doesn't expose the transparent leg. The wallet now enumerates them via `z_listsentbyaddress`, so **your History matches your balance.** (Your funds were never affected — only the display.)
|
||||
- **More accurate send rows** — a single transaction with two equal-value outputs to different recipients is no longer collapsed into one row, and a transparent self-send / change output is no longer double-counted as both sent and received.
|
||||
|
||||
### Sync & fund safety
|
||||
|
||||
- **A node that loses all its peers is no longer reported as "synced."** Previously, a wallet whose node dropped to **0 peers** could keep showing a stale balance as if it were current — and allow a Send against it. The wallet now treats a node with no peer-derived chain tip as *not synced*, and a new **"Connected, but no network peers — the node can't sync"** banner explains the stall instead of silently looking healthy.
|
||||
- **Migrate-to-seed hardening.** If a migrate-to-seed was interrupted (app or daemon restart) *after* the sweep transaction had broadcast but *before* it confirmed, the wallet could mistake the temporarily-zero balance for an empty wallet and adopt the new seed wallet — swapping `wallet.dat` — before the sweep was mined. It now records that a sweep is in flight and **will not swap your wallet until the sweep confirms**, so funds can't be stranded in the timestamped backup.
|
||||
- **Fixed a rare crash** caused by a data race on the daemon-status text (the daemon-monitor thread writing it while the UI thread read it).
|
||||
|
||||
### Display / internationalization
|
||||
|
||||
- **Chinese, Japanese, and Korean FAQ text renders correctly.** Several Help/FAQ titles showed `??` instead of characters (e.g. 什么, 门) after the previous translation back-fill; the bundled CJK font subset now includes those glyphs.
|
||||
|
||||
---
|
||||
|
||||
## Downloads
|
||||
|
||||
macOS builds are x86-64 (Intel; run on Apple Silicon via Rosetta 2).
|
||||
|
||||
| File | SHA-256 |
|
||||
|------|---------|
|
||||
| ObsidianDragon-2.0.2.AppImage | `9bf7328965c6348113ba7a30b0284f15787c635af17efae3dfa8a87e56e10c9b` |
|
||||
| ObsidianDragon-2.0.2-Linux-x64.zip | `1371880c4187a94d4cb56d535471142c5f1a8053ef73fffd99d59e7b78c75c42` |
|
||||
| ObsidianDragon-2.0.2.exe | `673347d2696fb6f5bd09dc25e415d5ae8e532abde75b8d48c3cd26545461de50` |
|
||||
| ObsidianDragon-2.0.2-Windows-x64.zip | `f5ea7c45e66c6c1b90a8127f96dee04235691acb913ddc86327f2060798cd28c` |
|
||||
| ObsidianDragon-2.0.2-macOS-x86_64.dmg | `7038580807a8e93e5443bdc04f2aede3ae1c30e5bc9f8776a207edb2e87771d1` |
|
||||
| ObsidianDragon-2.0.2-macOS-x86_64.app.zip | `78159512ca684b95528231fd46f2d07b5bcc6f2da4d815fc1ecb2ff52127f41c` |
|
||||
|
||||
---
|
||||
|
||||
For the full feature set (HushChat messaging, seed-phrase wallets & migrate-to-seed, in-app node/miner updaters, built-in mining, 8-language UI), see the **v2.0.0** release notes — v2.0.2 changes only the fixes listed above.
|
||||
@@ -1,167 +0,0 @@
|
||||
# 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.)
|
||||
File diff suppressed because one or more lines are too long
Binary file not shown.
+150
-31
@@ -25,6 +25,7 @@
|
||||
"about_website": "Webseite",
|
||||
"acrylic": "Acryl",
|
||||
"add": "Hinzufügen",
|
||||
"addr_type_mining": "Mining",
|
||||
"address": "Adresse",
|
||||
"address_book_add": "Adresse hinzufügen",
|
||||
"address_book_add_new": "Neue hinzufügen",
|
||||
@@ -68,6 +69,7 @@
|
||||
"appx_blockchain_maintenance_in_progress": "Eine Blockchain-Wartung läuft bereits.",
|
||||
"appx_blockchain_rescan_complete": "Blockchain-Neuscan abgeschlossen",
|
||||
"appx_bootstrap_complete_reconciling": "Bootstrap abgeschlossen — Ihr Wallet wird mit den neuen Chain-Daten abgeglichen.",
|
||||
"appx_bootstrap_reconcile_probe_failed": "Ihr Wallet konnte nicht mit den Bootstrap-Chain-Daten abgeglichen werden: Der Node war zu ausgelastet, um seine Blöcke zu prüfen. Starten Sie Neu scannen in den Einstellungen, sobald er aufgeholt hat.",
|
||||
"appx_cancel": "Abbrechen",
|
||||
"appx_cleaning_up": "Aufräumen...",
|
||||
"appx_confirm_your_backup": "Backup bestätigen",
|
||||
@@ -78,11 +80,15 @@
|
||||
"appx_creating_your_wallet": "Ihr Wallet wird erstellt…",
|
||||
"appx_daemon_error": "Daemon-Fehler",
|
||||
"appx_daemon_reinstall_in_progress": "Die Daemon-Neuinstallation läuft bereits.",
|
||||
"appx_daemon_reinstall_node_busy": "Der Node läuft noch, daher wurde der Daemon nicht neu installiert. Versuchen Sie es erneut, sobald er beendet ist.",
|
||||
"appx_disconnecting": "Verbindung wird getrennt...",
|
||||
"appx_done": "Fertig",
|
||||
"appx_dragonxd_output": "dragonxd-Ausgabe",
|
||||
"appx_encrypting_wallet": "Wallet wird verschlüsselt...",
|
||||
"appx_fullnode_lifecycle_unavailable_lite": "Full-Node-Lebenszyklusaktionen sind im Lite-Build nicht verfügbar",
|
||||
"appx_import_cancelled": "Abgebrochen: Der Node scannt möglicherweise noch. Wenn das Guthaben des Schlüssels nicht erscheint, führen Sie Einstellungen → Neu scannen aus.",
|
||||
"appx_import_interrupted": "Der Import wurde unterbrochen, daher ist der Schlüssel möglicherweise schon in der Wallet. Wenn sein Guthaben nicht erscheint, führen Sie Einstellungen → Neu scannen aus.",
|
||||
"appx_import_refused": "Der Node hat die Anfrage abgelehnt, daher wurde der Schlüssel nicht importiert.",
|
||||
"appx_installing_bundled_daemon": "Mitgelieferter Daemon wird installiert — der Node stoppt, aktualisiert und startet neu...",
|
||||
"appx_invalid_payment_uri_prefix": "Ungültige Zahlungs-URI: ",
|
||||
"appx_ive_written_it_down": "Ich habe sie notiert",
|
||||
@@ -99,7 +105,8 @@
|
||||
"appx_no_bundled_daemon_to_install": "Dieser Build enthält keinen mitgelieferten Daemon zur Installation",
|
||||
"appx_no_embedded_daemon_to_install": "Dieser Build enthält keinen eingebetteten Daemon zur Installation",
|
||||
"appx_node_busy_restarting": "Der Node ist mit dem Neustart beschäftigt — versuchen Sie es gleich erneut.",
|
||||
"appx_node_rebuilding_witness_cache": "Node baut seinen Witness-Cache neu auf",
|
||||
"appx_node_busy_try_later": "Der Node ist mit einer langen Aufgabe beschäftigt (etwa dem Neuaufbau der Note-Witnesses). Versuchen Sie es erneut, wenn sie abgeschlossen ist; ein Stoppen jetzt könnte diese Arbeit von vorn beginnen lassen.",
|
||||
"appx_node_not_stopped": "Der Node wurde nicht rechtzeitig beendet, daher wurde nichts geändert. Versuchen Sie es in ein paar Minuten erneut.",
|
||||
"appx_not_next_word": " — das ist nicht das nächste Wort",
|
||||
"appx_payment_request_loaded": "Zahlungsanforderung geladen",
|
||||
"appx_pool_miner_connected_and_hashing": "Pool-Miner verbunden und aktiv.",
|
||||
@@ -120,11 +127,7 @@
|
||||
"appx_simple_background_enabled": "Einfacher Hintergrund aktiviert",
|
||||
"appx_skip": "Überspringen",
|
||||
"appx_skip_anyway": "Trotzdem überspringen",
|
||||
"appx_still_status_prefix": "Weiterhin \"",
|
||||
"appx_still_status_suffix": "\" — ein erzwungenes Beenden jetzt kann die Chain-Daten beschädigen.",
|
||||
"appx_stop_anyway_and_quit": "Trotzdem stoppen & beenden",
|
||||
"appx_stopping_daemon_deleting_blockchain": "Daemon wird gestoppt und Blockchain-Daten werden gelöscht...",
|
||||
"appx_stopping_node_discards_rebuild": "Wenn Sie den Node jetzt stoppen, wird der laufende Neuaufbau verworfen und beim nächsten Öffnen des Wallets neu gestartet (mehrere Minuten). Sie können den Node stattdessen weiterlaufen lassen.",
|
||||
"appx_stopping_pool_miner": "Pool-Miner wird gestoppt...",
|
||||
"appx_syncing_pct_block_n_of_n": "Synchronisierung %.1f%% — Block %d / %d",
|
||||
"appx_tap_words_in_order": "Tippen Sie die Wörter in der richtigen Reihenfolge an, um zu bestätigen, dass Sie sie gespeichert haben.",
|
||||
@@ -134,6 +137,9 @@
|
||||
"appx_use_settings_restart_daemon_hint": "Verwenden Sie Einstellungen > Daemon neu starten, um es erneut zu versuchen",
|
||||
"appx_waiting_for_daemon_to_encrypt_wallet": "Warten, bis der Daemon das Wallet verschlüsselt...",
|
||||
"appx_wallet_created_and_backed_up": "Wallet erstellt und gesichert.",
|
||||
"appx_wallet_op_node_busy": "Der Node ist beschäftigt und antwortet nicht. Versuchen Sie den Import oder Neuscan erneut, wenn er fertig ist.",
|
||||
"appx_wallet_op_running": "Ein Schlüsselimport oder Neuscan läuft noch. Versuchen Sie es erneut, wenn er abgeschlossen ist.",
|
||||
"appx_wallet_op_shutting_down": "Die Wallet wird beendet.",
|
||||
"appx_wallet_open_failed_prefix": "Öffnen des Wallets fehlgeschlagen: ",
|
||||
"auto_shield": "Mining automatisch abschirmen",
|
||||
"autoshield_by_node": "Ihr Node schützt für Sie gemäß Coinbase automatisch.",
|
||||
@@ -174,21 +180,20 @@
|
||||
"balance_history_collecting": "Guthabenverlauf — Daten werden gesammelt...",
|
||||
"balance_layout": "Guthaben-Layout",
|
||||
"balance_layout_switched": "Layout: %s",
|
||||
"balance_mining_fmt": "Mining-Belohnungen: %.8f",
|
||||
"balance_mining_rate": "Schürfe %s",
|
||||
"balance_shielded_fmt": "Abgeschirmt: %.8f",
|
||||
"balance_syncing_pct": "Synchronisiere %.1f%%",
|
||||
"balance_transparent_fmt": "Transparent: %.8f",
|
||||
"baltab_market": "Markt",
|
||||
"baltab_market_price_4dp": "Markt: $%.4f",
|
||||
"baltab_market_price_8dp": "Markt: $%.8f",
|
||||
"baltab_mining_addresses_count": "%d Mining-Adressen",
|
||||
"baltab_mining_amount": "Mining-Belohnungen %.8f",
|
||||
"baltab_pct_change_24h": "%s%.1f%% 24h",
|
||||
"baltab_pct_of_total_zaddr": "%.0f%% des Gesamtbetrags · %d Z-addr",
|
||||
"baltab_shielded": "Geschützt",
|
||||
"baltab_shielded_amount": "Geschützt %.8f",
|
||||
"baltab_t_addresses_count": "%d T-Adressen",
|
||||
"baltab_total_balance": "Gesamtguthaben",
|
||||
"baltab_transparent": "Transparent",
|
||||
"baltab_transparent_amount": "Transparent %.8f",
|
||||
"ban": "Sperren",
|
||||
"banned_peers": "Gesperrte Peers",
|
||||
"block": "Block",
|
||||
@@ -238,6 +243,7 @@
|
||||
"change_pass_confirm": "Neue bestätigen:",
|
||||
"change_pass_current": "Aktuelle Passphrase:",
|
||||
"change_pass_new": "Neue Passphrase:",
|
||||
"change_pass_pin_note": "Diese Wallet hat eine Schnell-Entsperr-PIN. Nach der Änderung ist sie veraltet: Entfernen Sie die PIN in den Einstellungen und legen Sie sie neu fest.",
|
||||
"change_pass_title": "Passphrase ändern",
|
||||
"characters": "Zeichen",
|
||||
"chat": "Chat",
|
||||
@@ -513,7 +519,7 @@
|
||||
"contact_avatar_image_hint": "Das Bild wird in die App kopiert, damit es verfügbar bleibt, wenn das Original verschoben wird.",
|
||||
"contact_avatar_remove": "Entfernen",
|
||||
"contact_avatar_shielded": "Abgeschirmt",
|
||||
"contact_avatar_transparent": "Transparent",
|
||||
"contact_chip_mining": "Nur Mining",
|
||||
"contact_global": "In jeder Wallet anzeigen (globaler Kontakt)",
|
||||
"contact_global_badge_tt": "Globaler Kontakt — in jeder Wallet sichtbar",
|
||||
"contact_global_tt": "Ein: Dieser Kontakt bleibt sichtbar, egal welche Wallet Sie laden. Aus: Er gehört nur zur aktuellen Wallet.",
|
||||
@@ -633,6 +639,11 @@
|
||||
"download": "Herunterladen",
|
||||
"download_bootstrap": "Bootstrap herunterladen",
|
||||
"dragonx_green": "DragonX (Grün)",
|
||||
"earnings_format": "Anzeigeformat für Erträge",
|
||||
"earnings_format_coin": "Nur Münze",
|
||||
"earnings_format_coin_fiat": "Münze + Fiat",
|
||||
"earnings_format_fiat": "Nur Fiat",
|
||||
"earnings_label": "Erträge",
|
||||
"edit": "Bearbeiten",
|
||||
"empty_wallet_keys_suffix": "Schlüssel",
|
||||
"empty_wallet_open_manager": "Wallet-Verwaltung öffnen",
|
||||
@@ -709,8 +720,8 @@
|
||||
"export_tx_success": "Transaktionen erfolgreich exportiert",
|
||||
"export_tx_title": "Transaktionen als CSV exportieren",
|
||||
"export_viewing_key": "Betrachtungsschlüssel exportieren",
|
||||
"failed_create_mining_address": "Mining-Adresse konnte nicht erstellt werden",
|
||||
"failed_create_shielded": "Abgeschirmte Adresse konnte nicht erstellt werden",
|
||||
"failed_create_transparent": "Transparente Adresse konnte nicht erstellt werden",
|
||||
"faq": "FAQ",
|
||||
"faq_d_mgmt_1_a": "Der eingebettete Node startet automatisch mit der Wallet und stoppt bei Bedarf, sodass Sie ihn normalerweise nicht manuell verwalten müssen. Erweiterte Steuerelemente (einschließlich des Neustarts des Daemons) finden Sie unter Einstellungen > Node & Sicherheit.",
|
||||
"faq_d_mgmt_1_q": "Wie starte oder stoppe ich den Node?",
|
||||
@@ -788,13 +799,13 @@
|
||||
"faq_open_tooltip": "Hilfe & FAQ",
|
||||
"faq_search_hint": "FAQ durchsuchen…",
|
||||
"faq_title": "Hilfe & FAQ",
|
||||
"faq_w_addr_1_a": "Transparente Adressen (sie beginnen mit R oder t) funktionieren wie bei den meisten Coins: Beträge und Adressen sind öffentlich auf der Blockchain einsehbar.\n\nGeschützte Adressen (z-Adressen, beginnend mit zs) nutzen Zero-Knowledge-Kryptografie — Betrag, Absender und Empfänger sind on-chain verschlüsselt. Guthaben auf geschützten Adressen sind privat.",
|
||||
"faq_w_addr_1_a": "Abgeschirmte Adressen (z-Adressen, beginnend mit zs) sind die Adressen, die Sie bei DragonX verwenden. Sie nutzen Zero-Knowledge-Kryptografie: Betrag, Absender und Empfänger werden on-chain verschlüsselt, und Ihr Guthaben bleibt privat.\n\nTransparente Adressen (beginnend mit R) sind Mining-Adressen. DragonX ist eine vollständig private Chain, daher können sie nur Mining-Belohnungen empfangen – das Netzwerk lehnt jede an sie gesendete Zahlung ab. Dort eingehende Belohnungen sind öffentlich, bis sie abgeschirmt werden, was die Wallet automatisch erledigt.",
|
||||
"faq_w_addr_1_q": "Was ist der Unterschied zwischen transparenten und geschützten Adressen?",
|
||||
"faq_w_addr_2_a": "Bevorzugen Sie wann immer möglich geschützte (z-)Adressen — sie halten Ihr Guthaben und Ihren Zahlungsverlauf privat. DragonX ist eine Privacy-Coin, und geschützt ist die Standardeinstellung für empfangene Gelder.\n\nVerwenden Sie eine transparente Adresse nur, wenn ein Dienst, mit dem Sie interagieren, keine geschützten Adressen verarbeiten kann.",
|
||||
"faq_w_addr_2_a": "Verwenden Sie für alles, was Sie senden und empfangen, eine abgeschirmte (zs-)Adresse – bei DragonX ist sie die einzige Art, an die gezahlt werden kann.\n\nTransparente (Mining-)Adressen sind nur für Solo-Mining-Belohnungen da. Geben Sie nie eine für eine Zahlung heraus: Das Netzwerk würde sie ablehnen.",
|
||||
"faq_w_addr_2_q": "Welchen Adresstyp sollte ich verwenden?",
|
||||
"faq_w_addr_3_a": "Bei geschützten Adressen ist die Wiederverwendung unbedenklich und gibt keinen Verlauf preis. Bei transparenten Adressen verbessert eine neue Adresse pro Zahlung die Privatsphäre. Neue Adressen erstellen Sie im Tab Empfangen.",
|
||||
"faq_w_addr_3_a": "Eine abgeschirmte Adresse wiederzuverwenden ist unbedenklich und verrät nichts über Ihren Verlauf. Sie können trotzdem im Tab „Empfangen“ neue anlegen, zum Beispiel eine pro Kontakt, um zu sehen, wer Sie bezahlt hat. Mining-Adressen sind nur für Mining-Belohnungen da; eine neue brauchen Sie selten.",
|
||||
"faq_w_addr_3_q": "Sollte ich eine Adresse wiederverwenden oder eine neue erstellen?",
|
||||
"faq_w_addr_4_a": "Beim Schützen werden Coins von einer transparenten Adresse in eine geschützte verschoben und dadurch privat gemacht. Neu geschürfte Coins kommen transparent an und werden automatisch für Sie geschützt.\n\nSie können auch manuell aus der Wallet heraus schützen; ein Schutzvorgang ist einfach eine spezielle Transaktion und benötigt daher die normale Bestätigungszeit.",
|
||||
"faq_w_addr_4_a": "Beim Abschirmen werden Coins von einer transparenten (Mining-)Adresse auf eine abgeschirmte verschoben und damit privat. Neu geminte Coins kommen auf einer Mining-Adresse an und werden automatisch abgeschirmt, sobald sie gereift sind.\n\nSie können auch manuell in der Wallet abschirmen; eine Abschirmung ist nur eine besondere Transaktion und braucht daher die übliche Bestätigungszeit.",
|
||||
"faq_w_addr_4_q": "Was bedeutet „Schützen“?",
|
||||
"faq_w_addr_title": "Adressen & Privatsphäre",
|
||||
"faq_w_bal_1_a": "Solange Ihre Wallet noch synchronisiert, ist Ihr Guthaben unvollständig und kann 0 anzeigen — es wurden noch nicht alle Transaktionen gescannt. Es füllt sich, sobald die Synchronisierung abgeschlossen ist.\n\nBeobachten Sie die Statusleiste unten: Sie zeigt den Verbindungsstatus und die Blockhöhe an. Sobald die Synchronisierung abgeschlossen ist, ist Ihr Guthaben korrekt.",
|
||||
@@ -838,11 +849,11 @@
|
||||
"faq_w_seed_4_a": "Ihre Seed-Phrase ist die primäre Sicherung und reicht aus, um alles wiederherzustellen. Eine Kopie Ihrer Wallet-Datei ist eine praktische zusätzliche Sicherung, die außerdem Bezeichnungen und Einstellungen erhält. Bewahren Sie jede Sicherung offline und privat auf.",
|
||||
"faq_w_seed_4_q": "Sollte ich auch wallet.dat sichern?",
|
||||
"faq_w_seed_title": "Seed-Phrase & Sicherung",
|
||||
"faq_w_send_1_a": "Öffnen Sie den Tab Senden, fügen Sie die Adresse des Empfängers ein, geben Sie einen Betrag ein und bestätigen Sie. Wenn die Wallet verschlüsselt ist, werden Sie gebeten, sie für die Transaktion zu entsperren.\n\nSendungen an geschützte Adressen sind privat; Sendungen an transparente Adressen sind öffentlich.",
|
||||
"faq_w_send_1_a": "Öffnen Sie den Tab Senden, fügen Sie die Adresse des Empfängers ein, geben Sie einen Betrag ein und bestätigen Sie. Wenn die Wallet verschlüsselt ist, werden Sie gebeten, sie für die Transaktion zu entsperren.\n\nZahlungen gehen an abgeschirmte (zs-)Adressen und bleiben privat. Die Wallet sendet nicht an eine transparente (Mining-)Adresse: Das Netzwerk lehnt Zahlungen an sie ab.",
|
||||
"faq_w_send_1_q": "Wie sende ich Gelder?",
|
||||
"faq_w_send_2_a": "DragonX-Gebühren sind sehr niedrig und werden automatisch festgelegt. Die Gebühr wird angezeigt, bevor Sie bestätigen. Normalerweise müssen Sie sie nicht ändern.",
|
||||
"faq_w_send_2_q": "Welche Gebühr zahle ich?",
|
||||
"faq_w_send_3_a": "Ja — beim Senden an eine geschützte (z-)Adresse können Sie ein verschlüsseltes Memo anhängen. Nur der Empfänger kann es lesen. Transparente Adressen unterstützen keine Memos.",
|
||||
"faq_w_send_3_a": "Ja — beim Senden an eine geschützte (z-)Adresse können Sie ein verschlüsseltes Memo anhängen. Nur der Empfänger kann es lesen.",
|
||||
"faq_w_send_3_q": "Kann ich einer Zahlung eine Nachricht beifügen?",
|
||||
"faq_w_send_4_a": "Eine Transaktion ist „ausstehend“, bis sie in einen Block aufgenommen wird und Bestätigungen erhält. Das dauert normalerweise ein bis zwei Minuten. Geschützte Transaktionen erfordern außerdem, dass Ihre Wallet synchronisiert ist, um den Beweis zu erstellen.\n\nBleibt eine Sendung ungewöhnlich lange ausstehend, prüfen Sie, ob Ihre Wallet verbunden und synchronisiert ist (siehe die Statusleiste unten im Fenster).",
|
||||
"faq_w_send_4_q": "Ich habe eine Zahlung gesendet, aber sie zeigt „ausstehend“ — warum?",
|
||||
@@ -857,6 +868,7 @@
|
||||
"faq_w_set_4_q": "Wie zeige ich Fiat-Preise an?",
|
||||
"faq_w_set_title": "Einstellungen & Erscheinungsbild",
|
||||
"favorite_address": "Als Favorit markieren",
|
||||
"favorite_opacity": "Deckkraft der Favoriten-Hervorhebung",
|
||||
"fee": "Gebühr",
|
||||
"fee_high": "Hoch",
|
||||
"fee_label": "Gebühr:",
|
||||
@@ -864,14 +876,9 @@
|
||||
"fee_normal": "Normal",
|
||||
"fetch_prices": "Preise abrufen",
|
||||
"file": "Datei",
|
||||
"file_save_location": "Datei wird gespeichert in: ~/.config/ObsidianDragon/",
|
||||
"file_save_location": "Datei wird gespeichert in:",
|
||||
"filter": "Filtern...",
|
||||
"font_scale": "Schriftgröße",
|
||||
"force_quit": "Sofort beenden",
|
||||
"force_quit_confirm_msg": "Dies wird den Daemon sofort beenden ohne sauberes Herunterfahren.\nDies kann den Blockchain-Index beschädigen und eine Neusynchronisierung erfordern.",
|
||||
"force_quit_confirm_title": "Sofort beenden?",
|
||||
"force_quit_warning": "Dies wird den Daemon sofort beenden ohne sauberes Herunterfahren. Kann eine Neusynchronisierung der Blockchain erfordern.",
|
||||
"force_quit_yes": "Sofort beenden",
|
||||
"from": "Von",
|
||||
"from_upper": "VON",
|
||||
"full_details": "Alle Details",
|
||||
@@ -940,6 +947,7 @@
|
||||
"hidden_tag": " (versteckt)",
|
||||
"hide": "Ausblenden",
|
||||
"hide_address": "Adresse ausblenden",
|
||||
"hide_earnings": "Erträge ausblenden",
|
||||
"hide_qr": "QR ausblenden",
|
||||
"hide_zero_balances": "Nullsalden ausblenden",
|
||||
"history": "Verlauf",
|
||||
@@ -1014,11 +1022,13 @@
|
||||
"lite_birthday_backup": "Geburtstag: %llu (auch diesen sichern)",
|
||||
"lite_birthday_hint": "Blockhöhe, ab der gescannt werden soll. Bei 0 belassen, falls unbekannt (langsamerer vollständiger Scan).",
|
||||
"lite_birthday_label": "Geburtsblock",
|
||||
"lite_confirm_label": "Bestätigen",
|
||||
"lite_console_backend_commands": "Backend-Befehle:",
|
||||
"lite_console_help_passthrough": "Jede andere Eingabe wird als Lite-Wallet-Konsolenbefehl ausgeführt.",
|
||||
"lite_copy": "Kopieren",
|
||||
"lite_could_not_start": "Der Vorgang konnte nicht gestartet werden",
|
||||
"lite_could_not_write": "Konnte nicht schreiben ",
|
||||
"lite_encrypt_offline_warning": "Wer die Wallet-Datei hat, kann Passphrasen offline durchprobieren. Sichern Sie zuerst Ihre Wiederherstellungsphrase und verwenden Sie eine lange Passphrase.",
|
||||
"lite_encrypt_wallet": "Wallet verschlüsseln",
|
||||
"lite_encryption_removed": "Verschlüsselung entfernt",
|
||||
"lite_enter_all_seed_words": "Alle 24 Seed-Wörter zur Wiederherstellung eingeben (%d erhalten)",
|
||||
@@ -1026,6 +1036,7 @@
|
||||
"lite_hide_wipe": "Ausblenden & löschen",
|
||||
"lite_import": "Importieren",
|
||||
"lite_import_key_label": "Schlüssel importieren",
|
||||
"lite_key_import_failed": "Schlüssel konnte nicht importiert werden — prüfen Sie ihn, entsperren Sie die Wallet (falls verschlüsselt) und versuchen Sie es erneut.",
|
||||
"lite_key_imported": "Schlüssel importiert — führen Sie eine Synchronisierung durch, um den Verlauf zu scannen",
|
||||
"lite_lock_failed": "Sperren fehlgeschlagen",
|
||||
"lite_lock_now": "Jetzt sperren",
|
||||
@@ -1170,9 +1181,43 @@
|
||||
"merge_description": "Mehrere UTXOs zu einer einzelnen abgeschirmten Adresse zusammenführen. Dies kann die Wallet-Größe reduzieren und die Privatsphäre verbessern.",
|
||||
"merge_fee_hint": "Netzwerkgebühr für diese Transaktion.",
|
||||
"merge_funds": "Gelder zusammenführen",
|
||||
"merge_into_one": "In eine Adresse",
|
||||
"merge_keep_each": "In jeder Adresse belassen",
|
||||
"merge_keep_each_batches_fmt": "Adressen mit mehr als %d Notes werden in mehreren Durchgängen zusammengeführt; führen Sie es nach deren Bestätigung erneut aus, um den Rest zusammenzuführen.",
|
||||
"merge_keep_each_confirm_fmt": "%d Notes in %d Adressen konsolidieren? Jede Adresse behält ihre eigenen Mittel. Gebühren: etwa %s DRGX (eine pro Durchgang).",
|
||||
"merge_keep_each_desc": "Die Notes jeder Adresse werden in genau diese Adresse zurückgeführt, sodass Mittel, die Sie unter verschiedenen Bezeichnungen getrennt halten, getrennt bleiben. Nur abgeschirmte Adressen – transparente Mittel können nur abgeschirmt werden (In eine Adresse).",
|
||||
"merge_keep_each_none": "Jede abgeschirmte Adresse hat bereits nur eine Note – nichts zu konsolidieren.",
|
||||
"merge_keep_each_nothing_submitted": "Nichts wurde konsolidiert – der Node hat jede Zusammenführung abgelehnt.",
|
||||
"merge_keep_each_row_fmt": "%s · %d Notes · %s DRGX",
|
||||
"merge_keep_each_run_again": "Adressen, die in mehreren Durchgängen zusammengeführt wurden, haben jetzt eine Note pro Durchgang. Führen Sie dies nach deren Bestätigung erneut aus, um sie zu einer zusammenzuführen.",
|
||||
"merge_keep_each_select": "Wählen Sie mindestens eine Adresse zum Konsolidieren aus.",
|
||||
"merge_keep_each_some_failed": "Einige Zusammenführungen sind fehlgeschlagen – siehe Liste oben.",
|
||||
"merge_keep_each_status_done": "%s – fertig: %d Notes in %d Durchgang/Durchgängen zusammengeführt",
|
||||
"merge_keep_each_status_failed": "%s – fehlgeschlagen: %s",
|
||||
"merge_keep_each_status_not_started": "%s – nicht gestartet: %s",
|
||||
"merge_keep_each_status_running": "%s – %d Notes werden zusammengeführt (%d von %d Durchgängen fertig)…",
|
||||
"merge_keep_each_status_unconfirmed": "%s – Status konnte nicht bestätigt werden",
|
||||
"merge_keep_each_too_small": "Zu klein zum Konsolidieren: Die Gebühr würde diese Mittel aufbrauchen.",
|
||||
"merge_max_inputs": "Max. Inputs pro Batch",
|
||||
"merge_no_spendable": "Noch keine verfügbaren Inputs zum Konsolidieren.",
|
||||
"merge_progress": "Konsolidierung läuft… dies kann einige Minuten dauern. Sie können dieses Fenster schließen.",
|
||||
"merge_run_aborted_toast": "Die Verbindung zum Node wurde zurückgesetzt, daher wurde die Konsolidierung gestoppt. Führen Sie sie erneut aus, um fortzufahren.",
|
||||
"merge_run_busy": "Eine Konsolidierung läuft. Warten Sie, bis sie fertig ist, oder stoppen Sie sie zuerst.",
|
||||
"merge_run_chip_fmt": "Konsolidierung %d/%d",
|
||||
"merge_run_err_timeout": "Der Node hat nicht rechtzeitig geantwortet; der Durchgang ist möglicherweise trotzdem eingereiht.",
|
||||
"merge_run_halted_fmt": "Konsolidierung nach wiederholten Fehlern gestoppt: %s",
|
||||
"merge_run_migration_pending": "Schließen Sie die Seed-Migration ab oder brechen Sie sie ab, bevor Sie konsolidieren.",
|
||||
"merge_run_progress_fmt": "Konsolidierung läuft… Durchgang %d von etwa %d, bisher %d Notes zusammengeführt. Sie können dieses Fenster schließen; der Vorgang läuft im Hintergrund weiter.",
|
||||
"merge_run_start_failed": "Die Konsolidierung konnte nicht gestartet werden – der Node ist nicht verbunden.",
|
||||
"merge_run_status_not_run": "%s – nicht konsolidiert (%d Notes)",
|
||||
"merge_run_status_waiting": "%s – wartet (%d Notes)",
|
||||
"merge_run_stop": "Stoppen",
|
||||
"merge_run_stopped_toast": "Konsolidierung gestoppt. Führen Sie sie erneut aus, um den Rest zusammenzuführen.",
|
||||
"merge_run_stopping": "Wird nach den bereits gesendeten Durchgängen gestoppt…",
|
||||
"merge_run_wait_batch": "Die Durchgänge werden verteilt, damit der Node reaktionsfähig bleibt…",
|
||||
"merge_run_wait_send": "Pausiert, bis Ihre ausstehende Transaktion durch ist.",
|
||||
"merge_run_wait_sync": "Pausiert: Der Node synchronisiert noch.",
|
||||
"merge_scope_failed_fmt": "Ihre Inputs konnten nicht gelesen werden: %s",
|
||||
"merge_scope_fmt": "%d transparente + %d geschützte Inputs · ~%s DRGX verfügbar",
|
||||
"merge_scope_loading": "Ihre Inputs werden geprüft…",
|
||||
"merge_send_failed": "Zusammenführen fehlgeschlagen: ",
|
||||
@@ -1181,7 +1226,11 @@
|
||||
"merge_src_shielded": "Geschützt",
|
||||
"merge_src_transparent": "Transparent",
|
||||
"merge_started": "Zusammenführung gestartet",
|
||||
"merge_submit_uncertain": "Der Node hat das Zusammenführen nicht bestätigt, bevor die Verbindung unterbrochen wurde. Es kann trotzdem ausgeführt werden – prüfen Sie nach dem erneuten Verbinden den Verlauf, bevor Sie erneut zusammenführen.",
|
||||
"merge_title": "An Adresse zusammenführen",
|
||||
"merge_wait_locked": "Die Wallet ist gesperrt. Entsperren Sie sie, um fortzufahren.",
|
||||
"merge_waiting_node": "Warten auf den Node…",
|
||||
"merge_where": "Wohin sollen die Mittel gehen?",
|
||||
"mig_adopt": "Dies zu meiner Wallet machen",
|
||||
"mig_adopt_now": "Seed-Wallet übernehmen",
|
||||
"mig_adopting": "Ihre neue Wallet wird installiert und neu gescannt…",
|
||||
@@ -1233,6 +1282,8 @@
|
||||
"miner_fee": "Miner-Gebühr",
|
||||
"mining": "Mining",
|
||||
"mining_active": "Aktiv",
|
||||
"mining_addr_explainer": "Empfängt nur Solo-Mining-Belohnungen – das Netzwerk lehnt Zahlungen an diese Adresse ab. Belohnungen werden automatisch abgeschirmt, sobald sie gereift sind.",
|
||||
"mining_address": "Mining-Adresse",
|
||||
"mining_address_copied": "Mining-Adresse kopiert",
|
||||
"mining_all_time": "Gesamt",
|
||||
"mining_already_saved": "Pool-URL bereits gespeichert",
|
||||
@@ -1246,6 +1297,8 @@
|
||||
"mining_benchmark_testing": "Testen",
|
||||
"mining_benchmark_tooltip": "Optimale Thread-Anzahl für diese CPU finden",
|
||||
"mining_block_copied": "Block-Hash kopiert",
|
||||
"mining_card_click_to_shield": "Klicken, um gereifte Belohnungen jetzt abzuschirmen.",
|
||||
"mining_card_tooltip": "Solo-Mining-Belohnungen gehen an Ihre Mining-Adressen (transparent, R…). Diese Adressen können keine Zahlungen empfangen. Belohnungen werden automatisch abgeschirmt, sobald sie gereift sind.",
|
||||
"mining_chart_1m_ago": "vor 1m",
|
||||
"mining_chart_5m_ago": "vor 5m",
|
||||
"mining_chart_now": "Jetzt",
|
||||
@@ -1261,6 +1314,8 @@
|
||||
"mining_est_block": "Gesch. Block",
|
||||
"mining_est_daily": "Gesch. täglich",
|
||||
"mining_est_daily_pool_sub": "grobe Solo-Äquivalenz, vor Pool-Gebühr",
|
||||
"mining_est_daily_sub": "Ø",
|
||||
"mining_est_daily_sub_net": "Ø, netto",
|
||||
"mining_filter_all": "Alle",
|
||||
"mining_filter_tip_all": "Alle Einnahmen anzeigen",
|
||||
"mining_filter_tip_pool": "Nur Pool-Einnahmen anzeigen",
|
||||
@@ -1290,6 +1345,7 @@
|
||||
"mining_payout_address": "Auszahlungsadresse",
|
||||
"mining_payout_foreign": "⚠ Diese Auszahlungsadresse befindet sich nicht in Ihrer aktuellen Wallet — geschürfte Belohnungen würden an eine andere Wallet gehen. Aktualisieren Sie sie, wenn Sie die Wallet gewechselt haben.",
|
||||
"mining_payout_invalid": "Keine gültige DragonX-Adresse — vor dem Start korrigieren, sonst gehen die Mining-Belohnungen verloren.",
|
||||
"mining_payout_needs_shielded": "Pools zahlen mit gewöhnlichen Transaktionen aus, die bei DragonX keine Mining-Adresse (transparent, R…) erreichen können. Verwenden Sie eine abgeschirmte Adresse (sie beginnt mit zs) als Auszahlungsadresse.",
|
||||
"mining_payout_tooltip": "Adresse für Mining-Belohnungen",
|
||||
"mining_pool": "Pool",
|
||||
"mining_pool_fee": "Gebühr",
|
||||
@@ -1302,6 +1358,8 @@
|
||||
"mining_refresh": "Aktualisieren",
|
||||
"mining_remove": "Entfernen",
|
||||
"mining_reset_defaults": "Standardwerte zurücksetzen",
|
||||
"mining_rewards": "Mining-Belohnungen",
|
||||
"mining_rewards_unshielded": "Noch nicht abgeschirmt",
|
||||
"mining_save_payout_address": "Auszahlungsadresse speichern",
|
||||
"mining_save_pool_url": "Pool-URL speichern",
|
||||
"mining_saved_addresses": "Gespeicherte Adressen:",
|
||||
@@ -1335,10 +1393,10 @@
|
||||
"network_fee": "NETZWERKGEBÜHR",
|
||||
"network_hashrate": "Netzwerk-Hashrate",
|
||||
"new": "+ Neu",
|
||||
"new_mining_address": "Neue Mining-Adresse",
|
||||
"new_mining_address_created": "Neue Mining-Adresse erstellt",
|
||||
"new_shielded_created": "Neue abgeschirmte Adresse erstellt",
|
||||
"new_t_address": "Neue T-Adresse",
|
||||
"new_t_transparent": "Neue t-Adresse (Transparent)",
|
||||
"new_transparent_created": "Neue transparente Adresse erstellt",
|
||||
"new_z_address": "Neue Z-Adresse",
|
||||
"new_z_shielded": "Neue z-Adresse (Abgeschirmt)",
|
||||
"no_addresses": "Keine Adressen gefunden. Erstellen Sie eine mit den Schaltflächen oben.",
|
||||
@@ -1358,7 +1416,19 @@
|
||||
"node_banner_offline_title": "Nicht mit dem DragonX-Node verbunden",
|
||||
"node_banner_reconnect": "Erneut verbinden",
|
||||
"node_banner_restart": "Node neu starten",
|
||||
"node_busy_rebuild_desc": "Läuft seit %s. Bei großen Wallets kann das etwa eine Stunde dauern, und der Node kann der Wallet erst danach antworten.",
|
||||
"node_busy_rebuild_extending": "Witnesses werden erweitert (der Node meldet für diesen Schritt keinen Fortschritt).",
|
||||
"node_busy_rebuild_reading": "Blöcke werden gelesen: %ld / %ld",
|
||||
"node_busy_rebuild_title": "Node beschäftigt: Note-Witnesses werden neu aufgebaut",
|
||||
"node_busy_replay_desc": "Läuft seit %s. Der Node wendet Blöcke erneut an, die er vor dem letzten Beenden empfangen hat.",
|
||||
"node_busy_replay_title": "Node beschäftigt: Blöcke werden nachgeholt",
|
||||
"node_busy_rpc_desc": "Der Node läuft, ist aber seit %s zu beschäftigt, um der Wallet zu antworten – meist wegen eines langen Vorgangs wie dem Neuaufbau der Witnesses.",
|
||||
"node_busy_rpc_title": "Node beschäftigt: antwortet noch nicht",
|
||||
"node_busy_running": "Läuft seit %s",
|
||||
"node_security": "KNOTEN & SICHERHEIT",
|
||||
"node_starting_busy_desc": "Der Node ist erreichbar, aber beschäftigt und antwortet noch nicht. Das ist nach einem Update oder beim ersten Start normal und kann einige Minuten dauern.",
|
||||
"node_starting_launch_desc": "dragonxd wird gestartet, warte auf Verbindung…",
|
||||
"node_starting_title": "DragonX-Node wird gestartet…",
|
||||
"noise": "Rauschen",
|
||||
"not_connected": "Nicht mit Daemon verbunden...",
|
||||
"not_connected_to_daemon": "Nicht mit Daemon verbunden",
|
||||
@@ -1511,6 +1581,8 @@
|
||||
"ram_wallet_gb": "Wallet: %.1f GB",
|
||||
"ram_wallet_mb": "Wallet: %.0f MB",
|
||||
"receive": "Empfangen",
|
||||
"receive_not_for_payments": "Nicht für Zahlungen",
|
||||
"receive_use_shielded": "Abgeschirmte Adresse verwenden",
|
||||
"received": "empfangen",
|
||||
"received_filter": "Empfangen",
|
||||
"received_label": "Empfangen",
|
||||
@@ -1525,6 +1597,7 @@
|
||||
"reduce_motion": "Bewegung reduzieren",
|
||||
"refresh": "Aktualisieren",
|
||||
"refresh_now": "Jetzt aktualisieren",
|
||||
"refresh_waits_for_sync": "Der Knoten synchronisiert noch. Die Wallet-Daten werden aktualisiert, sobald er fertig ist.",
|
||||
"remove_favorite": "Favorit entfernen",
|
||||
"repair_wallet": "Wallet reparieren",
|
||||
"report_bug": "Fehler melden",
|
||||
@@ -1539,12 +1612,12 @@
|
||||
"request_select_address": "Adresse auswählen...",
|
||||
"request_shielded_addrs": "-- Abgeschirmte Adressen --",
|
||||
"request_title": "Zahlung anfordern",
|
||||
"request_transparent_addrs": "-- Transparente Adressen --",
|
||||
"request_uri_copied": "Zahlungs-URI in Zwischenablage kopiert",
|
||||
"rescan": "Neu scannen",
|
||||
"rescan_bootstrapped_msg": "Ihr Node wurde per Bootstrap eingerichtet, daher liegen Blöcke unterhalb des Snapshots nicht auf der Festplatte und ein Neuscan ab dem Genesis-Block würde fehlschlagen. Führen Sie den Neuscan ab einer Höhe durch, die Ihr Snapshot umfasst, um das ausgegebene Guthaben Ihrer Wallet abzugleichen. Ihre wallet.dat und Ihre Blockchain-Daten werden nicht gelöscht.",
|
||||
"rescan_detecting": "Prüfe, welche Blöcke Ihr Node auf der Festplatte hat…",
|
||||
"rescan_from_height": "Neu scannen ab Blockhöhe:",
|
||||
"rescan_probe_failed": "Es konnte nicht geprüft werden, welche Blöcke Ihr Node auf der Festplatte hat (er ist ausgelastet oder eine Anfrage ist abgelaufen). Wenn dieser Node per Bootstrap eingerichtet wurde, brechen Sie ab und versuchen Sie es in einer Minute erneut: Der vollständige -rescan-Neustart unten schlägt auf einem per Bootstrap eingerichteten Node fehl.",
|
||||
"reset_to_defaults": "Standardwerte zurücksetzen",
|
||||
"restarting_after_encryption": "Daemon wird nach Verschlüsselung neu gestartet...",
|
||||
"restore_address": "Adresse wiederherstellen",
|
||||
@@ -1587,6 +1660,7 @@
|
||||
"sb_net_khs": "Netz: %.2f KH/s",
|
||||
"sb_net_mhs": "Netz: %.2f MH/s",
|
||||
"sb_no_conf": "DRAGONX.conf nicht gefunden",
|
||||
"sb_node_busy": "Node beschäftigt",
|
||||
"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",
|
||||
@@ -1608,6 +1682,7 @@
|
||||
"screenshot_sweep": "Screenshot-Durchlauf ausführen",
|
||||
"screenshot_sweep_desc": "Durchläuft jedes Design über jeden Tab und speichert von jedem einen Screenshot in tab-spezifischen Unterordnern im Screenshots-Ordner des Konfigurationsverzeichnisses (überschreibt den vorherigen Durchlauf). Läuft einige Sekunden.",
|
||||
"screenshot_sweep_full": "Vollständiger UI-Durchlauf",
|
||||
"screenshot_sweep_refused": "Vollständiger UI-Durchlauf abgelehnt; siehe Debug-Log",
|
||||
"search_icons": "Symbole suchen...",
|
||||
"search_placeholder": "Suchen...",
|
||||
"sec_changing_passphrase": "Passphrase wird geändert...",
|
||||
@@ -1629,15 +1704,19 @@
|
||||
"sec_mode_passphrase": " Passphrase",
|
||||
"sec_not_connected_to_daemon": "Nicht mit Daemon verbunden",
|
||||
"sec_not_connected_to_daemon_pin": "Nicht mit Daemon verbunden",
|
||||
"sec_passphrase_changed_pin_stale": "Passphrase geändert; Ihre PIN ist jetzt veraltet",
|
||||
"sec_passphrase_changed_successfully": "Passphrase erfolgreich geändert",
|
||||
"sec_pin_changed_successfully": "PIN erfolgreich geändert",
|
||||
"sec_pin_removed": "PIN entfernt",
|
||||
"sec_pin_set_successfully": "PIN erfolgreich festgelegt",
|
||||
"sec_pin_stale_msg": "PIN veraltet. Verwenden Sie Ihre Passphrase.",
|
||||
"sec_pin_unreadable": "PIN-Datei unlesbar. Verwenden Sie Ihre Passphrase.",
|
||||
"sec_restart_daemon_for_encryption": "Bitte starten Sie Ihren Daemon neu, damit die Verschlüsselung wirksam wird.",
|
||||
"sec_too_many_attempts_wait": "Zu viele Versuche. Warten Sie %.0f Sekunden...",
|
||||
"sec_total_elapsed_fmt": "Gesamtdauer: %dm %02ds",
|
||||
"sec_unlock_button": "Entsperren",
|
||||
"sec_unlock_failed_prefix": "Entsperren fehlgeschlagen: ",
|
||||
"sec_unlock_node_busy": "The node is busy; try again in a moment.",
|
||||
"sec_unlocking_fmt": "Entsperren%s",
|
||||
"sec_use_passphrase_instead": "Stattdessen Passphrase verwenden",
|
||||
"sec_use_pin_instead": "Stattdessen PIN verwenden",
|
||||
@@ -1690,6 +1769,8 @@
|
||||
"send_from_this_address": "Von dieser Adresse senden",
|
||||
"send_go_to_receive": "Zum Empfangen",
|
||||
"send_keep": "Behalten",
|
||||
"send_mining_addr_blocked": "Dies ist eine Mining-Adresse (transparent). Bei DragonX können solche Adressen nur Mining-Belohnungen empfangen – das Netzwerk lehnt Zahlungen an sie ab. Bitten Sie den Empfänger um eine abgeschirmte Adresse (sie beginnt mit zs).",
|
||||
"send_mining_addr_short": "Mining-Adresse – kann keine Zahlungen empfangen",
|
||||
"send_network_fee": "NETZWERKGEBÜHR",
|
||||
"send_no_balance": "Kein Guthaben",
|
||||
"send_no_recent": "Keine kürzlichen Sendungen",
|
||||
@@ -1717,7 +1798,6 @@
|
||||
"send_txid_copied": "TxID in Zwischenablage kopiert",
|
||||
"send_txid_label": "TxID: %s",
|
||||
"send_valid_shielded": "Gültige abgeschirmte Adresse",
|
||||
"send_valid_transparent": "Gültige transparente Adresse",
|
||||
"send_wallet_empty": "Ihre Wallet ist leer",
|
||||
"send_yes_clear": "Ja, leeren",
|
||||
"sender_balance": "Absender: %.8f → %.8f DRGX",
|
||||
@@ -1780,6 +1860,7 @@
|
||||
"settings_open_log_folder": "Log-Ordner öffnen",
|
||||
"settings_other": "Sonstiges",
|
||||
"settings_pin_active": "PIN",
|
||||
"settings_pin_out_of_date": "PIN veraltet",
|
||||
"settings_privacy": "Datenschutz",
|
||||
"settings_quick_unlock_pin": "Schnell-Entsperr-PIN",
|
||||
"settings_reduce_transparency": "Transparenz reduzieren",
|
||||
@@ -1845,11 +1926,24 @@
|
||||
"shielded_type": "Abgeschirmt",
|
||||
"shielding_notice": "Hinweis: Dies wird Gelder von einer transparenten (T) Adresse auf eine private (Z) Adresse schirmen.",
|
||||
"show": "Anzeigen",
|
||||
"show_earnings": "Erträge anzeigen",
|
||||
"show_hidden": "Ausgeblendete anzeigen (%d)",
|
||||
"show_qr": "QR anzeigen",
|
||||
"show_qr_code": "QR-Code anzeigen",
|
||||
"showing_transactions": "Zeige %d–%d von %d Transaktionen (gesamt: %zu)",
|
||||
"showing_x_of_y": "%d von %d Adressen angezeigt",
|
||||
"shutdown_kill_confirm_msg": "Der Node zeigt seit Langem keine CPU- oder Festplattenaktivität und hängt möglicherweise.\nWenn Sie ihn erzwungen beenden, geht alles Ungespeicherte verloren; sein nächster Start kann deutlich länger dauern.",
|
||||
"shutdown_kill_confirm_title": "Node erzwungen beenden?",
|
||||
"shutdown_kill_confirm_yes": "Erzwungen beenden",
|
||||
"shutdown_kill_node": "Node erzwungen beenden (unsicher)",
|
||||
"shutdown_kill_node_tip": "Beendet den Node sofort. Alles, was er noch nicht gespeichert hat, geht verloren, und beim nächsten Start spielt er die Blöcke seit dem letzten Speichern erneut ein.",
|
||||
"shutdown_leave_running": "Wallet schließen – Node weiterlaufen lassen",
|
||||
"shutdown_leave_running_tip": "Schließt die Wallet sofort. Der Node läuft allein weiter und beendet, was er gerade tut; nichts geht verloren.",
|
||||
"shutdown_stop_asking": "Node wird zum Beenden aufgefordert…",
|
||||
"shutdown_stop_cpu_suffix": " · CPU %d%%",
|
||||
"shutdown_stop_delivered": "Der Node fährt herunter und speichert seinen Zustand",
|
||||
"shutdown_stop_idle": "Seit %d Min. keine CPU- oder Festplattenaktivität – der Node hängt möglicherweise",
|
||||
"shutdown_stop_queued": "Der Node hat die Stopp-Anforderung erhalten, ist aber beschäftigt – er stoppt, sobald er kann",
|
||||
"simple_background": "Einfacher Hintergrund",
|
||||
"slider_off": "Aus",
|
||||
"sort_amount_high": "Größter Betrag",
|
||||
@@ -1857,9 +1951,18 @@
|
||||
"sort_date_newest": "Neueste zuerst",
|
||||
"sort_date_oldest": "Älteste zuerst",
|
||||
"start_mining": "Mining starten",
|
||||
"startup_files_preparing": "Wallet-Dateien werden vorbereitet... %d%%",
|
||||
"startup_files_verifying": "Wallet-Dateien werden überprüft...",
|
||||
"status": "Status",
|
||||
"stop_external": "Externen Daemon stoppen",
|
||||
"stop_mining": "Mining stoppen",
|
||||
"stopconfirm_busy_body": "Er ist seit %s zu beschäftigt, um der Wallet zu antworten – vermutlich wegen eines langen Vorgangs wie dem Neuaufbau der Witnesses. Wenn Sie ihn jetzt stoppen, muss der nächste Start diese Arbeit eventuell wiederholen.\n\nEmpfohlen: Wallet schließen und den Node weiterlaufen lassen. Er beendet die Arbeit selbstständig.",
|
||||
"stopconfirm_busy_title": "Der Node ist beschäftigt",
|
||||
"stopconfirm_rebuild_body": "Er läuft seit %s, und bei großen Wallets kann das etwa eine Stunde dauern. Wenn Sie den Node jetzt stoppen, wird entweder bis zum Ende des Neuaufbaus gewartet, oder der nächste Start muss den Neuaufbau samt Nachholen wiederholen.\n\nEmpfohlen: Wallet schließen und den Node weiterlaufen lassen. Er beendet die Arbeit selbstständig.",
|
||||
"stopconfirm_rebuild_title": "Der Node baut die Note-Witnesses neu auf",
|
||||
"stopconfirm_stop_wait": "Node stoppen & warten",
|
||||
"stopconfirm_walletop_body": "Der Node durchsucht die Blockchain für einen Schlüsselimport oder Neuscan. Wenn Sie ihn jetzt stoppen, endet dieser Scan vorzeitig, und Guthaben, das er noch nicht erreicht hat, erscheint erst nach Einstellungen → Neu scannen.\n\nEmpfohlen: Wallet schließen und den Node weiterlaufen lassen. Er beendet den Scan selbstständig.",
|
||||
"stopconfirm_walletop_title": "Ein Schlüsselimport oder Neuscan läuft",
|
||||
"stratum_allowip_hint": "Miner von IP oder CIDR zulassen (leer = nur dieser Computer)",
|
||||
"stratum_expose_warn": "Öffnet einen Mining-Port für das von Ihnen zugelassene Netzwerk — nur in einem vertrauenswürdigen LAN verwenden.",
|
||||
"stratum_host": "Mining-Pool hosten (Stratum)",
|
||||
@@ -1906,7 +2009,6 @@
|
||||
"switch_stopnode_title": "Laufenden Knoten stoppen?",
|
||||
"switch_stopnode_warn": "Es läuft bereits ein Knoten, den diese Wallet nicht gestartet hat.",
|
||||
"syncing": "Synchronisiere...",
|
||||
"t_address": "T-Adresse",
|
||||
"t_addresses": "T-Adressen",
|
||||
"test_connection": "Testen",
|
||||
"theme": "Design",
|
||||
@@ -1928,6 +2030,7 @@
|
||||
"tools": "WERKZEUGE",
|
||||
"tools_actions": "Werkzeuge & Aktionen...",
|
||||
"tools_actions_hdr": "WERKZEUGE & AKTIONEN",
|
||||
"tor_never_applied_notice": "Die Option „Tor verwenden“ hat nie funktioniert: Ihre Verbindungen wurden NICHT über Tor geleitet. Sie wurde ausgeschaltet. Alternativen finden Sie im Tooltip der Option in den Einstellungen.",
|
||||
"total": "Gesamt",
|
||||
"total_balance_label": "Gesamtguthaben",
|
||||
"transaction_id": "TRANSAKTIONS-ID",
|
||||
@@ -1941,8 +2044,6 @@
|
||||
"transfer_sent": "Überweisung gesendet",
|
||||
"transfer_sent_desc": "Ihre Überweisung wurde an das Netzwerk gesendet.",
|
||||
"transfer_to": "Überweisen an:",
|
||||
"transparent": "Transparent",
|
||||
"transparent_address": "Transparente Adresse",
|
||||
"try_again": "Erneut versuchen",
|
||||
"tt_addr_url": "Basis-URL zum Anzeigen von Adressen in einem Block-Explorer",
|
||||
"tt_address_book": "Gespeicherte Adressen für schnelles Senden verwalten",
|
||||
@@ -1974,10 +2075,16 @@
|
||||
"tt_debug_expand": "Debug-Protokollierungsoptionen ausklappen",
|
||||
"tt_delete_blockchain": "Alle Blockchain-Daten löschen und neu synchronisieren. wallet.dat und Konfiguration bleiben erhalten.",
|
||||
"tt_download_bootstrap": "Blockchain-Bootstrap herunterladen, um die Synchronisierung zu beschleunigen\nVorhandene Blockdaten werden ersetzt",
|
||||
"tt_earn_interval": "Ertragszeitraum: 1H = diese Stunde, 1D = heute, 1M = dieser Monat, 1Y = dieses Jahr",
|
||||
"tt_earn_mode_inflows": "Ertragsmodus: erhaltene Beträge (einschließlich Mining)",
|
||||
"tt_earn_mode_net": "Ertragsmodus: netto (erhalten minus gesendet)",
|
||||
"tt_earn_show_all": "Ertragswert für jede Adresse anzeigen",
|
||||
"tt_earnings_format": "Wie die Ertragswerte pro Adresse und insgesamt angezeigt werden",
|
||||
"tt_encrypt": "wallet.dat mit einer Passphrase verschlüsseln",
|
||||
"tt_export_all": "Alle privaten Schlüssel in eine Datei exportieren",
|
||||
"tt_export_csv": "Transaktionsverlauf als CSV-Tabelle exportieren",
|
||||
"tt_export_key": "Den privaten Schlüssel der ausgewählten Adresse exportieren",
|
||||
"tt_favorite_opacity": "Deckkraft des Favoriten-Sterns und seiner goldenen Umrandung in der Adressliste (mindestens 15 %)",
|
||||
"tt_fetch_prices": "DRGX-Marktpreise von der CoinGecko-API abrufen",
|
||||
"tt_font_scale": "Alle Texte und UI skalieren (1.0x = Standard, bis 1.5x).",
|
||||
"tt_idle_delay": "Wie lange vor dem Start des Minings gewartet werden soll",
|
||||
@@ -1986,6 +2093,7 @@
|
||||
"tt_keep_daemon": "Der Daemon wird beim Ausführen des Einrichtungsassistenten gestoppt",
|
||||
"tt_language": "Schnittstellensprache der Wallet-UI",
|
||||
"tt_layout_hotkey": "Hotkey: Links-/Rechts-Pfeiltasten zum Wechseln der Balance-Layouts",
|
||||
"tt_lite_confirm_pass": "Geben Sie die neue Passphrase erneut ein; sie muss übereinstimmen und mindestens 8 Zeichen lang sein",
|
||||
"tt_lite_copy": "Das angezeigte Geheimnis in die Zwischenablage kopieren",
|
||||
"tt_lite_decrypt_pass": "Gib deine Passphrase ein, um die Verschlüsselung von der Wallet zu entfernen",
|
||||
"tt_lite_encrypt": "Die Wallet mit der obigen Passphrase verschlüsseln; sie wird sofort gesperrt und benötigt die Passphrase zum Entsperren",
|
||||
@@ -2014,11 +2122,16 @@
|
||||
"tt_low_spec": "Alle aufwendigen visuellen Effekte deaktivieren\\nHotkey: Ctrl+Shift+Down",
|
||||
"tt_merge": "Mehrere UTXOs einer Adresse zusammenführen",
|
||||
"tt_mine_idle": "Mining automatisch starten, wenn das\\nSystem inaktiv ist (keine Tastatur-/Mauseingabe)",
|
||||
"tt_mining_est_daily": "Ein langfristiger statistischer Durchschnitt für Ihren aktuellen Hashrate-Anteil am Netzwerk. Echte Mining-Belohnungen kommen zufällig (Poisson) – oft lange nichts, dann ein Batzen – das ist also KEIN gleichmäßiges Tageseinkommen.",
|
||||
"tt_mining_est_daily_fee_fmt": "Angezeigt nach der %.1f%% Pool-Gebühr.",
|
||||
"tt_mining_est_daily_nofee": "Vor Abzug der Pool-Gebühr angezeigt (Gebühr für diesen Pool unbekannt).",
|
||||
"tt_new_mining_address": "Erstellt eine transparente Adresse (R…) für Solo-Mining-Belohnungen. Sie kann keine Zahlungen empfangen – teilen Sie dafür eine abgeschirmte Adresse.",
|
||||
"tt_noise": "Körnungstextur-Intensität (0% = aus, 100% = maximum)",
|
||||
"tt_open_app_dir": "Den ObsidianDragon-Ordner (Einstellungen, Themes, Logs) im Dateimanager öffnen",
|
||||
"tt_open_data_dir": "Den Ordner mit Ihren Wallet- und Blockchain-Daten im Dateimanager öffnen",
|
||||
"tt_open_dir": "Klicken, um im Dateimanager zu öffnen",
|
||||
"tt_open_log_folder": "Öffnet den Ordner mit den Debug- und Absturzprotokollen",
|
||||
"tt_pin_out_of_date": "Die Wallet-Passphrase wurde geändert, nachdem diese PIN festgelegt wurde, daher kann die PIN die Wallet nicht mehr entsperren. Entfernen Sie die PIN und legen Sie sie neu fest.",
|
||||
"tt_reduce_motion": "Animierte Übergänge und Saldo-Lerp für Barrierefreiheit deaktivieren",
|
||||
"tt_remove_encrypt": "Verschlüsselung entfernen und Wallet ungeschützt speichern",
|
||||
"tt_remove_pin": "PIN entfernen und Passphrase zum Entsperren erfordern",
|
||||
@@ -2054,10 +2167,13 @@
|
||||
"tt_theme_effects": "Schimmer, Glühen, Farbton-Zyklen pro Theme",
|
||||
"tt_theme_hotkey": "Hotkey: Ctrl+Links/Rechts zum Wechseln der Themes",
|
||||
"tt_tor": "Daemon-Verbindungen für Anonymität über das Tor-Netzwerk leiten",
|
||||
"tt_tor_unavailable": "Noch nicht verfügbar: Diese Wallet leitet keinen Datenverkehr über Tor. Nur für den Node können Sie proxy=127.0.0.1:9050 zu DRAGONX.conf hinzufügen und den Node neu starten. Tor muss laufen, sonst findet der Node keine Peers. Preis-, Update-, Bootstrap- und Pool-Anfragen verbinden sich weiterhin direkt; nutzen Sie dafür ein systemweites VPN/Tor.",
|
||||
"tt_tor_unavailable_lite": "Noch nicht verfügbar: Diese Wallet leitet keinen Datenverkehr über Tor. Nutzen Sie ein systemweites VPN/Tor.",
|
||||
"tt_tx_url": "Basis-URL zum Anzeigen von Transaktionen in einem Block-Explorer",
|
||||
"tt_ui_opacity": "Karten- und Seitenleisten-Deckkraft (100% = vollständig undurchsichtig, niedriger = durchsichtiger)",
|
||||
"tt_validate": "Prüfen, ob eine DragonX-Adresse gültig ist",
|
||||
"tt_verbose": "Detaillierte Verbindungsdiagnosen,\\nDaemon-Status und Port-Besitzer-Info\\nin der Konsolen-Registerkarte protokollieren",
|
||||
"tt_view_transactions": "Transaktionen mit dieser Adresse im Verlauf-Tab anzeigen",
|
||||
"tt_wallet_size_warn": "Geschützte Wallets wachsen mit den Witness-Daten jedes Notes — das Zusammenführen vieler Notes in eine Adresse verringert die Größe. Zuerst sichern.",
|
||||
"tt_wallets_button": "Ihre Wallet-Dateien auflisten und zwischen ihnen wechseln",
|
||||
"tt_website": "Die DragonX-Website öffnen",
|
||||
@@ -2075,6 +2191,7 @@
|
||||
"tx_loading_queued": "Transaktionsaktualisierung in der Warteschlange",
|
||||
"tx_loading_refreshing_cached": "Wallet-Verlauf wird aktualisiert (%d zwischengespeichert)",
|
||||
"tx_loading_scanning_shielded": "Abgeschirmter Verlauf wird gescannt (%d Adressen)",
|
||||
"tx_loading_waits_for_sync": "Der Verlauf wird aktualisiert, sobald der Knoten fertig synchronisiert ist",
|
||||
"tx_mined": "GEMINED",
|
||||
"tx_progress_balances": "Guthaben wird aktualisiert",
|
||||
"tx_progress_finalizing": "Transaktion wird abgeschlossen",
|
||||
@@ -2106,6 +2223,7 @@
|
||||
"validate_description": "Geben Sie eine DragonX-Adresse ein, um zu prüfen, ob sie gültig ist und ob sie zu dieser Wallet gehört.",
|
||||
"validate_invalid": "UNGÜLTIG",
|
||||
"validate_is_mine": "Diese Wallet besitzt diese Adresse",
|
||||
"validate_mining_type": "Mining-Adresse (transparent)",
|
||||
"validate_not_mine": "Nicht im Besitz dieser Wallet",
|
||||
"validate_ownership": "Eigentum:",
|
||||
"validate_results": "Ergebnisse:",
|
||||
@@ -2113,7 +2231,6 @@
|
||||
"validate_shielded_type": "Abgeschirmt (z-Adresse)",
|
||||
"validate_status": "Status:",
|
||||
"validate_title": "Adresse validieren",
|
||||
"validate_transparent_type": "Transparent (t-Adresse)",
|
||||
"validate_type": "Typ:",
|
||||
"validate_valid": "GÜLTIG",
|
||||
"validating": "Validiere...",
|
||||
@@ -2123,6 +2240,7 @@
|
||||
"view_all": "Alle anzeigen",
|
||||
"view_details": "Details anzeigen",
|
||||
"view_on_explorer": "Im Explorer anzeigen",
|
||||
"view_transactions": "Transaktionen anzeigen",
|
||||
"waiting_for_daemon": "Warte auf Daemon-Verbindung...",
|
||||
"wallet": "WALLET",
|
||||
"wallet_degraded_notify": "Ihre Wallet wurde im eingeschränkten Modus geöffnet: Vorhandene Guthaben sind sicher und verfügbar, aber das Erstellen neuer Adressen und das Schützen von Guthaben sind deaktiviert. Sichern Sie Ihre Seed-Phrase und stellen Sie sie wieder her, um die Wallet vollständig zu reparieren.",
|
||||
@@ -2231,6 +2349,7 @@
|
||||
"wallets_reveal": "Ordner anzeigen",
|
||||
"wallets_scan_folder": "Einen weiteren Ordner nach Wallets durchsuchen…",
|
||||
"wallets_scanned_folders": "Durchsuchte Ordner:",
|
||||
"wallets_scanning": "Ordner werden durchsucht…",
|
||||
"wallets_sort_addresses": "Adressen",
|
||||
"wallets_sort_asc": "Aufsteigend (ältestes / wenigste / kleinstes zuerst)",
|
||||
"wallets_sort_by": "Sortieren:",
|
||||
|
||||
+150
-31
@@ -25,6 +25,7 @@
|
||||
"about_website": "Sitio Web",
|
||||
"acrylic": "Acrílico",
|
||||
"add": "Agregar",
|
||||
"addr_type_mining": "Minería",
|
||||
"address": "Dirección",
|
||||
"address_book_add": "Agregar Dirección",
|
||||
"address_book_add_new": "Agregar Nueva",
|
||||
@@ -68,6 +69,7 @@
|
||||
"appx_blockchain_maintenance_in_progress": "Ya hay una operación de mantenimiento de la cadena de bloques en curso.",
|
||||
"appx_blockchain_rescan_complete": "Reescaneo de la cadena de bloques completado",
|
||||
"appx_bootstrap_complete_reconciling": "Arranque inicial completado; reconciliando tu cartera con los nuevos datos de la cadena.",
|
||||
"appx_bootstrap_reconcile_probe_failed": "No se pudo reconciliar tu cartera con los datos de la cadena del bootstrap: el nodo estuvo demasiado ocupado para comprobar qué bloques tiene. Ejecuta Re-escanear desde Ajustes cuando se haya puesto al día.",
|
||||
"appx_cancel": "Cancelar",
|
||||
"appx_cleaning_up": "Limpiando...",
|
||||
"appx_confirm_your_backup": "Confirma tu copia de seguridad",
|
||||
@@ -78,11 +80,15 @@
|
||||
"appx_creating_your_wallet": "Creando tu cartera…",
|
||||
"appx_daemon_error": "Error del daemon",
|
||||
"appx_daemon_reinstall_in_progress": "La reinstalación del daemon ya está en curso.",
|
||||
"appx_daemon_reinstall_node_busy": "El nodo sigue en marcha, así que no se reinstaló el daemon. Vuelve a intentarlo cuando se haya detenido.",
|
||||
"appx_disconnecting": "Desconectando...",
|
||||
"appx_done": "Listo",
|
||||
"appx_dragonxd_output": "Salida de dragonxd",
|
||||
"appx_encrypting_wallet": "Cifrando la cartera...",
|
||||
"appx_fullnode_lifecycle_unavailable_lite": "Las acciones de ciclo de vida del nodo completo no están disponibles en la versión lite",
|
||||
"appx_import_cancelled": "Cancelado: puede que el nodo siga escaneando. Si los fondos de la clave no aparecen, ejecuta Ajustes → Re-escanear.",
|
||||
"appx_import_interrupted": "La importación se interrumpió, así que la clave puede que ya esté en la cartera. Si sus fondos no aparecen, ejecuta Ajustes → Re-escanear.",
|
||||
"appx_import_refused": "El nodo rechazó la solicitud, así que la clave no se importó.",
|
||||
"appx_installing_bundled_daemon": "Instalando el daemon incluido; el nodo se detendrá, se actualizará y se reiniciará...",
|
||||
"appx_invalid_payment_uri_prefix": "URI de pago no válida: ",
|
||||
"appx_ive_written_it_down": "Ya la anoté",
|
||||
@@ -99,7 +105,8 @@
|
||||
"appx_no_bundled_daemon_to_install": "Esta versión no incluye un daemon para instalar",
|
||||
"appx_no_embedded_daemon_to_install": "Esta versión no incluye un daemon integrado para instalar",
|
||||
"appx_node_busy_restarting": "El nodo está ocupado reiniciándose; inténtalo de nuevo en un momento.",
|
||||
"appx_node_rebuilding_witness_cache": "El nodo está reconstruyendo su caché de testigos",
|
||||
"appx_node_busy_try_later": "El nodo está ocupado terminando una tarea larga (como reconstruir los testigos de notas). Vuelve a intentarlo cuando termine; detenerlo ahora podría hacer que empiece ese trabajo de nuevo.",
|
||||
"appx_node_not_stopped": "El nodo no se detuvo a tiempo, así que no se cambió nada. Vuelve a intentarlo en unos minutos.",
|
||||
"appx_not_next_word": " — esa no es la siguiente palabra",
|
||||
"appx_payment_request_loaded": "Solicitud de pago cargada",
|
||||
"appx_pool_miner_connected_and_hashing": "Minero de pool conectado y calculando hashes.",
|
||||
@@ -120,11 +127,7 @@
|
||||
"appx_simple_background_enabled": "Fondo simple activado",
|
||||
"appx_skip": "Omitir",
|
||||
"appx_skip_anyway": "Omitir de todos modos",
|
||||
"appx_still_status_prefix": "Aún \"",
|
||||
"appx_still_status_suffix": "\" — forzar el cierre ahora podría dañar los datos de la cadena.",
|
||||
"appx_stop_anyway_and_quit": "Detener de todos modos y salir",
|
||||
"appx_stopping_daemon_deleting_blockchain": "Deteniendo el daemon y eliminando los datos de la cadena de bloques...",
|
||||
"appx_stopping_node_discards_rebuild": "Detener el nodo ahora descarta la reconstrucción en curso y la reinicia (varios minutos) la próxima vez que abras la cartera. También puedes dejar el nodo en ejecución.",
|
||||
"appx_stopping_pool_miner": "Deteniendo el minero de pool...",
|
||||
"appx_syncing_pct_block_n_of_n": "Sincronizando %.1f%% — Bloque %d / %d",
|
||||
"appx_tap_words_in_order": "Toca las palabras en el orden correcto para confirmar que las guardaste.",
|
||||
@@ -134,6 +137,9 @@
|
||||
"appx_use_settings_restart_daemon_hint": "Usa Ajustes > Reiniciar daemon para intentarlo de nuevo",
|
||||
"appx_waiting_for_daemon_to_encrypt_wallet": "Esperando a que el daemon cifre la cartera...",
|
||||
"appx_wallet_created_and_backed_up": "Cartera creada y respaldada.",
|
||||
"appx_wallet_op_node_busy": "El nodo está ocupado y no responde. Vuelve a intentar la importación o el reescaneo cuando termine.",
|
||||
"appx_wallet_op_running": "Aún se está ejecutando una importación de clave o un reescaneo. Vuelve a intentarlo cuando termine.",
|
||||
"appx_wallet_op_shutting_down": "La cartera se está cerrando.",
|
||||
"appx_wallet_open_failed_prefix": "Error al abrir la cartera: ",
|
||||
"auto_shield": "Auto-proteger minería",
|
||||
"autoshield_by_node": "Tu nodo blinda automáticamente el coinbase minado por ti.",
|
||||
@@ -174,21 +180,20 @@
|
||||
"balance_history_collecting": "Historial de saldo — recopilando datos...",
|
||||
"balance_layout": "Diseño de Saldo",
|
||||
"balance_layout_switched": "Diseño: %s",
|
||||
"balance_mining_fmt": "Recompensas de minería: %.8f",
|
||||
"balance_mining_rate": "Minando %s",
|
||||
"balance_shielded_fmt": "Protegido: %.8f",
|
||||
"balance_syncing_pct": "Sincronizando %.1f%%",
|
||||
"balance_transparent_fmt": "Transparente: %.8f",
|
||||
"baltab_market": "Mercado",
|
||||
"baltab_market_price_4dp": "Mercado: $%.4f",
|
||||
"baltab_market_price_8dp": "Mercado: $%.8f",
|
||||
"baltab_mining_addresses_count": "%d direcciones de minería",
|
||||
"baltab_mining_amount": "Recompensas de minería %.8f",
|
||||
"baltab_pct_change_24h": "%s%.1f%% 24h",
|
||||
"baltab_pct_of_total_zaddr": "%.0f%% del total · %d Z-addr",
|
||||
"baltab_shielded": "Blindado",
|
||||
"baltab_shielded_amount": "Blindado %.8f",
|
||||
"baltab_t_addresses_count": "%d T-addresses",
|
||||
"baltab_total_balance": "Saldo total",
|
||||
"baltab_transparent": "Transparente",
|
||||
"baltab_transparent_amount": "Transparente %.8f",
|
||||
"ban": "Bloquear",
|
||||
"banned_peers": "Nodos Bloqueados",
|
||||
"block": "Bloque",
|
||||
@@ -238,6 +243,7 @@
|
||||
"change_pass_confirm": "Confirmar nueva:",
|
||||
"change_pass_current": "Frase de contraseña actual:",
|
||||
"change_pass_new": "Nueva frase de contraseña:",
|
||||
"change_pass_pin_note": "Esta cartera tiene un PIN de desbloqueo rápido. Tras el cambio quedará desactualizado: quita el PIN en Ajustes y vuelve a establecerlo.",
|
||||
"change_pass_title": "Cambiar frase de contraseña",
|
||||
"characters": "caracteres",
|
||||
"chat": "Chat",
|
||||
@@ -513,7 +519,7 @@
|
||||
"contact_avatar_image_hint": "La imagen se copia en la app para que siga disponible si el original se mueve.",
|
||||
"contact_avatar_remove": "Quitar",
|
||||
"contact_avatar_shielded": "Blindada",
|
||||
"contact_avatar_transparent": "Transparente",
|
||||
"contact_chip_mining": "Solo minería",
|
||||
"contact_global": "Mostrar en todas las carteras (contacto global)",
|
||||
"contact_global_badge_tt": "Contacto global — visible en todas las carteras",
|
||||
"contact_global_tt": "Activado: este contacto permanece visible sin importar qué cartera cargues. Desactivado: pertenece solo a la cartera actual.",
|
||||
@@ -633,6 +639,11 @@
|
||||
"download": "Descargar",
|
||||
"download_bootstrap": "Descargar Bootstrap",
|
||||
"dragonx_green": "DragonX (Verde)",
|
||||
"earnings_format": "Formato de visualización de ganancias",
|
||||
"earnings_format_coin": "Solo moneda",
|
||||
"earnings_format_coin_fiat": "Moneda + fíat",
|
||||
"earnings_format_fiat": "Solo fíat",
|
||||
"earnings_label": "Ganancias",
|
||||
"edit": "Editar",
|
||||
"empty_wallet_keys_suffix": "claves",
|
||||
"empty_wallet_open_manager": "Abrir administrador de carteras",
|
||||
@@ -709,8 +720,8 @@
|
||||
"export_tx_success": "Transacciones exportadas exitosamente",
|
||||
"export_tx_title": "Exportar Transacciones a CSV",
|
||||
"export_viewing_key": "Exportar Clave de Vista",
|
||||
"failed_create_mining_address": "No se pudo crear la dirección de minería",
|
||||
"failed_create_shielded": "Error al crear dirección protegida",
|
||||
"failed_create_transparent": "Error al crear dirección transparente",
|
||||
"faq": "Preguntas frecuentes",
|
||||
"faq_d_mgmt_1_a": "El nodo integrado se inicia automáticamente con la cartera y se detiene cuando corresponde, así que normalmente no necesitas administrarlo a mano. Los controles avanzados (incluido reiniciar el daemon) están en Ajustes > Nodo y seguridad.",
|
||||
"faq_d_mgmt_1_q": "¿Cómo inicio o detengo el nodo?",
|
||||
@@ -788,13 +799,13 @@
|
||||
"faq_open_tooltip": "Ayuda y preguntas frecuentes",
|
||||
"faq_search_hint": "Buscar en las preguntas frecuentes…",
|
||||
"faq_title": "Ayuda y preguntas frecuentes",
|
||||
"faq_w_addr_1_a": "Las direcciones transparentes (empiezan con R o t) funcionan como la mayoría de las monedas: los montos y las direcciones son públicos en la cadena de bloques.\n\nLas direcciones blindadas (direcciones z, empiezan con zs) usan criptografía de conocimiento cero: el monto, el remitente y el destinatario quedan cifrados en la cadena. Los saldos en direcciones blindadas son privados.",
|
||||
"faq_w_addr_1_a": "Las direcciones blindadas (direcciones z, que empiezan por zs) son las que usas en DragonX. Utilizan criptografía de conocimiento cero, así que el importe, el remitente y el destinatario se cifran en la cadena y tu saldo sigue siendo privado.\n\nLas direcciones transparentes (que empiezan por R) son direcciones de minería. DragonX es una cadena totalmente privada, por lo que solo pueden recibir recompensas de minería; la red rechaza cualquier pago enviado a ellas. Las recompensas que llegan allí son públicas hasta que se blindan, algo que la cartera hace automáticamente.",
|
||||
"faq_w_addr_1_q": "¿Cuál es la diferencia entre direcciones transparentes y blindadas?",
|
||||
"faq_w_addr_2_a": "Prefiere las direcciones blindadas (z) siempre que sea posible: mantienen privados tu saldo y tu historial de pagos. DragonX es una moneda de privacidad y lo blindado es la opción predeterminada para los fondos recibidos.\n\nUsa una dirección transparente solo cuando un servicio con el que interactúas no admita direcciones blindadas.",
|
||||
"faq_w_addr_2_a": "Usa una dirección blindada (zs) para todo lo que envíes y recibas; en DragonX es el único tipo al que se puede pagar.\n\nLas direcciones transparentes (de minería) son solo para recompensas de minería en solitario. Nunca des una para recibir un pago: la red lo rechazará.",
|
||||
"faq_w_addr_2_q": "¿Qué tipo de dirección debería usar?",
|
||||
"faq_w_addr_3_a": "Para las direcciones blindadas, reutilizarlas está bien y no filtra el historial. Para las direcciones transparentes, usar una dirección nueva por cada pago mejora la privacidad. Crea nuevas direcciones desde la pestaña Recibir.",
|
||||
"faq_w_addr_3_a": "Reutilizar una dirección blindada no es un problema y no revela tu historial. Aun así puedes crear otras nuevas en la pestaña Recibir, por ejemplo una por contacto para saber quién te ha pagado. Las direcciones de minería son solo para recompensas de minería; rara vez necesitarás una nueva.",
|
||||
"faq_w_addr_3_q": "¿Debería reutilizar una dirección o crear una nueva?",
|
||||
"faq_w_addr_4_a": "Blindar mueve monedas de una dirección transparente a una blindada, haciéndolas privadas. Las monedas recién minadas llegan transparentes y se blindan automáticamente por ti.\n\nTambién puedes blindar manualmente desde la cartera; blindar es solo una transacción especial, así que tarda el tiempo de confirmación normal en completarse.",
|
||||
"faq_w_addr_4_a": "Blindar mueve monedas de una dirección transparente (de minería) a una blindada, haciéndolas privadas. Las monedas recién minadas llegan a una dirección de minería y se blindan automáticamente cuando maduran.\n\nTambién puedes blindar manualmente desde la cartera; un blindaje es solo una transacción especial, así que tarda el tiempo normal de confirmación.",
|
||||
"faq_w_addr_4_q": "¿Qué es \"blindar\"?",
|
||||
"faq_w_addr_title": "Direcciones y privacidad",
|
||||
"faq_w_bal_1_a": "Mientras tu cartera aún se está sincronizando, tu saldo está incompleto y puede mostrar 0 — todavía no ha escaneado todas tus transacciones. Se completa cuando termina la sincronización.\n\nObserva la barra de estado en la parte inferior: muestra el estado de la conexión y la altura de bloque. Una vez que la sincronización termina, tu saldo es exacto.",
|
||||
@@ -838,11 +849,11 @@
|
||||
"faq_w_seed_4_a": "Tu frase de recuperación es el respaldo principal y basta para restaurarlo todo. Una copia de tu archivo de cartera es un respaldo secundario práctico que además conserva las etiquetas y la configuración. Mantén cualquier respaldo sin conexión y privado.",
|
||||
"faq_w_seed_4_q": "¿Debería respaldar también wallet.dat?",
|
||||
"faq_w_seed_title": "Frase de recuperación y copia de seguridad",
|
||||
"faq_w_send_1_a": "Abre la pestaña Enviar, pega la dirección del destinatario, ingresa un monto y confirma. Si la cartera está cifrada, se te pedirá desbloquearla para la transacción.\n\nLos envíos a direcciones blindadas son privados; los envíos a direcciones transparentes son públicos.",
|
||||
"faq_w_send_1_a": "Abre la pestaña Enviar, pega la dirección del destinatario, ingresa un monto y confirma. Si la cartera está cifrada, se te pedirá desbloquearla para la transacción.\n\nLos pagos van a direcciones blindadas (zs) y son privados. La cartera no envía a una dirección transparente (de minería): la red rechaza los pagos a ellas.",
|
||||
"faq_w_send_1_q": "¿Cómo envío fondos?",
|
||||
"faq_w_send_2_a": "Las comisiones de DragonX son muy bajas y se establecen automáticamente. La comisión se muestra antes de confirmar. Normalmente no necesitas cambiarla.",
|
||||
"faq_w_send_2_q": "¿Qué comisión pago?",
|
||||
"faq_w_send_3_a": "Sí — al enviar a una dirección blindada (z) puedes incluir un memo cifrado. Solo el destinatario puede leerlo. Las direcciones transparentes no admiten memos.",
|
||||
"faq_w_send_3_a": "Sí — al enviar a una dirección blindada (z) puedes incluir un memo cifrado. Solo el destinatario puede leerlo.",
|
||||
"faq_w_send_3_q": "¿Puedo adjuntar un mensaje a un pago?",
|
||||
"faq_w_send_4_a": "Una transacción está \"pendiente\" hasta que se mina en un bloque y obtiene confirmaciones. Esto suele tardar uno o dos minutos. Las transacciones blindadas también necesitan que tu cartera esté sincronizada para construir la prueba.\n\nSi un envío permanece pendiente durante un tiempo inusualmente largo, verifica que tu cartera esté conectada y sincronizada (mira la barra de estado en la parte inferior de la ventana).",
|
||||
"faq_w_send_4_q": "Envié un pago pero dice pendiente — ¿por qué?",
|
||||
@@ -857,6 +868,7 @@
|
||||
"faq_w_set_4_q": "¿Cómo muestro precios en moneda fiat?",
|
||||
"faq_w_set_title": "Ajustes y apariencia",
|
||||
"favorite_address": "Marcar como favorito",
|
||||
"favorite_opacity": "Opacidad del resaltado de favoritos",
|
||||
"fee": "Comisión",
|
||||
"fee_high": "Alta",
|
||||
"fee_label": "Comisión:",
|
||||
@@ -864,14 +876,9 @@
|
||||
"fee_normal": "Normal",
|
||||
"fetch_prices": "Obtener precios",
|
||||
"file": "Archivo",
|
||||
"file_save_location": "El archivo se guardará en: ~/.config/ObsidianDragon/",
|
||||
"file_save_location": "El archivo se guardará en:",
|
||||
"filter": "Filtrar...",
|
||||
"font_scale": "Escala de fuente",
|
||||
"force_quit": "Forzar Salida",
|
||||
"force_quit_confirm_msg": "Esto matará inmediatamente el daemon sin un apagado limpio.\nEsto puede corromper el índice de la blockchain y requerir una resincronización.",
|
||||
"force_quit_confirm_title": "¿Forzar Salida?",
|
||||
"force_quit_warning": "Esto matará inmediatamente el daemon sin un apagado limpio. Puede requerir una resincronización de la blockchain.",
|
||||
"force_quit_yes": "Forzar Salida",
|
||||
"from": "Desde",
|
||||
"from_upper": "DESDE",
|
||||
"full_details": "Detalles Completos",
|
||||
@@ -940,6 +947,7 @@
|
||||
"hidden_tag": " (oculto)",
|
||||
"hide": "Ocultar",
|
||||
"hide_address": "Ocultar dirección",
|
||||
"hide_earnings": "Ocultar ganancias",
|
||||
"hide_qr": "Ocultar QR",
|
||||
"hide_zero_balances": "Ocultar saldos 0",
|
||||
"history": "Historial",
|
||||
@@ -1014,11 +1022,13 @@
|
||||
"lite_birthday_backup": "Cumpleaños: %llu (respalda esto también)",
|
||||
"lite_birthday_hint": "Altura de bloque desde la que empezar a escanear. Deja 0 si se desconoce (escaneo completo más lento).",
|
||||
"lite_birthday_label": "Fecha de creación",
|
||||
"lite_confirm_label": "Confirmar",
|
||||
"lite_console_backend_commands": "Comandos del backend:",
|
||||
"lite_console_help_passthrough": "Cualquier otra entrada se ejecuta como un comando de consola de la cartera lite.",
|
||||
"lite_copy": "Copiar",
|
||||
"lite_could_not_start": "No se pudo iniciar la operación",
|
||||
"lite_could_not_write": "No se pudo escribir ",
|
||||
"lite_encrypt_offline_warning": "Cualquiera que tenga el archivo de la cartera puede probar frases de contraseña sin conexión. Haz primero una copia de seguridad de tu frase de recuperación y usa una frase de contraseña larga.",
|
||||
"lite_encrypt_wallet": "Cifrar cartera",
|
||||
"lite_encryption_removed": "Cifrado eliminado",
|
||||
"lite_enter_all_seed_words": "Ingrese las 24 palabras semilla para restaurar (se obtuvieron %d)",
|
||||
@@ -1026,6 +1036,7 @@
|
||||
"lite_hide_wipe": "Ocultar y borrar",
|
||||
"lite_import": "Importar",
|
||||
"lite_import_key_label": "Importar clave",
|
||||
"lite_key_import_failed": "No se pudo importar la clave: compruébala, desbloquea el monedero (si está cifrado) e inténtalo de nuevo.",
|
||||
"lite_key_imported": "Clave importada: ejecuta una sincronización para escanear su historial",
|
||||
"lite_lock_failed": "Error al bloquear",
|
||||
"lite_lock_now": "Bloquear ahora",
|
||||
@@ -1170,9 +1181,43 @@
|
||||
"merge_description": "Fusiona múltiples UTXOs en una sola dirección protegida. Esto puede ayudar a reducir el tamaño de la cartera y mejorar la privacidad.",
|
||||
"merge_fee_hint": "Comisión de red para esta transacción.",
|
||||
"merge_funds": "Fusionar Fondos",
|
||||
"merge_into_one": "A una sola dirección",
|
||||
"merge_keep_each": "Mantener en cada dirección",
|
||||
"merge_keep_each_batches_fmt": "Las direcciones con más de %d notas se fusionan en varios lotes; vuelve a ejecutarlo cuando se confirmen para fusionar el resto.",
|
||||
"merge_keep_each_confirm_fmt": "¿Consolidar %d notas en %d direcciones? Cada dirección conserva sus propios fondos. Comisiones: unos %s DRGX (una por lote).",
|
||||
"merge_keep_each_desc": "Las notas de cada dirección se fusionan de nuevo en esa misma dirección, así que los fondos que mantienes separados con distintas etiquetas siguen separados. Solo direcciones blindadas: los fondos transparentes solo pueden blindarse (A una sola dirección).",
|
||||
"merge_keep_each_none": "Cada dirección blindada ya tiene una sola nota: no hay nada que consolidar.",
|
||||
"merge_keep_each_nothing_submitted": "No se consolidó nada: el nodo rechazó todas las fusiones.",
|
||||
"merge_keep_each_row_fmt": "%s · %d notas · %s DRGX",
|
||||
"merge_keep_each_run_again": "Las direcciones fusionadas en varios lotes tienen ahora una nota por lote. Vuelve a ejecutarlo cuando se confirmen para fusionarlas en una.",
|
||||
"merge_keep_each_select": "Marca al menos una dirección para consolidar.",
|
||||
"merge_keep_each_some_failed": "Algunas fusiones fallaron; consulta la lista de arriba.",
|
||||
"merge_keep_each_status_done": "%s: listo: %d notas fusionadas en %d lote(s)",
|
||||
"merge_keep_each_status_failed": "%s: falló: %s",
|
||||
"merge_keep_each_status_not_started": "%s: no iniciado: %s",
|
||||
"merge_keep_each_status_running": "%s: fusionando %d notas (%d de %d lotes listos)…",
|
||||
"merge_keep_each_status_unconfirmed": "%s: no se pudo confirmar el estado",
|
||||
"merge_keep_each_too_small": "Demasiado poco para consolidar: la comisión se llevaría estos fondos.",
|
||||
"merge_max_inputs": "Máximo de entradas por lote",
|
||||
"merge_no_spendable": "Aún no hay entradas disponibles para consolidar.",
|
||||
"merge_progress": "Consolidando… esto puede tardar varios minutos. Puedes cerrar esta ventana.",
|
||||
"merge_run_aborted_toast": "La conexión con el nodo se reinició, así que la consolidación se detuvo. Ejecútala de nuevo para continuar.",
|
||||
"merge_run_busy": "Hay una consolidación en curso. Espera a que termine o detenla primero.",
|
||||
"merge_run_chip_fmt": "Consolidando %d/%d",
|
||||
"merge_run_err_timeout": "El nodo no respondió a tiempo; puede que el lote siga en cola.",
|
||||
"merge_run_halted_fmt": "Consolidación detenida tras fallos repetidos: %s",
|
||||
"merge_run_migration_pending": "Termina o cancela la migración de la semilla antes de consolidar.",
|
||||
"merge_run_progress_fmt": "Consolidando… lote %d de unos %d, %d notas fusionadas hasta ahora. Puedes cerrar esta ventana; seguirá en segundo plano.",
|
||||
"merge_run_start_failed": "No se pudo iniciar la consolidación: el nodo no está conectado.",
|
||||
"merge_run_status_not_run": "%s: sin consolidar (%d notas)",
|
||||
"merge_run_status_waiting": "%s: en espera (%d notas)",
|
||||
"merge_run_stop": "Detener",
|
||||
"merge_run_stopped_toast": "Consolidación detenida. Ejecútala de nuevo para fusionar el resto.",
|
||||
"merge_run_stopping": "Deteniendo tras los lotes ya enviados…",
|
||||
"merge_run_wait_batch": "Espaciando los lotes para que el nodo siga respondiendo…",
|
||||
"merge_run_wait_send": "En pausa hasta que se complete tu transacción pendiente.",
|
||||
"merge_run_wait_sync": "En pausa: el nodo aún se está sincronizando.",
|
||||
"merge_scope_failed_fmt": "No se pudieron leer tus entradas: %s",
|
||||
"merge_scope_fmt": "%d entradas transparentes + %d blindadas · ~%s DRGX disponibles",
|
||||
"merge_scope_loading": "Comprobando tus entradas…",
|
||||
"merge_send_failed": "Error al fusionar: ",
|
||||
@@ -1181,7 +1226,11 @@
|
||||
"merge_src_shielded": "Blindadas",
|
||||
"merge_src_transparent": "Transparentes",
|
||||
"merge_started": "Operación de fusión iniciada",
|
||||
"merge_submit_uncertain": "El nodo no confirmó la fusión antes de que se interrumpiera la conexión. Puede que aún se complete: revisa el Historial tras reconectar antes de fusionar de nuevo.",
|
||||
"merge_title": "Fusionar a Dirección",
|
||||
"merge_wait_locked": "La cartera está bloqueada. Desbloquéala para continuar.",
|
||||
"merge_waiting_node": "Esperando al nodo…",
|
||||
"merge_where": "¿Adónde deben ir los fondos?",
|
||||
"mig_adopt": "Hacer que esta sea mi cartera",
|
||||
"mig_adopt_now": "Adoptar cartera con frase",
|
||||
"mig_adopting": "Instalando tu nueva cartera y reescaneando…",
|
||||
@@ -1233,6 +1282,8 @@
|
||||
"miner_fee": "Tarifa de minero",
|
||||
"mining": "Minería",
|
||||
"mining_active": "Activo",
|
||||
"mining_addr_explainer": "Solo recibe recompensas de minería en solitario; la red rechaza los pagos a esta dirección. Las recompensas se blindan automáticamente cuando maduran.",
|
||||
"mining_address": "Dirección de minería",
|
||||
"mining_address_copied": "Dirección de minería copiada",
|
||||
"mining_all_time": "Todo el Tiempo",
|
||||
"mining_already_saved": "URL del pool ya guardada",
|
||||
@@ -1246,6 +1297,8 @@
|
||||
"mining_benchmark_testing": "Probando",
|
||||
"mining_benchmark_tooltip": "Encontrar el número óptimo de hilos para esta CPU",
|
||||
"mining_block_copied": "Hash de bloque copiado",
|
||||
"mining_card_click_to_shield": "Haz clic para blindar ahora las recompensas maduras.",
|
||||
"mining_card_tooltip": "Las recompensas de minería en solitario llegan a tus direcciones de minería (transparentes, R…). Esas direcciones no pueden recibir pagos. Las recompensas se blindan automáticamente cuando maduran.",
|
||||
"mining_chart_1m_ago": "hace 1m",
|
||||
"mining_chart_5m_ago": "hace 5m",
|
||||
"mining_chart_now": "Ahora",
|
||||
@@ -1261,6 +1314,8 @@
|
||||
"mining_est_block": "Bloque Est.",
|
||||
"mining_est_daily": "Diario Est.",
|
||||
"mining_est_daily_pool_sub": "equivalente solo aproximado, antes de la comisión del pool",
|
||||
"mining_est_daily_sub": "prom.",
|
||||
"mining_est_daily_sub_net": "prom., neto",
|
||||
"mining_filter_all": "Todos",
|
||||
"mining_filter_tip_all": "Mostrar todas las ganancias",
|
||||
"mining_filter_tip_pool": "Mostrar solo ganancias del pool",
|
||||
@@ -1290,6 +1345,7 @@
|
||||
"mining_payout_address": "Dirección de Pago",
|
||||
"mining_payout_foreign": "⚠ Esta dirección de pago no está en tu cartera actual — las recompensas minadas irían a otra cartera. Actualízala si cambiaste de cartera.",
|
||||
"mining_payout_invalid": "No es una dirección DragonX válida — corrígela antes de empezar, o se pierden las recompensas de minería.",
|
||||
"mining_payout_needs_shielded": "Los pools pagan con transacciones normales, que en DragonX no pueden llegar a una dirección de minería (transparente, R…). Usa una dirección blindada (empieza por zs) como dirección de pago.",
|
||||
"mining_payout_tooltip": "Dirección para recibir recompensas de minería",
|
||||
"mining_pool": "Pool",
|
||||
"mining_pool_fee": "Comisión",
|
||||
@@ -1302,6 +1358,8 @@
|
||||
"mining_refresh": "Actualizar",
|
||||
"mining_remove": "Eliminar",
|
||||
"mining_reset_defaults": "Restablecer Valores",
|
||||
"mining_rewards": "Recompensas de minería",
|
||||
"mining_rewards_unshielded": "Aún sin blindar",
|
||||
"mining_save_payout_address": "Guardar dirección de pago",
|
||||
"mining_save_pool_url": "Guardar URL del pool",
|
||||
"mining_saved_addresses": "Direcciones Guardadas:",
|
||||
@@ -1335,10 +1393,10 @@
|
||||
"network_fee": "COMISIÓN DE RED",
|
||||
"network_hashrate": "Tasa de Hash de la Red",
|
||||
"new": "+ Nuevo",
|
||||
"new_mining_address": "Nueva dirección de minería",
|
||||
"new_mining_address_created": "Nueva dirección de minería creada",
|
||||
"new_shielded_created": "Nueva dirección protegida creada",
|
||||
"new_t_address": "Nueva Dirección T",
|
||||
"new_t_transparent": "Nueva dirección t (Transparente)",
|
||||
"new_transparent_created": "Nueva dirección transparente creada",
|
||||
"new_z_address": "Nueva Dirección Z",
|
||||
"new_z_shielded": "Nueva dirección z (Blindada)",
|
||||
"no_addresses": "No se encontraron direcciones. Cree una usando los botones de arriba.",
|
||||
@@ -1358,7 +1416,19 @@
|
||||
"node_banner_offline_title": "No conectado al nodo DragonX",
|
||||
"node_banner_reconnect": "Reconectar",
|
||||
"node_banner_restart": "Reiniciar nodo",
|
||||
"node_busy_rebuild_desc": "En curso desde hace %s. En carteras grandes puede tardar alrededor de una hora, y el nodo no puede responder a la cartera hasta que termine.",
|
||||
"node_busy_rebuild_extending": "Ampliando testigos (el nodo no informa del progreso de este paso).",
|
||||
"node_busy_rebuild_reading": "Leyendo bloques: %ld / %ld",
|
||||
"node_busy_rebuild_title": "Nodo ocupado: reconstruyendo testigos de notas",
|
||||
"node_busy_replay_desc": "En curso desde hace %s. El nodo está volviendo a aplicar bloques que recibió antes de detenerse por última vez.",
|
||||
"node_busy_replay_title": "Nodo ocupado: poniéndose al día con los bloques",
|
||||
"node_busy_rpc_desc": "El nodo está en marcha, pero lleva %s demasiado ocupado para responder a la cartera, normalmente por una operación larga como la reconstrucción de testigos.",
|
||||
"node_busy_rpc_title": "Nodo ocupado: aún no responde",
|
||||
"node_busy_running": "En curso desde hace %s",
|
||||
"node_security": "NODO Y SEGURIDAD",
|
||||
"node_starting_busy_desc": "El nodo está accesible pero ocupado y aún no responde. Es normal tras una actualización o en el primer inicio; puede tardar unos minutos.",
|
||||
"node_starting_launch_desc": "Iniciando dragonxd y esperando a que esté en línea…",
|
||||
"node_starting_title": "Iniciando el nodo DragonX…",
|
||||
"noise": "Ruido",
|
||||
"not_connected": "No conectado al daemon...",
|
||||
"not_connected_to_daemon": "No conectado al daemon",
|
||||
@@ -1511,6 +1581,8 @@
|
||||
"ram_wallet_gb": "Billetera: %.1f GB",
|
||||
"ram_wallet_mb": "Billetera: %.0f MB",
|
||||
"receive": "Recibir",
|
||||
"receive_not_for_payments": "No para pagos",
|
||||
"receive_use_shielded": "Usar una dirección blindada",
|
||||
"received": "recibido",
|
||||
"received_filter": "Recibido",
|
||||
"received_label": "Recibido",
|
||||
@@ -1525,6 +1597,7 @@
|
||||
"reduce_motion": "Reducir Movimiento",
|
||||
"refresh": "Actualizar",
|
||||
"refresh_now": "Actualizar Ahora",
|
||||
"refresh_waits_for_sync": "El nodo aún se está sincronizando. Los datos de la cartera se actualizarán cuando termine.",
|
||||
"remove_favorite": "Quitar favorito",
|
||||
"repair_wallet": "Reparar cartera",
|
||||
"report_bug": "Reportar Error",
|
||||
@@ -1539,12 +1612,12 @@
|
||||
"request_select_address": "Seleccionar dirección...",
|
||||
"request_shielded_addrs": "-- Direcciones Protegidas --",
|
||||
"request_title": "Solicitar Pago",
|
||||
"request_transparent_addrs": "-- Direcciones Transparentes --",
|
||||
"request_uri_copied": "URI de pago copiada al portapapeles",
|
||||
"rescan": "Re-escanear",
|
||||
"rescan_bootstrapped_msg": "Tu nodo se inicializó con un bootstrap, por lo que los bloques anteriores a la instantánea no están en disco y un reescaneo desde el génesis fallaría. Reescanea desde una altura que incluya tu instantánea para reconciliar el saldo gastado de tu cartera. Tu wallet.dat y los datos de la cadena no se eliminan.",
|
||||
"rescan_detecting": "Comprobando qué bloques tiene tu nodo en disco…",
|
||||
"rescan_from_height": "Reescanear desde la altura de bloque:",
|
||||
"rescan_probe_failed": "No se pudo comprobar qué bloques tiene tu nodo en disco (está ocupado o una solicitud agotó el tiempo de espera). Si este nodo se inicializó con un bootstrap, cancela y vuelve a intentarlo en un minuto: el reinicio completo con -rescan de abajo falla en un nodo inicializado con bootstrap.",
|
||||
"reset_to_defaults": "Restablecer Valores",
|
||||
"restarting_after_encryption": "Reiniciando daemon después del cifrado...",
|
||||
"restore_address": "Restaurar dirección",
|
||||
@@ -1587,6 +1660,7 @@
|
||||
"sb_net_khs": "Red: %.2f KH/s",
|
||||
"sb_net_mhs": "Red: %.2f MH/s",
|
||||
"sb_no_conf": "DRAGONX.conf no encontrado",
|
||||
"sb_node_busy": "Nodo ocupado",
|
||||
"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",
|
||||
@@ -1608,6 +1682,7 @@
|
||||
"screenshot_sweep": "Ejecutar barrido de capturas",
|
||||
"screenshot_sweep_desc": "Recorre cada tema en cada pestaña y guarda una captura de pantalla de cada una en subcarpetas por pestaña dentro de la carpeta de capturas del directorio de configuración (sobrescribiendo el barrido anterior). Se ejecuta durante unos segundos.",
|
||||
"screenshot_sweep_full": "Barrido completo de la interfaz",
|
||||
"screenshot_sweep_refused": "Barrido completo de la interfaz rechazado; ver registro de depuración",
|
||||
"search_icons": "Buscar iconos...",
|
||||
"search_placeholder": "Buscar...",
|
||||
"sec_changing_passphrase": "Cambiando la frase de contraseña...",
|
||||
@@ -1629,15 +1704,19 @@
|
||||
"sec_mode_passphrase": " Frase de contraseña",
|
||||
"sec_not_connected_to_daemon": "Sin conexión con el daemon",
|
||||
"sec_not_connected_to_daemon_pin": "Sin conexión con el daemon",
|
||||
"sec_passphrase_changed_pin_stale": "Frase de contraseña cambiada; tu PIN está desactualizado",
|
||||
"sec_passphrase_changed_successfully": "Frase de contraseña cambiada correctamente",
|
||||
"sec_pin_changed_successfully": "PIN cambiado correctamente",
|
||||
"sec_pin_removed": "PIN eliminado",
|
||||
"sec_pin_set_successfully": "PIN configurado correctamente",
|
||||
"sec_pin_stale_msg": "PIN desactualizado. Usa tu frase de contraseña.",
|
||||
"sec_pin_unreadable": "Archivo del PIN ilegible. Usa tu frase de contraseña.",
|
||||
"sec_restart_daemon_for_encryption": "Reinicia el daemon para que el cifrado surta efecto.",
|
||||
"sec_too_many_attempts_wait": "Demasiados intentos. Espera %.0f segundos...",
|
||||
"sec_total_elapsed_fmt": "Tiempo total: %dm %02ds",
|
||||
"sec_unlock_button": "Desbloquear",
|
||||
"sec_unlock_failed_prefix": "Error al desbloquear: ",
|
||||
"sec_unlock_node_busy": "The node is busy; try again in a moment.",
|
||||
"sec_unlocking_fmt": "Desbloqueando%s",
|
||||
"sec_use_passphrase_instead": "Usar frase de contraseña en su lugar",
|
||||
"sec_use_pin_instead": "Usar PIN en su lugar",
|
||||
@@ -1690,6 +1769,8 @@
|
||||
"send_from_this_address": "Enviar desde esta dirección",
|
||||
"send_go_to_receive": "Ir a Recibir",
|
||||
"send_keep": "Mantener",
|
||||
"send_mining_addr_blocked": "Esta es una dirección de minería (transparente). En DragonX solo pueden recibir recompensas de minería; la red rechaza los pagos a ellas. Pide al destinatario una dirección blindada (empieza por zs).",
|
||||
"send_mining_addr_short": "Dirección de minería: no puede recibir pagos",
|
||||
"send_network_fee": "COMISIÓN DE RED",
|
||||
"send_no_balance": "Sin saldo",
|
||||
"send_no_recent": "No hay envíos recientes",
|
||||
@@ -1717,7 +1798,6 @@
|
||||
"send_txid_copied": "TxID copiado al portapapeles",
|
||||
"send_txid_label": "TxID: %s",
|
||||
"send_valid_shielded": "Dirección protegida válida",
|
||||
"send_valid_transparent": "Dirección transparente válida",
|
||||
"send_wallet_empty": "Tu cartera está vacía",
|
||||
"send_yes_clear": "Sí, Limpiar",
|
||||
"sender_balance": "Remitente: %.8f → %.8f DRGX",
|
||||
@@ -1780,6 +1860,7 @@
|
||||
"settings_open_log_folder": "Abrir carpeta de registros",
|
||||
"settings_other": "Otros",
|
||||
"settings_pin_active": "PIN",
|
||||
"settings_pin_out_of_date": "PIN desactualizado",
|
||||
"settings_privacy": "Privacidad",
|
||||
"settings_quick_unlock_pin": "PIN de desbloqueo rápido",
|
||||
"settings_reduce_transparency": "Reducir transparencia",
|
||||
@@ -1845,11 +1926,24 @@
|
||||
"shielded_type": "Protegido",
|
||||
"shielding_notice": "Nota: Esto blindará fondos de una dirección transparente (T) a una dirección privada (Z).",
|
||||
"show": "Mostrar",
|
||||
"show_earnings": "Mostrar ganancias",
|
||||
"show_hidden": "Mostrar ocultos (%d)",
|
||||
"show_qr": "Mostrar QR",
|
||||
"show_qr_code": "Mostrar Código QR",
|
||||
"showing_transactions": "Mostrando %d–%d de %d transacciones (total: %zu)",
|
||||
"showing_x_of_y": "Mostrando %d de %d direcciones",
|
||||
"shutdown_kill_confirm_msg": "El nodo lleva mucho tiempo sin actividad de CPU ni de disco y puede estar bloqueado.\nSi fuerzas su cierre se perderá todo lo que no haya guardado; su próximo inicio puede tardar mucho más.",
|
||||
"shutdown_kill_confirm_title": "¿Forzar el cierre del nodo?",
|
||||
"shutdown_kill_confirm_yes": "Forzar cierre",
|
||||
"shutdown_kill_node": "Forzar cierre del nodo (inseguro)",
|
||||
"shutdown_kill_node_tip": "Detiene el nodo de inmediato. Se pierde todo lo que no haya guardado, y en el próximo inicio volverá a procesar los bloques desde el último guardado.",
|
||||
"shutdown_leave_running": "Cerrar la cartera y dejar el nodo en marcha",
|
||||
"shutdown_leave_running_tip": "Cierra la cartera ahora. El nodo sigue funcionando por su cuenta y termina lo que está haciendo; no se pierde nada.",
|
||||
"shutdown_stop_asking": "Pidiendo al nodo que se detenga…",
|
||||
"shutdown_stop_cpu_suffix": " · CPU %d%%",
|
||||
"shutdown_stop_delivered": "El nodo se está cerrando y guardando su estado",
|
||||
"shutdown_stop_idle": "Sin actividad de CPU ni de disco desde hace %d min; puede que el nodo esté bloqueado",
|
||||
"shutdown_stop_queued": "El nodo recibió la orden de detenerse, pero está ocupado; se detendrá en cuanto pueda",
|
||||
"simple_background": "Fondo simple",
|
||||
"slider_off": "Apagado",
|
||||
"sort_amount_high": "Mayor importe",
|
||||
@@ -1857,9 +1951,18 @@
|
||||
"sort_date_newest": "Más recientes primero",
|
||||
"sort_date_oldest": "Más antiguas primero",
|
||||
"start_mining": "Iniciar Minería",
|
||||
"startup_files_preparing": "Preparando los archivos de la cartera... %d%%",
|
||||
"startup_files_verifying": "Verificando los archivos de la cartera...",
|
||||
"status": "Estado",
|
||||
"stop_external": "Detener daemon externo",
|
||||
"stop_mining": "Detener Minería",
|
||||
"stopconfirm_busy_body": "Lleva %s demasiado ocupado para responder a la cartera, probablemente por una operación larga como la reconstrucción de testigos. Detenerlo ahora puede hacer que el próximo inicio repita ese trabajo.\n\nRecomendado: cierra la cartera y deja el nodo en marcha. Terminará por su cuenta.",
|
||||
"stopconfirm_busy_title": "El nodo está ocupado",
|
||||
"stopconfirm_rebuild_body": "Lleva %s en curso, y en carteras grandes puede tardar alrededor de una hora. Detener el nodo ahora o bien espera a que termine la reconstrucción, o bien hace que el próximo inicio la repita junto con cualquier puesta al día.\n\nRecomendado: cierra la cartera y deja el nodo en marcha. Terminará por su cuenta.",
|
||||
"stopconfirm_rebuild_title": "El nodo está reconstruyendo los testigos de notas",
|
||||
"stopconfirm_stop_wait": "Detener el nodo y esperar",
|
||||
"stopconfirm_walletop_body": "El nodo está escaneando la cadena para una importación de clave o un reescaneo. Si lo detienes ahora, ese escaneo termina antes de tiempo, y los fondos a los que aún no ha llegado solo aparecerán tras Ajustes → Re-escanear.\n\nRecomendado: cierra la cartera y deja el nodo en marcha. Terminará el escaneo por su cuenta.",
|
||||
"stopconfirm_walletop_title": "Hay una importación de clave o un reescaneo en curso",
|
||||
"stratum_allowip_hint": "Permitir mineros desde IP o CIDR (en blanco = solo este equipo)",
|
||||
"stratum_expose_warn": "Abre un puerto de minería a la red que permitas — úsalo solo en una LAN de confianza.",
|
||||
"stratum_host": "Alojar un pool de minería (stratum)",
|
||||
@@ -1906,7 +2009,6 @@
|
||||
"switch_stopnode_title": "¿Detener el nodo en ejecución?",
|
||||
"switch_stopnode_warn": "Ya hay un nodo en ejecución que esta cartera no inició.",
|
||||
"syncing": "Sincronizando...",
|
||||
"t_address": "Dirección T",
|
||||
"t_addresses": "Direcciones T",
|
||||
"test_connection": "Probar",
|
||||
"theme": "Tema",
|
||||
@@ -1928,6 +2030,7 @@
|
||||
"tools": "HERRAMIENTAS",
|
||||
"tools_actions": "Herramientas y Acciones...",
|
||||
"tools_actions_hdr": "HERRAMIENTAS Y ACCIONES",
|
||||
"tor_never_applied_notice": "La opción \"Usar Tor\" nunca funcionó: tus conexiones NO se enrutaron a través de Tor. Se ha desactivado. Consulta su descripción emergente en Ajustes para ver alternativas.",
|
||||
"total": "Total",
|
||||
"total_balance_label": "Saldo Total",
|
||||
"transaction_id": "ID DE TRANSACCIÓN",
|
||||
@@ -1941,8 +2044,6 @@
|
||||
"transfer_sent": "Transferencia Enviada",
|
||||
"transfer_sent_desc": "Su transferencia ha sido enviada a la red.",
|
||||
"transfer_to": "Transferir a:",
|
||||
"transparent": "Transparente",
|
||||
"transparent_address": "Dirección Transparente",
|
||||
"try_again": "Reintentar",
|
||||
"tt_addr_url": "URL base para ver direcciones en un explorador de bloques",
|
||||
"tt_address_book": "Administrar direcciones guardadas para envío rápido",
|
||||
@@ -1974,10 +2075,16 @@
|
||||
"tt_debug_expand": "Expandir opciones de registro de depuración",
|
||||
"tt_delete_blockchain": "Eliminar todos los datos de la blockchain e iniciar una nueva sincronización. Se preservan wallet.dat y la configuración.",
|
||||
"tt_download_bootstrap": "Descargar bootstrap de blockchain para acelerar la sincronización\nLos datos de bloques existentes serán reemplazados",
|
||||
"tt_earn_interval": "Periodo de ganancias: 1H = esta hora, 1D = hoy, 1M = este mes, 1Y = este año",
|
||||
"tt_earn_mode_inflows": "Modo de ganancias: importes recibidos (incluida la minería)",
|
||||
"tt_earn_mode_net": "Modo de ganancias: neto (recibido menos enviado)",
|
||||
"tt_earn_show_all": "Mostrar la cifra de ganancias para cada dirección",
|
||||
"tt_earnings_format": "Cómo se muestran las cifras de ganancias por dirección y totales",
|
||||
"tt_encrypt": "Cifrar wallet.dat con una contraseña",
|
||||
"tt_export_all": "Exportar todas las claves privadas a un archivo",
|
||||
"tt_export_csv": "Exportar historial de transacciones como hoja de cálculo CSV",
|
||||
"tt_export_key": "Exportar la clave privada de la dirección seleccionada",
|
||||
"tt_favorite_opacity": "Opacidad del botón de estrella de favoritos y su contorno dorado en la lista de direcciones (mínimo 15 %)",
|
||||
"tt_fetch_prices": "Obtener precios de mercado de DRGX desde la API de CoinGecko",
|
||||
"tt_font_scale": "Escalar todo el texto y la interfaz (1.0x = predeterminado, hasta 1.5x).",
|
||||
"tt_idle_delay": "Cuánto tiempo esperar antes de empezar a minar",
|
||||
@@ -1986,6 +2093,7 @@
|
||||
"tt_keep_daemon": "El daemon se detendrá cuando ejecute el asistente de configuración",
|
||||
"tt_language": "Idioma de la interfaz de la billetera",
|
||||
"tt_layout_hotkey": "Atajo: teclas de flecha izquierda/derecha para cambiar diseños de Balance",
|
||||
"tt_lite_confirm_pass": "Escribe de nuevo la nueva frase de contraseña; debe coincidir y tener al menos 8 caracteres",
|
||||
"tt_lite_copy": "Copia el secreto revelado al portapapeles",
|
||||
"tt_lite_decrypt_pass": "Introduce tu frase de contraseña para quitar el cifrado de la cartera",
|
||||
"tt_lite_encrypt": "Cifra la cartera con la frase de contraseña de arriba; se bloquea de inmediato y requiere la frase para desbloquearse",
|
||||
@@ -2014,11 +2122,16 @@
|
||||
"tt_low_spec": "Desactivar todos los efectos visuales pesados\\nAtajo: Ctrl+Shift+Down",
|
||||
"tt_merge": "Consolidar múltiples UTXOs en una dirección",
|
||||
"tt_mine_idle": "Iniciar minería automáticamente cuando el\\nsistema esté inactivo (sin entrada de teclado/ratón)",
|
||||
"tt_mining_est_daily": "Un promedio estadístico a largo plazo para tu proporción actual de hashrate en la red. Las recompensas reales de minería llegan al azar (Poisson): a menudo nada durante largos periodos y luego una suma; por eso NO es un ingreso diario constante.",
|
||||
"tt_mining_est_daily_fee_fmt": "Se muestra tras la comisión del pool del %.1f%%.",
|
||||
"tt_mining_est_daily_nofee": "Se muestra antes de la comisión del pool (comisión desconocida para este pool).",
|
||||
"tt_new_mining_address": "Crea una dirección transparente (R…) para recompensas de minería en solitario. No puede recibir pagos; para eso comparte una dirección blindada.",
|
||||
"tt_noise": "Intensidad de textura granulada (0% = apagado, 100% = máximo)",
|
||||
"tt_open_app_dir": "Abrir la carpeta de ObsidianDragon (configuración, temas, registros) en el explorador de archivos",
|
||||
"tt_open_data_dir": "Abre en el gestor de archivos la carpeta con los datos de tu cartera y de la blockchain",
|
||||
"tt_open_dir": "Clic para abrir en explorador de archivos",
|
||||
"tt_open_log_folder": "Abre la carpeta que contiene los registros de depuración y de fallos",
|
||||
"tt_pin_out_of_date": "La frase de contraseña de la cartera cambió después de establecer este PIN, así que el PIN ya no puede desbloquearla. Quita el PIN y vuelve a establecerlo.",
|
||||
"tt_reduce_motion": "Desactivar transiciones animadas y lerp de saldo para accesibilidad",
|
||||
"tt_remove_encrypt": "Quitar cifrado y almacenar la billetera sin protección",
|
||||
"tt_remove_pin": "Quitar PIN y requerir contraseña para desbloquear",
|
||||
@@ -2054,10 +2167,13 @@
|
||||
"tt_theme_effects": "Brillo, resplandor, ciclo de tono por tema",
|
||||
"tt_theme_hotkey": "Atajo: Ctrl+Izquierda/Derecha para cambiar temas",
|
||||
"tt_tor": "Enrutar conexiones del daemon a través de la red Tor para anonimato",
|
||||
"tt_tor_unavailable": "Aún no disponible: esta cartera no enruta ningún tráfico a través de Tor. Solo para el nodo, puedes añadir proxy=127.0.0.1:9050 a DRAGONX.conf y reiniciar el nodo. Tor debe estar en ejecución o el nodo no encontrará otros nodos. Las solicitudes de precios, actualizaciones, bootstrap y pool siguen conectándose directamente; para ellas usa una VPN/Tor de todo el sistema.",
|
||||
"tt_tor_unavailable_lite": "Aún no disponible: esta cartera no enruta ningún tráfico a través de Tor. Usa una VPN/Tor de todo el sistema.",
|
||||
"tt_tx_url": "URL base para ver transacciones en un explorador de bloques",
|
||||
"tt_ui_opacity": "Opacidad de tarjetas y barra lateral (100% = totalmente opaco, menor = más transparente)",
|
||||
"tt_validate": "Comprobar si una dirección DragonX es válida",
|
||||
"tt_verbose": "Registrar diagnósticos detallados de conexión,\\nestado del daemon e info de propietario de puerto\\nen la pestaña de Consola",
|
||||
"tt_view_transactions": "Mostrar las transacciones relacionadas con esta dirección en la pestaña Historial",
|
||||
"tt_wallet_size_warn": "Las carteras blindadas crecen con los datos de testigo de cada nota — fusionar muchas notas en una sola dirección lo reduce. Haz una copia de seguridad primero.",
|
||||
"tt_wallets_button": "Enumera tus archivos de cartera y cambia entre ellos",
|
||||
"tt_website": "Abrir el sitio web de DragonX",
|
||||
@@ -2075,6 +2191,7 @@
|
||||
"tx_loading_queued": "Actualización de transacciones en cola",
|
||||
"tx_loading_refreshing_cached": "Actualizando el historial de la cartera (%d en caché)",
|
||||
"tx_loading_scanning_shielded": "Escaneando historial protegido (%d direcciones)",
|
||||
"tx_loading_waits_for_sync": "El historial se actualizará cuando el nodo termine de sincronizarse",
|
||||
"tx_mined": "MINADO",
|
||||
"tx_progress_balances": "Actualizando saldos",
|
||||
"tx_progress_finalizing": "Finalizando transacción",
|
||||
@@ -2106,6 +2223,7 @@
|
||||
"validate_description": "Ingresa una dirección DragonX para verificar si es válida y si pertenece a esta cartera.",
|
||||
"validate_invalid": "INVÁLIDA",
|
||||
"validate_is_mine": "Esta cartera es dueña de esta dirección",
|
||||
"validate_mining_type": "Dirección de minería (transparente)",
|
||||
"validate_not_mine": "No es propiedad de esta cartera",
|
||||
"validate_ownership": "Propiedad:",
|
||||
"validate_results": "Resultados:",
|
||||
@@ -2113,7 +2231,6 @@
|
||||
"validate_shielded_type": "Protegida (dirección z)",
|
||||
"validate_status": "Estado:",
|
||||
"validate_title": "Validar Dirección",
|
||||
"validate_transparent_type": "Transparente (dirección t)",
|
||||
"validate_type": "Tipo:",
|
||||
"validate_valid": "VÁLIDA",
|
||||
"validating": "Validando...",
|
||||
@@ -2123,6 +2240,7 @@
|
||||
"view_all": "Ver todo",
|
||||
"view_details": "Ver Detalles",
|
||||
"view_on_explorer": "Ver en Explorador",
|
||||
"view_transactions": "Ver transacciones",
|
||||
"waiting_for_daemon": "Esperando conexión al daemon...",
|
||||
"wallet": "CARTERA",
|
||||
"wallet_degraded_notify": "Tu cartera se abrió en modo de funciones reducidas: los fondos existentes están seguros y disponibles, pero crear nuevas direcciones y blindar están desactivados. Respalda tu frase de recuperación y restáurala para reparar la cartera por completo.",
|
||||
@@ -2231,6 +2349,7 @@
|
||||
"wallets_reveal": "Mostrar carpeta",
|
||||
"wallets_scan_folder": "Escanear otra carpeta en busca de carteras…",
|
||||
"wallets_scanned_folders": "Carpetas escaneadas:",
|
||||
"wallets_scanning": "Buscando en las carpetas…",
|
||||
"wallets_sort_addresses": "Direcciones",
|
||||
"wallets_sort_asc": "Ascendente (más antiguo / menos / más pequeño primero)",
|
||||
"wallets_sort_by": "Ordenar:",
|
||||
|
||||
+150
-31
@@ -25,6 +25,7 @@
|
||||
"about_website": "Site web",
|
||||
"acrylic": "Acrylique",
|
||||
"add": "Ajouter",
|
||||
"addr_type_mining": "Minage",
|
||||
"address": "Adresse",
|
||||
"address_book_add": "Ajouter une adresse",
|
||||
"address_book_add_new": "Ajouter",
|
||||
@@ -68,6 +69,7 @@
|
||||
"appx_blockchain_maintenance_in_progress": "Une opération de maintenance de la blockchain est déjà en cours.",
|
||||
"appx_blockchain_rescan_complete": "Nouvelle analyse de la blockchain terminée",
|
||||
"appx_bootstrap_complete_reconciling": "Amorçage terminé — réconciliation de votre portefeuille avec les nouvelles données de la chaîne.",
|
||||
"appx_bootstrap_reconcile_probe_failed": "Impossible de réconcilier votre portefeuille avec les données de chaîne de l'amorçage : le nœud est resté trop occupé pour vérifier ses blocs. Lancez Re-scanner depuis les Paramètres une fois qu'il aura rattrapé son retard.",
|
||||
"appx_cancel": "Annuler",
|
||||
"appx_cleaning_up": "Nettoyage...",
|
||||
"appx_confirm_your_backup": "Confirmez votre sauvegarde",
|
||||
@@ -78,11 +80,15 @@
|
||||
"appx_creating_your_wallet": "Création de votre portefeuille…",
|
||||
"appx_daemon_error": "Erreur du démon",
|
||||
"appx_daemon_reinstall_in_progress": "La réinstallation du démon est déjà en cours.",
|
||||
"appx_daemon_reinstall_node_busy": "Le nœud est toujours actif, le démon n'a donc pas été réinstallé. Réessayez une fois qu'il sera arrêté.",
|
||||
"appx_disconnecting": "Déconnexion...",
|
||||
"appx_done": "Terminé",
|
||||
"appx_dragonxd_output": "Sortie de dragonxd",
|
||||
"appx_encrypting_wallet": "Chiffrement du portefeuille...",
|
||||
"appx_fullnode_lifecycle_unavailable_lite": "Les actions de cycle de vie du nœud complet ne sont pas disponibles dans la version allégée",
|
||||
"appx_import_cancelled": "Annulé : le nœud est peut-être encore en train d'analyser. Si les fonds de la clé n'apparaissent pas, lancez Paramètres → Re-scanner.",
|
||||
"appx_import_interrupted": "L'importation a été interrompue, la clé est donc peut-être déjà dans le portefeuille. Si ses fonds n'apparaissent pas, lancez Paramètres → Re-scanner.",
|
||||
"appx_import_refused": "Le nœud a refusé la requête, la clé n'a donc pas été importée.",
|
||||
"appx_installing_bundled_daemon": "Installation du démon intégré — le nœud va s'arrêter, se mettre à jour, puis redémarrer...",
|
||||
"appx_invalid_payment_uri_prefix": "URI de paiement invalide : ",
|
||||
"appx_ive_written_it_down": "Je les ai notés",
|
||||
@@ -99,7 +105,8 @@
|
||||
"appx_no_bundled_daemon_to_install": "Cette version ne contient aucun démon intégré à installer",
|
||||
"appx_no_embedded_daemon_to_install": "Cette version ne contient aucun démon embarqué à installer",
|
||||
"appx_node_busy_restarting": "Le nœud est occupé à redémarrer — réessayez dans un instant.",
|
||||
"appx_node_rebuilding_witness_cache": "Le nœud reconstruit son cache de témoins",
|
||||
"appx_node_busy_try_later": "Le nœud termine une longue tâche (comme la reconstruction des témoins de notes). Réessayez une fois qu'elle est finie ; l'arrêter maintenant pourrait lui faire recommencer ce travail.",
|
||||
"appx_node_not_stopped": "Le nœud ne s'est pas arrêté à temps, rien n'a donc été modifié. Réessayez dans quelques minutes.",
|
||||
"appx_not_next_word": " — ce n'est pas le mot suivant",
|
||||
"appx_payment_request_loaded": "Demande de paiement chargée",
|
||||
"appx_pool_miner_connected_and_hashing": "Mineur de pool connecté et en cours de hachage.",
|
||||
@@ -120,11 +127,7 @@
|
||||
"appx_simple_background_enabled": "Arrière-plan simple activé",
|
||||
"appx_skip": "Ignorer",
|
||||
"appx_skip_anyway": "Ignorer quand même",
|
||||
"appx_still_status_prefix": "Toujours « ",
|
||||
"appx_still_status_suffix": " » — forcer la fermeture maintenant peut corrompre les données de la chaîne.",
|
||||
"appx_stop_anyway_and_quit": "Arrêter quand même et quitter",
|
||||
"appx_stopping_daemon_deleting_blockchain": "Arrêt du démon et suppression des données de la blockchain...",
|
||||
"appx_stopping_node_discards_rebuild": "Arrêter le nœud maintenant abandonne la reconstruction en cours et la relance (plusieurs minutes) à la prochaine ouverture du portefeuille. Vous pouvez plutôt laisser le nœud en marche.",
|
||||
"appx_stopping_pool_miner": "Arrêt du mineur de pool...",
|
||||
"appx_syncing_pct_block_n_of_n": "Synchronisation %.1f%% — Bloc %d / %d",
|
||||
"appx_tap_words_in_order": "Touchez les mots dans le bon ordre pour confirmer que vous les avez enregistrés.",
|
||||
@@ -134,6 +137,9 @@
|
||||
"appx_use_settings_restart_daemon_hint": "Utilisez Paramètres > Redémarrer le démon pour réessayer",
|
||||
"appx_waiting_for_daemon_to_encrypt_wallet": "En attente du chiffrement du portefeuille par le démon...",
|
||||
"appx_wallet_created_and_backed_up": "Portefeuille créé et sauvegardé.",
|
||||
"appx_wallet_op_node_busy": "Le nœud est occupé et ne répond pas. Relancez l'importation ou la réanalyse une fois qu'il a fini.",
|
||||
"appx_wallet_op_running": "Une importation de clé ou une réanalyse est encore en cours. Réessayez une fois qu'elle est finie.",
|
||||
"appx_wallet_op_shutting_down": "Le portefeuille est en cours de fermeture.",
|
||||
"appx_wallet_open_failed_prefix": "Échec de l'ouverture du portefeuille : ",
|
||||
"auto_shield": "Auto-blindage du minage",
|
||||
"autoshield_by_node": "Votre nœud blinde automatiquement les coinbase minées pour vous.",
|
||||
@@ -174,21 +180,20 @@
|
||||
"balance_history_collecting": "Historique du solde — collecte des données...",
|
||||
"balance_layout": "Disposition du solde",
|
||||
"balance_layout_switched": "Disposition : %s",
|
||||
"balance_mining_fmt": "Récompenses de minage : %.8f",
|
||||
"balance_mining_rate": "Minage %s",
|
||||
"balance_shielded_fmt": "Blindé : %.8f",
|
||||
"balance_syncing_pct": "Synchronisation %.1f%%",
|
||||
"balance_transparent_fmt": "Transparent : %.8f",
|
||||
"baltab_market": "Marché",
|
||||
"baltab_market_price_4dp": "Marché : %.4f $",
|
||||
"baltab_market_price_8dp": "Marché : %.8f $",
|
||||
"baltab_mining_addresses_count": "%d adresses de minage",
|
||||
"baltab_mining_amount": "Récompenses de minage %.8f",
|
||||
"baltab_pct_change_24h": "%s%.1f%% 24h",
|
||||
"baltab_pct_of_total_zaddr": "%.0f%% du total · %d Z-addr",
|
||||
"baltab_shielded": "Protégé",
|
||||
"baltab_shielded_amount": "Protégé %.8f",
|
||||
"baltab_t_addresses_count": "%d T-addresses",
|
||||
"baltab_total_balance": "Solde total",
|
||||
"baltab_transparent": "Transparent",
|
||||
"baltab_transparent_amount": "Transparent %.8f",
|
||||
"ban": "Bannir",
|
||||
"banned_peers": "Pairs bannis",
|
||||
"block": "Bloc",
|
||||
@@ -238,6 +243,7 @@
|
||||
"change_pass_confirm": "Confirmer la nouvelle :",
|
||||
"change_pass_current": "Phrase secrète actuelle :",
|
||||
"change_pass_new": "Nouvelle phrase secrète :",
|
||||
"change_pass_pin_note": "Ce portefeuille a un PIN de déverrouillage rapide. Après le changement, il sera obsolète : supprimez le PIN dans les Paramètres puis définissez-le à nouveau.",
|
||||
"change_pass_title": "Changer la phrase secrète",
|
||||
"characters": "caractères",
|
||||
"chat": "Discussion",
|
||||
@@ -513,7 +519,7 @@
|
||||
"contact_avatar_image_hint": "L'image est copiée dans l'application pour rester disponible si l'original est déplacé.",
|
||||
"contact_avatar_remove": "Retirer",
|
||||
"contact_avatar_shielded": "Blindée",
|
||||
"contact_avatar_transparent": "Transparente",
|
||||
"contact_chip_mining": "Minage seul",
|
||||
"contact_global": "Afficher dans tous les portefeuilles (contact global)",
|
||||
"contact_global_badge_tt": "Contact global — visible dans tous les portefeuilles",
|
||||
"contact_global_tt": "Activé : ce contact reste visible quel que soit le portefeuille chargé. Désactivé : il appartient au portefeuille actuel uniquement.",
|
||||
@@ -633,6 +639,11 @@
|
||||
"download": "Télécharger",
|
||||
"download_bootstrap": "Télécharger Bootstrap",
|
||||
"dragonx_green": "DragonX (Vert)",
|
||||
"earnings_format": "Format d'affichage des gains",
|
||||
"earnings_format_coin": "Pièce uniquement",
|
||||
"earnings_format_coin_fiat": "Pièce + fiat",
|
||||
"earnings_format_fiat": "Fiat uniquement",
|
||||
"earnings_label": "Gains",
|
||||
"edit": "Modifier",
|
||||
"empty_wallet_keys_suffix": "clés",
|
||||
"empty_wallet_open_manager": "Ouvrir le gestionnaire de portefeuilles",
|
||||
@@ -709,8 +720,8 @@
|
||||
"export_tx_success": "Transactions exportées avec succès",
|
||||
"export_tx_title": "Exporter les transactions en CSV",
|
||||
"export_viewing_key": "Exporter la clé de visualisation",
|
||||
"failed_create_mining_address": "Impossible de créer l'adresse de minage",
|
||||
"failed_create_shielded": "Échec de la création de l'adresse blindée",
|
||||
"failed_create_transparent": "Échec de la création de l'adresse transparente",
|
||||
"faq": "FAQ",
|
||||
"faq_d_mgmt_1_a": "Le nœud intégré démarre automatiquement avec le portefeuille et s'arrête quand il le faut, donc vous n'avez normalement pas besoin de le gérer manuellement. Les contrôles avancés (y compris le redémarrage du démon) se trouvent dans Paramètres > Nœud et sécurité.",
|
||||
"faq_d_mgmt_1_q": "Comment démarrer ou arrêter le nœud ?",
|
||||
@@ -788,13 +799,13 @@
|
||||
"faq_open_tooltip": "Aide et FAQ",
|
||||
"faq_search_hint": "Rechercher dans la FAQ…",
|
||||
"faq_title": "Aide et FAQ",
|
||||
"faq_w_addr_1_a": "Les adresses transparentes (elles commencent par R ou t) fonctionnent comme la plupart des cryptomonnaies : les montants et adresses sont publics sur la blockchain.\n\nLes adresses blindées (adresses Z, commençant par zs) utilisent la cryptographie à divulgation nulle de connaissance — le montant, l'expéditeur et le destinataire sont chiffrés sur la chaîne. Les soldes détenus sur des adresses blindées sont privés.",
|
||||
"faq_w_addr_1_a": "Les adresses blindées (adresses z, commençant par zs) sont celles que vous utilisez sur DragonX. Elles reposent sur une cryptographie à divulgation nulle de connaissance : le montant, l'expéditeur et le destinataire sont chiffrés sur la blockchain, et votre solde reste privé.\n\nLes adresses transparentes (commençant par R) sont des adresses de minage. DragonX est une blockchain entièrement privée : elles ne peuvent recevoir que des récompenses de minage — le réseau refuse tout paiement qui leur est envoyé. Les récompenses qui y arrivent sont publiques jusqu'à ce qu'elles soient blindées, ce que le portefeuille fait automatiquement.",
|
||||
"faq_w_addr_1_q": "Quelle est la différence entre les adresses transparentes et blindées ?",
|
||||
"faq_w_addr_2_a": "Privilégiez les adresses blindées (Z) autant que possible — elles préservent la confidentialité de votre solde et de votre historique de paiements. DragonX est une cryptomonnaie axée sur la confidentialité, et le blindage est le mode par défaut pour les fonds reçus.\n\nN'utilisez une adresse transparente que lorsqu'un service avec lequel vous interagissez ne peut pas gérer les adresses blindées.",
|
||||
"faq_w_addr_2_a": "Utilisez une adresse blindée (zs) pour tout ce que vous envoyez et recevez — sur DragonX, c'est le seul type d'adresse qui puisse être payé.\n\nLes adresses transparentes (de minage) servent uniquement aux récompenses de minage en solo. Ne la communiquez jamais pour un paiement : le réseau le refuserait.",
|
||||
"faq_w_addr_2_q": "Quel type d'adresse dois-je utiliser ?",
|
||||
"faq_w_addr_3_a": "Pour les adresses blindées, la réutilisation ne pose aucun problème et ne révèle pas l'historique. Pour les adresses transparentes, utiliser une nouvelle adresse à chaque paiement améliore la confidentialité. Créez de nouvelles adresses depuis l'onglet Recevoir.",
|
||||
"faq_w_addr_3_a": "Réutiliser une adresse blindée ne pose aucun problème et ne révèle pas votre historique. Vous pouvez tout de même en créer de nouvelles depuis l'onglet Recevoir, par exemple une par contact pour savoir qui vous a payé. Les adresses de minage servent uniquement aux récompenses de minage ; vous aurez rarement besoin d'en créer une nouvelle.",
|
||||
"faq_w_addr_3_q": "Dois-je réutiliser une adresse ou en créer une nouvelle ?",
|
||||
"faq_w_addr_4_a": "Le blindage déplace des pièces d'une adresse transparente vers une adresse blindée, les rendant privées. Les pièces nouvellement minées arrivent en transparent et sont blindées automatiquement pour vous.\n\nVous pouvez aussi blinder manuellement depuis le portefeuille ; un blindage est simplement une transaction spéciale, il faut donc un temps de confirmation normal pour qu'elle se termine.",
|
||||
"faq_w_addr_4_a": "Le blindage déplace des pièces d'une adresse transparente (de minage) vers une adresse blindée, ce qui les rend privées. Les pièces fraîchement minées arrivent sur une adresse de minage et sont blindées automatiquement une fois arrivées à maturité.\n\nVous pouvez aussi blinder manuellement depuis le portefeuille ; un blindage n'est qu'une transaction particulière, il prend donc le temps de confirmation habituel.",
|
||||
"faq_w_addr_4_q": "Qu'est-ce que le « blindage » ?",
|
||||
"faq_w_addr_title": "Adresses et confidentialité",
|
||||
"faq_w_bal_1_a": "Tant que votre portefeuille est encore en cours de synchronisation, votre solde est incomplet et peut afficher 0 — il n'a pas encore analysé toutes vos transactions. Il se complète une fois la synchronisation terminée.\n\nSurveillez la barre d'état en bas : elle indique l'état de la connexion et la hauteur de bloc. Une fois la synchronisation terminée, votre solde est exact.",
|
||||
@@ -838,11 +849,11 @@
|
||||
"faq_w_seed_4_a": "Votre phrase de récupération est la sauvegarde principale et suffit à tout restaurer. Une copie de votre fichier de portefeuille est une sauvegarde secondaire pratique qui conserve aussi les libellés et les paramètres. Conservez toute sauvegarde hors ligne et en lieu privé.",
|
||||
"faq_w_seed_4_q": "Dois-je aussi sauvegarder wallet.dat ?",
|
||||
"faq_w_seed_title": "Phrase de récupération et sauvegarde",
|
||||
"faq_w_send_1_a": "Ouvrez l'onglet Envoyer, collez l'adresse du destinataire, saisissez un montant et confirmez. Si le portefeuille est chiffré, on vous demandera de le déverrouiller pour la transaction.\n\nLes envois vers des adresses blindées sont privés ; les envois vers des adresses transparentes sont publics.",
|
||||
"faq_w_send_1_a": "Ouvrez l'onglet Envoyer, collez l'adresse du destinataire, saisissez un montant et confirmez. Si le portefeuille est chiffré, on vous demandera de le déverrouiller pour la transaction.\n\nLes paiements vont vers des adresses blindées (zs) et restent privés. Le portefeuille n'envoie pas vers une adresse transparente (de minage) : le réseau refuse les paiements vers elles.",
|
||||
"faq_w_send_1_q": "Comment envoyer des fonds ?",
|
||||
"faq_w_send_2_a": "Les frais DragonX sont très faibles et définis automatiquement. Les frais sont affichés avant que vous ne confirmiez. Vous n'avez normalement pas besoin de les modifier.",
|
||||
"faq_w_send_2_q": "Quels frais dois-je payer ?",
|
||||
"faq_w_send_3_a": "Oui — lors de l'envoi vers une adresse blindée (Z), vous pouvez inclure un mémo chiffré. Seul le destinataire peut le lire. Les adresses transparentes ne prennent pas en charge les mémos.",
|
||||
"faq_w_send_3_a": "Oui — lors de l'envoi vers une adresse blindée (Z), vous pouvez inclure un mémo chiffré. Seul le destinataire peut le lire.",
|
||||
"faq_w_send_3_q": "Puis-je joindre un message à un paiement ?",
|
||||
"faq_w_send_4_a": "Une transaction est « en attente » jusqu'à ce qu'elle soit minée dans un bloc et gagne des confirmations. Cela prend généralement une minute ou deux. Les transactions blindées nécessitent aussi que votre portefeuille soit synchronisé pour construire la preuve.\n\nSi un envoi reste en attente anormalement longtemps, vérifiez que votre portefeuille est connecté et synchronisé (voir la barre d'état en bas de la fenêtre).",
|
||||
"faq_w_send_4_q": "J'ai envoyé un paiement mais il indique « en attente » — pourquoi ?",
|
||||
@@ -857,6 +868,7 @@
|
||||
"faq_w_set_4_q": "Comment afficher les prix en monnaie fiduciaire ?",
|
||||
"faq_w_set_title": "Paramètres et apparence",
|
||||
"favorite_address": "Ajouter aux favoris",
|
||||
"favorite_opacity": "Opacité de la surbrillance des favoris",
|
||||
"fee": "Frais",
|
||||
"fee_high": "Élevés",
|
||||
"fee_label": "Frais :",
|
||||
@@ -864,14 +876,9 @@
|
||||
"fee_normal": "Normal",
|
||||
"fetch_prices": "Récupérer les prix",
|
||||
"file": "Fichier",
|
||||
"file_save_location": "Le fichier sera enregistré dans : ~/.config/ObsidianDragon/",
|
||||
"file_save_location": "Le fichier sera enregistré dans :",
|
||||
"filter": "Filtrer...",
|
||||
"font_scale": "Taille de police",
|
||||
"force_quit": "Forcer la fermeture",
|
||||
"force_quit_confirm_msg": "Cela tuera immédiatement le daemon sans arrêt propre.\nCela peut corrompre l'index de la blockchain et nécessiter une resynchronisation.",
|
||||
"force_quit_confirm_title": "Forcer la fermeture ?",
|
||||
"force_quit_warning": "Cela tuera immédiatement le daemon sans arrêt propre. Peut nécessiter une resynchronisation de la blockchain.",
|
||||
"force_quit_yes": "Forcer la fermeture",
|
||||
"from": "De",
|
||||
"from_upper": "DE",
|
||||
"full_details": "Tous les détails",
|
||||
@@ -940,6 +947,7 @@
|
||||
"hidden_tag": " (masqué)",
|
||||
"hide": "Masquer",
|
||||
"hide_address": "Masquer l'adresse",
|
||||
"hide_earnings": "Masquer les gains",
|
||||
"hide_qr": "Masquer le QR",
|
||||
"hide_zero_balances": "Masquer les soldes à 0",
|
||||
"history": "Historique",
|
||||
@@ -1014,11 +1022,13 @@
|
||||
"lite_birthday_backup": "Date de création : %llu (à sauvegarder également)",
|
||||
"lite_birthday_hint": "Hauteur de bloc à partir de laquelle commencer l'analyse. Laissez 0 si inconnue (analyse complète plus lente).",
|
||||
"lite_birthday_label": "Bloc de création",
|
||||
"lite_confirm_label": "Confirmer",
|
||||
"lite_console_backend_commands": "Commandes du backend :",
|
||||
"lite_console_help_passthrough": "Toute autre entrée est exécutée comme une commande de la console du portefeuille lite.",
|
||||
"lite_copy": "Copier",
|
||||
"lite_could_not_start": "Impossible de démarrer l'opération",
|
||||
"lite_could_not_write": "Impossible d'écrire ",
|
||||
"lite_encrypt_offline_warning": "Toute personne disposant du fichier du portefeuille peut essayer des phrases secrètes hors ligne. Sauvegardez d'abord votre phrase de récupération et utilisez une phrase secrète longue.",
|
||||
"lite_encrypt_wallet": "Chiffrer le portefeuille",
|
||||
"lite_encryption_removed": "Chiffrement supprimé",
|
||||
"lite_enter_all_seed_words": "Entrez les 24 mots de la phrase de récupération (%d obtenus)",
|
||||
@@ -1026,6 +1036,7 @@
|
||||
"lite_hide_wipe": "Masquer et effacer",
|
||||
"lite_import": "Importer",
|
||||
"lite_import_key_label": "Importer une clé",
|
||||
"lite_key_import_failed": "Impossible d'importer la clé — vérifiez-la, déverrouillez le portefeuille (s'il est chiffré) et réessayez.",
|
||||
"lite_key_imported": "Clé importée — lancez une synchronisation pour analyser son historique",
|
||||
"lite_lock_failed": "Échec du verrouillage",
|
||||
"lite_lock_now": "Verrouiller maintenant",
|
||||
@@ -1170,9 +1181,43 @@
|
||||
"merge_description": "Fusionnez plusieurs UTXOs en une seule adresse blindée. Cela peut réduire la taille du portefeuille et améliorer la confidentialité.",
|
||||
"merge_fee_hint": "Frais réseau pour cette transaction.",
|
||||
"merge_funds": "Fusionner les fonds",
|
||||
"merge_into_one": "Vers une seule adresse",
|
||||
"merge_keep_each": "Garder dans chaque adresse",
|
||||
"merge_keep_each_batches_fmt": "Les adresses de plus de %d notes sont fusionnées en plusieurs lots ; relancez l'opération une fois ceux-ci confirmés pour fusionner le reste.",
|
||||
"merge_keep_each_confirm_fmt": "Consolider %d notes dans %d adresses ? Chaque adresse garde ses propres fonds. Frais : environ %s DRGX (un par lot).",
|
||||
"merge_keep_each_desc": "Les notes de chaque adresse sont refusionnées dans cette même adresse : les fonds que vous gardez séparés sous des libellés différents restent séparés. Adresses blindées uniquement — les fonds transparents ne peuvent qu'être blindés (Vers une seule adresse).",
|
||||
"merge_keep_each_none": "Chaque adresse blindée ne contient déjà qu'une seule note — rien à consolider.",
|
||||
"merge_keep_each_nothing_submitted": "Rien n'a été consolidé — le nœud a refusé chaque fusion.",
|
||||
"merge_keep_each_row_fmt": "%s · %d notes · %s DRGX",
|
||||
"merge_keep_each_run_again": "Les adresses fusionnées en plusieurs lots contiennent maintenant une note par lot. Relancez l'opération une fois ceux-ci confirmés pour les fusionner en une seule.",
|
||||
"merge_keep_each_select": "Cochez au moins une adresse à consolider.",
|
||||
"merge_keep_each_some_failed": "Certaines fusions ont échoué — voir la liste ci-dessus.",
|
||||
"merge_keep_each_status_done": "%s — terminé : %d notes fusionnées en %d lot(s)",
|
||||
"merge_keep_each_status_failed": "%s — échec : %s",
|
||||
"merge_keep_each_status_not_started": "%s — non démarré : %s",
|
||||
"merge_keep_each_status_running": "%s — fusion de %d notes (%d lots sur %d terminés)…",
|
||||
"merge_keep_each_status_unconfirmed": "%s — statut non confirmé",
|
||||
"merge_keep_each_too_small": "Trop faible pour être consolidé : les frais absorberaient ces fonds.",
|
||||
"merge_max_inputs": "Nombre max d'entrées par lot",
|
||||
"merge_no_spendable": "Aucune entrée dépensable à consolider pour l'instant.",
|
||||
"merge_progress": "Consolidation en cours… cela peut prendre quelques minutes. Vous pouvez fermer cette fenêtre.",
|
||||
"merge_run_aborted_toast": "La connexion au nœud a été réinitialisée, la consolidation s'est donc arrêtée. Relancez-la pour continuer.",
|
||||
"merge_run_busy": "Une consolidation est en cours. Attendez qu'elle se termine ou arrêtez-la d'abord.",
|
||||
"merge_run_chip_fmt": "Consolidation %d/%d",
|
||||
"merge_run_err_timeout": "Le nœud n'a pas répondu à temps ; le lot est peut-être encore en file d'attente.",
|
||||
"merge_run_halted_fmt": "Consolidation arrêtée après des échecs répétés : %s",
|
||||
"merge_run_migration_pending": "Terminez ou annulez la migration de la phrase de récupération avant de consolider.",
|
||||
"merge_run_progress_fmt": "Consolidation en cours… lot %d sur environ %d, %d notes fusionnées jusqu'ici. Vous pouvez fermer cette fenêtre ; l'opération continue en arrière-plan.",
|
||||
"merge_run_start_failed": "Impossible de démarrer la consolidation — le nœud n'est pas connecté.",
|
||||
"merge_run_status_not_run": "%s — non consolidé (%d notes)",
|
||||
"merge_run_status_waiting": "%s — en attente (%d notes)",
|
||||
"merge_run_stop": "Arrêter",
|
||||
"merge_run_stopped_toast": "Consolidation arrêtée. Relancez-la pour fusionner le reste.",
|
||||
"merge_run_stopping": "Arrêt après les lots déjà envoyés…",
|
||||
"merge_run_wait_batch": "Espacement des lots pour que le nœud reste réactif…",
|
||||
"merge_run_wait_send": "En pause jusqu'à ce que votre transaction en attente soit passée.",
|
||||
"merge_run_wait_sync": "En pause : le nœud est encore en cours de synchronisation.",
|
||||
"merge_scope_failed_fmt": "Impossible de lire vos entrées : %s",
|
||||
"merge_scope_fmt": "%d entrées transparentes + %d blindées · ~%s DRGX dépensables",
|
||||
"merge_scope_loading": "Vérification de vos entrées…",
|
||||
"merge_send_failed": "Échec de la fusion : ",
|
||||
@@ -1181,7 +1226,11 @@
|
||||
"merge_src_shielded": "Blindées",
|
||||
"merge_src_transparent": "Transparentes",
|
||||
"merge_started": "Opération de fusion démarrée",
|
||||
"merge_submit_uncertain": "Le nœud n'a pas confirmé la fusion avant l'interruption de la connexion. Elle peut tout de même aboutir — vérifiez l'Historique après la reconnexion avant de fusionner à nouveau.",
|
||||
"merge_title": "Fusionner vers une adresse",
|
||||
"merge_wait_locked": "Le portefeuille est verrouillé. Déverrouillez-le pour continuer.",
|
||||
"merge_waiting_node": "En attente du nœud…",
|
||||
"merge_where": "Où doivent aller les fonds ?",
|
||||
"mig_adopt": "Faire de ceci mon portefeuille",
|
||||
"mig_adopt_now": "Adopter le portefeuille à phrase",
|
||||
"mig_adopting": "Installation de votre nouveau portefeuille et rescan en cours…",
|
||||
@@ -1233,6 +1282,8 @@
|
||||
"miner_fee": "Frais de mineur",
|
||||
"mining": "Minage",
|
||||
"mining_active": "Actif",
|
||||
"mining_addr_explainer": "Ne reçoit que des récompenses de minage en solo — le réseau refuse les paiements vers cette adresse. Les récompenses sont blindées automatiquement une fois arrivées à maturité.",
|
||||
"mining_address": "Adresse de minage",
|
||||
"mining_address_copied": "Adresse de minage copiée",
|
||||
"mining_all_time": "Tout le temps",
|
||||
"mining_already_saved": "URL du pool déjà enregistrée",
|
||||
@@ -1246,6 +1297,8 @@
|
||||
"mining_benchmark_testing": "Test",
|
||||
"mining_benchmark_tooltip": "Trouver le nombre optimal de threads pour ce CPU",
|
||||
"mining_block_copied": "Hash du bloc copié",
|
||||
"mining_card_click_to_shield": "Cliquez pour blinder dès maintenant les récompenses arrivées à maturité.",
|
||||
"mining_card_tooltip": "Les récompenses de minage en solo arrivent sur vos adresses de minage (transparentes, R…). Ces adresses ne peuvent pas recevoir de paiements. Les récompenses sont blindées automatiquement une fois arrivées à maturité.",
|
||||
"mining_chart_1m_ago": "il y a 1m",
|
||||
"mining_chart_5m_ago": "il y a 5m",
|
||||
"mining_chart_now": "Maintenant",
|
||||
@@ -1261,6 +1314,8 @@
|
||||
"mining_est_block": "Bloc est.",
|
||||
"mining_est_daily": "Est. quotidien",
|
||||
"mining_est_daily_pool_sub": "équivalent solo approximatif, avant les frais du pool",
|
||||
"mining_est_daily_sub": "moy.",
|
||||
"mining_est_daily_sub_net": "moy., net",
|
||||
"mining_filter_all": "Tout",
|
||||
"mining_filter_tip_all": "Afficher tous les gains",
|
||||
"mining_filter_tip_pool": "Afficher uniquement les gains du pool",
|
||||
@@ -1290,6 +1345,7 @@
|
||||
"mining_payout_address": "Adresse de paiement",
|
||||
"mining_payout_foreign": "⚠ Cette adresse de paiement ne fait pas partie de votre portefeuille actuel — les récompenses minées iraient vers un autre portefeuille. Mettez-la à jour si vous avez changé de portefeuille.",
|
||||
"mining_payout_invalid": "Adresse DragonX invalide — corrigez-la avant de démarrer, sinon les récompenses de minage sont perdues.",
|
||||
"mining_payout_needs_shielded": "Les pools paient avec des transactions ordinaires, qui ne peuvent pas atteindre une adresse de minage (transparente, R…) sur DragonX. Utilisez une adresse blindée (elle commence par zs) comme adresse de paiement.",
|
||||
"mining_payout_tooltip": "Adresse pour recevoir les récompenses de minage",
|
||||
"mining_pool": "Pool",
|
||||
"mining_pool_fee": "Frais",
|
||||
@@ -1302,6 +1358,8 @@
|
||||
"mining_refresh": "Actualiser",
|
||||
"mining_remove": "Supprimer",
|
||||
"mining_reset_defaults": "Réinitialiser les paramètres",
|
||||
"mining_rewards": "Récompenses de minage",
|
||||
"mining_rewards_unshielded": "Pas encore blindées",
|
||||
"mining_save_payout_address": "Enregistrer l'adresse de paiement",
|
||||
"mining_save_pool_url": "Enregistrer l'URL du pool",
|
||||
"mining_saved_addresses": "Adresses enregistrées :",
|
||||
@@ -1335,10 +1393,10 @@
|
||||
"network_fee": "FRAIS RÉSEAU",
|
||||
"network_hashrate": "Hashrate du réseau",
|
||||
"new": "+ Nouveau",
|
||||
"new_mining_address": "Nouvelle adresse de minage",
|
||||
"new_mining_address_created": "Nouvelle adresse de minage créée",
|
||||
"new_shielded_created": "Nouvelle adresse blindée créée",
|
||||
"new_t_address": "Nouvelle adresse T",
|
||||
"new_t_transparent": "Nouvelle adresse t (Transparente)",
|
||||
"new_transparent_created": "Nouvelle adresse transparente créée",
|
||||
"new_z_address": "Nouvelle adresse Z",
|
||||
"new_z_shielded": "Nouvelle adresse z (Blindée)",
|
||||
"no_addresses": "Aucune adresse trouvée. Créez-en une avec les boutons ci-dessus.",
|
||||
@@ -1358,7 +1416,19 @@
|
||||
"node_banner_offline_title": "Non connecté au nœud DragonX",
|
||||
"node_banner_reconnect": "Reconnecter",
|
||||
"node_banner_restart": "Redémarrer le nœud",
|
||||
"node_busy_rebuild_desc": "En cours depuis %s. Pour un gros portefeuille, cela peut prendre environ une heure, et le nœud ne peut pas répondre au portefeuille avant la fin.",
|
||||
"node_busy_rebuild_extending": "Extension des témoins (le nœud ne signale aucune progression pour cette étape).",
|
||||
"node_busy_rebuild_reading": "Lecture des blocs : %ld / %ld",
|
||||
"node_busy_rebuild_title": "Nœud occupé : reconstruction des témoins de notes",
|
||||
"node_busy_replay_desc": "En cours depuis %s. Le nœud réapplique les blocs reçus avant son dernier arrêt.",
|
||||
"node_busy_replay_title": "Nœud occupé : rattrapage des blocs",
|
||||
"node_busy_rpc_desc": "Le nœud fonctionne mais est trop occupé pour répondre au portefeuille depuis %s, généralement à cause d'une longue opération comme la reconstruction des témoins.",
|
||||
"node_busy_rpc_title": "Nœud occupé : ne répond pas encore",
|
||||
"node_busy_running": "En cours depuis %s",
|
||||
"node_security": "NŒUD & SÉCURITÉ",
|
||||
"node_starting_busy_desc": "Le nœud est joignable mais occupé et ne répond pas encore. C'est normal après une mise à jour ou au premier lancement ; cela peut prendre quelques minutes.",
|
||||
"node_starting_launch_desc": "Lancement de dragonxd, en attente de sa disponibilité…",
|
||||
"node_starting_title": "Démarrage du nœud DragonX…",
|
||||
"noise": "Bruit",
|
||||
"not_connected": "Non connecté au daemon...",
|
||||
"not_connected_to_daemon": "Non connecté au daemon",
|
||||
@@ -1511,6 +1581,8 @@
|
||||
"ram_wallet_gb": "Portefeuille : %.1f Go",
|
||||
"ram_wallet_mb": "Portefeuille : %.0f Mo",
|
||||
"receive": "Recevoir",
|
||||
"receive_not_for_payments": "Pas pour les paiements",
|
||||
"receive_use_shielded": "Utiliser une adresse blindée",
|
||||
"received": "reçu",
|
||||
"received_filter": "Reçu",
|
||||
"received_label": "Reçu",
|
||||
@@ -1525,6 +1597,7 @@
|
||||
"reduce_motion": "Réduire les animations",
|
||||
"refresh": "Actualiser",
|
||||
"refresh_now": "Actualiser maintenant",
|
||||
"refresh_waits_for_sync": "Le nœud est encore en cours de synchronisation. Les données du portefeuille seront actualisées une fois celle-ci terminée.",
|
||||
"remove_favorite": "Retirer des favoris",
|
||||
"repair_wallet": "Réparer le portefeuille",
|
||||
"report_bug": "Signaler un bug",
|
||||
@@ -1539,12 +1612,12 @@
|
||||
"request_select_address": "Sélectionner une adresse...",
|
||||
"request_shielded_addrs": "-- Adresses blindées --",
|
||||
"request_title": "Demander un paiement",
|
||||
"request_transparent_addrs": "-- Adresses transparentes --",
|
||||
"request_uri_copied": "URI de paiement copiée dans le presse-papiers",
|
||||
"rescan": "Re-scanner",
|
||||
"rescan_bootstrapped_msg": "Votre nœud a été initialisé par bootstrap, les blocs situés en dessous de l'instantané ne sont donc pas sur le disque et un rescan depuis la genèse échouerait. Effectuez un rescan à partir d'une hauteur incluse dans votre instantané pour réconcilier le solde dépensé de votre portefeuille. Votre fichier wallet.dat et vos données de chaîne ne sont pas supprimés.",
|
||||
"rescan_detecting": "Vérification des blocs présents sur le disque de votre nœud…",
|
||||
"rescan_from_height": "Rescanner à partir de la hauteur de bloc :",
|
||||
"rescan_probe_failed": "Impossible de vérifier quels blocs votre nœud a sur le disque (il est occupé ou une requête a expiré). Si ce nœud a été initialisé par bootstrap, annulez et réessayez dans une minute : le redémarrage complet avec -rescan ci-dessous échoue sur un nœud initialisé par bootstrap.",
|
||||
"reset_to_defaults": "Réinitialiser les paramètres",
|
||||
"restarting_after_encryption": "Redémarrage du daemon après chiffrement...",
|
||||
"restore_address": "Restaurer l'adresse",
|
||||
@@ -1587,6 +1660,7 @@
|
||||
"sb_net_khs": "Rés: %.2f KH/s",
|
||||
"sb_net_mhs": "Rés: %.2f MH/s",
|
||||
"sb_no_conf": "DRAGONX.conf introuvable",
|
||||
"sb_node_busy": "Nœud occupé",
|
||||
"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",
|
||||
@@ -1608,6 +1682,7 @@
|
||||
"screenshot_sweep": "Lancer la capture d'écran",
|
||||
"screenshot_sweep_desc": "Parcourt chaque thème sur chaque onglet et enregistre une capture d'écran de chacun dans des sous-dossiers par onglet, sous le dossier screenshots du répertoire de configuration (en écrasant le balayage précédent). Dure quelques secondes.",
|
||||
"screenshot_sweep_full": "Balayage complet de l'interface",
|
||||
"screenshot_sweep_refused": "Balayage complet de l'interface refusé ; voir le journal de débogage",
|
||||
"search_icons": "Rechercher des icônes...",
|
||||
"search_placeholder": "Rechercher...",
|
||||
"sec_changing_passphrase": "Modification de la phrase secrète...",
|
||||
@@ -1629,15 +1704,19 @@
|
||||
"sec_mode_passphrase": " Phrase secrète",
|
||||
"sec_not_connected_to_daemon": "Non connecté au démon",
|
||||
"sec_not_connected_to_daemon_pin": "Non connecté au démon",
|
||||
"sec_passphrase_changed_pin_stale": "Phrase secrète modifiée ; votre PIN est obsolète",
|
||||
"sec_passphrase_changed_successfully": "Phrase secrète modifiée avec succès",
|
||||
"sec_pin_changed_successfully": "PIN modifié avec succès",
|
||||
"sec_pin_removed": "PIN supprimé",
|
||||
"sec_pin_set_successfully": "PIN défini avec succès",
|
||||
"sec_pin_stale_msg": "PIN obsolète. Utilisez votre phrase secrète.",
|
||||
"sec_pin_unreadable": "Fichier du PIN illisible. Utilisez votre phrase secrète.",
|
||||
"sec_restart_daemon_for_encryption": "Veuillez redémarrer votre démon pour que le chiffrement prenne effet.",
|
||||
"sec_too_many_attempts_wait": "Trop de tentatives. Patientez %.0f secondes...",
|
||||
"sec_total_elapsed_fmt": "Temps total écoulé : %dm %02ds",
|
||||
"sec_unlock_button": "Déverrouiller",
|
||||
"sec_unlock_failed_prefix": "Échec du déverrouillage : ",
|
||||
"sec_unlock_node_busy": "The node is busy; try again in a moment.",
|
||||
"sec_unlocking_fmt": "Déverrouillage%s",
|
||||
"sec_use_passphrase_instead": "Utiliser une phrase secrète",
|
||||
"sec_use_pin_instead": "Utiliser un PIN",
|
||||
@@ -1690,6 +1769,8 @@
|
||||
"send_from_this_address": "Envoyer depuis cette adresse",
|
||||
"send_go_to_receive": "Aller à Recevoir",
|
||||
"send_keep": "Conserver",
|
||||
"send_mining_addr_blocked": "Ceci est une adresse de minage (transparente). Sur DragonX, elles ne peuvent recevoir que des récompenses de minage — le réseau refuse les paiements vers elles. Demandez au destinataire une adresse blindée (elle commence par zs).",
|
||||
"send_mining_addr_short": "Adresse de minage — ne peut pas recevoir de paiements",
|
||||
"send_network_fee": "FRAIS RÉSEAU",
|
||||
"send_no_balance": "Pas de solde",
|
||||
"send_no_recent": "Aucun envoi récent",
|
||||
@@ -1717,7 +1798,6 @@
|
||||
"send_txid_copied": "TxID copié dans le presse-papiers",
|
||||
"send_txid_label": "TxID : %s",
|
||||
"send_valid_shielded": "Adresse blindée valide",
|
||||
"send_valid_transparent": "Adresse transparente valide",
|
||||
"send_wallet_empty": "Votre portefeuille est vide",
|
||||
"send_yes_clear": "Oui, effacer",
|
||||
"sender_balance": "Expéditeur : %.8f → %.8f DRGX",
|
||||
@@ -1780,6 +1860,7 @@
|
||||
"settings_open_log_folder": "Ouvrir le dossier des journaux",
|
||||
"settings_other": "Autres",
|
||||
"settings_pin_active": "PIN",
|
||||
"settings_pin_out_of_date": "PIN obsolète",
|
||||
"settings_privacy": "Confidentialité",
|
||||
"settings_quick_unlock_pin": "PIN de déverrouillage rapide",
|
||||
"settings_reduce_transparency": "Réduire la transparence",
|
||||
@@ -1845,11 +1926,24 @@
|
||||
"shielded_type": "Blindé",
|
||||
"shielding_notice": "Note : Cela blindera des fonds d'une adresse transparente (T) vers une adresse privée (Z).",
|
||||
"show": "Afficher",
|
||||
"show_earnings": "Afficher les gains",
|
||||
"show_hidden": "Afficher masqués (%d)",
|
||||
"show_qr": "Afficher le QR",
|
||||
"show_qr_code": "Afficher le code QR",
|
||||
"showing_transactions": "Affichage %d–%d sur %d transactions (total : %zu)",
|
||||
"showing_x_of_y": "Affichage de %d sur %d adresses",
|
||||
"shutdown_kill_confirm_msg": "Le nœud ne montre plus d'activité processeur ni disque depuis longtemps et est peut-être bloqué.\nLe tuer fait perdre tout ce qu'il n'a pas enregistré ; son prochain démarrage peut être beaucoup plus long.",
|
||||
"shutdown_kill_confirm_title": "Tuer le nœud ?",
|
||||
"shutdown_kill_confirm_yes": "Tuer le nœud",
|
||||
"shutdown_kill_node": "Tuer le nœud (risqué)",
|
||||
"shutdown_kill_node_tip": "Arrête le nœud immédiatement. Tout ce qu'il n'a pas enregistré est perdu, et au prochain démarrage il rejouera les blocs depuis son dernier enregistrement.",
|
||||
"shutdown_leave_running": "Fermer le portefeuille — laisser le nœud actif",
|
||||
"shutdown_leave_running_tip": "Ferme le portefeuille maintenant. Le nœud continue de fonctionner seul et termine ce qu'il fait ; rien n'est perdu.",
|
||||
"shutdown_stop_asking": "Demande d'arrêt envoyée au nœud…",
|
||||
"shutdown_stop_cpu_suffix": " · CPU %d%%",
|
||||
"shutdown_stop_delivered": "Le nœud s'arrête et enregistre son état",
|
||||
"shutdown_stop_idle": "Aucune activité processeur ni disque depuis %d min — le nœud est peut-être bloqué",
|
||||
"shutdown_stop_queued": "Le nœud a reçu la demande d'arrêt mais il est occupé — il s'arrêtera dès que possible",
|
||||
"simple_background": "Arrière-plan simple",
|
||||
"slider_off": "Désactivé",
|
||||
"sort_amount_high": "Montant le plus élevé",
|
||||
@@ -1857,9 +1951,18 @@
|
||||
"sort_date_newest": "Plus récent d'abord",
|
||||
"sort_date_oldest": "Plus ancien d'abord",
|
||||
"start_mining": "Démarrer le minage",
|
||||
"startup_files_preparing": "Préparation des fichiers du portefeuille... %d%%",
|
||||
"startup_files_verifying": "Vérification des fichiers du portefeuille...",
|
||||
"status": "Statut",
|
||||
"stop_external": "Arrêter le daemon externe",
|
||||
"stop_mining": "Arrêter le minage",
|
||||
"stopconfirm_busy_body": "Il est trop occupé pour répondre au portefeuille depuis %s, probablement à cause d'une longue opération comme la reconstruction des témoins. L'arrêter maintenant peut obliger son prochain démarrage à refaire ce travail.\n\nRecommandé : fermez le portefeuille et laissez le nœud actif. Il terminera seul.",
|
||||
"stopconfirm_busy_title": "Le nœud est occupé",
|
||||
"stopconfirm_rebuild_body": "Elle est en cours depuis %s, et pour un gros portefeuille elle peut prendre environ une heure. Arrêter le nœud maintenant attend la fin de la reconstruction, ou oblige son prochain démarrage à la refaire, rattrapage compris.\n\nRecommandé : fermez le portefeuille et laissez le nœud actif. Il terminera seul.",
|
||||
"stopconfirm_rebuild_title": "Le nœud reconstruit les témoins de notes",
|
||||
"stopconfirm_stop_wait": "Arrêter le nœud et attendre",
|
||||
"stopconfirm_walletop_body": "Le nœud analyse la chaîne pour une importation de clé ou une réanalyse. L'arrêter maintenant met fin à cette analyse plus tôt, et les fonds qu'elle n'a pas encore atteints n'apparaîtront qu'après Paramètres → Re-scanner.\n\nRecommandé : fermez le portefeuille et laissez le nœud actif. Il terminera l'analyse seul.",
|
||||
"stopconfirm_walletop_title": "Une importation de clé ou une réanalyse est en cours",
|
||||
"stratum_allowip_hint": "Autoriser les mineurs depuis une IP ou un CIDR (vide = cet ordinateur uniquement)",
|
||||
"stratum_expose_warn": "Ouvre un port de minage vers le réseau que vous autorisez — à utiliser uniquement sur un réseau local de confiance.",
|
||||
"stratum_host": "Héberger un pool de minage (stratum)",
|
||||
@@ -1906,7 +2009,6 @@
|
||||
"switch_stopnode_title": "Arrêter le nœud en cours d'exécution ?",
|
||||
"switch_stopnode_warn": "Un nœud que ce portefeuille n'a pas démarré est déjà en cours d'exécution.",
|
||||
"syncing": "Synchronisation...",
|
||||
"t_address": "Adresse T",
|
||||
"t_addresses": "Adresses T",
|
||||
"test_connection": "Tester",
|
||||
"theme": "Thème",
|
||||
@@ -1928,6 +2030,7 @@
|
||||
"tools": "OUTILS",
|
||||
"tools_actions": "Outils & Actions...",
|
||||
"tools_actions_hdr": "OUTILS ET ACTIONS",
|
||||
"tor_never_applied_notice": "L'option « Utiliser Tor » n'a jamais fonctionné : vos connexions n'ont PAS été acheminées via Tor. Elle a été désactivée. Consultez son info-bulle dans Paramètres pour des alternatives.",
|
||||
"total": "Total",
|
||||
"total_balance_label": "Solde total",
|
||||
"transaction_id": "ID DE TRANSACTION",
|
||||
@@ -1941,8 +2044,6 @@
|
||||
"transfer_sent": "Transfert envoyé",
|
||||
"transfer_sent_desc": "Votre transfert a été soumis au réseau.",
|
||||
"transfer_to": "Transférer à :",
|
||||
"transparent": "Transparent",
|
||||
"transparent_address": "Adresse transparente",
|
||||
"try_again": "Réessayer",
|
||||
"tt_addr_url": "URL de base pour consulter les adresses dans un explorateur de blocs",
|
||||
"tt_address_book": "Gérer les adresses enregistrées pour un envoi rapide",
|
||||
@@ -1974,10 +2075,16 @@
|
||||
"tt_debug_expand": "Développer les options de journalisation de débogage",
|
||||
"tt_delete_blockchain": "Supprimer toutes les données de la blockchain et démarrer une nouvelle synchronisation. wallet.dat et la configuration sont préservés.",
|
||||
"tt_download_bootstrap": "Télécharger le bootstrap blockchain pour accélérer la synchronisation\nLes données de blocs existantes seront remplacées",
|
||||
"tt_earn_interval": "Période de gains : 1H = cette heure, 1D = aujourd'hui, 1M = ce mois, 1Y = cette année",
|
||||
"tt_earn_mode_inflows": "Mode de gains : montants reçus (minage inclus)",
|
||||
"tt_earn_mode_net": "Mode de gains : net (reçu moins envoyé)",
|
||||
"tt_earn_show_all": "Afficher le montant des gains pour chaque adresse",
|
||||
"tt_earnings_format": "Comment les montants de gains par adresse et totaux sont affichés",
|
||||
"tt_encrypt": "Chiffrer wallet.dat avec une phrase secrète",
|
||||
"tt_export_all": "Exporter toutes les clés privées dans un fichier",
|
||||
"tt_export_csv": "Exporter l'historique des transactions en feuille de calcul CSV",
|
||||
"tt_export_key": "Exporter la clé privée de l'adresse sélectionnée",
|
||||
"tt_favorite_opacity": "Opacité du bouton étoile des favoris et de son contour doré dans la liste d'adresses (minimum 15 %)",
|
||||
"tt_fetch_prices": "Récupérer les prix du marché DRGX depuis l'API CoinGecko",
|
||||
"tt_font_scale": "Mettre à l'échelle tout le texte et l'interface (1.0x = par défaut, jusqu'à 1.5x).",
|
||||
"tt_idle_delay": "Combien de temps attendre avant de commencer le minage",
|
||||
@@ -1986,6 +2093,7 @@
|
||||
"tt_keep_daemon": "Le daemon s'arrêtera lors de l'exécution de l'assistant de configuration",
|
||||
"tt_language": "Langue de l'interface du portefeuille",
|
||||
"tt_layout_hotkey": "Raccourci : touches fléchées gauche/droite pour changer les dispositions de Balance",
|
||||
"tt_lite_confirm_pass": "Saisissez à nouveau la nouvelle phrase secrète ; elle doit correspondre et comporter au moins 8 caractères",
|
||||
"tt_lite_copy": "Copie le secret révélé dans le presse-papiers",
|
||||
"tt_lite_decrypt_pass": "Saisissez votre phrase de passe pour retirer le chiffrement du portefeuille",
|
||||
"tt_lite_encrypt": "Chiffre le portefeuille avec la phrase de passe ci-dessus ; il se verrouille immédiatement et requiert la phrase pour se déverrouiller",
|
||||
@@ -2014,11 +2122,16 @@
|
||||
"tt_low_spec": "Désactiver tous les effets visuels lourds\\nRaccourci : Ctrl+Shift+Down",
|
||||
"tt_merge": "Consolider plusieurs UTXOs vers une adresse",
|
||||
"tt_mine_idle": "Démarrer le minage automatiquement quand le\\nsystème est inactif (aucune entrée clavier/souris)",
|
||||
"tt_mining_est_daily": "Une moyenne statistique à long terme pour votre part actuelle de hashrate dans le réseau. Les récompenses réelles de minage arrivent au hasard (Poisson) — souvent rien pendant longtemps, puis une somme — ce n'est donc PAS un revenu quotidien régulier.",
|
||||
"tt_mining_est_daily_fee_fmt": "Affiché après les %.1f%% de frais de pool.",
|
||||
"tt_mining_est_daily_nofee": "Affiché avant les frais de pool (frais inconnus pour ce pool).",
|
||||
"tt_new_mining_address": "Crée une adresse transparente (R…) pour les récompenses de minage en solo. Elle ne peut pas recevoir de paiements — partagez une adresse blindée pour cela.",
|
||||
"tt_noise": "Intensité de texture grainée (0% = désactivé, 100% = maximum)",
|
||||
"tt_open_app_dir": "Ouvrir le dossier ObsidianDragon (paramètres, thèmes, journaux) dans le gestionnaire de fichiers",
|
||||
"tt_open_data_dir": "Ouvrir le dossier contenant les données de votre portefeuille et de la blockchain dans le gestionnaire de fichiers",
|
||||
"tt_open_dir": "Cliquer pour ouvrir dans l'explorateur de fichiers",
|
||||
"tt_open_log_folder": "Ouvre le dossier contenant les journaux de débogage et de plantage",
|
||||
"tt_pin_out_of_date": "La phrase secrète du portefeuille a changé après la définition de ce PIN ; le PIN ne peut plus le déverrouiller. Supprimez le PIN puis définissez-le à nouveau.",
|
||||
"tt_reduce_motion": "Désactiver les transitions animées et le lerp de solde pour l'accessibilité",
|
||||
"tt_remove_encrypt": "Supprimer le chiffrement et stocker le portefeuille sans protection",
|
||||
"tt_remove_pin": "Supprimer le PIN et exiger la phrase secrète pour déverrouiller",
|
||||
@@ -2054,10 +2167,13 @@
|
||||
"tt_theme_effects": "Scintillement, lueur, cycle de teinte par thème",
|
||||
"tt_theme_hotkey": "Raccourci : Ctrl+Gauche/Droite pour changer de thème",
|
||||
"tt_tor": "Acheminer les connexions du daemon via le réseau Tor pour l'anonymat",
|
||||
"tt_tor_unavailable": "Pas encore disponible : ce portefeuille n'achemine aucun trafic via Tor. Pour le nœud uniquement, vous pouvez ajouter proxy=127.0.0.1:9050 à DRAGONX.conf et redémarrer le nœud. Tor doit être en cours d'exécution, sinon le nœud ne trouve aucun pair. Les requêtes de prix, de mise à jour, de bootstrap et de pool se connectent toujours directement ; pour celles-ci, utilisez un VPN/Tor à l'échelle du système.",
|
||||
"tt_tor_unavailable_lite": "Pas encore disponible : ce portefeuille n'achemine aucun trafic via Tor. Utilisez un VPN/Tor à l'échelle du système.",
|
||||
"tt_tx_url": "URL de base pour consulter les transactions dans un explorateur de blocs",
|
||||
"tt_ui_opacity": "Opacité des cartes et de la barre latérale (100% = entièrement opaque, plus bas = plus transparent)",
|
||||
"tt_validate": "Vérifier si une adresse DragonX est valide",
|
||||
"tt_verbose": "Journaliser les diagnostics de connexion détaillés,\\nl'état du daemon et les informations de propriétaire de port\\ndans l'onglet Console",
|
||||
"tt_view_transactions": "Afficher les transactions impliquant cette adresse dans l'onglet Historique",
|
||||
"tt_wallet_size_warn": "Les portefeuilles blindés grandissent avec les données de témoin de chaque note — fusionner de nombreuses notes en une seule adresse réduit cette taille. Sauvegardez d'abord.",
|
||||
"tt_wallets_button": "Répertoriez vos fichiers de portefeuille et passez de l'un à l'autre",
|
||||
"tt_website": "Ouvrir le site web DragonX",
|
||||
@@ -2075,6 +2191,7 @@
|
||||
"tx_loading_queued": "Actualisation des transactions en file d'attente",
|
||||
"tx_loading_refreshing_cached": "Actualisation de l'historique du portefeuille (%d en cache)",
|
||||
"tx_loading_scanning_shielded": "Analyse de l'historique blindé (%d adresses)",
|
||||
"tx_loading_waits_for_sync": "L'historique sera mis à jour une fois le nœud synchronisé",
|
||||
"tx_mined": "MINÉ",
|
||||
"tx_progress_balances": "Actualisation des soldes",
|
||||
"tx_progress_finalizing": "Finalisation de la transaction",
|
||||
@@ -2106,6 +2223,7 @@
|
||||
"validate_description": "Entrez une adresse DragonX pour vérifier si elle est valide et si elle appartient à ce portefeuille.",
|
||||
"validate_invalid": "INVALIDE",
|
||||
"validate_is_mine": "Ce portefeuille possède cette adresse",
|
||||
"validate_mining_type": "Adresse de minage (transparente)",
|
||||
"validate_not_mine": "N'appartient pas à ce portefeuille",
|
||||
"validate_ownership": "Propriété :",
|
||||
"validate_results": "Résultats :",
|
||||
@@ -2113,7 +2231,6 @@
|
||||
"validate_shielded_type": "Blindée (z-adresse)",
|
||||
"validate_status": "Statut :",
|
||||
"validate_title": "Valider l'adresse",
|
||||
"validate_transparent_type": "Transparente (t-adresse)",
|
||||
"validate_type": "Type :",
|
||||
"validate_valid": "VALIDE",
|
||||
"validating": "Validation...",
|
||||
@@ -2123,6 +2240,7 @@
|
||||
"view_all": "Tout afficher",
|
||||
"view_details": "Voir les détails",
|
||||
"view_on_explorer": "Voir dans l'explorateur",
|
||||
"view_transactions": "Voir les transactions",
|
||||
"waiting_for_daemon": "En attente de la connexion au daemon...",
|
||||
"wallet": "PORTEFEUILLE",
|
||||
"wallet_degraded_notify": "Votre portefeuille s'est ouvert en mode à fonctionnalités réduites : les fonds existants sont en sécurité et dépensables, mais la création de nouvelles adresses et le blindage sont désactivés. Sauvegardez votre phrase de récupération et restaurez-la pour réparer entièrement le portefeuille.",
|
||||
@@ -2231,6 +2349,7 @@
|
||||
"wallets_reveal": "Afficher le dossier",
|
||||
"wallets_scan_folder": "Analyser un autre dossier pour des portefeuilles…",
|
||||
"wallets_scanned_folders": "Dossiers analysés :",
|
||||
"wallets_scanning": "Analyse des dossiers…",
|
||||
"wallets_sort_addresses": "Adresses",
|
||||
"wallets_sort_asc": "Croissant (plus ancien / moins / plus petit d'abord)",
|
||||
"wallets_sort_by": "Trier :",
|
||||
|
||||
+150
-31
@@ -25,6 +25,7 @@
|
||||
"about_website": "ウェブサイト",
|
||||
"acrylic": "アクリル",
|
||||
"add": "追加",
|
||||
"addr_type_mining": "マイニング",
|
||||
"address": "アドレス",
|
||||
"address_book_add": "アドレスを追加",
|
||||
"address_book_add_new": "新規追加",
|
||||
@@ -68,6 +69,7 @@
|
||||
"appx_blockchain_maintenance_in_progress": "ブロックチェーンのメンテナンス操作がすでに進行中です。",
|
||||
"appx_blockchain_rescan_complete": "ブロックチェーンの再スキャンが完了しました",
|
||||
"appx_bootstrap_complete_reconciling": "ブートストラップが完了しました。新しいチェーンデータとウォレットを照合しています。",
|
||||
"appx_bootstrap_reconcile_probe_failed": "ブートストラップのチェーンデータとウォレットを照合できませんでした。ノードが処理中のため、保有しているブロックを確認できませんでした。同期が追いついたら、設定から再スキャンを実行してください。",
|
||||
"appx_cancel": "キャンセル",
|
||||
"appx_cleaning_up": "クリーンアップしています...",
|
||||
"appx_confirm_your_backup": "バックアップを確認",
|
||||
@@ -78,11 +80,15 @@
|
||||
"appx_creating_your_wallet": "ウォレットを作成しています…",
|
||||
"appx_daemon_error": "デーモンエラー",
|
||||
"appx_daemon_reinstall_in_progress": "デーモンの再インストールがすでに進行中です。",
|
||||
"appx_daemon_reinstall_node_busy": "ノードがまだ実行中のため、デーモンは再インストールされませんでした。停止してからもう一度お試しください。",
|
||||
"appx_disconnecting": "切断しています...",
|
||||
"appx_done": "完了",
|
||||
"appx_dragonxd_output": "dragonxd の出力",
|
||||
"appx_encrypting_wallet": "ウォレットを暗号化しています...",
|
||||
"appx_fullnode_lifecycle_unavailable_lite": "フルノードのライフサイクル操作はライトビルドでは利用できません",
|
||||
"appx_import_cancelled": "キャンセルしました:ノードはまだスキャン中の可能性があります。鍵の資金が表示されない場合は、設定 → 再スキャン を実行してください。",
|
||||
"appx_import_interrupted": "インポートが中断されたため、鍵はすでにウォレットにある可能性があります。資金が表示されない場合は、設定 → 再スキャン を実行してください。",
|
||||
"appx_import_refused": "ノードが要求を拒否したため、鍵はインポートされませんでした。",
|
||||
"appx_installing_bundled_daemon": "同梱デーモンをインストールしています。ノードが停止、更新、再起動されます...",
|
||||
"appx_invalid_payment_uri_prefix": "無効な支払いURI: ",
|
||||
"appx_ive_written_it_down": "書き留めました",
|
||||
@@ -99,7 +105,8 @@
|
||||
"appx_no_bundled_daemon_to_install": "このビルドにはインストールできる同梱デーモンがありません",
|
||||
"appx_no_embedded_daemon_to_install": "このビルドにはインストールできる組み込みデーモンがありません",
|
||||
"appx_node_busy_restarting": "ノードが再起動中でビジー状態です。少し待ってから再試行してください。",
|
||||
"appx_node_rebuilding_witness_cache": "ノードがウィットネスキャッシュを再構築しています",
|
||||
"appx_node_busy_try_later": "ノードは長時間の処理(ノートウィットネスの再構築など)を実行中です。完了してからもう一度お試しください。今停止すると、その処理を最初からやり直すことがあります。",
|
||||
"appx_node_not_stopped": "ノードが時間内に停止しなかったため、何も変更されませんでした。数分後にもう一度お試しください。",
|
||||
"appx_not_next_word": " — 次の単語ではありません",
|
||||
"appx_payment_request_loaded": "支払いリクエストを読み込みました",
|
||||
"appx_pool_miner_connected_and_hashing": "プールマイナーが接続し、ハッシュ計算中です。",
|
||||
@@ -120,11 +127,7 @@
|
||||
"appx_simple_background_enabled": "シンプル背景を有効にしました",
|
||||
"appx_skip": "スキップ",
|
||||
"appx_skip_anyway": "それでもスキップ",
|
||||
"appx_still_status_prefix": "まだ「",
|
||||
"appx_still_status_suffix": "」です — 今強制終了するとチェーンデータが破損する可能性があります。",
|
||||
"appx_stop_anyway_and_quit": "それでも停止して終了",
|
||||
"appx_stopping_daemon_deleting_blockchain": "デーモンを停止し、ブロックチェーンデータを削除しています...",
|
||||
"appx_stopping_node_discards_rebuild": "今ノードを停止すると、進行中の再構築が破棄され、次回ウォレットを開いたときに再度実行されます(数分かかります)。代わりにノードを起動したままにすることもできます。",
|
||||
"appx_stopping_pool_miner": "プールマイナーを停止しています...",
|
||||
"appx_syncing_pct_block_n_of_n": "同期中 %.1f%% — ブロック %d / %d",
|
||||
"appx_tap_words_in_order": "保存したことを確認するため、単語を正しい順番でタップしてください。",
|
||||
@@ -134,6 +137,9 @@
|
||||
"appx_use_settings_restart_daemon_hint": "設定 > デーモンを再起動 から再試行してください",
|
||||
"appx_waiting_for_daemon_to_encrypt_wallet": "デーモンによるウォレットの暗号化を待っています...",
|
||||
"appx_wallet_created_and_backed_up": "ウォレットを作成し、バックアップしました。",
|
||||
"appx_wallet_op_node_busy": "ノードは処理中で応答していません。完了してからインポートまたは再スキャンをもう一度お試しください。",
|
||||
"appx_wallet_op_running": "鍵のインポートまたは再スキャンがまだ実行中です。完了してからもう一度お試しください。",
|
||||
"appx_wallet_op_shutting_down": "ウォレットを終了しています。",
|
||||
"appx_wallet_open_failed_prefix": "ウォレットを開けませんでした: ",
|
||||
"auto_shield": "マイニング自動シールド",
|
||||
"autoshield_by_node": "あなたのノードは、マイニングしたコインベースを自動的にシールドします。",
|
||||
@@ -174,21 +180,20 @@
|
||||
"balance_history_collecting": "残高履歴 — データを収集中...",
|
||||
"balance_layout": "残高レイアウト",
|
||||
"balance_layout_switched": "レイアウト: %s",
|
||||
"balance_mining_fmt": "マイニング報酬:%.8f",
|
||||
"balance_mining_rate": "マイニング中 %s",
|
||||
"balance_shielded_fmt": "シールド: %.8f",
|
||||
"balance_syncing_pct": "同期中 %.1f%%",
|
||||
"balance_transparent_fmt": "透明: %.8f",
|
||||
"baltab_market": "市場",
|
||||
"baltab_market_price_4dp": "市場価格: $%.4f",
|
||||
"baltab_market_price_8dp": "市場価格: $%.8f",
|
||||
"baltab_mining_addresses_count": "マイニングアドレス %d 件",
|
||||
"baltab_mining_amount": "マイニング報酬 %.8f",
|
||||
"baltab_pct_change_24h": "%s%.1f%% 24h",
|
||||
"baltab_pct_of_total_zaddr": "全体の%.0f%% · %d Z-addr",
|
||||
"baltab_shielded": "シールド",
|
||||
"baltab_shielded_amount": "シールド %.8f",
|
||||
"baltab_t_addresses_count": "%d 個のT-address",
|
||||
"baltab_total_balance": "合計残高",
|
||||
"baltab_transparent": "透明",
|
||||
"baltab_transparent_amount": "透明 %.8f",
|
||||
"ban": "ブロック",
|
||||
"banned_peers": "ブロック済みピア",
|
||||
"block": "ブロック",
|
||||
@@ -238,6 +243,7 @@
|
||||
"change_pass_confirm": "新しいパスフレーズ(確認):",
|
||||
"change_pass_current": "現在のパスフレーズ:",
|
||||
"change_pass_new": "新しいパスフレーズ:",
|
||||
"change_pass_pin_note": "このウォレットにはクイックアンロック PIN が設定されています。変更後は古くなるため、設定で PIN を削除してから再設定してください。",
|
||||
"change_pass_title": "パスフレーズを変更",
|
||||
"characters": "文字",
|
||||
"chat": "チャット",
|
||||
@@ -513,7 +519,7 @@
|
||||
"contact_avatar_image_hint": "画像はアプリ内にコピーされ、元のファイルが移動しても利用できます。",
|
||||
"contact_avatar_remove": "削除",
|
||||
"contact_avatar_shielded": "シールド",
|
||||
"contact_avatar_transparent": "透明",
|
||||
"contact_chip_mining": "マイニング専用",
|
||||
"contact_global": "すべてのウォレットで表示(グローバル連絡先)",
|
||||
"contact_global_badge_tt": "グローバル連絡先 — すべてのウォレットで表示",
|
||||
"contact_global_tt": "オン:この連絡先はどのウォレットを読み込んでも表示されます。オフ:現在のウォレットにのみ属します。",
|
||||
@@ -633,6 +639,11 @@
|
||||
"download": "ダウンロード",
|
||||
"download_bootstrap": "ブートストラップをダウンロード",
|
||||
"dragonx_green": "DragonX(グリーン)",
|
||||
"earnings_format": "収益の表示形式",
|
||||
"earnings_format_coin": "コインのみ",
|
||||
"earnings_format_coin_fiat": "コイン+法定通貨",
|
||||
"earnings_format_fiat": "法定通貨のみ",
|
||||
"earnings_label": "収益",
|
||||
"edit": "編集",
|
||||
"empty_wallet_keys_suffix": "個の鍵",
|
||||
"empty_wallet_open_manager": "ウォレットマネージャーを開く",
|
||||
@@ -709,8 +720,8 @@
|
||||
"export_tx_success": "取引のエクスポートに成功しました",
|
||||
"export_tx_title": "取引をCSVにエクスポート",
|
||||
"export_viewing_key": "閲覧鍵をエクスポート",
|
||||
"failed_create_mining_address": "マイニングアドレスを作成できませんでした",
|
||||
"failed_create_shielded": "シールドアドレスの作成に失敗しました",
|
||||
"failed_create_transparent": "透明アドレスの作成に失敗しました",
|
||||
"faq": "FAQ",
|
||||
"faq_d_mgmt_1_a": "組み込みノードはウォレットと同時に自動的に起動し、適切なタイミングで停止するため、通常は手動で管理する必要はありません。高度な操作(デーモンの再起動を含む)は設定 > ノードとセキュリティにあります。",
|
||||
"faq_d_mgmt_1_q": "ノードの起動・停止はどうすればいいですか?",
|
||||
@@ -788,13 +799,13 @@
|
||||
"faq_open_tooltip": "ヘルプ&FAQ",
|
||||
"faq_search_hint": "FAQを検索…",
|
||||
"faq_title": "ヘルプ&FAQ",
|
||||
"faq_w_addr_1_a": "透明アドレス(RまたはtまたはR始まり)はほとんどのコインと同様に動作します。金額とアドレスはブロックチェーン上で公開されます。\n\nシールドアドレス(zアドレス、zsで始まる)はゼロ知識暗号を使用し、金額・送信者・受信者はチェーン上で暗号化されます。シールドアドレスに保有された残高は非公開です。",
|
||||
"faq_w_addr_1_a": "シールドアドレス(zs で始まる z アドレス)は、DragonX で使用するアドレスです。ゼロ知識暗号を使用するため、金額・送信者・受信者はチェーン上で暗号化され、残高は非公開に保たれます。\n\n透明アドレス(R で始まる)はマイニングアドレスです。DragonX は完全にプライベートなチェーンのため、マイニング報酬しか受け取れず、送られた支払いはネットワークによって拒否されます。そこに届いた報酬はシールドされるまで公開されますが、シールドはウォレットが自動的に行います。",
|
||||
"faq_w_addr_1_q": "透明アドレスとシールドアドレスの違いは何ですか?",
|
||||
"faq_w_addr_2_a": "できる限りシールド(z)アドレスを使用してください。残高と支払い履歴を非公開に保てます。DragonXはプライバシーコインであり、受け取る資金はデフォルトでシールドされます。\n\n透明アドレスは、やり取りするサービスがシールドアドレスに対応していない場合のみ使用してください。",
|
||||
"faq_w_addr_2_a": "送受信にはすべてシールド(zs)アドレスを使用してください。DragonX で支払いを受け取れるのはこの種類だけです。\n\n透明(マイニング)アドレスはソロマイニング報酬専用です。支払いのために他人に教えないでください。ネットワークが拒否します。",
|
||||
"faq_w_addr_2_q": "どちらのアドレスタイプを使うべきですか?",
|
||||
"faq_w_addr_3_a": "シールドアドレスは再利用しても問題なく、履歴が漏れることもありません。透明アドレスは支払いごとに新しいアドレスを使うとプライバシーが向上します。新しいアドレスは受信タブから作成できます。",
|
||||
"faq_w_addr_3_a": "シールドアドレスを再利用しても問題はなく、履歴が漏れることもありません。それでも「受信」タブで新しいアドレスを作成できます。たとえば連絡先ごとに 1 つ作れば、誰が支払ったかがわかります。マイニングアドレスはマイニング報酬専用で、新しく作る必要はほとんどありません。",
|
||||
"faq_w_addr_3_q": "アドレスは使い回すべきですか、それとも新しく作るべきですか?",
|
||||
"faq_w_addr_4_a": "シールド化とは、コインを透明アドレスからシールドアドレスへ移し、非公開にすることです。新しくマイニングされたコインは透明な状態で届き、自動的にシールドされます。\n\n手動でシールドすることもできます。シールドは特別な種類のトランザクションにすぎないため、完了までに通常の確認時間がかかります。",
|
||||
"faq_w_addr_4_a": "シールドとは、透明(マイニング)アドレスのコインをシールドアドレスに移して非公開にすることです。新たにマイニングされたコインはマイニングアドレスに届き、成熟すると自動的にシールドされます。\n\nウォレットから手動でシールドすることもできます。シールドは特別なトランザクションにすぎないため、通常の承認時間がかかります。",
|
||||
"faq_w_addr_4_q": "「シールド化」とは何ですか?",
|
||||
"faq_w_addr_title": "アドレスとプライバシー",
|
||||
"faq_w_bal_1_a": "ウォレットがまだ同期中の場合、残高は不完全で0と表示されることがあります。まだすべてのトランザクションをスキャンしていないためです。同期が完了すると反映されます。\n\n画面下部のステータスバーを確認してください。接続状態とブロック高が表示されます。同期が完了すれば残高は正確になります。",
|
||||
@@ -838,11 +849,11 @@
|
||||
"faq_w_seed_4_a": "シードフレーズは主要なバックアップであり、これだけですべてを復元できます。ウォレットファイルのコピーは便利な二次バックアップで、ラベルや設定も保持されます。どちらのバックアップもオフラインかつ非公開の場所に保管してください。",
|
||||
"faq_w_seed_4_q": "wallet.dat もバックアップすべきですか?",
|
||||
"faq_w_seed_title": "シードフレーズとバックアップ",
|
||||
"faq_w_send_1_a": "送金タブを開き、受取人のアドレスを貼り付け、金額を入力して確認してください。ウォレットが暗号化されている場合、トランザクションのためにロック解除を求められます。\n\nシールドアドレスへの送金は非公開、透明アドレスへの送金は公開されます。",
|
||||
"faq_w_send_1_a": "送金タブを開き、受取人のアドレスを貼り付け、金額を入力して確認してください。ウォレットが暗号化されている場合、トランザクションのためにロック解除を求められます。\n\n支払いはシールド(zs)アドレスに送られ、非公開に保たれます。ウォレットは透明(マイニング)アドレスには送金しません。ネットワークがそのアドレスへの支払いを拒否するためです。",
|
||||
"faq_w_send_1_q": "資金を送金するにはどうすればよいですか?",
|
||||
"faq_w_send_2_a": "DragonXの手数料は非常に低く、自動的に設定されます。手数料は確認前に表示されます。通常は変更する必要はありません。",
|
||||
"faq_w_send_2_q": "どのくらいの手数料がかかりますか?",
|
||||
"faq_w_send_3_a": "はい — シールド(z)アドレスへ送金する際は、暗号化されたメモを添付できます。読めるのは受取人だけです。透明アドレスはメモに対応していません。",
|
||||
"faq_w_send_3_a": "はい — シールド(z)アドレスへ送金する際は、暗号化されたメモを添付できます。読めるのは受取人だけです。",
|
||||
"faq_w_send_3_q": "支払いにメッセージを添付できますか?",
|
||||
"faq_w_send_4_a": "トランザクションはブロックに取り込まれ、確認数を得るまで「保留中」となります。通常1~2分ほどかかります。シールドトランザクションでは、証明を生成するためにウォレットが同期済みである必要もあります。\n\n送金が異常に長く保留中のままの場合は、ウォレットが接続され、同期済みであることを確認してください(ウィンドウ下部のステータスバーを参照)。",
|
||||
"faq_w_send_4_q": "支払いを送りましたが「保留中」のままです — なぜですか?",
|
||||
@@ -857,6 +868,7 @@
|
||||
"faq_w_set_4_q": "法定通貨での価格を表示するには?",
|
||||
"faq_w_set_title": "設定と外観",
|
||||
"favorite_address": "お気に入りに追加",
|
||||
"favorite_opacity": "お気に入りハイライトの不透明度",
|
||||
"fee": "手数料",
|
||||
"fee_high": "高い",
|
||||
"fee_label": "手数料:",
|
||||
@@ -864,14 +876,9 @@
|
||||
"fee_normal": "通常",
|
||||
"fetch_prices": "価格を取得",
|
||||
"file": "ファイル",
|
||||
"file_save_location": "ファイルの保存先:~/.config/ObsidianDragon/",
|
||||
"file_save_location": "ファイルの保存先:",
|
||||
"filter": "フィルター...",
|
||||
"font_scale": "フォントサイズ",
|
||||
"force_quit": "強制終了",
|
||||
"force_quit_confirm_msg": "クリーンシャットダウンなしでデーモンを即座に終了します。\nブロックチェーンインデックスが破損し、再同期が必要になる可能性があります。",
|
||||
"force_quit_confirm_title": "強制終了しますか?",
|
||||
"force_quit_warning": "クリーンシャットダウンなしでデーモンを即座に終了します。ブロックチェーンの再同期が必要になる場合があります。",
|
||||
"force_quit_yes": "強制終了",
|
||||
"from": "送信元",
|
||||
"from_upper": "送信元",
|
||||
"full_details": "詳細情報",
|
||||
@@ -940,6 +947,7 @@
|
||||
"hidden_tag": " (非表示)",
|
||||
"hide": "非表示",
|
||||
"hide_address": "アドレスを非表示",
|
||||
"hide_earnings": "収益を非表示",
|
||||
"hide_qr": "QRを非表示",
|
||||
"hide_zero_balances": "残高0を非表示",
|
||||
"history": "履歴",
|
||||
@@ -1014,11 +1022,13 @@
|
||||
"lite_birthday_backup": "誕生日:%llu (これもバックアップしてください)",
|
||||
"lite_birthday_hint": "スキャンを開始するブロック高。不明な場合は0のままにしてください(完全スキャンが遅くなります)。",
|
||||
"lite_birthday_label": "バースデー",
|
||||
"lite_confirm_label": "確認",
|
||||
"lite_console_backend_commands": "バックエンドコマンド:",
|
||||
"lite_console_help_passthrough": "その他の入力はライトウォレットのコンソールコマンドとして実行されます。",
|
||||
"lite_copy": "コピー",
|
||||
"lite_could_not_start": "操作を開始できませんでした",
|
||||
"lite_could_not_write": "書き込めませんでした ",
|
||||
"lite_encrypt_offline_warning": "ウォレットファイルを持つ人は誰でも、オフラインでパスフレーズを試せます。先にシードフレーズをバックアップし、長いパスフレーズを使ってください。",
|
||||
"lite_encrypt_wallet": "ウォレットを暗号化",
|
||||
"lite_encryption_removed": "暗号化を解除しました",
|
||||
"lite_enter_all_seed_words": "復元するには24個のシードワードをすべて入力してください(現在 %d 個)",
|
||||
@@ -1026,6 +1036,7 @@
|
||||
"lite_hide_wipe": "非表示にして消去",
|
||||
"lite_import": "インポート",
|
||||
"lite_import_key_label": "鍵をインポート",
|
||||
"lite_key_import_failed": "鍵をインポートできませんでした。鍵を確認し、ウォレットのロックを解除して(暗号化されている場合)再試行してください。",
|
||||
"lite_key_imported": "鍵をインポートしました — 履歴をスキャンするには同期を実行してください",
|
||||
"lite_lock_failed": "ロックに失敗しました",
|
||||
"lite_lock_now": "今すぐロック",
|
||||
@@ -1170,9 +1181,43 @@
|
||||
"merge_description": "複数のUTXOを単一のシールドアドレスに統合します。ウォレットサイズの縮小とプライバシーの向上に役立ちます。",
|
||||
"merge_fee_hint": "この取引にかかるネットワーク手数料です。",
|
||||
"merge_funds": "資金を統合",
|
||||
"merge_into_one": "1 つのアドレスへ",
|
||||
"merge_keep_each": "各アドレスに残す",
|
||||
"merge_keep_each_batches_fmt": "ノートが %d 件を超えるアドレスは複数回に分けて統合されます。承認後にもう一度実行すると残りが統合されます。",
|
||||
"merge_keep_each_confirm_fmt": "%d 件のノートを %d 個のアドレスで統合しますか?各アドレスの資金はそのまま残ります。手数料:約 %s DRGX(1 回ごとに 1 件)。",
|
||||
"merge_keep_each_desc": "各アドレスのノートはそのアドレス自身に統合されるため、ラベルごとに分けている資金は分かれたままです。シールドアドレスのみ対象です。透明アドレスの資金はシールドすることしかできません(1 つのアドレスへ)。",
|
||||
"merge_keep_each_none": "すべてのシールドアドレスのノートはすでに 1 つです。統合するものはありません。",
|
||||
"merge_keep_each_nothing_submitted": "何も統合されませんでした。ノードがすべての統合を拒否しました。",
|
||||
"merge_keep_each_row_fmt": "%s · ノート %d 件 · %s DRGX",
|
||||
"merge_keep_each_run_again": "複数回に分けて統合したアドレスには、回数分のノートが残っています。承認後にもう一度実行すると 1 つにまとめられます。",
|
||||
"merge_keep_each_select": "統合するアドレスを 1 つ以上選んでください。",
|
||||
"merge_keep_each_some_failed": "一部の統合に失敗しました。上の一覧を確認してください。",
|
||||
"merge_keep_each_status_done": "%s — 完了:%d 件のノートを %d 回で統合",
|
||||
"merge_keep_each_status_failed": "%s — 失敗:%s",
|
||||
"merge_keep_each_status_not_started": "%s — 開始されませんでした:%s",
|
||||
"merge_keep_each_status_running": "%s — %d 件のノートを統合中(%d / %d 回完了)…",
|
||||
"merge_keep_each_status_unconfirmed": "%s — ステータスを確認できませんでした",
|
||||
"merge_keep_each_too_small": "少なすぎて統合できません。手数料でこの資金がなくなります。",
|
||||
"merge_max_inputs": "バッチあたりの最大入力数",
|
||||
"merge_no_spendable": "統合できる使用可能な入力がまだありません。",
|
||||
"merge_progress": "統合中… 数分かかることがあります。このウィンドウは閉じても構いません。",
|
||||
"merge_run_aborted_toast": "ノードとの接続がリセットされたため、統合を停止しました。続けるには再度実行してください。",
|
||||
"merge_run_busy": "統合を実行中です。完了を待つか、先に停止してください。",
|
||||
"merge_run_chip_fmt": "統合中 %d/%d",
|
||||
"merge_run_err_timeout": "ノードが時間内に応答しませんでした。この回はまだキューに入っている可能性があります。",
|
||||
"merge_run_halted_fmt": "失敗が続いたため統合を停止しました:%s",
|
||||
"merge_run_migration_pending": "統合する前に、シードの移行を完了するかキャンセルしてください。",
|
||||
"merge_run_progress_fmt": "統合中… %d 回目(全体で約 %d 回)、これまでに %d 件のノートを統合しました。このウィンドウを閉じてもバックグラウンドで続行します。",
|
||||
"merge_run_start_failed": "統合を開始できませんでした — ノードが接続されていません。",
|
||||
"merge_run_status_not_run": "%s — 未統合(%d 件のノート)",
|
||||
"merge_run_status_waiting": "%s — 待機中(%d 件のノート)",
|
||||
"merge_run_stop": "停止",
|
||||
"merge_run_stopped_toast": "統合を停止しました。残りを統合するには再度実行してください。",
|
||||
"merge_run_stopping": "送信済みの回が終わったら停止します…",
|
||||
"merge_run_wait_batch": "ノードの応答性を保つため、間隔を空けて実行しています…",
|
||||
"merge_run_wait_send": "保留中の取引が完了するまで一時停止しています。",
|
||||
"merge_run_wait_sync": "一時停止中:ノードはまだ同期中です。",
|
||||
"merge_scope_failed_fmt": "入力を読み取れませんでした: %s",
|
||||
"merge_scope_fmt": "透明%d件 + シールド%d件の入力 · 使用可能 約%s DRGX",
|
||||
"merge_scope_loading": "入力を確認中…",
|
||||
"merge_send_failed": "マージに失敗しました: ",
|
||||
@@ -1181,7 +1226,11 @@
|
||||
"merge_src_shielded": "シールド",
|
||||
"merge_src_transparent": "透明",
|
||||
"merge_started": "統合操作を開始しました",
|
||||
"merge_submit_uncertain": "接続が中断される前に、ノードはマージを確認しませんでした。マージは実行される可能性があります — 再接続後、もう一度マージする前に「履歴」を確認してください。",
|
||||
"merge_title": "アドレスに統合",
|
||||
"merge_wait_locked": "ウォレットがロックされています。続行するにはロックを解除してください。",
|
||||
"merge_waiting_node": "ノードを待っています…",
|
||||
"merge_where": "資金の移動先",
|
||||
"mig_adopt": "これを自分のウォレットにする",
|
||||
"mig_adopt_now": "シードウォレットを採用",
|
||||
"mig_adopting": "新しいウォレットをインストールして再スキャン中…",
|
||||
@@ -1233,6 +1282,8 @@
|
||||
"miner_fee": "マイナー手数料",
|
||||
"mining": "マイニング",
|
||||
"mining_active": "アクティブ",
|
||||
"mining_addr_explainer": "ソロマイニング報酬のみを受け取ります。ネットワークはこのアドレスへの支払いを拒否します。報酬は成熟すると自動的にシールドされます。",
|
||||
"mining_address": "マイニングアドレス",
|
||||
"mining_address_copied": "マイニングアドレスをコピーしました",
|
||||
"mining_all_time": "全期間",
|
||||
"mining_already_saved": "プールURLは既に保存済みです",
|
||||
@@ -1246,6 +1297,8 @@
|
||||
"mining_benchmark_testing": "テスト中",
|
||||
"mining_benchmark_tooltip": "このCPUに最適なスレッド数を検出",
|
||||
"mining_block_copied": "ブロックハッシュをコピーしました",
|
||||
"mining_card_click_to_shield": "クリックすると成熟した報酬を今すぐシールドします。",
|
||||
"mining_card_tooltip": "ソロマイニング報酬はマイニング(透明、R…)アドレスに届きます。これらのアドレスは支払いを受け取れません。報酬は成熟すると自動的にシールドされます。",
|
||||
"mining_chart_1m_ago": "1分前",
|
||||
"mining_chart_5m_ago": "5分前",
|
||||
"mining_chart_now": "現在",
|
||||
@@ -1261,6 +1314,8 @@
|
||||
"mining_est_block": "予測ブロック",
|
||||
"mining_est_daily": "予測日収",
|
||||
"mining_est_daily_pool_sub": "おおよそのソロ換算(プール手数料前)",
|
||||
"mining_est_daily_sub": "平均",
|
||||
"mining_est_daily_sub_net": "平均・手取り",
|
||||
"mining_filter_all": "すべて",
|
||||
"mining_filter_tip_all": "すべての収益を表示",
|
||||
"mining_filter_tip_pool": "プール収益のみ表示",
|
||||
@@ -1290,6 +1345,7 @@
|
||||
"mining_payout_address": "支払いアドレス",
|
||||
"mining_payout_foreign": "⚠ この支払いアドレスは現在のウォレットに含まれていません — マイニング報酬が別のウォレットに送られます。ウォレットを切り替えた場合は更新してください。",
|
||||
"mining_payout_invalid": "有効な DragonX アドレスではありません — 開始前に修正してください。さもないとマイニング報酬が失われます。",
|
||||
"mining_payout_needs_shielded": "プールは通常のトランザクションで支払いますが、DragonX ではそれがマイニング(透明、R…)アドレスに届きません。支払い先にはシールドアドレス(zs で始まる)を使用してください。",
|
||||
"mining_payout_tooltip": "マイニング報酬の受取アドレス",
|
||||
"mining_pool": "プール",
|
||||
"mining_pool_fee": "手数料",
|
||||
@@ -1302,6 +1358,8 @@
|
||||
"mining_refresh": "更新",
|
||||
"mining_remove": "削除",
|
||||
"mining_reset_defaults": "デフォルトにリセット",
|
||||
"mining_rewards": "マイニング報酬",
|
||||
"mining_rewards_unshielded": "未シールド",
|
||||
"mining_save_payout_address": "支払いアドレスを保存",
|
||||
"mining_save_pool_url": "プールURLを保存",
|
||||
"mining_saved_addresses": "保存済みアドレス:",
|
||||
@@ -1335,10 +1393,10 @@
|
||||
"network_fee": "ネットワーク手数料",
|
||||
"network_hashrate": "ネットワークハッシュレート",
|
||||
"new": "+ 新規",
|
||||
"new_mining_address": "新しいマイニングアドレス",
|
||||
"new_mining_address_created": "新しいマイニングアドレスを作成しました",
|
||||
"new_shielded_created": "新しいシールドアドレスを作成しました",
|
||||
"new_t_address": "新しいTアドレス",
|
||||
"new_t_transparent": "新しいtアドレス(透明)",
|
||||
"new_transparent_created": "新しい透明アドレスを作成しました",
|
||||
"new_z_address": "新しいZアドレス",
|
||||
"new_z_shielded": "新しいzアドレス(シールド)",
|
||||
"no_addresses": "アドレスが見つかりません。上のボタンを使用して作成してください。",
|
||||
@@ -1358,7 +1416,19 @@
|
||||
"node_banner_offline_title": "DragonX ノードに接続されていません",
|
||||
"node_banner_reconnect": "再接続",
|
||||
"node_banner_restart": "ノードを再起動",
|
||||
"node_busy_rebuild_desc": "経過時間 %s。大きなウォレットでは約 1 時間かかることがあり、完了するまでノードはウォレットに応答できません。",
|
||||
"node_busy_rebuild_extending": "ウィットネスを拡張しています(この段階ではノードは進捗を報告しません)。",
|
||||
"node_busy_rebuild_reading": "ブロックを読み込み中:%ld / %ld",
|
||||
"node_busy_rebuild_title": "ノード処理中:ノートウィットネスを再構築しています",
|
||||
"node_busy_replay_desc": "経過時間 %s。ノードは前回停止する前に受信したブロックを再適用しています。",
|
||||
"node_busy_replay_title": "ノード処理中:ブロックを追いかけています",
|
||||
"node_busy_rpc_desc": "ノードは実行中ですが、%s の間ウォレットに応答できないほど処理中です。通常はウィットネスの再構築などの長時間の処理が原因です。",
|
||||
"node_busy_rpc_title": "ノード処理中:まだ応答していません",
|
||||
"node_busy_running": "経過時間 %s",
|
||||
"node_security": "ノードとセキュリティ",
|
||||
"node_starting_busy_desc": "ノードには接続できますが処理中のため、まだ応答していません。更新後や初回起動時には正常な状態で、数分かかることがあります。",
|
||||
"node_starting_launch_desc": "dragonxd を起動し、オンラインになるのを待っています…",
|
||||
"node_starting_title": "DragonX ノードを起動しています…",
|
||||
"noise": "ノイズ",
|
||||
"not_connected": "デーモンに未接続...",
|
||||
"not_connected_to_daemon": "デーモンに未接続",
|
||||
@@ -1511,6 +1581,8 @@
|
||||
"ram_wallet_gb": "ウォレット:%.1f GB",
|
||||
"ram_wallet_mb": "ウォレット:%.0f MB",
|
||||
"receive": "受信",
|
||||
"receive_not_for_payments": "支払いには使えません",
|
||||
"receive_use_shielded": "シールドアドレスを使う",
|
||||
"received": "受信済み",
|
||||
"received_filter": "受信済み",
|
||||
"received_label": "受信済み",
|
||||
@@ -1525,6 +1597,7 @@
|
||||
"reduce_motion": "モーションを減らす",
|
||||
"refresh": "更新",
|
||||
"refresh_now": "今すぐ更新",
|
||||
"refresh_waits_for_sync": "ノードはまだ同期中です。同期が完了するとウォレットのデータが更新されます。",
|
||||
"remove_favorite": "お気に入りを削除",
|
||||
"repair_wallet": "ウォレットを修復",
|
||||
"report_bug": "バグを報告",
|
||||
@@ -1539,12 +1612,12 @@
|
||||
"request_select_address": "アドレスを選択...",
|
||||
"request_shielded_addrs": "-- シールドアドレス --",
|
||||
"request_title": "支払いを請求",
|
||||
"request_transparent_addrs": "-- 透明アドレス --",
|
||||
"request_uri_copied": "支払いURIをクリップボードにコピーしました",
|
||||
"rescan": "再スキャン",
|
||||
"rescan_bootstrapped_msg": "ノードはブートストラップされているため、スナップショットより下のブロックはディスク上になく、ジェネシスからの再スキャンは失敗します。ウォレットの使用済み残高を照合するには、スナップショットに含まれる高さから再スキャンしてください。wallet.dat とチェーンデータは削除されません。",
|
||||
"rescan_detecting": "ノードがディスク上に保有しているブロックを確認中…",
|
||||
"rescan_from_height": "ブロック高から再スキャン:",
|
||||
"rescan_probe_failed": "ノードがディスク上に保有しているブロックを確認できませんでした(ノードが処理中か、リクエストがタイムアウトしました)。このノードがブートストラップされている場合は、キャンセルして1分後に再試行してください。ブートストラップされたノードでは、下の -rescan による再起動は失敗します。",
|
||||
"reset_to_defaults": "デフォルトにリセット",
|
||||
"restarting_after_encryption": "暗号化後にデーモンを再起動中...",
|
||||
"restore_address": "アドレスを復元",
|
||||
@@ -1587,6 +1660,7 @@
|
||||
"sb_net_khs": "ネット: %.2f KH/s",
|
||||
"sb_net_mhs": "ネット: %.2f MH/s",
|
||||
"sb_no_conf": "DRAGONX.conf が見つかりません",
|
||||
"sb_node_busy": "ノード処理中",
|
||||
"sb_peers": "ピア: %zu",
|
||||
"sb_plaintext_remote_blocked": "リモートホストへRPC認証情報を平文で送信することを拒否しました。許可するにはDRAGONX.confにrpcallowplaintext=1を追加するか、rpctls=1でTLSを有効にしてください。",
|
||||
"sb_rescanning": "再スキャン",
|
||||
@@ -1608,6 +1682,7 @@
|
||||
"screenshot_sweep": "スクリーンショットスイープを実行",
|
||||
"screenshot_sweep_desc": "すべてのテーマをすべてのタブで巡回し、それぞれのスクリーンショットを設定ディレクトリの screenshots フォルダ内のタブごとのサブフォルダに保存します(前回のスイープを上書きします)。数秒間実行されます。",
|
||||
"screenshot_sweep_full": "UI全体スイープ",
|
||||
"screenshot_sweep_refused": "UI全体スイープは拒否されました。デバッグログを確認してください",
|
||||
"search_icons": "アイコンを検索...",
|
||||
"search_placeholder": "検索...",
|
||||
"sec_changing_passphrase": "パスフレーズを変更しています...",
|
||||
@@ -1629,15 +1704,19 @@
|
||||
"sec_mode_passphrase": " パスフレーズ",
|
||||
"sec_not_connected_to_daemon": "デーモンに接続されていません",
|
||||
"sec_not_connected_to_daemon_pin": "デーモンに接続されていません",
|
||||
"sec_passphrase_changed_pin_stale": "パスフレーズを変更しました。PIN は古くなりました",
|
||||
"sec_passphrase_changed_successfully": "パスフレーズを変更しました",
|
||||
"sec_pin_changed_successfully": "PINを変更しました",
|
||||
"sec_pin_removed": "PINを削除しました",
|
||||
"sec_pin_set_successfully": "PINを設定しました",
|
||||
"sec_pin_stale_msg": "PIN が古くなっています。パスフレーズを使用してください。",
|
||||
"sec_pin_unreadable": "PIN ファイルが読めません。パスフレーズで解除してください。",
|
||||
"sec_restart_daemon_for_encryption": "暗号化を有効にするには、デーモンを再起動してください。",
|
||||
"sec_too_many_attempts_wait": "試行回数が多すぎます。%.0f秒お待ちください...",
|
||||
"sec_total_elapsed_fmt": "合計経過時間: %d分 %02d秒",
|
||||
"sec_unlock_button": "ロック解除",
|
||||
"sec_unlock_failed_prefix": "ロック解除に失敗しました: ",
|
||||
"sec_unlock_node_busy": "The node is busy; try again in a moment.",
|
||||
"sec_unlocking_fmt": "ロック解除中%s",
|
||||
"sec_use_passphrase_instead": "代わりにパスフレーズを使用",
|
||||
"sec_use_pin_instead": "代わりにPINを使用",
|
||||
@@ -1690,6 +1769,8 @@
|
||||
"send_from_this_address": "このアドレスから送金",
|
||||
"send_go_to_receive": "受信へ移動",
|
||||
"send_keep": "保持",
|
||||
"send_mining_addr_blocked": "これはマイニング(透明)アドレスです。DragonX ではこのアドレスはマイニング報酬しか受け取れず、ネットワークはこのアドレスへの支払いを拒否します。受取人にシールドアドレス(zs で始まる)を依頼してください。",
|
||||
"send_mining_addr_short": "マイニングアドレス — 支払いを受け取れません",
|
||||
"send_network_fee": "ネットワーク手数料",
|
||||
"send_no_balance": "残高なし",
|
||||
"send_no_recent": "最近の送信なし",
|
||||
@@ -1717,7 +1798,6 @@
|
||||
"send_txid_copied": "TxIDをクリップボードにコピーしました",
|
||||
"send_txid_label": "TxID:%s",
|
||||
"send_valid_shielded": "有効なシールドアドレス",
|
||||
"send_valid_transparent": "有効な透明アドレス",
|
||||
"send_wallet_empty": "ウォレットは空です",
|
||||
"send_yes_clear": "はい、クリア",
|
||||
"sender_balance": "送信者: %.8f → %.8f DRGX",
|
||||
@@ -1780,6 +1860,7 @@
|
||||
"settings_open_log_folder": "ログフォルダを開く",
|
||||
"settings_other": "その他",
|
||||
"settings_pin_active": "PIN",
|
||||
"settings_pin_out_of_date": "PIN が古くなっています",
|
||||
"settings_privacy": "プライバシー",
|
||||
"settings_quick_unlock_pin": "クイックアンロック PIN",
|
||||
"settings_reduce_transparency": "透明度を下げる",
|
||||
@@ -1845,11 +1926,24 @@
|
||||
"shielded_type": "シールド",
|
||||
"shielding_notice": "注意:トランスペアレント (T) アドレスからプライベート (Z) アドレスへ資金をシールドします。",
|
||||
"show": "表示",
|
||||
"show_earnings": "収益を表示",
|
||||
"show_hidden": "非表示を表示 (%d)",
|
||||
"show_qr": "QRを表示",
|
||||
"show_qr_code": "QRコードを表示",
|
||||
"showing_transactions": "%d–%d / %d 件の取引を表示中(合計:%zu)",
|
||||
"showing_x_of_y": "%d / %d アドレスを表示",
|
||||
"shutdown_kill_confirm_msg": "ノードは長時間 CPU やディスクの活動がなく、停止している可能性があります。\n強制終了すると保存されていない内容はすべて失われ、次回の起動に大幅に時間がかかることがあります。",
|
||||
"shutdown_kill_confirm_title": "ノードを強制終了しますか?",
|
||||
"shutdown_kill_confirm_yes": "強制終了",
|
||||
"shutdown_kill_node": "ノードを強制終了(危険)",
|
||||
"shutdown_kill_node_tip": "ノードを直ちに終了します。保存されていない内容はすべて失われ、次回の起動時に前回の保存以降のブロックを再処理します。",
|
||||
"shutdown_leave_running": "ウォレットを閉じる — ノードは実行したまま",
|
||||
"shutdown_leave_running_tip": "ウォレットをすぐに閉じます。ノードは単独で実行を続け、処理中の作業を完了します。何も失われません。",
|
||||
"shutdown_stop_asking": "ノードに停止を要求しています…",
|
||||
"shutdown_stop_cpu_suffix": " · CPU %d%%",
|
||||
"shutdown_stop_delivered": "ノードを終了し、状態を保存しています",
|
||||
"shutdown_stop_idle": "%d 分間 CPU やディスクの活動がありません — ノードが停止している可能性があります",
|
||||
"shutdown_stop_queued": "ノードは停止要求を受け取りましたが処理中です — 可能になり次第停止します",
|
||||
"simple_background": "シンプル背景",
|
||||
"slider_off": "オフ",
|
||||
"sort_amount_high": "金額が大きい順",
|
||||
@@ -1857,9 +1951,18 @@
|
||||
"sort_date_newest": "新しい順",
|
||||
"sort_date_oldest": "古い順",
|
||||
"start_mining": "マイニング開始",
|
||||
"startup_files_preparing": "ウォレットファイルを準備しています... %d%%",
|
||||
"startup_files_verifying": "ウォレットファイルを検証しています...",
|
||||
"status": "ステータス",
|
||||
"stop_external": "外部デーモンを停止",
|
||||
"stop_mining": "マイニング停止",
|
||||
"stopconfirm_busy_body": "%s の間、ウォレットに応答できないほど処理中です。ウィットネスの再構築などの長時間の処理が原因と考えられます。今停止すると、次回の起動時にその処理をやり直すことがあります。\n\n推奨:ウォレットを閉じ、ノードは実行したままにしてください。ノードは自動的に完了します。",
|
||||
"stopconfirm_busy_title": "ノードは処理中です",
|
||||
"stopconfirm_rebuild_body": "経過時間は %s で、大きなウォレットでは約 1 時間かかることがあります。今ノードを停止すると、再構築の完了まで待つか、次回の起動時に再構築とブロックの追いつきをやり直すことになります。\n\n推奨:ウォレットを閉じ、ノードは実行したままにしてください。ノードは自動的に完了します。",
|
||||
"stopconfirm_rebuild_title": "ノードはノートウィットネスを再構築しています",
|
||||
"stopconfirm_stop_wait": "ノードを停止して待つ",
|
||||
"stopconfirm_walletop_body": "ノードは鍵のインポートまたは再スキャンのためにチェーンをスキャンしています。今停止するとスキャンが途中で終了し、まだスキャンされていない資金は 設定 → 再スキャン の後にのみ表示されます。\n\n推奨:ウォレットを閉じ、ノードは実行したままにしてください。ノードは自動的にスキャンを完了します。",
|
||||
"stopconfirm_walletop_title": "鍵のインポートまたは再スキャンを実行中です",
|
||||
"stratum_allowip_hint": "マイナーの接続を許可するIPまたはCIDR(空欄=このコンピューターのみ)",
|
||||
"stratum_expose_warn": "許可したネットワークにマイニングポートを公開します — 信頼できるLANでのみ使用してください。",
|
||||
"stratum_host": "マイニングプールをホストする(stratum)",
|
||||
@@ -1906,7 +2009,6 @@
|
||||
"switch_stopnode_title": "実行中のノードを停止しますか?",
|
||||
"switch_stopnode_warn": "このウォレットが起動していないノードがすでに実行中です。",
|
||||
"syncing": "同期中...",
|
||||
"t_address": "Tアドレス",
|
||||
"t_addresses": "Tアドレス",
|
||||
"test_connection": "テスト",
|
||||
"theme": "テーマ",
|
||||
@@ -1928,6 +2030,7 @@
|
||||
"tools": "ツール",
|
||||
"tools_actions": "ツールとアクション...",
|
||||
"tools_actions_hdr": "ツールと操作",
|
||||
"tor_never_applied_notice": "「Torを使用」オプションは一度も機能していませんでした:接続は Tor 経由でルーティングされていませんでした。このオプションはオフにしました。代替手段については、設定にあるこのオプションのツールチップをご覧ください。",
|
||||
"total": "合計",
|
||||
"total_balance_label": "総残高",
|
||||
"transaction_id": "取引ID",
|
||||
@@ -1941,8 +2044,6 @@
|
||||
"transfer_sent": "送金完了",
|
||||
"transfer_sent_desc": "送金がネットワークに送信されました。",
|
||||
"transfer_to": "送金先:",
|
||||
"transparent": "透明",
|
||||
"transparent_address": "トランスペアレントアドレス",
|
||||
"try_again": "再試行",
|
||||
"tt_addr_url": "ブロックエクスプローラーでアドレスを表示するためのベース URL",
|
||||
"tt_address_book": "クイック送信用の保存済みアドレスを管理",
|
||||
@@ -1974,10 +2075,16 @@
|
||||
"tt_debug_expand": "デバッグログオプションを展開",
|
||||
"tt_delete_blockchain": "すべてのブロックチェーンデータを削除して新規同期を開始します。wallet.dat と設定は保持されます。",
|
||||
"tt_download_bootstrap": "ブロックチェーンブートストラップをダウンロードして同期を高速化\n既存のブロックデータは置き換えられます",
|
||||
"tt_earn_interval": "収益期間:1H=今の時間、1D=今日、1M=今月、1Y=今年",
|
||||
"tt_earn_mode_inflows": "収益モード:受取額(マイニングを含む)",
|
||||
"tt_earn_mode_net": "収益モード:純額(受取から送金を差し引いた額)",
|
||||
"tt_earn_show_all": "すべてのアドレスの収益額を表示",
|
||||
"tt_earnings_format": "アドレスごとおよび合計の収益額の表示方法",
|
||||
"tt_encrypt": "パスフレーズで wallet.dat を暗号化",
|
||||
"tt_export_all": "すべての秘密鍵をファイルにエクスポート",
|
||||
"tt_export_csv": "トランザクション履歴を CSV スプレッドシートとしてエクスポート",
|
||||
"tt_export_key": "選択したアドレスの秘密鍵をエクスポート",
|
||||
"tt_favorite_opacity": "アドレス一覧のお気に入りスターボタンとその金色の枠線の不透明度(最小15%)",
|
||||
"tt_fetch_prices": "CoinGecko API から DRGX の市場価格を取得",
|
||||
"tt_font_scale": "すべてのテキストと UI をスケーリング(1.0x = デフォルト、最大 1.5x)。",
|
||||
"tt_idle_delay": "マイニング開始前の待機時間",
|
||||
@@ -1986,6 +2093,7 @@
|
||||
"tt_keep_daemon": "セットアップウィザード実行時にデーモンは停止します",
|
||||
"tt_language": "ウォレット UI のインターフェース言語",
|
||||
"tt_layout_hotkey": "ホットキー:左右矢印キーでバランスレイアウトを切り替え",
|
||||
"tt_lite_confirm_pass": "新しいパスフレーズをもう一度入力してください。一致し、8 文字以上である必要があります",
|
||||
"tt_lite_copy": "表示された秘密情報をクリップボードにコピーします",
|
||||
"tt_lite_decrypt_pass": "ウォレットの暗号化を解除するためにパスフレーズを入力します",
|
||||
"tt_lite_encrypt": "上のパスフレーズでウォレットを暗号化します。すぐにロックされ、解除にはパスフレーズが必要です",
|
||||
@@ -2014,11 +2122,16 @@
|
||||
"tt_low_spec": "すべての重い視覚効果を無効化\\nホットキー:Ctrl+Shift+Down",
|
||||
"tt_merge": "複数の UTXO を一つのアドレスに統合",
|
||||
"tt_mine_idle": "システムがアイドル状態(キーボード/マウス入力なし)\\nのとき自動的にマイニングを開始",
|
||||
"tt_mining_est_daily": "ネットワーク全体に占める現在のハッシュレート比率に基づく長期的な統計平均です。実際のマイニング報酬はランダム(ポアソン分布)に発生し、長期間ゼロが続いた後にまとまって得られることが多いため、これは安定した日次収入ではありません。",
|
||||
"tt_mining_est_daily_fee_fmt": "%.1f%% のプール手数料を差し引いた値です。",
|
||||
"tt_mining_est_daily_nofee": "プール手数料を差し引く前の値です(このプールの手数料は不明)。",
|
||||
"tt_new_mining_address": "ソロマイニング報酬用の透明(R…)アドレスを作成します。支払いは受け取れません。支払いにはシールドアドレスを共有してください。",
|
||||
"tt_noise": "グレインテクスチャ強度(0% = オフ、100% = 最大)",
|
||||
"tt_open_app_dir": "ObsidianDragon フォルダ(設定、テーマ、ログ)をファイルマネージャーで開く",
|
||||
"tt_open_data_dir": "ファイルマネージャーでウォレットとブロックチェーンデータのフォルダを開きます",
|
||||
"tt_open_dir": "クリックしてファイルエクスプローラーで開く",
|
||||
"tt_open_log_folder": "デバッグログとクラッシュログが入ったフォルダを開きます",
|
||||
"tt_pin_out_of_date": "この PIN の設定後にウォレットのパスフレーズが変更されたため、PIN ではウォレットをアンロックできません。PIN を削除して再設定してください。",
|
||||
"tt_reduce_motion": "アクセシビリティのためにアニメーション遷移と残高補間を無効にする",
|
||||
"tt_remove_encrypt": "暗号化を解除してウォレットを保護なしで保存",
|
||||
"tt_remove_pin": "PIN を削除しアンロックにパスフレーズを要求",
|
||||
@@ -2054,10 +2167,13 @@
|
||||
"tt_theme_effects": "テーマごとのシマー、グロー、色相サイクル",
|
||||
"tt_theme_hotkey": "ホットキー:Ctrl+左/右でテーマを切り替え",
|
||||
"tt_tor": "匿名性のためにデーモン接続を Tor ネットワーク経由でルーティング",
|
||||
"tt_tor_unavailable": "まだ利用できません:このウォレットはどの通信も Tor 経由でルーティングしません。ノードに限り、DRAGONX.conf に proxy=127.0.0.1:9050 を追加してノードを再起動できます。Tor が起動している必要があり、起動していないとノードはピアを見つけられません。価格、アップデート、ブートストラップ、プールのリクエストは引き続き直接接続されます。これらにはシステム全体の VPN/Tor を使用してください。",
|
||||
"tt_tor_unavailable_lite": "まだ利用できません:このウォレットはどの通信も Tor 経由でルーティングしません。システム全体の VPN/Tor を使用してください。",
|
||||
"tt_tx_url": "ブロックエクスプローラーでトランザクションを表示するためのベース URL",
|
||||
"tt_ui_opacity": "カードとサイドバーの不透明度(100% = 完全不透明、低い = より透過)",
|
||||
"tt_validate": "DragonX アドレスが有効かどうかを確認",
|
||||
"tt_verbose": "詳細な接続診断、デーモン状態、\\nポート所有者情報をコンソールタブに記録",
|
||||
"tt_view_transactions": "このアドレスに関連する取引を履歴タブに表示",
|
||||
"tt_wallet_size_warn": "シールドウォレットはノートごとのウィットネスデータによって肥大化します — 多数のノートを1つのアドレスに統合するとサイズを抑えられます。事前にバックアップしてください。",
|
||||
"tt_wallets_button": "ウォレットファイルを一覧表示して切り替えます",
|
||||
"tt_website": "DragonX ウェブサイトを開く",
|
||||
@@ -2075,6 +2191,7 @@
|
||||
"tx_loading_queued": "取引の更新を待機中",
|
||||
"tx_loading_refreshing_cached": "ウォレット履歴を更新中(%d 件キャッシュ済み)",
|
||||
"tx_loading_scanning_shielded": "シールド履歴をスキャン中 (%d アドレス)",
|
||||
"tx_loading_waits_for_sync": "ノードの同期が完了すると履歴が更新されます",
|
||||
"tx_mined": "採掘済み",
|
||||
"tx_progress_balances": "残高を更新中",
|
||||
"tx_progress_finalizing": "トランザクションを確定中",
|
||||
@@ -2106,6 +2223,7 @@
|
||||
"validate_description": "DragonXアドレスを入力して、有効かどうか、そしてこのウォレットに属しているかどうかを確認します。",
|
||||
"validate_invalid": "無効",
|
||||
"validate_is_mine": "このウォレットがこのアドレスを所有しています",
|
||||
"validate_mining_type": "マイニングアドレス(透明)",
|
||||
"validate_not_mine": "このウォレットに属していません",
|
||||
"validate_ownership": "所有者:",
|
||||
"validate_results": "結果:",
|
||||
@@ -2113,7 +2231,6 @@
|
||||
"validate_shielded_type": "シールド(zアドレス)",
|
||||
"validate_status": "ステータス:",
|
||||
"validate_title": "アドレスを検証",
|
||||
"validate_transparent_type": "透明(tアドレス)",
|
||||
"validate_type": "タイプ:",
|
||||
"validate_valid": "有効",
|
||||
"validating": "検証中...",
|
||||
@@ -2123,6 +2240,7 @@
|
||||
"view_all": "すべて表示",
|
||||
"view_details": "詳細を表示",
|
||||
"view_on_explorer": "エクスプローラーで表示",
|
||||
"view_transactions": "取引を表示",
|
||||
"waiting_for_daemon": "デーモン接続を待機中...",
|
||||
"wallet": "ウォレット",
|
||||
"wallet_degraded_notify": "ウォレットは機能制限モードで開かれました。既存の資金は安全で使用可能ですが、新しいアドレスの作成とシールドは無効になっています。ウォレットを完全に修復するには、シードフレーズをバックアップしてそこから復元してください。",
|
||||
@@ -2231,6 +2349,7 @@
|
||||
"wallets_reveal": "フォルダを開く",
|
||||
"wallets_scan_folder": "別のフォルダをスキャンしてウォレットを検索…",
|
||||
"wallets_scanned_folders": "スキャン対象フォルダー:",
|
||||
"wallets_scanning": "フォルダをスキャンしています…",
|
||||
"wallets_sort_addresses": "アドレス",
|
||||
"wallets_sort_asc": "昇順(古い/少ない/小さい順)",
|
||||
"wallets_sort_by": "並べ替え:",
|
||||
|
||||
+150
-31
@@ -25,6 +25,7 @@
|
||||
"about_website": "웹사이트",
|
||||
"acrylic": "아크릴",
|
||||
"add": "추가",
|
||||
"addr_type_mining": "채굴",
|
||||
"address": "주소",
|
||||
"address_book_add": "주소 추가",
|
||||
"address_book_add_new": "새로 추가",
|
||||
@@ -68,6 +69,7 @@
|
||||
"appx_blockchain_maintenance_in_progress": "블록체인 유지 관리 작업이 이미 진행 중입니다.",
|
||||
"appx_blockchain_rescan_complete": "블록체인 다시 스캔 완료",
|
||||
"appx_bootstrap_complete_reconciling": "부트스트랩 완료 — 새 체인 데이터와 지갑을 대조하는 중입니다.",
|
||||
"appx_bootstrap_reconcile_probe_failed": "부트스트랩 체인 데이터와 지갑을 대조하지 못했습니다. 노드가 사용 중이어서 보유한 블록을 확인할 수 없었습니다. 동기화가 따라잡으면 설정에서 재스캔을 실행하세요.",
|
||||
"appx_cancel": "취소",
|
||||
"appx_cleaning_up": "정리하는 중...",
|
||||
"appx_confirm_your_backup": "백업 확인",
|
||||
@@ -78,11 +80,15 @@
|
||||
"appx_creating_your_wallet": "지갑을 생성하는 중…",
|
||||
"appx_daemon_error": "데몬 오류",
|
||||
"appx_daemon_reinstall_in_progress": "데몬 재설치가 이미 진행 중입니다.",
|
||||
"appx_daemon_reinstall_node_busy": "노드가 아직 실행 중이라 데몬을 다시 설치하지 않았습니다. 노드가 멈춘 뒤 다시 시도하세요.",
|
||||
"appx_disconnecting": "연결 해제 중...",
|
||||
"appx_done": "완료",
|
||||
"appx_dragonxd_output": "dragonxd 출력",
|
||||
"appx_encrypting_wallet": "지갑 암호화 중...",
|
||||
"appx_fullnode_lifecycle_unavailable_lite": "풀노드 수명 주기 작업은 라이트 빌드에서 사용할 수 없습니다",
|
||||
"appx_import_cancelled": "취소됨: 노드가 아직 스캔 중일 수 있습니다. 키의 자금이 보이지 않으면 설정 → 재스캔을 실행하세요.",
|
||||
"appx_import_interrupted": "가져오기가 중단되어 키가 이미 지갑에 있을 수 있습니다. 자금이 보이지 않으면 설정 → 재스캔을 실행하세요.",
|
||||
"appx_import_refused": "노드가 요청을 거부하여 키를 가져오지 않았습니다.",
|
||||
"appx_installing_bundled_daemon": "번들 데몬을 설치하는 중 — 노드가 중지, 업데이트 후 다시 시작됩니다...",
|
||||
"appx_invalid_payment_uri_prefix": "잘못된 결제 URI: ",
|
||||
"appx_ive_written_it_down": "적어 두었습니다",
|
||||
@@ -99,7 +105,8 @@
|
||||
"appx_no_bundled_daemon_to_install": "이 빌드에는 설치할 번들 데몬이 없습니다",
|
||||
"appx_no_embedded_daemon_to_install": "이 빌드에는 설치할 내장 데몬이 없습니다",
|
||||
"appx_node_busy_restarting": "노드가 재시작 중입니다 — 잠시 후 다시 시도하세요.",
|
||||
"appx_node_rebuilding_witness_cache": "노드가 위트니스 캐시를 재구성하는 중입니다",
|
||||
"appx_node_busy_try_later": "노드가 긴 작업(노트 위트니스 재구성 등)을 마무리하는 중입니다. 끝난 뒤 다시 시도하세요. 지금 중지하면 그 작업을 처음부터 다시 할 수 있습니다.",
|
||||
"appx_node_not_stopped": "노드가 제때 중지되지 않아 아무것도 변경되지 않았습니다. 몇 분 후 다시 시도하세요.",
|
||||
"appx_not_next_word": " — 다음 단어가 아닙니다",
|
||||
"appx_payment_request_loaded": "결제 요청을 불러왔습니다",
|
||||
"appx_pool_miner_connected_and_hashing": "풀 채굴기가 연결되어 해싱 중입니다.",
|
||||
@@ -120,11 +127,7 @@
|
||||
"appx_simple_background_enabled": "단순 배경 활성화됨",
|
||||
"appx_skip": "건너뛰기",
|
||||
"appx_skip_anyway": "그래도 건너뛰기",
|
||||
"appx_still_status_prefix": "아직 \"",
|
||||
"appx_still_status_suffix": "\" 상태입니다 — 지금 강제 종료하면 체인 데이터가 손상될 수 있습니다.",
|
||||
"appx_stop_anyway_and_quit": "그래도 중지하고 종료",
|
||||
"appx_stopping_daemon_deleting_blockchain": "데몬을 중지하고 블록체인 데이터를 삭제하는 중...",
|
||||
"appx_stopping_node_discards_rebuild": "지금 노드를 중지하면 진행 중인 재구성이 취소되며, 다음에 지갑을 열 때 다시 시작됩니다(몇 분 소요). 대신 노드를 계속 실행할 수 있습니다.",
|
||||
"appx_stopping_pool_miner": "풀 채굴기를 중지하는 중...",
|
||||
"appx_syncing_pct_block_n_of_n": "동기화 중 %.1f%% — 블록 %d / %d",
|
||||
"appx_tap_words_in_order": "저장한 내용을 확인하도록 단어를 올바른 순서대로 탭하세요.",
|
||||
@@ -134,6 +137,9 @@
|
||||
"appx_use_settings_restart_daemon_hint": "설정 > 데몬 다시 시작을 사용해 다시 시도하세요",
|
||||
"appx_waiting_for_daemon_to_encrypt_wallet": "데몬이 지갑을 암호화하기를 기다리는 중...",
|
||||
"appx_wallet_created_and_backed_up": "지갑이 생성되고 백업되었습니다.",
|
||||
"appx_wallet_op_node_busy": "노드가 바빠서 응답하지 않습니다. 끝난 뒤 가져오기 또는 재스캔을 다시 시도하세요.",
|
||||
"appx_wallet_op_running": "키 가져오기 또는 재스캔이 아직 실행 중입니다. 끝난 뒤 다시 시도하세요.",
|
||||
"appx_wallet_op_shutting_down": "지갑을 종료하는 중입니다.",
|
||||
"appx_wallet_open_failed_prefix": "지갑 열기 실패: ",
|
||||
"auto_shield": "채굴 자동 차폐",
|
||||
"autoshield_by_node": "노드가 채굴한 코인베이스를 자동으로 보호 처리합니다.",
|
||||
@@ -174,21 +180,20 @@
|
||||
"balance_history_collecting": "잔액 내역 — 데이터 수집 중...",
|
||||
"balance_layout": "잔액 레이아웃",
|
||||
"balance_layout_switched": "레이아웃: %s",
|
||||
"balance_mining_fmt": "채굴 보상: %.8f",
|
||||
"balance_mining_rate": "채굴 중 %s",
|
||||
"balance_shielded_fmt": "차폐: %.8f",
|
||||
"balance_syncing_pct": "동기화 중 %.1f%%",
|
||||
"balance_transparent_fmt": "투명: %.8f",
|
||||
"baltab_market": "시세",
|
||||
"baltab_market_price_4dp": "시세: $%.4f",
|
||||
"baltab_market_price_8dp": "시세: $%.8f",
|
||||
"baltab_mining_addresses_count": "채굴 주소 %d개",
|
||||
"baltab_mining_amount": "채굴 보상 %.8f",
|
||||
"baltab_pct_change_24h": "%s%.1f%% 24시간",
|
||||
"baltab_pct_of_total_zaddr": "전체의 %.0f%% · Z-addr %d개",
|
||||
"baltab_shielded": "실드됨",
|
||||
"baltab_shielded_amount": "실드됨 %.8f",
|
||||
"baltab_t_addresses_count": "T-address %d개",
|
||||
"baltab_total_balance": "총 잔액",
|
||||
"baltab_transparent": "투명",
|
||||
"baltab_transparent_amount": "투명 %.8f",
|
||||
"ban": "차단",
|
||||
"banned_peers": "차단된 피어",
|
||||
"block": "블록",
|
||||
@@ -238,6 +243,7 @@
|
||||
"change_pass_confirm": "새 암호 확인:",
|
||||
"change_pass_current": "현재 암호:",
|
||||
"change_pass_new": "새 암호:",
|
||||
"change_pass_pin_note": "이 지갑에는 빠른 잠금 해제 PIN이 있습니다. 변경 후에는 PIN이 오래된 상태가 되므로 설정에서 PIN을 제거한 뒤 다시 설정하세요.",
|
||||
"change_pass_title": "암호 변경",
|
||||
"characters": "문자",
|
||||
"chat": "채팅",
|
||||
@@ -513,7 +519,7 @@
|
||||
"contact_avatar_image_hint": "이미지는 앱에 복사되어 원본이 이동해도 계속 사용할 수 있습니다.",
|
||||
"contact_avatar_remove": "제거",
|
||||
"contact_avatar_shielded": "보호",
|
||||
"contact_avatar_transparent": "투명",
|
||||
"contact_chip_mining": "채굴 전용",
|
||||
"contact_global": "모든 지갑에 표시(전역 연락처)",
|
||||
"contact_global_badge_tt": "전역 연락처 — 모든 지갑에서 표시됨",
|
||||
"contact_global_tt": "켜짐: 어떤 지갑을 불러오든 이 연락처가 계속 표시됩니다. 꺼짐: 현재 지갑에만 속합니다.",
|
||||
@@ -633,6 +639,11 @@
|
||||
"download": "다운로드",
|
||||
"download_bootstrap": "부트스트랩 다운로드",
|
||||
"dragonx_green": "DragonX(그린)",
|
||||
"earnings_format": "수익 표시 형식",
|
||||
"earnings_format_coin": "코인만",
|
||||
"earnings_format_coin_fiat": "코인 + 법정화폐",
|
||||
"earnings_format_fiat": "법정화폐만",
|
||||
"earnings_label": "수익",
|
||||
"edit": "편집",
|
||||
"empty_wallet_keys_suffix": "개 키",
|
||||
"empty_wallet_open_manager": "지갑 관리자 열기",
|
||||
@@ -709,8 +720,8 @@
|
||||
"export_tx_success": "거래 내보내기 성공",
|
||||
"export_tx_title": "거래를 CSV로 내보내기",
|
||||
"export_viewing_key": "조회 키 내보내기",
|
||||
"failed_create_mining_address": "채굴 주소를 만들지 못했습니다",
|
||||
"failed_create_shielded": "차폐 주소 생성 실패",
|
||||
"failed_create_transparent": "투명 주소 생성 실패",
|
||||
"faq": "FAQ",
|
||||
"faq_d_mgmt_1_a": "내장 노드는 지갑과 함께 자동으로 시작되고 적절한 시점에 중지되므로, 보통 직접 관리할 필요가 없습니다. 고급 제어 기능(데몬 재시작 포함)은 설정 > 노드 및 보안에 있습니다.",
|
||||
"faq_d_mgmt_1_q": "노드를 시작하거나 중지하려면 어떻게 하나요?",
|
||||
@@ -788,13 +799,13 @@
|
||||
"faq_open_tooltip": "도움말 및 FAQ",
|
||||
"faq_search_hint": "FAQ 검색…",
|
||||
"faq_title": "도움말 및 FAQ",
|
||||
"faq_w_addr_1_a": "투명 주소(R 또는 t로 시작)는 대부분의 코인처럼 작동합니다. 금액과 주소가 블록체인에 공개됩니다.\n\n보호 주소(zs로 시작하는 z-주소)는 영지식 암호화를 사용합니다 — 금액, 발신자, 수신자가 체인상에서 암호화됩니다. 보호 주소에 보관된 잔액은 비공개입니다.",
|
||||
"faq_w_addr_1_a": "차폐 주소(zs로 시작하는 z 주소)는 DragonX에서 사용하는 주소입니다. 영지식 암호를 사용하므로 금액, 보낸 사람, 받는 사람이 체인에서 암호화되고 잔액이 비공개로 유지됩니다.\n\n투명 주소(R로 시작)는 채굴 주소입니다. DragonX는 완전한 프라이빗 체인이므로 이 주소는 채굴 보상만 받을 수 있으며, 네트워크가 이 주소로 보내는 모든 결제를 거부합니다. 그곳에 들어온 보상은 차폐되기 전까지 공개되며, 차폐는 지갑이 자동으로 처리합니다.",
|
||||
"faq_w_addr_1_q": "투명 주소와 보호 주소의 차이는 무엇인가요?",
|
||||
"faq_w_addr_2_a": "가능하면 항상 보호(z) 주소를 사용하세요 — 잔액과 결제 내역이 비공개로 유지됩니다. DragonX는 프라이버시 코인이며, 수신 자금의 기본값은 보호 주소입니다.\n\n상대방 서비스가 보호 주소를 처리할 수 없는 경우에만 투명 주소를 사용하세요.",
|
||||
"faq_w_addr_2_a": "보내고 받는 모든 거래에 차폐(zs) 주소를 사용하세요. DragonX에서는 이 주소만 결제를 받을 수 있습니다.\n\n투명(채굴) 주소는 솔로 채굴 보상 전용입니다. 결제용으로 절대 알려주지 마세요. 네트워크가 거부합니다.",
|
||||
"faq_w_addr_2_q": "어떤 주소 유형을 사용해야 하나요?",
|
||||
"faq_w_addr_3_a": "보호 주소는 재사용해도 괜찮으며 내역이 노출되지 않습니다. 투명 주소는 결제마다 새 주소를 사용하는 것이 프라이버시에 좋습니다. 수신 탭에서 새 주소를 만들 수 있습니다.",
|
||||
"faq_w_addr_3_a": "차폐 주소는 재사용해도 괜찮으며 기록이 노출되지 않습니다. 그래도 받기 탭에서 새 주소를 만들 수 있습니다. 예를 들어 연락처마다 하나씩 만들면 누가 보냈는지 알 수 있습니다. 채굴 주소는 채굴 보상 전용이라 새로 만들 일은 거의 없습니다.",
|
||||
"faq_w_addr_3_q": "주소를 재사용해야 하나요, 아니면 새로 만들어야 하나요?",
|
||||
"faq_w_addr_4_a": "보호(shielding)는 코인을 투명 주소에서 보호 주소로 옮겨 비공개로 만드는 것입니다. 새로 채굴된 코인은 투명 상태로 도착하며 자동으로 보호 처리됩니다.\n\n지갑에서 수동으로 보호 처리할 수도 있습니다. 보호는 일반 트랜잭션의 일종이므로 완료까지 일반적인 확인 시간이 걸립니다.",
|
||||
"faq_w_addr_4_a": "차폐는 투명(채굴) 주소의 코인을 차폐 주소로 옮겨 비공개로 만드는 것입니다. 새로 채굴된 코인은 채굴 주소로 들어오며, 성숙하면 자동으로 차폐됩니다.\n\n지갑에서 직접 차폐할 수도 있습니다. 차폐는 특별한 트랜잭션일 뿐이라 일반적인 확인 시간이 걸립니다.",
|
||||
"faq_w_addr_4_q": "'보호(shielding)'란 무엇인가요?",
|
||||
"faq_w_addr_title": "주소 및 프라이버시",
|
||||
"faq_w_bal_1_a": "지갑이 아직 동기화 중일 때는 잔액이 불완전하며 0으로 표시될 수 있습니다 — 아직 모든 트랜잭션을 스캔하지 않았기 때문입니다. 동기화가 끝나면 채워집니다.\n\n하단 상태 표시줄을 확인하세요. 연결 상태와 블록 높이가 표시됩니다. 동기화가 완료되면 잔액이 정확해집니다.",
|
||||
@@ -838,11 +849,11 @@
|
||||
"faq_w_seed_4_a": "시드 문구는 기본 백업이며 모든 것을 복원하기에 충분합니다. 지갑 파일 사본은 라벨과 설정까지 보존하는 편리한 보조 백업입니다. 모든 백업은 오프라인에 비공개로 보관하세요.",
|
||||
"faq_w_seed_4_q": "wallet.dat도 백업해야 하나요?",
|
||||
"faq_w_seed_title": "시드 문구 및 백업",
|
||||
"faq_w_send_1_a": "전송 탭을 열고 받는 사람의 주소를 붙여넣은 다음 금액을 입력하고 확인하세요. 지갑이 암호화되어 있다면 트랜잭션을 위해 잠금 해제를 요청받습니다.\n\n보호 주소로의 전송은 비공개이며, 투명 주소로의 전송은 공개됩니다.",
|
||||
"faq_w_send_1_a": "전송 탭을 열고 받는 사람의 주소를 붙여넣은 다음 금액을 입력하고 확인하세요. 지갑이 암호화되어 있다면 트랜잭션을 위해 잠금 해제를 요청받습니다.\n\n결제는 차폐(zs) 주소로 보내지며 비공개로 유지됩니다. 지갑은 투명(채굴) 주소로는 보내지 않습니다. 네트워크가 그런 주소로의 결제를 거부하기 때문입니다.",
|
||||
"faq_w_send_1_q": "자금은 어떻게 보내나요?",
|
||||
"faq_w_send_2_a": "DragonX 수수료는 매우 낮으며 자동으로 설정됩니다. 확인 전에 수수료가 표시되며, 보통 변경할 필요는 없습니다.",
|
||||
"faq_w_send_2_q": "어떤 수수료를 내나요?",
|
||||
"faq_w_send_3_a": "네 — 보호(z) 주소로 전송할 때 암호화된 메모를 첨부할 수 있습니다. 받는 사람만 읽을 수 있습니다. 투명 주소는 메모를 지원하지 않습니다.",
|
||||
"faq_w_send_3_a": "네 — 보호(z) 주소로 전송할 때 암호화된 메모를 첨부할 수 있습니다. 받는 사람만 읽을 수 있습니다.",
|
||||
"faq_w_send_3_q": "결제에 메시지를 첨부할 수 있나요?",
|
||||
"faq_w_send_4_a": "트랜잭션은 블록에 채굴되어 확인을 얻기 전까지 '대기 중'입니다. 보통 1~2분 정도 걸립니다. 보호 트랜잭션은 증명을 생성하기 위해 지갑이 동기화되어 있어야 합니다.\n\n전송이 유난히 오래 대기 중이라면 지갑이 연결되고 동기화되어 있는지 확인하세요(창 하단의 상태 표시줄 참고).",
|
||||
"faq_w_send_4_q": "결제를 보냈는데 대기 중이라고 나와요 — 왜 그런가요?",
|
||||
@@ -857,6 +868,7 @@
|
||||
"faq_w_set_4_q": "법정화폐 가격을 어떻게 표시하나요?",
|
||||
"faq_w_set_title": "설정 및 화면",
|
||||
"favorite_address": "즐겨찾기에 추가",
|
||||
"favorite_opacity": "즐겨찾기 강조 불투명도",
|
||||
"fee": "수수료",
|
||||
"fee_high": "높음",
|
||||
"fee_label": "수수료:",
|
||||
@@ -864,14 +876,9 @@
|
||||
"fee_normal": "보통",
|
||||
"fetch_prices": "가격 조회",
|
||||
"file": "파일",
|
||||
"file_save_location": "파일 저장 위치: ~/.config/ObsidianDragon/",
|
||||
"file_save_location": "파일 저장 위치:",
|
||||
"filter": "필터...",
|
||||
"font_scale": "글꼴 크기",
|
||||
"force_quit": "강제 종료",
|
||||
"force_quit_confirm_msg": "정상 종료 없이 데몬을 즉시 종료합니다.\n블록체인 인덱스가 손상되어 재동기화가 필요할 수 있습니다.",
|
||||
"force_quit_confirm_title": "강제 종료하시겠습니까?",
|
||||
"force_quit_warning": "정상 종료 없이 데몬을 즉시 종료합니다. 블록체인 재동기화가 필요할 수 있습니다.",
|
||||
"force_quit_yes": "강제 종료",
|
||||
"from": "보낸 곳",
|
||||
"from_upper": "보낸 곳",
|
||||
"full_details": "전체 세부 정보",
|
||||
@@ -940,6 +947,7 @@
|
||||
"hidden_tag": " (숨김)",
|
||||
"hide": "숨기기",
|
||||
"hide_address": "주소 숨기기",
|
||||
"hide_earnings": "수익 숨기기",
|
||||
"hide_qr": "QR 숨기기",
|
||||
"hide_zero_balances": "잔액 0 숨기기",
|
||||
"history": "내역",
|
||||
@@ -1014,11 +1022,13 @@
|
||||
"lite_birthday_backup": "생성 블록: %llu (이 값도 백업하세요)",
|
||||
"lite_birthday_hint": "스캔을 시작할 블록 높이입니다. 모르면 0으로 두세요(전체 스캔이 느려짐).",
|
||||
"lite_birthday_label": "생일 블록",
|
||||
"lite_confirm_label": "확인",
|
||||
"lite_console_backend_commands": "백엔드 명령:",
|
||||
"lite_console_help_passthrough": "그 외 입력은 라이트 지갑 콘솔 명령으로 실행됩니다.",
|
||||
"lite_copy": "복사",
|
||||
"lite_could_not_start": "작업을 시작할 수 없습니다",
|
||||
"lite_could_not_write": "쓸 수 없습니다: ",
|
||||
"lite_encrypt_offline_warning": "지갑 파일을 가진 사람은 누구나 오프라인으로 암호를 시도할 수 있습니다. 먼저 시드 문구를 백업하고 긴 암호를 사용하세요.",
|
||||
"lite_encrypt_wallet": "지갑 암호화",
|
||||
"lite_encryption_removed": "암호화가 제거되었습니다",
|
||||
"lite_enter_all_seed_words": "복구하려면 24개의 시드 단어를 모두 입력하세요 (현재 %d개)",
|
||||
@@ -1026,6 +1036,7 @@
|
||||
"lite_hide_wipe": "숨기고 삭제",
|
||||
"lite_import": "가져오기",
|
||||
"lite_import_key_label": "키 가져오기",
|
||||
"lite_key_import_failed": "키를 가져오지 못했습니다. 키를 확인하고 지갑 잠금을 해제한 후(암호화된 경우) 다시 시도하세요.",
|
||||
"lite_key_imported": "키를 가져왔습니다 — 동기화를 실행하여 기록을 스캔하세요",
|
||||
"lite_lock_failed": "잠금 실패",
|
||||
"lite_lock_now": "지금 잠그기",
|
||||
@@ -1170,9 +1181,43 @@
|
||||
"merge_description": "여러 UTXO를 단일 차폐 주소로 통합합니다. 지갑 크기를 줄이고 프라이버시를 향상시킵니다.",
|
||||
"merge_fee_hint": "이 거래에 대한 네트워크 수수료입니다.",
|
||||
"merge_funds": "자금 통합",
|
||||
"merge_into_one": "한 주소로",
|
||||
"merge_keep_each": "각 주소에 유지",
|
||||
"merge_keep_each_batches_fmt": "노트가 %d개를 넘는 주소는 여러 번에 나눠 병합됩니다. 확인된 후 다시 실행하면 나머지가 병합됩니다.",
|
||||
"merge_keep_each_confirm_fmt": "노트 %d개를 주소 %d개에서 통합할까요? 각 주소는 자기 자금을 그대로 유지합니다. 수수료: 약 %s DRGX(배치마다 1회).",
|
||||
"merge_keep_each_desc": "각 주소의 노트는 같은 주소로 다시 병합되므로, 다른 라벨로 나눠 둔 자금은 계속 나뉜 상태로 유지됩니다. 차폐 주소만 해당되며, 투명 자금은 차폐만 할 수 있습니다(한 주소로).",
|
||||
"merge_keep_each_none": "모든 차폐 주소에 노트가 이미 하나뿐입니다. 통합할 것이 없습니다.",
|
||||
"merge_keep_each_nothing_submitted": "아무것도 통합되지 않았습니다. 노드가 모든 병합을 거부했습니다.",
|
||||
"merge_keep_each_row_fmt": "%s · 노트 %d개 · %s DRGX",
|
||||
"merge_keep_each_run_again": "여러 번에 나눠 병합된 주소에는 배치마다 노트가 하나씩 남아 있습니다. 확인된 후 다시 실행하면 하나로 병합됩니다.",
|
||||
"merge_keep_each_select": "통합할 주소를 하나 이상 선택하세요.",
|
||||
"merge_keep_each_some_failed": "일부 병합에 실패했습니다. 위 목록을 확인하세요.",
|
||||
"merge_keep_each_status_done": "%s — 완료: 노트 %d개를 %d번에 걸쳐 병합",
|
||||
"merge_keep_each_status_failed": "%s — 실패: %s",
|
||||
"merge_keep_each_status_not_started": "%s — 시작되지 않음: %s",
|
||||
"merge_keep_each_status_running": "%s — 노트 %d개 병합 중(%d/%d 배치 완료)…",
|
||||
"merge_keep_each_status_unconfirmed": "%s — 상태를 확인할 수 없음",
|
||||
"merge_keep_each_too_small": "너무 적어서 통합할 수 없습니다. 수수료가 이 자금을 모두 소모합니다.",
|
||||
"merge_max_inputs": "배치당 최대 입력 수",
|
||||
"merge_no_spendable": "아직 통합할 사용 가능한 입력이 없습니다.",
|
||||
"merge_progress": "통합 중… 몇 분 정도 걸릴 수 있습니다. 이 창을 닫아도 됩니다.",
|
||||
"merge_run_aborted_toast": "노드 연결이 재설정되어 통합이 중지되었습니다. 계속하려면 다시 실행하세요.",
|
||||
"merge_run_busy": "통합이 진행 중입니다. 완료될 때까지 기다리거나 먼저 중지하세요.",
|
||||
"merge_run_chip_fmt": "통합 중 %d/%d",
|
||||
"merge_run_err_timeout": "노드가 제때 응답하지 않았습니다. 배치가 아직 대기열에 있을 수 있습니다.",
|
||||
"merge_run_halted_fmt": "반복된 실패로 통합이 중지되었습니다: %s",
|
||||
"merge_run_migration_pending": "통합하기 전에 시드 마이그레이션을 완료하거나 취소하세요.",
|
||||
"merge_run_progress_fmt": "통합 중… 배치 %d(약 %d개 중), 지금까지 노트 %d개 병합됨. 이 창을 닫아도 백그라운드에서 계속 진행됩니다.",
|
||||
"merge_run_start_failed": "통합을 시작할 수 없습니다 — 노드가 연결되어 있지 않습니다.",
|
||||
"merge_run_status_not_run": "%s — 통합 안 됨(노트 %d개)",
|
||||
"merge_run_status_waiting": "%s — 대기 중(노트 %d개)",
|
||||
"merge_run_stop": "중지",
|
||||
"merge_run_stopped_toast": "통합이 중지되었습니다. 나머지를 병합하려면 다시 실행하세요.",
|
||||
"merge_run_stopping": "이미 보낸 배치가 끝나면 중지합니다…",
|
||||
"merge_run_wait_batch": "노드가 응답성을 유지하도록 배치 간격을 두는 중…",
|
||||
"merge_run_wait_send": "보류 중인 거래가 완료될 때까지 일시 중지됩니다.",
|
||||
"merge_run_wait_sync": "일시 중지됨: 노드가 아직 동기화 중입니다.",
|
||||
"merge_scope_failed_fmt": "입력을 읽을 수 없습니다: %s",
|
||||
"merge_scope_fmt": "투명 입력 %d개 + 보호 입력 %d개 · 사용 가능 약 %s DRGX",
|
||||
"merge_scope_loading": "입력을 확인하는 중…",
|
||||
"merge_send_failed": "통합 실패: ",
|
||||
@@ -1181,7 +1226,11 @@
|
||||
"merge_src_shielded": "보호",
|
||||
"merge_src_transparent": "투명",
|
||||
"merge_started": "통합 작업이 시작되었습니다",
|
||||
"merge_submit_uncertain": "연결이 끊기기 전에 노드가 통합을 확인하지 않았습니다. 그래도 처리될 수 있습니다 — 다시 연결한 후 다시 통합하기 전에 내역을 확인하세요.",
|
||||
"merge_title": "주소로 통합",
|
||||
"merge_wait_locked": "지갑이 잠겨 있습니다. 계속하려면 잠금을 해제하세요.",
|
||||
"merge_waiting_node": "노드를 기다리는 중…",
|
||||
"merge_where": "자금을 어디로 보낼까요?",
|
||||
"mig_adopt": "이 지갑을 내 지갑으로 사용",
|
||||
"mig_adopt_now": "시드 지갑 사용",
|
||||
"mig_adopting": "새 지갑을 설치하고 재스캔하는 중…",
|
||||
@@ -1233,6 +1282,8 @@
|
||||
"miner_fee": "채굴 수수료",
|
||||
"mining": "채굴",
|
||||
"mining_active": "활성",
|
||||
"mining_addr_explainer": "솔로 채굴 보상만 받습니다. 네트워크가 이 주소로의 결제를 거부합니다. 보상은 성숙하면 자동으로 차폐됩니다.",
|
||||
"mining_address": "채굴 주소",
|
||||
"mining_address_copied": "채굴 주소가 복사되었습니다",
|
||||
"mining_all_time": "전체 기간",
|
||||
"mining_already_saved": "풀 URL이 이미 저장되어 있습니다",
|
||||
@@ -1246,6 +1297,8 @@
|
||||
"mining_benchmark_testing": "테스트 중",
|
||||
"mining_benchmark_tooltip": "이 CPU에 최적의 스레드 수 찾기",
|
||||
"mining_block_copied": "블록 해시가 복사되었습니다",
|
||||
"mining_card_click_to_shield": "클릭하면 성숙한 보상을 지금 차폐합니다.",
|
||||
"mining_card_tooltip": "솔로 채굴 보상은 채굴(투명, R…) 주소로 들어옵니다. 이 주소는 결제를 받을 수 없습니다. 보상은 성숙하면 자동으로 차폐됩니다.",
|
||||
"mining_chart_1m_ago": "1분 전",
|
||||
"mining_chart_5m_ago": "5분 전",
|
||||
"mining_chart_now": "현재",
|
||||
@@ -1261,6 +1314,8 @@
|
||||
"mining_est_block": "예상 블록",
|
||||
"mining_est_daily": "예상 일일 수익",
|
||||
"mining_est_daily_pool_sub": "대략적인 솔로 환산, 풀 수수료 전",
|
||||
"mining_est_daily_sub": "평균",
|
||||
"mining_est_daily_sub_net": "평균, 순",
|
||||
"mining_filter_all": "전체",
|
||||
"mining_filter_tip_all": "모든 수익 표시",
|
||||
"mining_filter_tip_pool": "풀 수익만 표시",
|
||||
@@ -1290,6 +1345,7 @@
|
||||
"mining_payout_address": "지급 주소",
|
||||
"mining_payout_foreign": "⚠ 이 지급 주소는 현재 지갑에 없습니다 — 채굴한 보상이 다른 지갑으로 전송됩니다. 지갑을 전환했다면 주소를 업데이트하세요.",
|
||||
"mining_payout_invalid": "유효한 DragonX 주소가 아닙니다 — 시작하기 전에 수정하세요. 그렇지 않으면 채굴 보상이 사라집니다.",
|
||||
"mining_payout_needs_shielded": "풀은 일반 트랜잭션으로 지급하는데, DragonX에서는 이것이 채굴(투명, R…) 주소에 도달할 수 없습니다. 지급 주소로 차폐 주소(zs로 시작)를 사용하세요.",
|
||||
"mining_payout_tooltip": "채굴 보상 수신 주소",
|
||||
"mining_pool": "풀",
|
||||
"mining_pool_fee": "수수료",
|
||||
@@ -1302,6 +1358,8 @@
|
||||
"mining_refresh": "새로고침",
|
||||
"mining_remove": "제거",
|
||||
"mining_reset_defaults": "기본값으로 재설정",
|
||||
"mining_rewards": "채굴 보상",
|
||||
"mining_rewards_unshielded": "아직 차폐되지 않음",
|
||||
"mining_save_payout_address": "지급 주소 저장",
|
||||
"mining_save_pool_url": "풀 URL 저장",
|
||||
"mining_saved_addresses": "저장된 주소:",
|
||||
@@ -1335,10 +1393,10 @@
|
||||
"network_fee": "네트워크 수수료",
|
||||
"network_hashrate": "네트워크 해시레이트",
|
||||
"new": "+ 새로 만들기",
|
||||
"new_mining_address": "새 채굴 주소",
|
||||
"new_mining_address_created": "새 채굴 주소를 만들었습니다",
|
||||
"new_shielded_created": "새 차폐 주소가 생성되었습니다",
|
||||
"new_t_address": "새 T 주소",
|
||||
"new_t_transparent": "새 t 주소 (투명)",
|
||||
"new_transparent_created": "새 투명 주소가 생성되었습니다",
|
||||
"new_z_address": "새 Z 주소",
|
||||
"new_z_shielded": "새 z 주소 (차폐)",
|
||||
"no_addresses": "주소가 없습니다. 위의 버튼을 사용하여 생성하세요.",
|
||||
@@ -1358,7 +1416,19 @@
|
||||
"node_banner_offline_title": "DragonX 노드에 연결되지 않음",
|
||||
"node_banner_reconnect": "재연결",
|
||||
"node_banner_restart": "노드 재시작",
|
||||
"node_busy_rebuild_desc": "%s 동안 진행 중입니다. 큰 지갑은 약 1시간이 걸릴 수 있으며, 끝날 때까지 노드가 지갑에 응답할 수 없습니다.",
|
||||
"node_busy_rebuild_extending": "위트니스 확장 중(이 단계에서는 노드가 진행 상황을 보고하지 않습니다).",
|
||||
"node_busy_rebuild_reading": "블록 읽는 중: %ld / %ld",
|
||||
"node_busy_rebuild_title": "노드 작업 중: 노트 위트니스 재구성 중",
|
||||
"node_busy_replay_desc": "%s 동안 진행 중입니다. 노드가 마지막으로 멈추기 전에 받은 블록을 다시 적용하고 있습니다.",
|
||||
"node_busy_replay_title": "노드 작업 중: 블록 따라잡는 중",
|
||||
"node_busy_rpc_desc": "노드는 실행 중이지만 %s 동안 지갑에 응답할 수 없을 만큼 바쁩니다. 보통 위트니스 재구성 같은 긴 작업 때문입니다.",
|
||||
"node_busy_rpc_title": "노드 작업 중: 아직 응답하지 않음",
|
||||
"node_busy_running": "%s 동안 진행 중",
|
||||
"node_security": "노드 및 보안",
|
||||
"node_starting_busy_desc": "노드에 연결할 수 있지만 바빠서 아직 응답하지 않습니다. 업데이트 후나 처음 실행할 때는 정상이며, 몇 분 걸릴 수 있습니다.",
|
||||
"node_starting_launch_desc": "dragonxd를 시작하고 온라인 상태가 되기를 기다리는 중…",
|
||||
"node_starting_title": "DragonX 노드 시작 중…",
|
||||
"noise": "노이즈",
|
||||
"not_connected": "데몬에 연결되지 않음...",
|
||||
"not_connected_to_daemon": "데몬에 연결되지 않음",
|
||||
@@ -1511,6 +1581,8 @@
|
||||
"ram_wallet_gb": "지갑: %.1f GB",
|
||||
"ram_wallet_mb": "지갑: %.0f MB",
|
||||
"receive": "수신",
|
||||
"receive_not_for_payments": "결제용이 아님",
|
||||
"receive_use_shielded": "차폐 주소 사용",
|
||||
"received": "수신됨",
|
||||
"received_filter": "수신됨",
|
||||
"received_label": "수신됨",
|
||||
@@ -1525,6 +1597,7 @@
|
||||
"reduce_motion": "모션 줄이기",
|
||||
"refresh": "새로고침",
|
||||
"refresh_now": "지금 새로고침",
|
||||
"refresh_waits_for_sync": "노드가 아직 동기화 중입니다. 동기화가 끝나면 지갑 데이터가 새로고침됩니다.",
|
||||
"remove_favorite": "즐겨찾기 제거",
|
||||
"repair_wallet": "지갑 복구",
|
||||
"report_bug": "버그 신고",
|
||||
@@ -1539,12 +1612,12 @@
|
||||
"request_select_address": "주소 선택...",
|
||||
"request_shielded_addrs": "-- 차폐 주소 --",
|
||||
"request_title": "결제 요청",
|
||||
"request_transparent_addrs": "-- 투명 주소 --",
|
||||
"request_uri_copied": "결제 URI가 클립보드에 복사되었습니다",
|
||||
"rescan": "재스캔",
|
||||
"rescan_bootstrapped_msg": "노드가 부트스트랩되어 스냅샷 이전 블록은 디스크에 없으므로 제네시스부터의 재스캔은 실패합니다. 지갑의 사용된 잔액을 조정하려면 스냅샷에 포함된 높이부터 재스캔하세요. wallet.dat과 체인 데이터는 삭제되지 않습니다.",
|
||||
"rescan_detecting": "노드가 디스크에 어떤 블록을 가지고 있는지 확인하는 중…",
|
||||
"rescan_from_height": "재스캔 시작 블록 높이:",
|
||||
"rescan_probe_failed": "노드가 디스크에 어떤 블록을 가지고 있는지 확인하지 못했습니다(노드가 사용 중이거나 요청 시간이 초과되었습니다). 이 노드가 부트스트랩된 경우 취소하고 1분 후에 다시 시도하세요. 부트스트랩된 노드에서는 아래의 -rescan 재시작이 실패합니다.",
|
||||
"reset_to_defaults": "기본값으로 재설정",
|
||||
"restarting_after_encryption": "암호화 후 데몬 재시작 중...",
|
||||
"restore_address": "주소 복원",
|
||||
@@ -1587,6 +1660,7 @@
|
||||
"sb_net_khs": "네트: %.2f KH/s",
|
||||
"sb_net_mhs": "네트: %.2f MH/s",
|
||||
"sb_no_conf": "DRAGONX.conf를 찾을 수 없음",
|
||||
"sb_node_busy": "노드 작업 중",
|
||||
"sb_peers": "피어: %zu",
|
||||
"sb_plaintext_remote_blocked": "원격 호스트로 RPC 자격 증명을 평문으로 보내는 것을 거부했습니다. 허용하려면 DRAGONX.conf에 rpcallowplaintext=1을 추가하거나 rpctls=1로 TLS를 활성화하세요.",
|
||||
"sb_rescanning": "재스캔",
|
||||
@@ -1608,6 +1682,7 @@
|
||||
"screenshot_sweep": "스크린샷 스윕 실행",
|
||||
"screenshot_sweep_desc": "모든 탭에 대해 모든 테마를 순회하며 각각의 스크린샷을 설정 디렉터리의 screenshots 폴더 아래 탭별 하위 폴더에 저장합니다(이전 스윕을 덮어씀). 몇 초 동안 실행됩니다.",
|
||||
"screenshot_sweep_full": "전체 UI 스윕",
|
||||
"screenshot_sweep_refused": "전체 UI 스윕이 거부되었습니다. 디버그 로그를 확인하세요",
|
||||
"search_icons": "아이콘 검색...",
|
||||
"search_placeholder": "검색...",
|
||||
"sec_changing_passphrase": "암호문 변경 중...",
|
||||
@@ -1629,15 +1704,19 @@
|
||||
"sec_mode_passphrase": " 암호문",
|
||||
"sec_not_connected_to_daemon": "데몬에 연결되지 않음",
|
||||
"sec_not_connected_to_daemon_pin": "데몬에 연결되지 않음",
|
||||
"sec_passphrase_changed_pin_stale": "암호문이 변경되었습니다. PIN이 오래되었습니다",
|
||||
"sec_passphrase_changed_successfully": "암호문이 성공적으로 변경되었습니다",
|
||||
"sec_pin_changed_successfully": "PIN이 성공적으로 변경되었습니다",
|
||||
"sec_pin_removed": "PIN이 제거되었습니다",
|
||||
"sec_pin_set_successfully": "PIN이 성공적으로 설정되었습니다",
|
||||
"sec_pin_stale_msg": "PIN이 오래되었습니다. 암호문을 사용하세요.",
|
||||
"sec_pin_unreadable": "PIN 파일을 읽을 수 없습니다. 암호문을 사용하세요.",
|
||||
"sec_restart_daemon_for_encryption": "암호화를 적용하려면 데몬을 다시 시작하세요.",
|
||||
"sec_too_many_attempts_wait": "시도 횟수가 너무 많습니다. %.0f초 기다리세요...",
|
||||
"sec_total_elapsed_fmt": "총 경과 시간: %d분 %02d초",
|
||||
"sec_unlock_button": "잠금 해제",
|
||||
"sec_unlock_failed_prefix": "잠금 해제 실패: ",
|
||||
"sec_unlock_node_busy": "The node is busy; try again in a moment.",
|
||||
"sec_unlocking_fmt": "잠금 해제 중%s",
|
||||
"sec_use_passphrase_instead": "대신 암호문 사용",
|
||||
"sec_use_pin_instead": "대신 PIN 사용",
|
||||
@@ -1690,6 +1769,8 @@
|
||||
"send_from_this_address": "이 주소에서 전송",
|
||||
"send_go_to_receive": "수신으로 이동",
|
||||
"send_keep": "유지",
|
||||
"send_mining_addr_blocked": "채굴(투명) 주소입니다. DragonX에서 이런 주소는 채굴 보상만 받을 수 있으며, 네트워크가 이 주소로의 결제를 거부합니다. 받는 사람에게 차폐 주소(zs로 시작)를 요청하세요.",
|
||||
"send_mining_addr_short": "채굴 주소 — 결제를 받을 수 없음",
|
||||
"send_network_fee": "네트워크 수수료",
|
||||
"send_no_balance": "잔액 없음",
|
||||
"send_no_recent": "최근 전송 없음",
|
||||
@@ -1717,7 +1798,6 @@
|
||||
"send_txid_copied": "TxID가 클립보드에 복사되었습니다",
|
||||
"send_txid_label": "TxID: %s",
|
||||
"send_valid_shielded": "유효한 차폐 주소",
|
||||
"send_valid_transparent": "유효한 투명 주소",
|
||||
"send_wallet_empty": "지갑이 비어 있습니다",
|
||||
"send_yes_clear": "예, 지우기",
|
||||
"sender_balance": "발신자: %.8f → %.8f DRGX",
|
||||
@@ -1780,6 +1860,7 @@
|
||||
"settings_open_log_folder": "로그 폴더 열기",
|
||||
"settings_other": "기타",
|
||||
"settings_pin_active": "PIN",
|
||||
"settings_pin_out_of_date": "PIN이 오래됨",
|
||||
"settings_privacy": "개인 정보",
|
||||
"settings_quick_unlock_pin": "빠른 잠금 해제 PIN",
|
||||
"settings_reduce_transparency": "투명도 줄이기",
|
||||
@@ -1845,11 +1926,24 @@
|
||||
"shielded_type": "차폐",
|
||||
"shielding_notice": "참고: 투명 (T) 주소에서 프라이빗 (Z) 주소로 자금을 실딩합니다.",
|
||||
"show": "표시",
|
||||
"show_earnings": "수익 표시",
|
||||
"show_hidden": "숨겨진 항목 표시 (%d)",
|
||||
"show_qr": "QR 표시",
|
||||
"show_qr_code": "QR 코드 표시",
|
||||
"showing_transactions": "%d–%d / %d건의 거래 표시 중 (총: %zu)",
|
||||
"showing_x_of_y": "%d / %d 주소 표시",
|
||||
"shutdown_kill_confirm_msg": "노드가 오랫동안 CPU나 디스크 활동이 없어 멈췄을 수 있습니다.\n강제 종료하면 저장되지 않은 내용이 모두 사라지고, 다음 시작이 훨씬 오래 걸릴 수 있습니다.",
|
||||
"shutdown_kill_confirm_title": "노드를 강제 종료할까요?",
|
||||
"shutdown_kill_confirm_yes": "강제 종료",
|
||||
"shutdown_kill_node": "노드 강제 종료(위험)",
|
||||
"shutdown_kill_node_tip": "노드를 즉시 종료합니다. 저장되지 않은 내용은 모두 사라지며, 다음 시작 시 마지막 저장 이후의 블록을 다시 처리합니다.",
|
||||
"shutdown_leave_running": "지갑 닫기 — 노드는 계속 실행",
|
||||
"shutdown_leave_running_tip": "지갑을 지금 닫습니다. 노드는 혼자 계속 실행되며 진행 중인 작업을 마칩니다. 잃는 것은 없습니다.",
|
||||
"shutdown_stop_asking": "노드에 중지를 요청하는 중…",
|
||||
"shutdown_stop_cpu_suffix": " · CPU %d%%",
|
||||
"shutdown_stop_delivered": "노드가 종료 중이며 상태를 저장하고 있습니다",
|
||||
"shutdown_stop_idle": "%d분 동안 CPU나 디스크 활동이 없습니다 — 노드가 멈췄을 수 있습니다",
|
||||
"shutdown_stop_queued": "노드가 중지 요청을 받았지만 작업 중입니다 — 가능해지면 중지됩니다",
|
||||
"simple_background": "단순 배경",
|
||||
"slider_off": "끔",
|
||||
"sort_amount_high": "금액 큰 순",
|
||||
@@ -1857,9 +1951,18 @@
|
||||
"sort_date_newest": "최신순",
|
||||
"sort_date_oldest": "오래된 순",
|
||||
"start_mining": "채굴 시작",
|
||||
"startup_files_preparing": "지갑 파일 준비 중... %d%%",
|
||||
"startup_files_verifying": "지갑 파일 확인 중...",
|
||||
"status": "상태",
|
||||
"stop_external": "외부 데몬 중지",
|
||||
"stop_mining": "채굴 중지",
|
||||
"stopconfirm_busy_body": "%s 동안 지갑에 응답할 수 없을 만큼 바빴습니다. 위트니스 재구성 같은 긴 작업 때문일 가능성이 큽니다. 지금 중지하면 다음 시작 시 그 작업을 다시 해야 할 수 있습니다.\n\n권장: 지갑을 닫고 노드는 계속 실행하세요. 노드가 스스로 마칩니다.",
|
||||
"stopconfirm_busy_title": "노드가 작업 중입니다",
|
||||
"stopconfirm_rebuild_body": "%s 동안 진행 중이며, 큰 지갑은 약 1시간이 걸릴 수 있습니다. 지금 노드를 중지하면 재구성이 끝날 때까지 기다리거나, 다음 시작 시 재구성과 블록 따라잡기를 다시 해야 합니다.\n\n권장: 지갑을 닫고 노드는 계속 실행하세요. 노드가 스스로 마칩니다.",
|
||||
"stopconfirm_rebuild_title": "노드가 노트 위트니스를 재구성하고 있습니다",
|
||||
"stopconfirm_stop_wait": "노드 중지 후 대기",
|
||||
"stopconfirm_walletop_body": "노드가 키 가져오기 또는 재스캔을 위해 체인을 스캔하고 있습니다. 지금 중지하면 스캔이 일찍 끝나고, 아직 스캔하지 못한 자금은 설정 → 재스캔 후에만 표시됩니다.\n\n권장: 지갑을 닫고 노드는 계속 실행하세요. 노드가 스스로 스캔을 마칩니다.",
|
||||
"stopconfirm_walletop_title": "키 가져오기 또는 재스캔이 실행 중입니다",
|
||||
"stratum_allowip_hint": "채굴자를 허용할 IP 또는 CIDR (비워두면 이 컴퓨터만 허용)",
|
||||
"stratum_expose_warn": "허용한 네트워크로 채굴 포트를 엽니다 — 신뢰할 수 있는 LAN에서만 사용하세요.",
|
||||
"stratum_host": "마이닝 풀 호스팅 (stratum)",
|
||||
@@ -1906,7 +2009,6 @@
|
||||
"switch_stopnode_title": "실행 중인 노드를 중지할까요?",
|
||||
"switch_stopnode_warn": "이 지갑이 시작하지 않은 노드가 이미 실행 중입니다.",
|
||||
"syncing": "동기화 중...",
|
||||
"t_address": "T 주소",
|
||||
"t_addresses": "T 주소",
|
||||
"test_connection": "테스트",
|
||||
"theme": "테마",
|
||||
@@ -1928,6 +2030,7 @@
|
||||
"tools": "도구",
|
||||
"tools_actions": "도구 및 작업...",
|
||||
"tools_actions_hdr": "도구 및 작업",
|
||||
"tor_never_applied_notice": "\"Tor 사용\" 옵션은 한 번도 작동하지 않았습니다. 연결이 Tor를 통해 라우팅되지 않았습니다. 이 옵션은 꺼졌습니다. 대안은 설정에서 이 옵션의 설명을 참고하세요.",
|
||||
"total": "합계",
|
||||
"total_balance_label": "총 잔액",
|
||||
"transaction_id": "거래 ID",
|
||||
@@ -1941,8 +2044,6 @@
|
||||
"transfer_sent": "이체 전송됨",
|
||||
"transfer_sent_desc": "이체가 네트워크에 제출되었습니다.",
|
||||
"transfer_to": "이체 대상:",
|
||||
"transparent": "투명",
|
||||
"transparent_address": "투명 주소",
|
||||
"try_again": "다시 시도",
|
||||
"tt_addr_url": "블록 탐색기에서 주소를 보기 위한 기본 URL",
|
||||
"tt_address_book": "빠른 전송을 위해 저장된 주소 관리",
|
||||
@@ -1974,10 +2075,16 @@
|
||||
"tt_debug_expand": "디버그 로깅 옵션 펼치기",
|
||||
"tt_delete_blockchain": "모든 블록체인 데이터를 삭제하고 새로 동기화합니다. wallet.dat 및 설정은 보존됩니다.",
|
||||
"tt_download_bootstrap": "블록체인 부트스트랩을 다운로드하여 동기화 가속\n기존 블록 데이터가 교체됩니다",
|
||||
"tt_earn_interval": "수익 기간: 1H = 현재 시간, 1D = 오늘, 1M = 이번 달, 1Y = 올해",
|
||||
"tt_earn_mode_inflows": "수익 모드: 받은 금액(채굴 포함)",
|
||||
"tt_earn_mode_net": "수익 모드: 순액(받은 금액에서 보낸 금액을 뺀 값)",
|
||||
"tt_earn_show_all": "모든 주소의 수익 금액 표시",
|
||||
"tt_earnings_format": "주소별 및 합계 수익 금액의 표시 방식",
|
||||
"tt_encrypt": "비밀번호로 wallet.dat 암호화",
|
||||
"tt_export_all": "모든 개인키를 파일로 내보내기",
|
||||
"tt_export_csv": "거래 내역을 CSV 스프레드시트로 내보내기",
|
||||
"tt_export_key": "선택한 주소의 개인키 내보내기",
|
||||
"tt_favorite_opacity": "주소 목록에서 즐겨찾기 별 버튼과 금색 테두리의 불투명도(최소 15%)",
|
||||
"tt_fetch_prices": "CoinGecko API에서 DRGX 시장 가격 가져오기",
|
||||
"tt_font_scale": "모든 텍스트 및 UI 크기 조정 (1.0x = 기본, 최대 1.5x).",
|
||||
"tt_idle_delay": "채굴 시작 전 대기 시간",
|
||||
@@ -1986,6 +2093,7 @@
|
||||
"tt_keep_daemon": "설정 마법사를 실행하면 데몬이 여전히 중지됩니다",
|
||||
"tt_language": "지갑 UI 인터페이스 언어",
|
||||
"tt_layout_hotkey": "단축키: 좌/우 화살표 키로 잔액 레이아웃 전환",
|
||||
"tt_lite_confirm_pass": "새 암호를 한 번 더 입력하세요. 일치해야 하며 8자 이상이어야 합니다",
|
||||
"tt_lite_copy": "표시된 비밀을 클립보드에 복사합니다",
|
||||
"tt_lite_decrypt_pass": "지갑에서 암호화를 제거하려면 암호를 입력하세요",
|
||||
"tt_lite_encrypt": "위 암호로 지갑을 암호화합니다; 즉시 잠기며 잠금 해제하려면 암호가 필요합니다",
|
||||
@@ -2014,11 +2122,16 @@
|
||||
"tt_low_spec": "모든 고부하 시각 효과 비활성화\\n단축키: Ctrl+Shift+Down",
|
||||
"tt_merge": "여러 UTXO를 하나의 주소로 통합",
|
||||
"tt_mine_idle": "시스템이 유휴 상태(키보드/마우스 입력 없음)일 때\\n자동으로 채굴 시작",
|
||||
"tt_mining_est_daily": "네트워크에서 현재 해시레이트 비중에 대한 장기 통계 평균입니다. 실제 채굴 보상은 무작위(푸아송)로 도착하여 오랫동안 아무것도 없다가 한꺼번에 들어오는 경우가 많으므로, 이는 꾸준한 일일 수입이 아닙니다.",
|
||||
"tt_mining_est_daily_fee_fmt": "%.1f%% 풀 수수료를 제외하고 표시됩니다.",
|
||||
"tt_mining_est_daily_nofee": "풀 수수료를 제외하기 전 값입니다(이 풀의 수수료는 알 수 없음).",
|
||||
"tt_new_mining_address": "솔로 채굴 보상용 투명(R…) 주소를 만듭니다. 결제는 받을 수 없으니, 결제용으로는 차폐 주소를 공유하세요.",
|
||||
"tt_noise": "그레인 텍스처 강도 (0% = 끔, 100% = 최대)",
|
||||
"tt_open_app_dir": "파일 관리자에서 ObsidianDragon 폴더(설정, 테마, 로그)를 엽니다",
|
||||
"tt_open_data_dir": "지갑 및 블록체인 데이터가 있는 폴더를 파일 탐색기에서 엽니다",
|
||||
"tt_open_dir": "파일 탐색기에서 열려면 클릭",
|
||||
"tt_open_log_folder": "디버그 및 충돌 로그가 있는 폴더를 엽니다",
|
||||
"tt_pin_out_of_date": "이 PIN을 설정한 후 지갑 암호문이 변경되어 PIN으로 더 이상 지갑을 잠금 해제할 수 없습니다. PIN을 제거한 뒤 다시 설정하세요.",
|
||||
"tt_reduce_motion": "접근성을 위해 애니메이션 전환 및 잔액 보간 비활성화",
|
||||
"tt_remove_encrypt": "암호화를 제거하고 지갑을 보호 없이 저장",
|
||||
"tt_remove_pin": "PIN을 제거하고 잠금 해제 시 비밀번호 요구",
|
||||
@@ -2054,10 +2167,13 @@
|
||||
"tt_theme_effects": "테마별 반짝임, 글로우, 색조 순환",
|
||||
"tt_theme_hotkey": "단축키: Ctrl+왼쪽/오른쪽으로 테마 전환",
|
||||
"tt_tor": "익명성을 위해 데몬 연결을 Tor 네트워크를 통해 라우팅",
|
||||
"tt_tor_unavailable": "아직 사용할 수 없음: 이 지갑은 어떤 통신도 Tor를 통해 라우팅하지 않습니다. 노드에 한해 DRAGONX.conf에 proxy=127.0.0.1:9050을 추가하고 노드를 재시작할 수 있습니다. Tor가 실행 중이어야 하며, 그렇지 않으면 노드가 피어를 찾지 못합니다. 가격, 업데이트, 부트스트랩, 풀 요청은 계속 직접 연결됩니다. 이러한 요청에는 시스템 전체 VPN/Tor를 사용하세요.",
|
||||
"tt_tor_unavailable_lite": "아직 사용할 수 없음: 이 지갑은 어떤 통신도 Tor를 통해 라우팅하지 않습니다. 시스템 전체 VPN/Tor를 사용하세요.",
|
||||
"tt_tx_url": "블록 탐색기에서 거래를 보기 위한 기본 URL",
|
||||
"tt_ui_opacity": "카드 및 사이드바 불투명도 (100% = 완전 불투명, 낮을수록 더 투명)",
|
||||
"tt_validate": "DragonX 주소가 유효한지 확인",
|
||||
"tt_verbose": "콘솔 탭에 상세 연결 진단,\\n데몬 상태 및 포트 소유자 정보 기록",
|
||||
"tt_view_transactions": "이 주소와 관련된 거래를 기록 탭에 표시",
|
||||
"tt_wallet_size_warn": "보호 지갑은 각 노트의 위트니스 데이터로 인해 커집니다 — 여러 노트를 하나의 주소로 병합하면 크기를 줄일 수 있습니다. 먼저 백업하세요.",
|
||||
"tt_wallets_button": "지갑 파일 목록을 보고 전환합니다",
|
||||
"tt_website": "DragonX 웹사이트 열기",
|
||||
@@ -2075,6 +2191,7 @@
|
||||
"tx_loading_queued": "거래 새로고침 대기 중",
|
||||
"tx_loading_refreshing_cached": "지갑 내역을 새로고침하는 중 (%d개 캐시됨)",
|
||||
"tx_loading_scanning_shielded": "차폐 내역 스캔 중 (%d개 주소)",
|
||||
"tx_loading_waits_for_sync": "노드 동기화가 끝나면 내역이 업데이트됩니다",
|
||||
"tx_mined": "채굴됨",
|
||||
"tx_progress_balances": "잔액을 새로 고치는 중",
|
||||
"tx_progress_finalizing": "트랜잭션 마무리 중",
|
||||
@@ -2106,6 +2223,7 @@
|
||||
"validate_description": "DragonX 주소를 입력하여 유효한지 그리고 이 지갑에 속하는지 확인합니다.",
|
||||
"validate_invalid": "유효하지 않음",
|
||||
"validate_is_mine": "이 지갑이 이 주소를 소유합니다",
|
||||
"validate_mining_type": "채굴 주소(투명)",
|
||||
"validate_not_mine": "이 지갑에 속하지 않음",
|
||||
"validate_ownership": "소유자:",
|
||||
"validate_results": "결과:",
|
||||
@@ -2113,7 +2231,6 @@
|
||||
"validate_shielded_type": "차폐 (z 주소)",
|
||||
"validate_status": "상태:",
|
||||
"validate_title": "주소 검증",
|
||||
"validate_transparent_type": "투명 (t 주소)",
|
||||
"validate_type": "유형:",
|
||||
"validate_valid": "유효함",
|
||||
"validating": "검증 중...",
|
||||
@@ -2123,6 +2240,7 @@
|
||||
"view_all": "모두 보기",
|
||||
"view_details": "상세 보기",
|
||||
"view_on_explorer": "탐색기에서 보기",
|
||||
"view_transactions": "거래 보기",
|
||||
"waiting_for_daemon": "데몬 연결 대기 중...",
|
||||
"wallet": "지갑",
|
||||
"wallet_degraded_notify": "지갑이 기능 제한 모드로 열렸습니다. 기존 자금은 안전하며 사용할 수 있지만, 새 주소 생성과 보호 처리는 비활성화되어 있습니다. 시드 문구를 백업하고 이를 통해 복원하면 지갑을 완전히 복구할 수 있습니다.",
|
||||
@@ -2231,6 +2349,7 @@
|
||||
"wallets_reveal": "폴더 열기",
|
||||
"wallets_scan_folder": "다른 폴더에서 지갑 검색…",
|
||||
"wallets_scanned_folders": "스캔한 폴더:",
|
||||
"wallets_scanning": "폴더 검색 중…",
|
||||
"wallets_sort_addresses": "주소",
|
||||
"wallets_sort_asc": "오름차순 (오래된/적은/작은 순)",
|
||||
"wallets_sort_by": "정렬:",
|
||||
|
||||
+150
-31
@@ -25,6 +25,7 @@
|
||||
"about_website": "Website",
|
||||
"acrylic": "Acrílico",
|
||||
"add": "Adicionar",
|
||||
"addr_type_mining": "Mineração",
|
||||
"address": "Endereço",
|
||||
"address_book_add": "Adicionar Endereço",
|
||||
"address_book_add_new": "Adicionar Novo",
|
||||
@@ -68,6 +69,7 @@
|
||||
"appx_blockchain_maintenance_in_progress": "Uma operação de manutenção da blockchain já está em andamento.",
|
||||
"appx_blockchain_rescan_complete": "Reescaneamento da blockchain concluído",
|
||||
"appx_bootstrap_complete_reconciling": "Bootstrap concluído — reconciliando sua carteira com os novos dados da chain.",
|
||||
"appx_bootstrap_reconcile_probe_failed": "Não foi possível reconciliar sua carteira com os dados da chain do bootstrap: o nó ficou ocupado demais para verificar quais blocos tem. Execute Reescanear nos Ajustes quando ele estiver em dia.",
|
||||
"appx_cancel": "Cancelar",
|
||||
"appx_cleaning_up": "Limpando...",
|
||||
"appx_confirm_your_backup": "Confirme seu backup",
|
||||
@@ -78,11 +80,15 @@
|
||||
"appx_creating_your_wallet": "Criando sua carteira…",
|
||||
"appx_daemon_error": "Erro do Daemon",
|
||||
"appx_daemon_reinstall_in_progress": "A reinstalação do daemon já está em andamento.",
|
||||
"appx_daemon_reinstall_node_busy": "O nó ainda está em execução, por isso o daemon não foi reinstalado. Tente novamente quando ele tiver parado.",
|
||||
"appx_disconnecting": "Desconectando...",
|
||||
"appx_done": "Concluído",
|
||||
"appx_dragonxd_output": "saída do dragonxd",
|
||||
"appx_encrypting_wallet": "Criptografando a carteira...",
|
||||
"appx_fullnode_lifecycle_unavailable_lite": "As ações de ciclo de vida do nó completo não estão disponíveis na versão lite",
|
||||
"appx_import_cancelled": "Cancelado: o nó ainda pode estar escaneando. Se os fundos da chave não aparecerem, execute Ajustes → Reescanear.",
|
||||
"appx_import_interrupted": "A importação foi interrompida, então a chave pode já estar na carteira. Se os fundos dela não aparecerem, execute Ajustes → Reescanear.",
|
||||
"appx_import_refused": "O nó recusou a solicitação, então a chave não foi importada.",
|
||||
"appx_installing_bundled_daemon": "Instalando o daemon incluído — o nó vai parar, atualizar e reiniciar...",
|
||||
"appx_invalid_payment_uri_prefix": "URI de pagamento inválida: ",
|
||||
"appx_ive_written_it_down": "Já anotei",
|
||||
@@ -99,7 +105,8 @@
|
||||
"appx_no_bundled_daemon_to_install": "Esta versão não tem daemon incluído para instalar",
|
||||
"appx_no_embedded_daemon_to_install": "Esta versão não tem daemon embutido para instalar",
|
||||
"appx_node_busy_restarting": "O nó está ocupado reiniciando — tente novamente em instantes.",
|
||||
"appx_node_rebuilding_witness_cache": "O nó está reconstruindo seu cache de testemunhas",
|
||||
"appx_node_busy_try_later": "O nó está ocupado concluindo uma tarefa longa (como reconstruir as testemunhas de notas). Tente novamente quando terminar; pará-lo agora pode fazê-lo recomeçar esse trabalho.",
|
||||
"appx_node_not_stopped": "O nó não parou a tempo, por isso nada foi alterado. Tente novamente em alguns minutos.",
|
||||
"appx_not_next_word": " — essa não é a próxima palavra",
|
||||
"appx_payment_request_loaded": "Solicitação de pagamento carregada",
|
||||
"appx_pool_miner_connected_and_hashing": "Minerador de pool conectado e processando hashes.",
|
||||
@@ -120,11 +127,7 @@
|
||||
"appx_simple_background_enabled": "Fundo simples ativado",
|
||||
"appx_skip": "Pular",
|
||||
"appx_skip_anyway": "Pular mesmo assim",
|
||||
"appx_still_status_prefix": "Ainda \"",
|
||||
"appx_still_status_suffix": "\" — forçar o encerramento agora pode corromper os dados da chain.",
|
||||
"appx_stop_anyway_and_quit": "Parar mesmo assim e sair",
|
||||
"appx_stopping_daemon_deleting_blockchain": "Parando o daemon e excluindo os dados da blockchain...",
|
||||
"appx_stopping_node_discards_rebuild": "Parar o nó agora descarta a reconstrução em andamento e a reinicia (vários minutos) na próxima vez que você abrir a carteira. Em vez disso, você pode manter o nó em execução.",
|
||||
"appx_stopping_pool_miner": "Parando o minerador de pool...",
|
||||
"appx_syncing_pct_block_n_of_n": "Sincronizando %.1f%% — Bloco %d / %d",
|
||||
"appx_tap_words_in_order": "Toque nas palavras na ordem correta para confirmar que você as salvou.",
|
||||
@@ -134,6 +137,9 @@
|
||||
"appx_use_settings_restart_daemon_hint": "Use Configurações > Reiniciar Daemon para tentar novamente",
|
||||
"appx_waiting_for_daemon_to_encrypt_wallet": "Aguardando o daemon criptografar a carteira...",
|
||||
"appx_wallet_created_and_backed_up": "Carteira criada e com backup feito.",
|
||||
"appx_wallet_op_node_busy": "O nó está ocupado e não responde. Tente a importação ou o reescaneamento novamente quando terminar.",
|
||||
"appx_wallet_op_running": "Ainda há uma importação de chave ou um reescaneamento em andamento. Tente novamente quando terminar.",
|
||||
"appx_wallet_op_shutting_down": "A carteira está sendo encerrada.",
|
||||
"appx_wallet_open_failed_prefix": "Falha ao abrir a carteira: ",
|
||||
"auto_shield": "Auto-blindar mineração",
|
||||
"autoshield_by_node": "Seu node blinda automaticamente o coinbase minerado para você.",
|
||||
@@ -174,21 +180,20 @@
|
||||
"balance_history_collecting": "Histórico de saldo — coletando dados...",
|
||||
"balance_layout": "Layout do Saldo",
|
||||
"balance_layout_switched": "Layout: %s",
|
||||
"balance_mining_fmt": "Recompensas de mineração: %.8f",
|
||||
"balance_mining_rate": "Minerando %s",
|
||||
"balance_shielded_fmt": "Blindado: %.8f",
|
||||
"balance_syncing_pct": "Sincronizando %.1f%%",
|
||||
"balance_transparent_fmt": "Transparente: %.8f",
|
||||
"baltab_market": "Mercado",
|
||||
"baltab_market_price_4dp": "Mercado: $%.4f",
|
||||
"baltab_market_price_8dp": "Mercado: $%.8f",
|
||||
"baltab_mining_addresses_count": "%d endereços de mineração",
|
||||
"baltab_mining_amount": "Recompensas de mineração %.8f",
|
||||
"baltab_pct_change_24h": "%s%.1f%% 24h",
|
||||
"baltab_pct_of_total_zaddr": "%.0f%% do total · %d Z-addr",
|
||||
"baltab_shielded": "Blindado",
|
||||
"baltab_shielded_amount": "Blindado %.8f",
|
||||
"baltab_t_addresses_count": "%d T-addresses",
|
||||
"baltab_total_balance": "Saldo Total",
|
||||
"baltab_transparent": "Transparente",
|
||||
"baltab_transparent_amount": "Transparente %.8f",
|
||||
"ban": "Banir",
|
||||
"banned_peers": "Pares Banidos",
|
||||
"block": "Bloco",
|
||||
@@ -238,6 +243,7 @@
|
||||
"change_pass_confirm": "Confirmar nova:",
|
||||
"change_pass_current": "Senha atual:",
|
||||
"change_pass_new": "Nova senha:",
|
||||
"change_pass_pin_note": "Esta carteira tem um PIN de desbloqueio rápido. Após a alteração ele ficará desatualizado: remova o PIN em Ajustes e defina-o novamente.",
|
||||
"change_pass_title": "Alterar senha",
|
||||
"characters": "caracteres",
|
||||
"chat": "Chat",
|
||||
@@ -513,7 +519,7 @@
|
||||
"contact_avatar_image_hint": "A imagem é copiada para o app para continuar disponível se o original for movido.",
|
||||
"contact_avatar_remove": "Remover",
|
||||
"contact_avatar_shielded": "Blindado",
|
||||
"contact_avatar_transparent": "Transparente",
|
||||
"contact_chip_mining": "Só mineração",
|
||||
"contact_global": "Mostrar em todas as carteiras (contato global)",
|
||||
"contact_global_badge_tt": "Contato global — visível em todas as carteiras",
|
||||
"contact_global_tt": "Ativado: este contato permanece visível em qualquer carteira que você carregar. Desativado: ele pertence apenas à carteira atual.",
|
||||
@@ -633,6 +639,11 @@
|
||||
"download": "Baixar",
|
||||
"download_bootstrap": "Baixar Bootstrap",
|
||||
"dragonx_green": "DragonX (Verde)",
|
||||
"earnings_format": "Formato de exibição de ganhos",
|
||||
"earnings_format_coin": "Apenas moeda",
|
||||
"earnings_format_coin_fiat": "Moeda + fiduciária",
|
||||
"earnings_format_fiat": "Apenas fiduciária",
|
||||
"earnings_label": "Ganhos",
|
||||
"edit": "Editar",
|
||||
"empty_wallet_keys_suffix": "chaves",
|
||||
"empty_wallet_open_manager": "Abrir gerenciador de carteiras",
|
||||
@@ -709,8 +720,8 @@
|
||||
"export_tx_success": "Transações exportadas com sucesso",
|
||||
"export_tx_title": "Exportar Transações para CSV",
|
||||
"export_viewing_key": "Exportar Chave de Visualização",
|
||||
"failed_create_mining_address": "Falha ao criar o endereço de mineração",
|
||||
"failed_create_shielded": "Falha ao criar endereço blindado",
|
||||
"failed_create_transparent": "Falha ao criar endereço transparente",
|
||||
"faq": "Perguntas frequentes",
|
||||
"faq_d_mgmt_1_a": "O node incorporado inicia automaticamente com a carteira e para quando apropriado, então normalmente você não precisa gerenciá-lo manualmente. Os controles avançados (incluindo reiniciar o daemon) ficam em Configurações > Node e Segurança.",
|
||||
"faq_d_mgmt_1_q": "Como inicio ou paro o node?",
|
||||
@@ -788,13 +799,13 @@
|
||||
"faq_open_tooltip": "Ajuda e FAQ",
|
||||
"faq_search_hint": "Buscar no FAQ…",
|
||||
"faq_title": "Ajuda e FAQ",
|
||||
"faq_w_addr_1_a": "Endereços transparentes (começam com R ou t) funcionam como a maioria das moedas: os valores e endereços são públicos na blockchain.\n\nEndereços blindados (endereços-z, começam com zs) usam criptografia de conhecimento zero — o valor, remetente e destinatário são encriptados on-chain. Saldos em endereços blindados são privados.",
|
||||
"faq_w_addr_1_a": "Os endereços blindados (endereços z, que começam com zs) são os que você usa no DragonX. Eles usam criptografia de conhecimento zero, então o valor, o remetente e o destinatário são criptografados na blockchain e seu saldo permanece privado.\n\nOs endereços transparentes (que começam com R) são endereços de mineração. O DragonX é uma blockchain totalmente privada, então eles só podem receber recompensas de mineração — a rede rejeita qualquer pagamento enviado a eles. As recompensas que chegam ali são públicas até serem blindadas, o que a carteira faz automaticamente.",
|
||||
"faq_w_addr_1_q": "Qual a diferença entre endereços transparentes e blindados?",
|
||||
"faq_w_addr_2_a": "Prefira endereços blindados (z) sempre que possível — eles mantêm seu saldo e histórico de pagamentos privados. DragonX é uma moeda de privacidade, e blindado é o padrão para fundos recebidos.\n\nUse um endereço transparente apenas quando um serviço com o qual você interage não conseguir lidar com endereços blindados.",
|
||||
"faq_w_addr_2_a": "Use um endereço blindado (zs) para tudo o que você envia e recebe — no DragonX é o único tipo que pode receber pagamentos.\n\nEndereços transparentes (de mineração) servem apenas para recompensas de mineração solo. Nunca passe um para receber um pagamento: a rede vai rejeitá-lo.",
|
||||
"faq_w_addr_2_q": "Qual tipo de endereço devo usar?",
|
||||
"faq_w_addr_3_a": "Para endereços blindados, reutilizar é tranquilo e não expõe o histórico. Para endereços transparentes, usar um endereço novo a cada pagamento melhora a privacidade. Crie novos endereços na aba Receber.",
|
||||
"faq_w_addr_3_a": "Reutilizar um endereço blindado não tem problema e não revela seu histórico. Você ainda pode criar novos na aba Receber, por exemplo um por contato para saber quem pagou você. Endereços de mineração servem apenas para recompensas de mineração; raramente você precisará de um novo.",
|
||||
"faq_w_addr_3_q": "Devo reutilizar um endereço ou criar um novo?",
|
||||
"faq_w_addr_4_a": "Blindar move moedas de um endereço transparente para um blindado, tornando-as privadas. Moedas recém-mineradas chegam transparentes e são blindadas automaticamente para você.\n\nVocê também pode blindar manualmente pela carteira; um blindamento é apenas uma transação especial, então leva o tempo normal de confirmação para ser concluído.",
|
||||
"faq_w_addr_4_a": "Blindar move moedas de um endereço transparente (de mineração) para um blindado, tornando-as privadas. Moedas recém-mineradas chegam a um endereço de mineração e são blindadas automaticamente quando amadurecem.\n\nVocê também pode blindar manualmente pela carteira; uma blindagem é apenas uma transação especial, então leva o tempo normal de confirmação.",
|
||||
"faq_w_addr_4_q": "O que é \"blindar\"?",
|
||||
"faq_w_addr_title": "Endereços e Privacidade",
|
||||
"faq_w_bal_1_a": "Enquanto sua carteira ainda está sincronizando, seu saldo está incompleto e pode aparecer como 0 — ainda não foram escaneadas todas as suas transações. Ele se completa assim que a sincronização terminar.\n\nObserve a barra de status na parte inferior: ela mostra o estado da conexão e a altura do bloco. Quando a sincronização estiver completa, seu saldo estará correto.",
|
||||
@@ -838,11 +849,11 @@
|
||||
"faq_w_seed_4_a": "Sua frase de recuperação é o backup principal e é suficiente para restaurar tudo. Uma cópia do arquivo da sua carteira é um backup secundário conveniente que também preserva rótulos e configurações. Mantenha qualquer backup offline e privado.",
|
||||
"faq_w_seed_4_q": "Devo também fazer backup do wallet.dat?",
|
||||
"faq_w_seed_title": "Frase de Recuperação e Backup",
|
||||
"faq_w_send_1_a": "Abra a aba Enviar, cole o endereço do destinatário, informe um valor e confirme. Se a carteira estiver encriptada, você será solicitado a desbloqueá-la para a transação.\n\nEnvios para endereços blindados são privados; envios para endereços transparentes são públicos.",
|
||||
"faq_w_send_1_a": "Abra a aba Enviar, cole o endereço do destinatário, informe um valor e confirme. Se a carteira estiver encriptada, você será solicitado a desbloqueá-la para a transação.\n\nOs pagamentos vão para endereços blindados (zs) e continuam privados. A carteira não envia para um endereço transparente (de mineração): a rede rejeita pagamentos para eles.",
|
||||
"faq_w_send_1_q": "Como eu envio fundos?",
|
||||
"faq_w_send_2_a": "As taxas do DragonX são muito baixas e definidas automaticamente. A taxa é mostrada antes de você confirmar. Normalmente você não precisa alterá-la.",
|
||||
"faq_w_send_2_q": "Qual taxa eu pago?",
|
||||
"faq_w_send_3_a": "Sim — ao enviar para um endereço blindado (z) você pode incluir um memo encriptado. Só o destinatário pode lê-lo. Endereços transparentes não suportam memos.",
|
||||
"faq_w_send_3_a": "Sim — ao enviar para um endereço blindado (z) você pode incluir um memo encriptado. Só o destinatário pode lê-lo.",
|
||||
"faq_w_send_3_q": "Posso anexar uma mensagem a um pagamento?",
|
||||
"faq_w_send_4_a": "Uma transação fica \"pendente\" até ser minerada em um bloco e ganhar confirmações. Isso costuma levar um ou dois minutos. Transações blindadas também precisam que sua carteira esteja sincronizada para gerar a prova.\n\nSe um envio permanecer pendente por tempo incomumente longo, verifique se sua carteira está conectada e sincronizada (veja a barra de status na parte inferior da janela).",
|
||||
"faq_w_send_4_q": "Enviei um pagamento, mas ele diz pendente — por quê?",
|
||||
@@ -857,6 +868,7 @@
|
||||
"faq_w_set_4_q": "Como exibo preços em moeda fiduciária?",
|
||||
"faq_w_set_title": "Configurações e aparência",
|
||||
"favorite_address": "Adicionar aos favoritos",
|
||||
"favorite_opacity": "Opacidade do destaque de favoritos",
|
||||
"fee": "Taxa",
|
||||
"fee_high": "Alta",
|
||||
"fee_label": "Taxa:",
|
||||
@@ -864,14 +876,9 @@
|
||||
"fee_normal": "Normal",
|
||||
"fetch_prices": "Buscar preços",
|
||||
"file": "Arquivo",
|
||||
"file_save_location": "O arquivo será salvo em: ~/.config/ObsidianDragon/",
|
||||
"file_save_location": "O arquivo será salvo em:",
|
||||
"filter": "Filtrar...",
|
||||
"font_scale": "Escala da Fonte",
|
||||
"force_quit": "Forçar Saída",
|
||||
"force_quit_confirm_msg": "Isso matará imediatamente o daemon sem um desligamento limpo.\nIsso pode corromper o índice da blockchain e exigir uma ressincronização.",
|
||||
"force_quit_confirm_title": "Forçar Saída?",
|
||||
"force_quit_warning": "Isso matará imediatamente o daemon sem um desligamento limpo. Pode exigir uma ressincronização da blockchain.",
|
||||
"force_quit_yes": "Forçar Saída",
|
||||
"from": "De",
|
||||
"from_upper": "DE",
|
||||
"full_details": "Detalhes Completos",
|
||||
@@ -940,6 +947,7 @@
|
||||
"hidden_tag": " (oculto)",
|
||||
"hide": "Ocultar",
|
||||
"hide_address": "Ocultar endereço",
|
||||
"hide_earnings": "Ocultar ganhos",
|
||||
"hide_qr": "Ocultar QR",
|
||||
"hide_zero_balances": "Ocultar saldos zero",
|
||||
"history": "Histórico",
|
||||
@@ -1014,11 +1022,13 @@
|
||||
"lite_birthday_backup": "Aniversário: %llu (faça o backup disto também)",
|
||||
"lite_birthday_hint": "Altura do bloco a partir da qual começar a escanear. Deixe 0 se desconhecida (escaneamento completo mais lento).",
|
||||
"lite_birthday_label": "Data de nascimento",
|
||||
"lite_confirm_label": "Confirmar",
|
||||
"lite_console_backend_commands": "Comandos do backend:",
|
||||
"lite_console_help_passthrough": "Qualquer outra entrada é executada como um comando de console da carteira leve.",
|
||||
"lite_copy": "Copiar",
|
||||
"lite_could_not_start": "Não foi possível iniciar a operação",
|
||||
"lite_could_not_write": "Não foi possível gravar ",
|
||||
"lite_encrypt_offline_warning": "Qualquer pessoa com o arquivo da carteira pode testar frases-passe offline. Faça primeiro o backup da sua frase de recuperação e use uma frase-passe longa.",
|
||||
"lite_encrypt_wallet": "Criptografar carteira",
|
||||
"lite_encryption_removed": "Criptografia removida",
|
||||
"lite_enter_all_seed_words": "Informe todas as 24 palavras-semente para restaurar (obtidas %d)",
|
||||
@@ -1026,6 +1036,7 @@
|
||||
"lite_hide_wipe": "Ocultar e apagar",
|
||||
"lite_import": "Importar",
|
||||
"lite_import_key_label": "Importar chave",
|
||||
"lite_key_import_failed": "Não foi possível importar a chave — verifique-a, desbloqueie a carteira (se estiver criptografada) e tente novamente.",
|
||||
"lite_key_imported": "Chave importada — execute uma sincronização para escanear seu histórico",
|
||||
"lite_lock_failed": "Falha ao bloquear",
|
||||
"lite_lock_now": "Bloquear agora",
|
||||
@@ -1170,9 +1181,43 @@
|
||||
"merge_description": "Fundir múltiplos UTXOs em um único endereço blindado. Isso pode ajudar a reduzir o tamanho da carteira e melhorar a privacidade.",
|
||||
"merge_fee_hint": "Taxa de rede para esta transação.",
|
||||
"merge_funds": "Fundir Fundos",
|
||||
"merge_into_one": "Para um só endereço",
|
||||
"merge_keep_each": "Manter em cada endereço",
|
||||
"merge_keep_each_batches_fmt": "Endereços com mais de %d notas são mesclados em vários lotes; execute de novo depois que forem confirmados para mesclar o restante.",
|
||||
"merge_keep_each_confirm_fmt": "Consolidar %d notas em %d endereços? Cada endereço mantém seus próprios fundos. Taxas: cerca de %s DRGX (uma por lote).",
|
||||
"merge_keep_each_desc": "As notas de cada endereço são mescladas de volta nesse mesmo endereço, então os fundos que você mantém separados com rótulos diferentes continuam separados. Só endereços blindados — fundos transparentes só podem ser blindados (Para um só endereço).",
|
||||
"merge_keep_each_none": "Cada endereço blindado já tem uma única nota — nada a consolidar.",
|
||||
"merge_keep_each_nothing_submitted": "Nada foi consolidado — o nó recusou todas as mesclagens.",
|
||||
"merge_keep_each_row_fmt": "%s · %d notas · %s DRGX",
|
||||
"merge_keep_each_run_again": "Endereços mesclados em vários lotes agora têm uma nota por lote. Execute de novo depois que forem confirmados para mesclá-las em uma.",
|
||||
"merge_keep_each_select": "Marque pelo menos um endereço para consolidar.",
|
||||
"merge_keep_each_some_failed": "Algumas mesclagens falharam — veja a lista acima.",
|
||||
"merge_keep_each_status_done": "%s — concluído: %d notas mescladas em %d lote(s)",
|
||||
"merge_keep_each_status_failed": "%s — falhou: %s",
|
||||
"merge_keep_each_status_not_started": "%s — não iniciado: %s",
|
||||
"merge_keep_each_status_running": "%s — mesclando %d notas (%d de %d lotes concluídos)…",
|
||||
"merge_keep_each_status_unconfirmed": "%s — não foi possível confirmar o status",
|
||||
"merge_keep_each_too_small": "Pequeno demais para consolidar: a taxa consumiria esses fundos.",
|
||||
"merge_max_inputs": "Máx. de entradas por lote",
|
||||
"merge_no_spendable": "Ainda não há entradas disponíveis para consolidar.",
|
||||
"merge_progress": "Consolidando… isso pode levar alguns minutos. Você pode fechar esta janela.",
|
||||
"merge_run_aborted_toast": "A conexão com o nó foi redefinida, então a consolidação parou. Execute-a novamente para continuar.",
|
||||
"merge_run_busy": "Uma consolidação está em andamento. Aguarde que termine ou interrompa-a primeiro.",
|
||||
"merge_run_chip_fmt": "Consolidando %d/%d",
|
||||
"merge_run_err_timeout": "O nó não respondeu a tempo; o lote ainda pode estar na fila.",
|
||||
"merge_run_halted_fmt": "Consolidação interrompida após falhas repetidas: %s",
|
||||
"merge_run_migration_pending": "Conclua ou cancele a migração da semente antes de consolidar.",
|
||||
"merge_run_progress_fmt": "Consolidando… lote %d de cerca de %d, %d notas mescladas até agora. Você pode fechar esta janela; a operação continua em segundo plano.",
|
||||
"merge_run_start_failed": "Não foi possível iniciar a consolidação — o nó não está conectado.",
|
||||
"merge_run_status_not_run": "%s — não consolidado (%d notas)",
|
||||
"merge_run_status_waiting": "%s — aguardando (%d notas)",
|
||||
"merge_run_stop": "Parar",
|
||||
"merge_run_stopped_toast": "Consolidação interrompida. Execute-a novamente para mesclar o restante.",
|
||||
"merge_run_stopping": "Parando após os lotes já enviados…",
|
||||
"merge_run_wait_batch": "Espaçando os lotes para que o nó continue respondendo…",
|
||||
"merge_run_wait_send": "Pausado até que sua transação pendente seja concluída.",
|
||||
"merge_run_wait_sync": "Pausado: o nó ainda está sincronizando.",
|
||||
"merge_scope_failed_fmt": "Não foi possível ler suas entradas: %s",
|
||||
"merge_scope_fmt": "%d entradas transparentes + %d blindadas · ~%s DRGX disponíveis",
|
||||
"merge_scope_loading": "Verificando suas entradas…",
|
||||
"merge_send_failed": "Falha na fusão: ",
|
||||
@@ -1181,7 +1226,11 @@
|
||||
"merge_src_shielded": "Blindado",
|
||||
"merge_src_transparent": "Transparente",
|
||||
"merge_started": "Operação de fusão iniciada",
|
||||
"merge_submit_uncertain": "O nó não confirmou a fusão antes de a conexão ser interrompida. Ela ainda pode ser concluída — verifique o Histórico após reconectar antes de fundir novamente.",
|
||||
"merge_title": "Fundir para Endereço",
|
||||
"merge_wait_locked": "A carteira está bloqueada. Desbloqueie-a para continuar.",
|
||||
"merge_waiting_node": "Aguardando o nó…",
|
||||
"merge_where": "Para onde devem ir os fundos?",
|
||||
"mig_adopt": "Tornar esta minha carteira",
|
||||
"mig_adopt_now": "Adotar carteira semente",
|
||||
"mig_adopting": "Instalando sua nova carteira e reescaneando…",
|
||||
@@ -1233,6 +1282,8 @@
|
||||
"miner_fee": "Taxa de Minerador",
|
||||
"mining": "Mineração",
|
||||
"mining_active": "Ativo",
|
||||
"mining_addr_explainer": "Recebe apenas recompensas de mineração solo — a rede rejeita pagamentos para este endereço. As recompensas são blindadas automaticamente quando amadurecem.",
|
||||
"mining_address": "Endereço de mineração",
|
||||
"mining_address_copied": "Endereço de mineração copiado",
|
||||
"mining_all_time": "Todo o Tempo",
|
||||
"mining_already_saved": "URL do pool já salva",
|
||||
@@ -1246,6 +1297,8 @@
|
||||
"mining_benchmark_testing": "Testando",
|
||||
"mining_benchmark_tooltip": "Encontrar o número ideal de threads para esta CPU",
|
||||
"mining_block_copied": "Hash do bloco copiado",
|
||||
"mining_card_click_to_shield": "Clique para blindar agora as recompensas amadurecidas.",
|
||||
"mining_card_tooltip": "As recompensas de mineração solo chegam aos seus endereços de mineração (transparentes, R…). Esses endereços não podem receber pagamentos. As recompensas são blindadas automaticamente quando amadurecem.",
|
||||
"mining_chart_1m_ago": "1m atrás",
|
||||
"mining_chart_5m_ago": "5m atrás",
|
||||
"mining_chart_now": "Agora",
|
||||
@@ -1261,6 +1314,8 @@
|
||||
"mining_est_block": "Bloco Est.",
|
||||
"mining_est_daily": "Est. Diário",
|
||||
"mining_est_daily_pool_sub": "equivalente solo aproximado, antes da taxa do pool",
|
||||
"mining_est_daily_sub": "méd.",
|
||||
"mining_est_daily_sub_net": "méd., líq.",
|
||||
"mining_filter_all": "Todos",
|
||||
"mining_filter_tip_all": "Mostrar todos os ganhos",
|
||||
"mining_filter_tip_pool": "Mostrar apenas ganhos do pool",
|
||||
@@ -1290,6 +1345,7 @@
|
||||
"mining_payout_address": "Endereço de Pagamento",
|
||||
"mining_payout_foreign": "⚠ Este endereço de pagamento não está na sua carteira atual — as recompensas mineradas iriam para uma carteira diferente. Atualize-o se você trocou de carteira.",
|
||||
"mining_payout_invalid": "Endereço DragonX inválido — corrija antes de iniciar, ou as recompensas de mineração serão perdidas.",
|
||||
"mining_payout_needs_shielded": "Os pools pagam com transações comuns, que no DragonX não conseguem chegar a um endereço de mineração (transparente, R…). Use um endereço blindado (começa com zs) como endereço de pagamento.",
|
||||
"mining_payout_tooltip": "Endereço para receber recompensas de mineração",
|
||||
"mining_pool": "Pool",
|
||||
"mining_pool_fee": "Taxa",
|
||||
@@ -1302,6 +1358,8 @@
|
||||
"mining_refresh": "Atualizar",
|
||||
"mining_remove": "Remover",
|
||||
"mining_reset_defaults": "Redefinir Padrões",
|
||||
"mining_rewards": "Recompensas de mineração",
|
||||
"mining_rewards_unshielded": "Ainda não blindadas",
|
||||
"mining_save_payout_address": "Salvar endereço de pagamento",
|
||||
"mining_save_pool_url": "Salvar URL do pool",
|
||||
"mining_saved_addresses": "Endereços Salvos:",
|
||||
@@ -1335,10 +1393,10 @@
|
||||
"network_fee": "TAXA DA REDE",
|
||||
"network_hashrate": "Hashrate da Rede",
|
||||
"new": "+ Novo",
|
||||
"new_mining_address": "Novo endereço de mineração",
|
||||
"new_mining_address_created": "Novo endereço de mineração criado",
|
||||
"new_shielded_created": "Novo endereço blindado criado",
|
||||
"new_t_address": "Novo Endereço T",
|
||||
"new_t_transparent": "Novo endereço t (Transparente)",
|
||||
"new_transparent_created": "Novo endereço transparente criado",
|
||||
"new_z_address": "Novo Endereço Z",
|
||||
"new_z_shielded": "Novo endereço z (Blindado)",
|
||||
"no_addresses": "Nenhum endereço encontrado. Crie um usando os botões acima.",
|
||||
@@ -1358,7 +1416,19 @@
|
||||
"node_banner_offline_title": "Não conectado ao nó DragonX",
|
||||
"node_banner_reconnect": "Reconectar",
|
||||
"node_banner_restart": "Reiniciar nó",
|
||||
"node_busy_rebuild_desc": "Em andamento há %s. Em carteiras grandes pode levar cerca de uma hora, e o nó não consegue responder à carteira até terminar.",
|
||||
"node_busy_rebuild_extending": "Estendendo testemunhas (o nó não informa o progresso desta etapa).",
|
||||
"node_busy_rebuild_reading": "Lendo blocos: %ld / %ld",
|
||||
"node_busy_rebuild_title": "Nó ocupado: reconstruindo testemunhas de notas",
|
||||
"node_busy_replay_desc": "Em andamento há %s. O nó está reaplicando blocos que recebeu antes da última parada.",
|
||||
"node_busy_replay_title": "Nó ocupado: recuperando blocos",
|
||||
"node_busy_rpc_desc": "O nó está em execução, mas está ocupado demais para responder à carteira há %s, geralmente por causa de uma operação longa como a reconstrução de testemunhas.",
|
||||
"node_busy_rpc_title": "Nó ocupado: ainda não responde",
|
||||
"node_busy_running": "Em andamento há %s",
|
||||
"node_security": "NÓ & SEGURANÇA",
|
||||
"node_starting_busy_desc": "O nó está acessível, mas ocupado, e ainda não responde. Isso é normal após uma atualização ou na primeira execução; pode levar alguns minutos.",
|
||||
"node_starting_launch_desc": "Iniciando o dragonxd e aguardando ficar online…",
|
||||
"node_starting_title": "Iniciando o nó DragonX…",
|
||||
"noise": "Ruído",
|
||||
"not_connected": "Não conectado ao daemon...",
|
||||
"not_connected_to_daemon": "Não conectado ao daemon",
|
||||
@@ -1511,6 +1581,8 @@
|
||||
"ram_wallet_gb": "Carteira: %.1f GB",
|
||||
"ram_wallet_mb": "Carteira: %.0f MB",
|
||||
"receive": "Receber",
|
||||
"receive_not_for_payments": "Não é para pagamentos",
|
||||
"receive_use_shielded": "Usar um endereço blindado",
|
||||
"received": "recebido",
|
||||
"received_filter": "Recebido",
|
||||
"received_label": "Recebido",
|
||||
@@ -1525,6 +1597,7 @@
|
||||
"reduce_motion": "Reduzir Movimento",
|
||||
"refresh": "Atualizar",
|
||||
"refresh_now": "Atualizar Agora",
|
||||
"refresh_waits_for_sync": "O nó ainda está sincronizando. Os dados da carteira serão atualizados quando terminar.",
|
||||
"remove_favorite": "Remover favorito",
|
||||
"repair_wallet": "Reparar Carteira",
|
||||
"report_bug": "Reportar Bug",
|
||||
@@ -1539,12 +1612,12 @@
|
||||
"request_select_address": "Selecionar endereço...",
|
||||
"request_shielded_addrs": "-- Endereços Blindados --",
|
||||
"request_title": "Solicitar Pagamento",
|
||||
"request_transparent_addrs": "-- Endereços Transparentes --",
|
||||
"request_uri_copied": "URI de pagamento copiada para a área de transferência",
|
||||
"rescan": "Reescanear",
|
||||
"rescan_bootstrapped_msg": "Seu nó foi inicializado por bootstrap, então os blocos abaixo do snapshot não estão em disco e um reescaneamento a partir do gênesis falharia. Reescaneie a partir de uma altura que seu snapshot inclua para reconciliar o saldo gasto da sua carteira. Seu wallet.dat e os dados da cadeia não são excluídos.",
|
||||
"rescan_detecting": "Verificando quais blocos seu nó tem em disco…",
|
||||
"rescan_from_height": "Reescanear a partir da altura do bloco:",
|
||||
"rescan_probe_failed": "Não foi possível verificar quais blocos seu nó tem em disco (ele está ocupado ou uma solicitação expirou). Se este nó foi inicializado por bootstrap, cancele e tente novamente em um minuto: a reinicialização completa com -rescan abaixo falha em um nó inicializado por bootstrap.",
|
||||
"reset_to_defaults": "Redefinir Padrões",
|
||||
"restarting_after_encryption": "Reiniciando daemon após criptografia...",
|
||||
"restore_address": "Restaurar endereço",
|
||||
@@ -1587,6 +1660,7 @@
|
||||
"sb_net_khs": "Rede: %.2f KH/s",
|
||||
"sb_net_mhs": "Rede: %.2f MH/s",
|
||||
"sb_no_conf": "DRAGONX.conf não encontrado",
|
||||
"sb_node_busy": "Nó ocupado",
|
||||
"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",
|
||||
@@ -1608,6 +1682,7 @@
|
||||
"screenshot_sweep": "Executar varredura de capturas de tela",
|
||||
"screenshot_sweep_desc": "Percorre cada tema em cada aba e salva uma captura de tela de cada um em subpastas por aba dentro da pasta de capturas de tela do diretório de configuração (sobrescrevendo a varredura anterior). É executado por alguns segundos.",
|
||||
"screenshot_sweep_full": "Varredura completa da interface",
|
||||
"screenshot_sweep_refused": "Varredura completa da interface recusada; veja o log de depuração",
|
||||
"search_icons": "Pesquisar ícones...",
|
||||
"search_placeholder": "Pesquisar...",
|
||||
"sec_changing_passphrase": "Alterando a frase-senha...",
|
||||
@@ -1629,15 +1704,19 @@
|
||||
"sec_mode_passphrase": " Frase-senha",
|
||||
"sec_not_connected_to_daemon": "Não conectado ao daemon",
|
||||
"sec_not_connected_to_daemon_pin": "Não conectado ao daemon",
|
||||
"sec_passphrase_changed_pin_stale": "Frase-senha alterada; seu PIN está desatualizado",
|
||||
"sec_passphrase_changed_successfully": "Frase-senha alterada com sucesso",
|
||||
"sec_pin_changed_successfully": "PIN alterado com sucesso",
|
||||
"sec_pin_removed": "PIN removido",
|
||||
"sec_pin_set_successfully": "PIN definido com sucesso",
|
||||
"sec_pin_stale_msg": "PIN desatualizado. Use sua frase-senha.",
|
||||
"sec_pin_unreadable": "Arquivo do PIN ilegível. Use sua frase-senha.",
|
||||
"sec_restart_daemon_for_encryption": "Reinicie o daemon para que a criptografia entre em vigor.",
|
||||
"sec_too_many_attempts_wait": "Tentativas em excesso. Aguarde %.0f segundos...",
|
||||
"sec_total_elapsed_fmt": "Tempo total: %dm %02ds",
|
||||
"sec_unlock_button": "Desbloquear",
|
||||
"sec_unlock_failed_prefix": "Falha ao desbloquear: ",
|
||||
"sec_unlock_node_busy": "The node is busy; try again in a moment.",
|
||||
"sec_unlocking_fmt": "Desbloqueando%s",
|
||||
"sec_use_passphrase_instead": "Usar frase-senha",
|
||||
"sec_use_pin_instead": "Usar PIN",
|
||||
@@ -1690,6 +1769,8 @@
|
||||
"send_from_this_address": "Enviar deste endereço",
|
||||
"send_go_to_receive": "Ir para Receber",
|
||||
"send_keep": "Manter",
|
||||
"send_mining_addr_blocked": "Este é um endereço de mineração (transparente). No DragonX eles só podem receber recompensas de mineração — a rede rejeita pagamentos para eles. Peça ao destinatário um endereço blindado (começa com zs).",
|
||||
"send_mining_addr_short": "Endereço de mineração — não pode receber pagamentos",
|
||||
"send_network_fee": "TAXA DA REDE",
|
||||
"send_no_balance": "Sem saldo",
|
||||
"send_no_recent": "Nenhum envio recente",
|
||||
@@ -1717,7 +1798,6 @@
|
||||
"send_txid_copied": "TxID copiado para a área de transferência",
|
||||
"send_txid_label": "TxID: %s",
|
||||
"send_valid_shielded": "Endereço blindado válido",
|
||||
"send_valid_transparent": "Endereço transparente válido",
|
||||
"send_wallet_empty": "Sua carteira está vazia",
|
||||
"send_yes_clear": "Sim, Limpar",
|
||||
"sender_balance": "Remetente: %.8f → %.8f DRGX",
|
||||
@@ -1780,6 +1860,7 @@
|
||||
"settings_open_log_folder": "Abrir pasta de logs",
|
||||
"settings_other": "Outros",
|
||||
"settings_pin_active": "PIN",
|
||||
"settings_pin_out_of_date": "PIN desatualizado",
|
||||
"settings_privacy": "Privacidade",
|
||||
"settings_quick_unlock_pin": "PIN de desbloqueio rápido",
|
||||
"settings_reduce_transparency": "Reduzir transparência",
|
||||
@@ -1845,11 +1926,24 @@
|
||||
"shielded_type": "Blindado",
|
||||
"shielding_notice": "Nota: Isso blindará fundos de um endereço transparente (T) para um endereço privado (Z).",
|
||||
"show": "Mostrar",
|
||||
"show_earnings": "Mostrar ganhos",
|
||||
"show_hidden": "Mostrar ocultos (%d)",
|
||||
"show_qr": "Mostrar QR",
|
||||
"show_qr_code": "Mostrar Código QR",
|
||||
"showing_transactions": "Mostrando %d–%d de %d transações (total: %zu)",
|
||||
"showing_x_of_y": "Mostrando %d de %d endereços",
|
||||
"shutdown_kill_confirm_msg": "O nó não mostra atividade de CPU nem de disco há muito tempo e pode estar travado.\nForçar o encerramento perde tudo o que não foi salvo; o próximo início pode demorar muito mais.",
|
||||
"shutdown_kill_confirm_title": "Forçar o encerramento do nó?",
|
||||
"shutdown_kill_confirm_yes": "Forçar encerramento",
|
||||
"shutdown_kill_node": "Forçar encerramento do nó (inseguro)",
|
||||
"shutdown_kill_node_tip": "Encerra o nó imediatamente. Tudo o que não foi salvo se perde, e no próximo início ele reprocessa os blocos desde o último salvamento.",
|
||||
"shutdown_leave_running": "Fechar a carteira e deixar o nó em execução",
|
||||
"shutdown_leave_running_tip": "Fecha a carteira agora. O nó continua em execução sozinho e termina o que está fazendo; nada é perdido.",
|
||||
"shutdown_stop_asking": "Pedindo ao nó para parar…",
|
||||
"shutdown_stop_cpu_suffix": " · CPU %d%%",
|
||||
"shutdown_stop_delivered": "O nó está encerrando e salvando seu estado",
|
||||
"shutdown_stop_idle": "Sem atividade de CPU ou disco há %d min; o nó pode estar travado",
|
||||
"shutdown_stop_queued": "O nó recebeu o pedido de parada, mas está ocupado; ele vai parar assim que puder",
|
||||
"simple_background": "Fundo simples",
|
||||
"slider_off": "Desligado",
|
||||
"sort_amount_high": "Maior valor",
|
||||
@@ -1857,9 +1951,18 @@
|
||||
"sort_date_newest": "Mais recentes primeiro",
|
||||
"sort_date_oldest": "Mais antigas primeiro",
|
||||
"start_mining": "Iniciar Mineração",
|
||||
"startup_files_preparing": "Preparando os arquivos da carteira... %d%%",
|
||||
"startup_files_verifying": "Verificando os arquivos da carteira...",
|
||||
"status": "Status",
|
||||
"stop_external": "Parar daemon externo",
|
||||
"stop_mining": "Parar Mineração",
|
||||
"stopconfirm_busy_body": "Está ocupado demais para responder à carteira há %s, provavelmente por causa de uma operação longa como a reconstrução de testemunhas. Pará-lo agora pode fazer o próximo início refazer esse trabalho.\n\nRecomendado: feche a carteira e deixe o nó em execução. Ele termina sozinho.",
|
||||
"stopconfirm_busy_title": "O nó está ocupado",
|
||||
"stopconfirm_rebuild_body": "Está em andamento há %s, e em carteiras grandes pode levar cerca de uma hora. Parar o nó agora ou espera a reconstrução terminar, ou faz o próximo início refazê-la junto com qualquer recuperação.\n\nRecomendado: feche a carteira e deixe o nó em execução. Ele termina sozinho.",
|
||||
"stopconfirm_rebuild_title": "O nó está reconstruindo as testemunhas de notas",
|
||||
"stopconfirm_stop_wait": "Parar o nó e aguardar",
|
||||
"stopconfirm_walletop_body": "O nó está escaneando a cadeia para uma importação de chave ou um reescaneamento. Pará-lo agora encerra esse escaneamento antes do tempo, e os fundos que ele ainda não alcançou só aparecerão depois de Ajustes → Reescanear.\n\nRecomendado: feche a carteira e deixe o nó em execução. Ele termina o escaneamento sozinho.",
|
||||
"stopconfirm_walletop_title": "Uma importação de chave ou um reescaneamento está em andamento",
|
||||
"stratum_allowip_hint": "Permitir mineradores a partir de IP ou CIDR (em branco = apenas este computador)",
|
||||
"stratum_expose_warn": "Abre uma porta de mineração para a rede que você permitir — use apenas em uma LAN confiável.",
|
||||
"stratum_host": "Hospedar um pool de mineração (stratum)",
|
||||
@@ -1906,7 +2009,6 @@
|
||||
"switch_stopnode_title": "Parar o nó em execução?",
|
||||
"switch_stopnode_warn": "Já há um nó em execução que esta carteira não iniciou.",
|
||||
"syncing": "Sincronizando...",
|
||||
"t_address": "Endereço T",
|
||||
"t_addresses": "Endereços T",
|
||||
"test_connection": "Testar",
|
||||
"theme": "Tema",
|
||||
@@ -1928,6 +2030,7 @@
|
||||
"tools": "FERRAMENTAS",
|
||||
"tools_actions": "Ferramentas e Ações...",
|
||||
"tools_actions_hdr": "FERRAMENTAS E AÇÕES",
|
||||
"tor_never_applied_notice": "A opção \"Usar Tor\" nunca funcionou: suas conexões NÃO foram roteadas pelo Tor. Ela foi desativada. Veja a dica dela em Ajustes para alternativas.",
|
||||
"total": "Total",
|
||||
"total_balance_label": "Saldo Total",
|
||||
"transaction_id": "ID DA TRANSAÇÃO",
|
||||
@@ -1941,8 +2044,6 @@
|
||||
"transfer_sent": "Transferência Enviada",
|
||||
"transfer_sent_desc": "Sua transferência foi enviada à rede.",
|
||||
"transfer_to": "Transferir para:",
|
||||
"transparent": "Transparente",
|
||||
"transparent_address": "Endereço Transparente",
|
||||
"try_again": "Tentar novamente",
|
||||
"tt_addr_url": "URL base para visualizar endereços em um explorador de blocos",
|
||||
"tt_address_book": "Gerenciar endereços salvos para envio rápido",
|
||||
@@ -1974,10 +2075,16 @@
|
||||
"tt_debug_expand": "Expandir opções de registro de depuração",
|
||||
"tt_delete_blockchain": "Excluir todos os dados da blockchain e iniciar uma nova sincronização. wallet.dat e configuração são preservados.",
|
||||
"tt_download_bootstrap": "Baixar bootstrap da blockchain para acelerar a sincronização\nDados de blocos existentes serão substituídos",
|
||||
"tt_earn_interval": "Período de ganhos: 1H = esta hora, 1D = hoje, 1M = este mês, 1Y = este ano",
|
||||
"tt_earn_mode_inflows": "Modo de ganhos: valores recebidos (incluindo mineração)",
|
||||
"tt_earn_mode_net": "Modo de ganhos: líquido (recebido menos enviado)",
|
||||
"tt_earn_show_all": "Mostrar o valor de ganhos para cada endereço",
|
||||
"tt_earnings_format": "Como os valores de ganhos por endereço e totais são exibidos",
|
||||
"tt_encrypt": "Encriptar wallet.dat com uma frase secreta",
|
||||
"tt_export_all": "Exportar todas as chaves privadas para um arquivo",
|
||||
"tt_export_csv": "Exportar histórico de transações como planilha CSV",
|
||||
"tt_export_key": "Exportar a chave privada do endereço selecionado",
|
||||
"tt_favorite_opacity": "Opacidade do botão de estrela de favoritos e do seu contorno dourado na lista de endereços (mínimo 15%)",
|
||||
"tt_fetch_prices": "Obter preços de mercado DRGX da API CoinGecko",
|
||||
"tt_font_scale": "Escalar todo o texto e interface (1.0x = padrão, até 1.5x).",
|
||||
"tt_idle_delay": "Quanto tempo esperar antes de iniciar a mineração",
|
||||
@@ -1986,6 +2093,7 @@
|
||||
"tt_keep_daemon": "O daemon será parado ao executar o assistente de configuração",
|
||||
"tt_language": "Idioma da interface da carteira",
|
||||
"tt_layout_hotkey": "Atalho: teclas de seta esquerda/direita para alternar layouts de Saldo",
|
||||
"tt_lite_confirm_pass": "Digite a nova frase-passe novamente; ela deve coincidir e ter pelo menos 8 caracteres",
|
||||
"tt_lite_copy": "Copiar o segredo revelado para a área de transferência",
|
||||
"tt_lite_decrypt_pass": "Digite sua frase-senha para remover a criptografia da carteira",
|
||||
"tt_lite_encrypt": "Criptografar a carteira com a frase-senha acima; ela é bloqueada imediatamente e exige a frase-senha para desbloquear",
|
||||
@@ -2014,11 +2122,16 @@
|
||||
"tt_low_spec": "Desativar todos os efeitos visuais pesados\\nAtalho: Ctrl+Shift+Down",
|
||||
"tt_merge": "Consolidar múltiplos UTXOs em um endereço",
|
||||
"tt_mine_idle": "Iniciar mineração automaticamente quando o\\nsistema estiver ocioso (sem entrada de teclado/mouse)",
|
||||
"tt_mining_est_daily": "Uma média estatística de longo prazo para a sua fração atual de hashrate na rede. As recompensas reais de mineração chegam de forma aleatória (Poisson) — muitas vezes nada por longos períodos e depois um valor — por isso NÃO é uma renda diária constante.",
|
||||
"tt_mining_est_daily_fee_fmt": "Mostrado após a taxa de %.1f%% do pool.",
|
||||
"tt_mining_est_daily_nofee": "Mostrado antes da taxa do pool (taxa desconhecida para este pool).",
|
||||
"tt_new_mining_address": "Cria um endereço transparente (R…) para recompensas de mineração solo. Ele não pode receber pagamentos — compartilhe um endereço blindado para isso.",
|
||||
"tt_noise": "Intensidade de textura granulada (0% = desligado, 100% = máximo)",
|
||||
"tt_open_app_dir": "Abrir a pasta ObsidianDragon (configurações, temas, logs) no gerenciador de arquivos",
|
||||
"tt_open_data_dir": "Abrir a pasta com os dados da sua carteira e da blockchain no gerenciador de arquivos",
|
||||
"tt_open_dir": "Clique para abrir no explorador de arquivos",
|
||||
"tt_open_log_folder": "Abre a pasta que contém os logs de depuração e de falhas",
|
||||
"tt_pin_out_of_date": "A frase-senha da carteira mudou depois que este PIN foi definido, então o PIN não consegue mais desbloqueá-la. Remova o PIN e defina-o novamente.",
|
||||
"tt_reduce_motion": "Desativar transições animadas e lerp de saldo para acessibilidade",
|
||||
"tt_remove_encrypt": "Remover encriptação e armazenar a carteira desprotegida",
|
||||
"tt_remove_pin": "Remover PIN e exigir frase secreta para desbloquear",
|
||||
@@ -2054,10 +2167,13 @@
|
||||
"tt_theme_effects": "Brilho, luminescência, ciclo de matiz por tema",
|
||||
"tt_theme_hotkey": "Atalho: Ctrl+Esquerda/Direita para alternar temas",
|
||||
"tt_tor": "Rotear conexões do daemon através da rede Tor para anonimato",
|
||||
"tt_tor_unavailable": "Ainda não disponível: esta carteira não encaminha nenhum tráfego pelo Tor. Apenas para o nó, você pode adicionar proxy=127.0.0.1:9050 ao DRAGONX.conf e reiniciar o nó. O Tor precisa estar em execução, ou o nó não encontrará pares. Solicitações de preço, atualização, bootstrap e pool continuam se conectando diretamente; para elas, use uma VPN/Tor em todo o sistema.",
|
||||
"tt_tor_unavailable_lite": "Ainda não disponível: esta carteira não encaminha nenhum tráfego pelo Tor. Use uma VPN/Tor em todo o sistema.",
|
||||
"tt_tx_url": "URL base para visualizar transações em um explorador de blocos",
|
||||
"tt_ui_opacity": "Opacidade de cartões e barra lateral (100% = totalmente opaco, menor = mais transparente)",
|
||||
"tt_validate": "Verificar se um endereço DragonX é válido",
|
||||
"tt_verbose": "Registrar diagnósticos detalhados de conexão,\\nestado do daemon e info de proprietário de porta\\nna aba Console",
|
||||
"tt_view_transactions": "Mostrar as transações que envolvem este endereço na aba Histórico",
|
||||
"tt_wallet_size_warn": "Carteiras blindadas crescem com os dados de testemunha de cada nota — mesclar várias notas em um único endereço reduz esse tamanho. Faça backup antes.",
|
||||
"tt_wallets_button": "Liste os arquivos de carteira e alterne entre eles",
|
||||
"tt_website": "Abrir o site do DragonX",
|
||||
@@ -2075,6 +2191,7 @@
|
||||
"tx_loading_queued": "Atualização de transação na fila",
|
||||
"tx_loading_refreshing_cached": "Atualizando histórico da carteira (%d em cache)",
|
||||
"tx_loading_scanning_shielded": "Verificando histórico blindado (%d endereços)",
|
||||
"tx_loading_waits_for_sync": "O histórico será atualizado quando o nó terminar de sincronizar",
|
||||
"tx_mined": "MINERADO",
|
||||
"tx_progress_balances": "Atualizando saldos",
|
||||
"tx_progress_finalizing": "Finalizando transação",
|
||||
@@ -2106,6 +2223,7 @@
|
||||
"validate_description": "Digite um endereço DragonX para verificar se é válido e se pertence a esta carteira.",
|
||||
"validate_invalid": "INVÁLIDO",
|
||||
"validate_is_mine": "Esta carteira possui este endereço",
|
||||
"validate_mining_type": "Endereço de mineração (transparente)",
|
||||
"validate_not_mine": "Não pertence a esta carteira",
|
||||
"validate_ownership": "Propriedade:",
|
||||
"validate_results": "Resultados:",
|
||||
@@ -2113,7 +2231,6 @@
|
||||
"validate_shielded_type": "Blindado (z-endereço)",
|
||||
"validate_status": "Status:",
|
||||
"validate_title": "Validar Endereço",
|
||||
"validate_transparent_type": "Transparente (t-endereço)",
|
||||
"validate_type": "Tipo:",
|
||||
"validate_valid": "VÁLIDO",
|
||||
"validating": "Validando...",
|
||||
@@ -2123,6 +2240,7 @@
|
||||
"view_all": "Ver Tudo",
|
||||
"view_details": "Ver Detalhes",
|
||||
"view_on_explorer": "Ver no Explorador",
|
||||
"view_transactions": "Ver transações",
|
||||
"waiting_for_daemon": "Aguardando conexão com o daemon...",
|
||||
"wallet": "CARTEIRA",
|
||||
"wallet_degraded_notify": "Sua carteira foi aberta em modo de função reduzida: os fundos existentes estão seguros e disponíveis para gasto, mas a criação de novos endereços e o blindamento estão desativados. Faça backup da sua frase de recuperação e restaure-a para reparar totalmente a carteira.",
|
||||
@@ -2231,6 +2349,7 @@
|
||||
"wallets_reveal": "Mostrar pasta",
|
||||
"wallets_scan_folder": "Escanear outra pasta em busca de carteiras…",
|
||||
"wallets_scanned_folders": "Pastas verificadas:",
|
||||
"wallets_scanning": "Procurando nas pastas…",
|
||||
"wallets_sort_addresses": "Endereços",
|
||||
"wallets_sort_asc": "Crescente (mais antigo / menos / menor primeiro)",
|
||||
"wallets_sort_by": "Ordenar:",
|
||||
|
||||
+150
-31
@@ -25,6 +25,7 @@
|
||||
"about_website": "Веб-сайт",
|
||||
"acrylic": "Акрил",
|
||||
"add": "Добавить",
|
||||
"addr_type_mining": "Майнинг",
|
||||
"address": "Адрес",
|
||||
"address_book_add": "Добавить адрес",
|
||||
"address_book_add_new": "Добавить новый",
|
||||
@@ -68,6 +69,7 @@
|
||||
"appx_blockchain_maintenance_in_progress": "Операция обслуживания блокчейна уже выполняется.",
|
||||
"appx_blockchain_rescan_complete": "Повторное сканирование блокчейна завершено",
|
||||
"appx_bootstrap_complete_reconciling": "Начальная загрузка завершена — сверка кошелька с новыми данными цепи.",
|
||||
"appx_bootstrap_reconcile_probe_failed": "Не удалось сверить кошелёк с данными цепи из бутстрапа: узел был слишком занят, чтобы проверить свои блоки. Запустите Пересканировать в Настройках, когда узел догонит сеть.",
|
||||
"appx_cancel": "Отмена",
|
||||
"appx_cleaning_up": "Очистка...",
|
||||
"appx_confirm_your_backup": "Подтвердите резервную копию",
|
||||
@@ -78,11 +80,15 @@
|
||||
"appx_creating_your_wallet": "Создание кошелька…",
|
||||
"appx_daemon_error": "Ошибка демона",
|
||||
"appx_daemon_reinstall_in_progress": "Переустановка демона уже выполняется.",
|
||||
"appx_daemon_reinstall_node_busy": "Узел всё ещё работает, поэтому демон не был переустановлен. Повторите попытку, когда он остановится.",
|
||||
"appx_disconnecting": "Отключение...",
|
||||
"appx_done": "Готово",
|
||||
"appx_dragonxd_output": "вывод dragonxd",
|
||||
"appx_encrypting_wallet": "Шифрование кошелька...",
|
||||
"appx_fullnode_lifecycle_unavailable_lite": "Действия жизненного цикла полного узла недоступны в lite-сборке",
|
||||
"appx_import_cancelled": "Отменено: узел, возможно, ещё сканирует. Если средства ключа не появятся, запустите Настройки → Пересканировать.",
|
||||
"appx_import_interrupted": "Импорт был прерван, поэтому ключ, возможно, уже в кошельке. Если его средства не появятся, запустите Настройки → Пересканировать.",
|
||||
"appx_import_refused": "Узел отклонил запрос, поэтому ключ не был импортирован.",
|
||||
"appx_installing_bundled_daemon": "Установка встроенного демона — узел остановится, обновится и перезапустится...",
|
||||
"appx_invalid_payment_uri_prefix": "Неверный платёжный URI: ",
|
||||
"appx_ive_written_it_down": "Я записал её",
|
||||
@@ -99,7 +105,8 @@
|
||||
"appx_no_bundled_daemon_to_install": "В этой сборке нет встроенного демона для установки",
|
||||
"appx_no_embedded_daemon_to_install": "В этой сборке нет встроенного демона для установки",
|
||||
"appx_node_busy_restarting": "Узел занят перезапуском — попробуйте ещё раз через мгновение.",
|
||||
"appx_node_rebuilding_witness_cache": "Узел перестраивает кэш свидетелей",
|
||||
"appx_node_busy_try_later": "Узел занят долгой задачей (например, перестройкой свидетелей заметок). Повторите попытку, когда она завершится; если остановить его сейчас, эту работу, возможно, придётся начать заново.",
|
||||
"appx_node_not_stopped": "Узел не остановился вовремя, поэтому ничего не изменено. Повторите попытку через несколько минут.",
|
||||
"appx_not_next_word": " — это не следующее слово",
|
||||
"appx_payment_request_loaded": "Запрос на оплату загружен",
|
||||
"appx_pool_miner_connected_and_hashing": "Пул-майнер подключён и вычисляет хеши.",
|
||||
@@ -120,11 +127,7 @@
|
||||
"appx_simple_background_enabled": "Простой фон включён",
|
||||
"appx_skip": "Пропустить",
|
||||
"appx_skip_anyway": "Всё равно пропустить",
|
||||
"appx_still_status_prefix": "Всё ещё \"",
|
||||
"appx_still_status_suffix": "\" — принудительный выход сейчас может повредить данные цепи.",
|
||||
"appx_stop_anyway_and_quit": "Всё равно остановить и выйти",
|
||||
"appx_stopping_daemon_deleting_blockchain": "Остановка демона и удаление данных блокчейна...",
|
||||
"appx_stopping_node_discards_rebuild": "Остановка узла сейчас отменит текущую перестройку и запустит её заново (несколько минут) при следующем открытии кошелька. Вместо этого можно оставить узел работать.",
|
||||
"appx_stopping_pool_miner": "Остановка пул-майнера...",
|
||||
"appx_syncing_pct_block_n_of_n": "Синхронизация %.1f%% — Блок %d / %d",
|
||||
"appx_tap_words_in_order": "Нажимайте слова в правильном порядке, чтобы подтвердить, что вы их сохранили.",
|
||||
@@ -134,6 +137,9 @@
|
||||
"appx_use_settings_restart_daemon_hint": "Откройте Настройки > Перезапустить демон, чтобы повторить попытку",
|
||||
"appx_waiting_for_daemon_to_encrypt_wallet": "Ожидание шифрования кошелька демоном...",
|
||||
"appx_wallet_created_and_backed_up": "Кошелёк создан, резервная копия сохранена.",
|
||||
"appx_wallet_op_node_busy": "Узел занят и не отвечает. Повторите импорт или повторное сканирование, когда он освободится.",
|
||||
"appx_wallet_op_running": "Импорт ключа или повторное сканирование ещё выполняется. Повторите попытку, когда оно завершится.",
|
||||
"appx_wallet_op_shutting_down": "Кошелёк завершает работу.",
|
||||
"appx_wallet_open_failed_prefix": "Не удалось открыть кошелёк: ",
|
||||
"auto_shield": "Авто-экранирование майнинга",
|
||||
"autoshield_by_node": "Ваш узел автоматически защищает добытый coinbase за вас.",
|
||||
@@ -174,21 +180,20 @@
|
||||
"balance_history_collecting": "История баланса — сбор данных...",
|
||||
"balance_layout": "Макет баланса",
|
||||
"balance_layout_switched": "Раскладка: %s",
|
||||
"balance_mining_fmt": "Награды за майнинг: %.8f",
|
||||
"balance_mining_rate": "Майнинг %s",
|
||||
"balance_shielded_fmt": "Экранировано: %.8f",
|
||||
"balance_syncing_pct": "Синхронизация %.1f%%",
|
||||
"balance_transparent_fmt": "Прозрачный: %.8f",
|
||||
"baltab_market": "Рынок",
|
||||
"baltab_market_price_4dp": "Рынок: $%.4f",
|
||||
"baltab_market_price_8dp": "Рынок: $%.8f",
|
||||
"baltab_mining_addresses_count": "Адресов для майнинга: %d",
|
||||
"baltab_mining_amount": "Награды за майнинг %.8f",
|
||||
"baltab_pct_change_24h": "%s%.1f%% за 24ч",
|
||||
"baltab_pct_of_total_zaddr": "%.0f%% от общего · %d Z-addr",
|
||||
"baltab_shielded": "Скрытый",
|
||||
"baltab_shielded_amount": "Скрытый %.8f",
|
||||
"baltab_t_addresses_count": "%d T-адресов",
|
||||
"baltab_total_balance": "Общий баланс",
|
||||
"baltab_transparent": "Прозрачный",
|
||||
"baltab_transparent_amount": "Прозрачный %.8f",
|
||||
"ban": "Заблокировать",
|
||||
"banned_peers": "Заблокированные узлы",
|
||||
"block": "Блок",
|
||||
@@ -238,6 +243,7 @@
|
||||
"change_pass_confirm": "Подтвердите новый:",
|
||||
"change_pass_current": "Текущий пароль:",
|
||||
"change_pass_new": "Новый пароль:",
|
||||
"change_pass_pin_note": "У этого кошелька есть PIN для быстрой разблокировки. После смены он устареет: удалите PIN в настройках и установите его заново.",
|
||||
"change_pass_title": "Сменить пароль",
|
||||
"characters": "символов",
|
||||
"chat": "Чат",
|
||||
@@ -513,7 +519,7 @@
|
||||
"contact_avatar_image_hint": "Изображение копируется в приложение, чтобы оставаться доступным, если оригинал переместят.",
|
||||
"contact_avatar_remove": "Удалить",
|
||||
"contact_avatar_shielded": "Защищённый",
|
||||
"contact_avatar_transparent": "Прозрачный",
|
||||
"contact_chip_mining": "Только майнинг",
|
||||
"contact_global": "Показывать во всех кошельках (глобальный контакт)",
|
||||
"contact_global_badge_tt": "Глобальный контакт — виден во всех кошельках",
|
||||
"contact_global_tt": "Вкл.: этот контакт остаётся видимым, какой бы кошелёк вы ни загрузили. Выкл.: он принадлежит только текущему кошельку.",
|
||||
@@ -633,6 +639,11 @@
|
||||
"download": "Скачать",
|
||||
"download_bootstrap": "Скачать бутстрап",
|
||||
"dragonx_green": "DragonX (Зелёная)",
|
||||
"earnings_format": "Формат отображения дохода",
|
||||
"earnings_format_coin": "Только монета",
|
||||
"earnings_format_coin_fiat": "Монета + фиат",
|
||||
"earnings_format_fiat": "Только фиат",
|
||||
"earnings_label": "Доход",
|
||||
"edit": "Редактировать",
|
||||
"empty_wallet_keys_suffix": "ключей",
|
||||
"empty_wallet_open_manager": "Открыть менеджер кошельков",
|
||||
@@ -709,8 +720,8 @@
|
||||
"export_tx_success": "Транзакции успешно экспортированы",
|
||||
"export_tx_title": "Экспорт транзакций в CSV",
|
||||
"export_viewing_key": "Экспорт ключа просмотра",
|
||||
"failed_create_mining_address": "Не удалось создать адрес для майнинга",
|
||||
"failed_create_shielded": "Не удалось создать экранированный адрес",
|
||||
"failed_create_transparent": "Не удалось создать прозрачный адрес",
|
||||
"faq": "ЧаВо",
|
||||
"faq_d_mgmt_1_a": "Встроенный узел запускается автоматически вместе с кошельком и останавливается, когда это уместно, поэтому обычно управлять им вручную не нужно. Расширенные элементы управления (включая перезапуск демона) находятся в Настройки > Узел и безопасность.",
|
||||
"faq_d_mgmt_1_q": "Как запустить или остановить узел?",
|
||||
@@ -788,13 +799,13 @@
|
||||
"faq_open_tooltip": "Справка и FAQ",
|
||||
"faq_search_hint": "Поиск по FAQ…",
|
||||
"faq_title": "Справка и FAQ",
|
||||
"faq_w_addr_1_a": "Прозрачные адреса (начинаются с R или t) работают как в большинстве монет: суммы и адреса видны в блокчейне.\n\nЗащищённые адреса (Z-адреса, начинаются с zs) используют криптографию с нулевым разглашением — сумма, отправитель и получатель зашифрованы в цепочке. Балансы на защищённых адресах приватны.",
|
||||
"faq_w_addr_1_a": "Экранированные адреса (z-адреса, начинаются с zs) — это адреса, которыми вы пользуетесь в DragonX. Они используют криптографию с нулевым разглашением: сумма, отправитель и получатель шифруются в блокчейне, а ваш баланс остаётся приватным.\n\nПрозрачные адреса (начинаются с R) — это адреса для майнинга. DragonX — полностью приватная сеть, поэтому они могут получать только награды за майнинг: сеть отклоняет любой платёж на такой адрес. Поступившие туда награды остаются публичными, пока их не экранируют, а кошелёк делает это автоматически.",
|
||||
"faq_w_addr_1_q": "В чём разница между прозрачными и защищёнными адресами?",
|
||||
"faq_w_addr_2_a": "По возможности используйте защищённые (Z) адреса — они сохраняют приватность вашего баланса и истории платежей. DragonX — приватная монета, и защищённый адрес используется по умолчанию для получения средств.\n\nПрозрачный адрес стоит использовать только если сервис, с которым вы работаете, не поддерживает защищённые адреса.",
|
||||
"faq_w_addr_2_a": "Используйте экранированный (zs) адрес для всего, что отправляете и получаете, — в DragonX только на такой адрес можно заплатить.\n\nПрозрачные адреса (для майнинга) предназначены только для наград за соло-майнинг. Никогда не давайте такой адрес для платежа: сеть его отклонит.",
|
||||
"faq_w_addr_2_q": "Какой тип адреса мне использовать?",
|
||||
"faq_w_addr_3_a": "Для защищённых адресов повторное использование допустимо и не раскрывает историю. Для прозрачных адресов использование нового адреса для каждого платежа повышает приватность. Создавайте новые адреса на вкладке «Получить».",
|
||||
"faq_w_addr_3_a": "Повторно использовать экранированный адрес безопасно — это не раскрывает вашу историю. При желании можно создавать новые на вкладке «Получить», например по одному на контакт, чтобы видеть, кто вам заплатил. Адреса для майнинга нужны только для наград за майнинг; новый понадобится редко.",
|
||||
"faq_w_addr_3_q": "Использовать один адрес повторно или создавать новый?",
|
||||
"faq_w_addr_4_a": "Экранирование перемещает монеты с прозрачного адреса на защищённый, делая их приватными. Новые добытые монеты поступают как прозрачные и экранируются автоматически.\n\nВы также можете экранировать монеты вручную из кошелька — экранирование — это обычная транзакция, поэтому она подтверждается за обычное время.",
|
||||
"faq_w_addr_4_a": "Экранирование переводит монеты с прозрачного адреса (для майнинга) на экранированный, делая их приватными. Новые намайненные монеты приходят на адрес для майнинга и экранируются автоматически, когда созревают.\n\nЭкранировать можно и вручную в кошельке; экранирование — это обычная транзакция особого вида, поэтому подтверждается в обычные сроки.",
|
||||
"faq_w_addr_4_q": "Что такое «экранирование»?",
|
||||
"faq_w_addr_title": "Адреса и приватность",
|
||||
"faq_w_bal_1_a": "Пока кошелёк синхронизируется, ваш баланс неполный и может отображаться как 0 — не все транзакции ещё просканированы. Он заполнится после завершения синхронизации.\n\nСледите за строкой состояния внизу окна: она показывает состояние соединения и высоту блока. После завершения синхронизации баланс будет точным.",
|
||||
@@ -838,11 +849,11 @@
|
||||
"faq_w_seed_4_a": "Ваша сид-фраза — это основная резервная копия, и её достаточно для полного восстановления. Копия файла кошелька — удобная дополнительная резервная копия, которая также сохраняет метки и настройки. Храните любые резервные копии офлайн и в тайне.",
|
||||
"faq_w_seed_4_q": "Нужно ли также сохранять резервную копию wallet.dat?",
|
||||
"faq_w_seed_title": "Сид-фраза и резервное копирование",
|
||||
"faq_w_send_1_a": "Откройте вкладку «Отправить», вставьте адрес получателя, укажите сумму и подтвердите. Если кошелёк зашифрован, вам предложат разблокировать его для транзакции.\n\nОтправка на защищённые адреса приватна; отправка на прозрачные адреса публична.",
|
||||
"faq_w_send_1_a": "Откройте вкладку «Отправить», вставьте адрес получателя, укажите сумму и подтвердите. Если кошелёк зашифрован, вам предложат разблокировать его для транзакции.\n\nПлатежи отправляются на экранированные (zs) адреса и остаются приватными. Кошелёк не отправляет на прозрачный адрес (для майнинга): сеть отклоняет платежи на такие адреса.",
|
||||
"faq_w_send_1_q": "Как отправить средства?",
|
||||
"faq_w_send_2_a": "Комиссии DragonX очень низкие и устанавливаются автоматически. Комиссия показывается перед подтверждением. Обычно менять её не нужно.",
|
||||
"faq_w_send_2_q": "Какую комиссию я плачу?",
|
||||
"faq_w_send_3_a": "Да — при отправке на защищённый (Z) адрес можно приложить зашифрованную заметку. Прочитать её сможет только получатель. Прозрачные адреса не поддерживают заметки.",
|
||||
"faq_w_send_3_a": "Да — при отправке на защищённый (Z) адрес можно приложить зашифрованную заметку. Прочитать её сможет только получатель.",
|
||||
"faq_w_send_3_q": "Можно ли прикрепить сообщение к платежу?",
|
||||
"faq_w_send_4_a": "Транзакция остаётся «в ожидании», пока не будет добыта в блок и не получит подтверждения. Обычно это занимает минуту-две. Защищённым транзакциям также нужно, чтобы кошелёк был синхронизирован, для построения доказательства.\n\nЕсли отправка необычно долго остаётся в ожидании, проверьте, что кошелёк подключён и синхронизирован (см. строку состояния внизу окна).",
|
||||
"faq_w_send_4_q": "Я отправил платёж, но он в статусе «в ожидании» — почему?",
|
||||
@@ -857,6 +868,7 @@
|
||||
"faq_w_set_4_q": "Как показать цены в фиатной валюте?",
|
||||
"faq_w_set_title": "Настройки и внешний вид",
|
||||
"favorite_address": "Добавить в избранное",
|
||||
"favorite_opacity": "Непрозрачность выделения избранного",
|
||||
"fee": "Комиссия",
|
||||
"fee_high": "Высокая",
|
||||
"fee_label": "Комиссия:",
|
||||
@@ -864,14 +876,9 @@
|
||||
"fee_normal": "Обычная",
|
||||
"fetch_prices": "Получить цены",
|
||||
"file": "Файл",
|
||||
"file_save_location": "Файл будет сохранён в: ~/.config/ObsidianDragon/",
|
||||
"file_save_location": "Файл будет сохранён в:",
|
||||
"filter": "Фильтр...",
|
||||
"font_scale": "Масштаб шрифта",
|
||||
"force_quit": "Принудительный выход",
|
||||
"force_quit_confirm_msg": "Это немедленно завершит демон без корректного завершения.\nЭто может повредить индекс блокчейна и потребовать повторной синхронизации.",
|
||||
"force_quit_confirm_title": "Принудительный выход?",
|
||||
"force_quit_warning": "Это немедленно завершит демон без корректного завершения. Может потребоваться повторная синхронизация блокчейна.",
|
||||
"force_quit_yes": "Принудительный выход",
|
||||
"from": "От",
|
||||
"from_upper": "ОТ",
|
||||
"full_details": "Полные детали",
|
||||
@@ -940,6 +947,7 @@
|
||||
"hidden_tag": " (скрыт)",
|
||||
"hide": "Скрыть",
|
||||
"hide_address": "Скрыть адрес",
|
||||
"hide_earnings": "Скрыть доход",
|
||||
"hide_qr": "Скрыть QR",
|
||||
"hide_zero_balances": "Скрыть нулевые балансы",
|
||||
"history": "История",
|
||||
@@ -1014,11 +1022,13 @@
|
||||
"lite_birthday_backup": "Дата рождения: %llu (сохраните её тоже)",
|
||||
"lite_birthday_hint": "Высота блока, с которой начинать сканирование. Оставьте 0, если неизвестно (медленное полное сканирование).",
|
||||
"lite_birthday_label": "Дата рождения",
|
||||
"lite_confirm_label": "Повтор",
|
||||
"lite_console_backend_commands": "Команды бэкенда:",
|
||||
"lite_console_help_passthrough": "Любой другой ввод выполняется как команда консоли лайт-кошелька.",
|
||||
"lite_copy": "Копировать",
|
||||
"lite_could_not_start": "Не удалось запустить операцию",
|
||||
"lite_could_not_write": "Не удалось записать ",
|
||||
"lite_encrypt_offline_warning": "Любой, у кого есть файл кошелька, может подбирать парольные фразы офлайн. Сначала сохраните сид-фразу и используйте длинную парольную фразу.",
|
||||
"lite_encrypt_wallet": "Зашифровать кошелёк",
|
||||
"lite_encryption_removed": "Шифрование удалено",
|
||||
"lite_enter_all_seed_words": "Введите все 24 слова seed-фразы для восстановления (введено %d)",
|
||||
@@ -1026,6 +1036,7 @@
|
||||
"lite_hide_wipe": "Скрыть и стереть",
|
||||
"lite_import": "Импорт",
|
||||
"lite_import_key_label": "Импортировать ключ",
|
||||
"lite_key_import_failed": "Не удалось импортировать ключ — проверьте его, разблокируйте кошелёк (если зашифрован) и повторите попытку.",
|
||||
"lite_key_imported": "Ключ импортирован — выполните синхронизацию, чтобы просканировать его историю",
|
||||
"lite_lock_failed": "Не удалось заблокировать",
|
||||
"lite_lock_now": "Заблокировать сейчас",
|
||||
@@ -1170,9 +1181,43 @@
|
||||
"merge_description": "Объедините несколько UTXO в один экранированный адрес. Это может уменьшить размер кошелька и улучшить конфиденциальность.",
|
||||
"merge_fee_hint": "Сетевая комиссия за эту транзакцию.",
|
||||
"merge_funds": "Объединить средства",
|
||||
"merge_into_one": "На один адрес",
|
||||
"merge_keep_each": "Оставить в каждом адресе",
|
||||
"merge_keep_each_batches_fmt": "Адреса с более чем %d заметками объединяются в несколько партий; запустите снова после их подтверждения, чтобы объединить остальное.",
|
||||
"merge_keep_each_confirm_fmt": "Объединить %d заметок на %d адресах? Каждый адрес сохраняет свои средства. Комиссии: около %s DRGX (по одной на партию).",
|
||||
"merge_keep_each_desc": "Заметки каждого адреса объединяются обратно в тот же адрес, поэтому средства, которые вы держите раздельно под разными метками, остаются раздельными. Только экранированные адреса — прозрачные средства можно только экранировать («На один адрес»).",
|
||||
"merge_keep_each_none": "На каждом экранированном адресе уже одна заметка — объединять нечего.",
|
||||
"merge_keep_each_nothing_submitted": "Ничего не объединено — узел отклонил все объединения.",
|
||||
"merge_keep_each_row_fmt": "%s · заметок: %d · %s DRGX",
|
||||
"merge_keep_each_run_again": "На адресах, объединённых в несколько партий, теперь по одной заметке на партию. Запустите снова после их подтверждения, чтобы объединить их в одну.",
|
||||
"merge_keep_each_select": "Отметьте хотя бы один адрес для объединения.",
|
||||
"merge_keep_each_some_failed": "Некоторые объединения не удались — см. список выше.",
|
||||
"merge_keep_each_status_done": "%s — готово: объединено заметок: %d, партий: %d",
|
||||
"merge_keep_each_status_failed": "%s — ошибка: %s",
|
||||
"merge_keep_each_status_not_started": "%s — не запущено: %s",
|
||||
"merge_keep_each_status_running": "%s — объединение %d заметок (готово партий: %d из %d)…",
|
||||
"merge_keep_each_status_unconfirmed": "%s — статус не удалось подтвердить",
|
||||
"merge_keep_each_too_small": "Слишком мало для объединения: комиссия съест эти средства.",
|
||||
"merge_max_inputs": "Макс. входов за партию",
|
||||
"merge_no_spendable": "Пока нет доступных для расходования входов для объединения.",
|
||||
"merge_progress": "Объединение… это может занять несколько минут. Вы можете закрыть это окно.",
|
||||
"merge_run_aborted_toast": "Соединение с узлом было сброшено, поэтому объединение остановлено. Запустите его снова, чтобы продолжить.",
|
||||
"merge_run_busy": "Идёт объединение. Дождитесь его завершения или сначала остановите его.",
|
||||
"merge_run_chip_fmt": "Объединение %d/%d",
|
||||
"merge_run_err_timeout": "Узел не ответил вовремя; партия, возможно, всё ещё в очереди.",
|
||||
"merge_run_halted_fmt": "Объединение остановлено после повторных сбоев: %s",
|
||||
"merge_run_migration_pending": "Завершите или отмените перенос сид-фразы перед объединением.",
|
||||
"merge_run_progress_fmt": "Объединение… партия %d из примерно %d, объединено заметок: %d. Это окно можно закрыть — операция продолжится в фоне.",
|
||||
"merge_run_start_failed": "Не удалось начать объединение — узел не подключён.",
|
||||
"merge_run_status_not_run": "%s — не объединено (%d заметок)",
|
||||
"merge_run_status_waiting": "%s — ожидание (%d заметок)",
|
||||
"merge_run_stop": "Остановить",
|
||||
"merge_run_stopped_toast": "Объединение остановлено. Запустите его снова, чтобы объединить остальное.",
|
||||
"merge_run_stopping": "Остановка после уже отправленных партий…",
|
||||
"merge_run_wait_batch": "Партии отправляются с паузами, чтобы узел оставался отзывчивым…",
|
||||
"merge_run_wait_send": "Приостановлено до завершения вашей ожидающей транзакции.",
|
||||
"merge_run_wait_sync": "Приостановлено: узел ещё синхронизируется.",
|
||||
"merge_scope_failed_fmt": "Не удалось прочитать ваши входы: %s",
|
||||
"merge_scope_fmt": "%d прозрачных + %d защищённых входов · ~%s DRGX доступно",
|
||||
"merge_scope_loading": "Проверка ваших входов…",
|
||||
"merge_send_failed": "Ошибка объединения: ",
|
||||
@@ -1181,7 +1226,11 @@
|
||||
"merge_src_shielded": "Защищённые",
|
||||
"merge_src_transparent": "Прозрачные",
|
||||
"merge_started": "Операция объединения начата",
|
||||
"merge_submit_uncertain": "Узел не подтвердил объединение до разрыва соединения. Оно всё ещё может выполниться — после переподключения проверьте Историю, прежде чем объединять снова.",
|
||||
"merge_title": "Объединить на адрес",
|
||||
"merge_wait_locked": "Кошелёк заблокирован. Разблокируйте его, чтобы продолжить.",
|
||||
"merge_waiting_node": "Ожидание узла…",
|
||||
"merge_where": "Куда направить средства?",
|
||||
"mig_adopt": "Сделать это моим кошельком",
|
||||
"mig_adopt_now": "Принять сид-кошелёк",
|
||||
"mig_adopting": "Установка вашего нового кошелька и пересканирование…",
|
||||
@@ -1233,6 +1282,8 @@
|
||||
"miner_fee": "Комиссия майнера",
|
||||
"mining": "Майнинг",
|
||||
"mining_active": "Активен",
|
||||
"mining_addr_explainer": "Принимает только награды за соло-майнинг — сеть отклоняет платежи на этот адрес. Награды экранируются автоматически, когда созревают.",
|
||||
"mining_address": "Адрес для майнинга",
|
||||
"mining_address_copied": "Адрес майнинга скопирован",
|
||||
"mining_all_time": "За всё время",
|
||||
"mining_already_saved": "URL пула уже сохранён",
|
||||
@@ -1246,6 +1297,8 @@
|
||||
"mining_benchmark_testing": "Тестирование",
|
||||
"mining_benchmark_tooltip": "Найти оптимальное количество потоков для этого процессора",
|
||||
"mining_block_copied": "Хэш блока скопирован",
|
||||
"mining_card_click_to_shield": "Нажмите, чтобы экранировать созревшие награды сейчас.",
|
||||
"mining_card_tooltip": "Награды за соло-майнинг поступают на ваши адреса для майнинга (прозрачные, R…). Эти адреса не принимают платежи. Награды экранируются автоматически, когда созревают.",
|
||||
"mining_chart_1m_ago": "1м назад",
|
||||
"mining_chart_5m_ago": "5м назад",
|
||||
"mining_chart_now": "Сейчас",
|
||||
@@ -1261,6 +1314,8 @@
|
||||
"mining_est_block": "Расч. блок",
|
||||
"mining_est_daily": "Расч. за день",
|
||||
"mining_est_daily_pool_sub": "примерный соло-эквивалент, до комиссии пула",
|
||||
"mining_est_daily_sub": "сред.",
|
||||
"mining_est_daily_sub_net": "сред., нетто",
|
||||
"mining_filter_all": "Все",
|
||||
"mining_filter_tip_all": "Показать все доходы",
|
||||
"mining_filter_tip_pool": "Показать только доходы пула",
|
||||
@@ -1290,6 +1345,7 @@
|
||||
"mining_payout_address": "Адрес выплат",
|
||||
"mining_payout_foreign": "⚠ Этот адрес выплат отсутствует в вашем текущем кошельке — намайненные вознаграждения будут отправлены в другой кошелёк. Обновите его, если вы сменили кошелёк.",
|
||||
"mining_payout_invalid": "Недействительный адрес DragonX — исправьте перед запуском, иначе награды за майнинг будут потеряны.",
|
||||
"mining_payout_needs_shielded": "Пулы выплачивают обычными транзакциями, которые в DragonX не могут дойти до адреса для майнинга (прозрачного, R…). Укажите экранированный адрес (начинается с zs) как адрес для выплат.",
|
||||
"mining_payout_tooltip": "Адрес для получения вознаграждений за майнинг",
|
||||
"mining_pool": "Пул",
|
||||
"mining_pool_fee": "Комиссия",
|
||||
@@ -1302,6 +1358,8 @@
|
||||
"mining_refresh": "Обновить",
|
||||
"mining_remove": "Удалить",
|
||||
"mining_reset_defaults": "Сбросить настройки",
|
||||
"mining_rewards": "Награды за майнинг",
|
||||
"mining_rewards_unshielded": "Ещё не экранированы",
|
||||
"mining_save_payout_address": "Сохранить адрес выплат",
|
||||
"mining_save_pool_url": "Сохранить URL пула",
|
||||
"mining_saved_addresses": "Сохранённые адреса:",
|
||||
@@ -1335,10 +1393,10 @@
|
||||
"network_fee": "СЕТЕВАЯ КОМИССИЯ",
|
||||
"network_hashrate": "Хешрейт сети",
|
||||
"new": "+ Новый",
|
||||
"new_mining_address": "Новый адрес для майнинга",
|
||||
"new_mining_address_created": "Новый адрес для майнинга создан",
|
||||
"new_shielded_created": "Создан новый экранированный адрес",
|
||||
"new_t_address": "Новый T-адрес",
|
||||
"new_t_transparent": "Новый t-адрес (Прозрачный)",
|
||||
"new_transparent_created": "Создан новый прозрачный адрес",
|
||||
"new_z_address": "Новый Z-адрес",
|
||||
"new_z_shielded": "Новый z-адрес (Экранированный)",
|
||||
"no_addresses": "Адреса не найдены. Создайте один, используя кнопки выше.",
|
||||
@@ -1358,7 +1416,19 @@
|
||||
"node_banner_offline_title": "Нет подключения к узлу DragonX",
|
||||
"node_banner_reconnect": "Переподключить",
|
||||
"node_banner_restart": "Перезапустить узел",
|
||||
"node_busy_rebuild_desc": "Выполняется %s. Для больших кошельков это может занять около часа, и до окончания узел не может отвечать кошельку.",
|
||||
"node_busy_rebuild_extending": "Расширение свидетелей (узел не сообщает о ходе этого этапа).",
|
||||
"node_busy_rebuild_reading": "Чтение блоков: %ld / %ld",
|
||||
"node_busy_rebuild_title": "Узел занят: перестройка свидетелей заметок",
|
||||
"node_busy_replay_desc": "Выполняется %s. Узел повторно применяет блоки, полученные до последней остановки.",
|
||||
"node_busy_replay_title": "Узел занят: догоняет блоки",
|
||||
"node_busy_rpc_desc": "Узел работает, но уже %s слишком занят, чтобы отвечать кошельку, обычно из-за долгой операции, например перестройки свидетелей.",
|
||||
"node_busy_rpc_title": "Узел занят: пока не отвечает",
|
||||
"node_busy_running": "Выполняется %s",
|
||||
"node_security": "УЗЕЛ И БЕЗОПАСНОСТЬ",
|
||||
"node_starting_busy_desc": "Узел доступен, но занят и пока не отвечает. Это нормально после обновления или при первом запуске; может занять несколько минут.",
|
||||
"node_starting_launch_desc": "Запуск dragonxd и ожидание его готовности…",
|
||||
"node_starting_title": "Запуск узла DragonX…",
|
||||
"noise": "Шум",
|
||||
"not_connected": "Не подключено к daemon...",
|
||||
"not_connected_to_daemon": "Не подключено к daemon",
|
||||
@@ -1511,6 +1581,8 @@
|
||||
"ram_wallet_gb": "Кошелёк: %.1f ГБ",
|
||||
"ram_wallet_mb": "Кошелёк: %.0f МБ",
|
||||
"receive": "Получить",
|
||||
"receive_not_for_payments": "Не для платежей",
|
||||
"receive_use_shielded": "Использовать экранированный адрес",
|
||||
"received": "получено",
|
||||
"received_filter": "Получено",
|
||||
"received_label": "Получено",
|
||||
@@ -1525,6 +1597,7 @@
|
||||
"reduce_motion": "Уменьшить анимацию",
|
||||
"refresh": "Обновить",
|
||||
"refresh_now": "Обновить сейчас",
|
||||
"refresh_waits_for_sync": "Узел ещё синхронизируется. Данные кошелька обновятся после её завершения.",
|
||||
"remove_favorite": "Удалить из избранного",
|
||||
"repair_wallet": "Восстановить кошелёк",
|
||||
"report_bug": "Сообщить об ошибке",
|
||||
@@ -1539,12 +1612,12 @@
|
||||
"request_select_address": "Выбрать адрес...",
|
||||
"request_shielded_addrs": "-- Экранированные адреса --",
|
||||
"request_title": "Запрос оплаты",
|
||||
"request_transparent_addrs": "-- Прозрачные адреса --",
|
||||
"request_uri_copied": "URI платежа скопирован в буфер обмена",
|
||||
"rescan": "Пересканировать",
|
||||
"rescan_bootstrapped_msg": "Ваш узел был инициализирован из бутстрапа, поэтому блоки ниже снимка отсутствуют на диске, и пересканирование с генезиса не удастся. Пересканируйте с высоты, входящей в ваш снимок, чтобы согласовать потраченный баланс кошелька. Ваш wallet.dat и данные блокчейна не удаляются.",
|
||||
"rescan_detecting": "Проверка, какие блоки уже есть на диске вашего узла…",
|
||||
"rescan_from_height": "Пересканировать с высоты блока:",
|
||||
"rescan_probe_failed": "Не удалось проверить, какие блоки есть на диске вашего узла (он занят или истекло время ожидания запроса). Если узел был инициализирован из бутстрапа, отмените и повторите попытку через минуту: полный перезапуск с -rescan ниже на таком узле не удастся.",
|
||||
"reset_to_defaults": "Сбросить настройки",
|
||||
"restarting_after_encryption": "Перезапуск демона после шифрования...",
|
||||
"restore_address": "Восстановить адрес",
|
||||
@@ -1587,6 +1660,7 @@
|
||||
"sb_net_khs": "Сеть: %.2f KH/s",
|
||||
"sb_net_mhs": "Сеть: %.2f MH/s",
|
||||
"sb_no_conf": "DRAGONX.conf не найден",
|
||||
"sb_node_busy": "Узел занят",
|
||||
"sb_peers": "Пиры: %zu",
|
||||
"sb_plaintext_remote_blocked": "Отправка учётных данных RPC открытым текстом на удалённый узел запрещена. Добавьте rpcallowplaintext=1 в DRAGONX.conf, чтобы разрешить, или включите TLS с помощью rpctls=1.",
|
||||
"sb_rescanning": "Пересканирование",
|
||||
@@ -1608,6 +1682,7 @@
|
||||
"screenshot_sweep": "Запустить прогон скриншотов",
|
||||
"screenshot_sweep_desc": "Перебирает каждую тему по всем вкладкам и сохраняет скриншот каждой в подпапки по вкладкам внутри папки screenshots в каталоге конфигурации (перезаписывая предыдущий проход). Выполняется несколько секунд.",
|
||||
"screenshot_sweep_full": "Полный обход интерфейса",
|
||||
"screenshot_sweep_refused": "Полный обход интерфейса отклонён; см. журнал отладки",
|
||||
"search_icons": "Поиск значков...",
|
||||
"search_placeholder": "Поиск...",
|
||||
"sec_changing_passphrase": "Смена парольной фразы...",
|
||||
@@ -1629,15 +1704,19 @@
|
||||
"sec_mode_passphrase": " Парольная фраза",
|
||||
"sec_not_connected_to_daemon": "Нет соединения с демоном",
|
||||
"sec_not_connected_to_daemon_pin": "Нет соединения с демоном",
|
||||
"sec_passphrase_changed_pin_stale": "Парольная фраза изменена; PIN устарел",
|
||||
"sec_passphrase_changed_successfully": "Парольная фраза успешно изменена",
|
||||
"sec_pin_changed_successfully": "PIN успешно изменён",
|
||||
"sec_pin_removed": "PIN удалён",
|
||||
"sec_pin_set_successfully": "PIN успешно задан",
|
||||
"sec_pin_stale_msg": "PIN устарел. Используйте парольную фразу.",
|
||||
"sec_pin_unreadable": "Файл PIN не читается. Используйте парольную фразу.",
|
||||
"sec_restart_daemon_for_encryption": "Перезапустите демон, чтобы шифрование вступило в силу.",
|
||||
"sec_too_many_attempts_wait": "Слишком много попыток. Подождите %.0f сек...",
|
||||
"sec_total_elapsed_fmt": "Всего прошло: %dм %02dс",
|
||||
"sec_unlock_button": "Разблокировать",
|
||||
"sec_unlock_failed_prefix": "Ошибка разблокировки: ",
|
||||
"sec_unlock_node_busy": "The node is busy; try again in a moment.",
|
||||
"sec_unlocking_fmt": "Разблокировка%s",
|
||||
"sec_use_passphrase_instead": "Использовать парольную фразу",
|
||||
"sec_use_pin_instead": "Использовать PIN",
|
||||
@@ -1690,6 +1769,8 @@
|
||||
"send_from_this_address": "Отправить с этого адреса",
|
||||
"send_go_to_receive": "Перейти к получению",
|
||||
"send_keep": "Сохранить",
|
||||
"send_mining_addr_blocked": "Это адрес для майнинга (прозрачный). В DragonX такие адреса могут получать только награды за майнинг — сеть отклоняет платежи на них. Попросите у получателя экранированный адрес (начинается с zs).",
|
||||
"send_mining_addr_short": "Адрес для майнинга — не принимает платежи",
|
||||
"send_network_fee": "СЕТЕВАЯ КОМИССИЯ",
|
||||
"send_no_balance": "Нет баланса",
|
||||
"send_no_recent": "Нет недавних отправлений",
|
||||
@@ -1717,7 +1798,6 @@
|
||||
"send_txid_copied": "TxID скопирован в буфер обмена",
|
||||
"send_txid_label": "TxID: %s",
|
||||
"send_valid_shielded": "Действительный экранированный адрес",
|
||||
"send_valid_transparent": "Действительный прозрачный адрес",
|
||||
"send_wallet_empty": "Ваш кошелёк пуст",
|
||||
"send_yes_clear": "Да, очистить",
|
||||
"sender_balance": "Отправитель: %.8f → %.8f DRGX",
|
||||
@@ -1780,6 +1860,7 @@
|
||||
"settings_open_log_folder": "Открыть папку журналов",
|
||||
"settings_other": "Прочее",
|
||||
"settings_pin_active": "PIN",
|
||||
"settings_pin_out_of_date": "PIN устарел",
|
||||
"settings_privacy": "Конфиденциальность",
|
||||
"settings_quick_unlock_pin": "Быстрый PIN-код разблокировки",
|
||||
"settings_reduce_transparency": "Уменьшить прозрачность",
|
||||
@@ -1845,11 +1926,24 @@
|
||||
"shielded_type": "Экранированный",
|
||||
"shielding_notice": "Примечание: это переведёт средства с прозрачного (T) адреса на приватный (Z) адрес.",
|
||||
"show": "Показать",
|
||||
"show_earnings": "Показать доход",
|
||||
"show_hidden": "Показать скрытые (%d)",
|
||||
"show_qr": "Показать QR",
|
||||
"show_qr_code": "Показать QR-код",
|
||||
"showing_transactions": "Показано %d–%d из %d транзакций (всего: %zu)",
|
||||
"showing_x_of_y": "Показано %d из %d адресов",
|
||||
"shutdown_kill_confirm_msg": "Узел давно не проявляет активности процессора и диска и, возможно, завис.\nПри принудительном завершении всё несохранённое будет потеряно; следующий запуск может занять гораздо больше времени.",
|
||||
"shutdown_kill_confirm_title": "Принудительно завершить узел?",
|
||||
"shutdown_kill_confirm_yes": "Завершить узел",
|
||||
"shutdown_kill_node": "Принудительно завершить узел (небезопасно)",
|
||||
"shutdown_kill_node_tip": "Немедленно завершает узел. Всё несохранённое будет потеряно, а при следующем запуске он заново обработает блоки с момента последнего сохранения.",
|
||||
"shutdown_leave_running": "Закрыть кошелёк — оставить узел работать",
|
||||
"shutdown_leave_running_tip": "Закрывает кошелёк сейчас. Узел продолжит работать сам и завершит текущую операцию; ничего не теряется.",
|
||||
"shutdown_stop_asking": "Отправка узлу команды остановки…",
|
||||
"shutdown_stop_cpu_suffix": " · ЦП %d%%",
|
||||
"shutdown_stop_delivered": "Узел завершает работу и сохраняет своё состояние",
|
||||
"shutdown_stop_idle": "Нет активности процессора и диска уже %d мин — возможно, узел завис",
|
||||
"shutdown_stop_queued": "Узел получил команду остановки, но занят — он остановится, как только сможет",
|
||||
"simple_background": "Простой фон",
|
||||
"slider_off": "Выкл",
|
||||
"sort_amount_high": "Наибольшая сумма",
|
||||
@@ -1857,9 +1951,18 @@
|
||||
"sort_date_newest": "Сначала новые",
|
||||
"sort_date_oldest": "Сначала старые",
|
||||
"start_mining": "Начать майнинг",
|
||||
"startup_files_preparing": "Подготовка файлов кошелька... %d%%",
|
||||
"startup_files_verifying": "Проверка файлов кошелька...",
|
||||
"status": "Статус",
|
||||
"stop_external": "Остановить внешний daemon",
|
||||
"stop_mining": "Остановить майнинг",
|
||||
"stopconfirm_busy_body": "Уже %s он слишком занят, чтобы отвечать кошельку, вероятно, из-за долгой операции, например перестройки свидетелей. Если остановить его сейчас, при следующем запуске эту работу, возможно, придётся выполнить заново.\n\nРекомендуется: закройте кошелёк и оставьте узел работать. Он завершит всё сам.",
|
||||
"stopconfirm_busy_title": "Узел занят",
|
||||
"stopconfirm_rebuild_body": "Она выполняется уже %s, а для больших кошельков может занять около часа. Если остановить узел сейчас, он либо дождётся окончания перестройки, либо при следующем запуске выполнит её заново вместе с догонянием блоков.\n\nРекомендуется: закройте кошелёк и оставьте узел работать. Он завершит всё сам.",
|
||||
"stopconfirm_rebuild_title": "Узел перестраивает свидетелей заметок",
|
||||
"stopconfirm_stop_wait": "Остановить узел и подождать",
|
||||
"stopconfirm_walletop_body": "Узел сканирует цепочку для импорта ключа или повторного сканирования. Если остановить его сейчас, это сканирование закончится раньше, а средства, до которых оно ещё не дошло, появятся только после Настройки → Пересканировать.\n\nРекомендуется: закройте кошелёк и оставьте узел работать. Он завершит сканирование сам.",
|
||||
"stopconfirm_walletop_title": "Выполняется импорт ключа или повторное сканирование",
|
||||
"stratum_allowip_hint": "Разрешить майнерам подключаться с IP или CIDR (пусто = только этот компьютер)",
|
||||
"stratum_expose_warn": "Открывает порт для майнинга в разрешённую вами сеть — используйте только в доверенной локальной сети.",
|
||||
"stratum_host": "Разместить майнинг-пул (stratum)",
|
||||
@@ -1906,7 +2009,6 @@
|
||||
"switch_stopnode_title": "Остановить запущенный узел?",
|
||||
"switch_stopnode_warn": "Уже запущен узел, который не был запущен этим кошельком.",
|
||||
"syncing": "Синхронизация...",
|
||||
"t_address": "T-адрес",
|
||||
"t_addresses": "T-адреса",
|
||||
"test_connection": "Тест",
|
||||
"theme": "Тема",
|
||||
@@ -1928,6 +2030,7 @@
|
||||
"tools": "УТИЛИТЫ",
|
||||
"tools_actions": "Инструменты и действия...",
|
||||
"tools_actions_hdr": "ИНСТРУМЕНТЫ И ДЕЙСТВИЯ",
|
||||
"tor_never_applied_notice": "Опция «Использовать Tor» никогда не работала: ваши соединения НЕ направлялись через Tor. Она отключена. Альтернативы описаны в её подсказке в Настройках.",
|
||||
"total": "Итого",
|
||||
"total_balance_label": "Общий баланс",
|
||||
"transaction_id": "ID ТРАНЗАКЦИИ",
|
||||
@@ -1941,8 +2044,6 @@
|
||||
"transfer_sent": "Перевод отправлен",
|
||||
"transfer_sent_desc": "Ваш перевод отправлен в сеть.",
|
||||
"transfer_to": "Перевести на:",
|
||||
"transparent": "Прозрачный",
|
||||
"transparent_address": "Прозрачный адрес",
|
||||
"try_again": "Повторить",
|
||||
"tt_addr_url": "Базовый URL для просмотра адресов в обозревателе блоков",
|
||||
"tt_address_book": "Управление сохранёнными адресами для быстрой отправки",
|
||||
@@ -1974,10 +2075,16 @@
|
||||
"tt_debug_expand": "Развернуть параметры журнала отладки",
|
||||
"tt_delete_blockchain": "Удалить все данные блокчейна и начать синхронизацию заново. wallet.dat и конфигурация сохраняются.",
|
||||
"tt_download_bootstrap": "Скачать бутстрап блокчейна для ускорения синхронизации\nСуществующие данные блоков будут заменены",
|
||||
"tt_earn_interval": "Период дохода: 1H — текущий час, 1D — сегодня, 1M — текущий месяц, 1Y — текущий год",
|
||||
"tt_earn_mode_inflows": "Режим дохода: полученные суммы (включая майнинг)",
|
||||
"tt_earn_mode_net": "Режим дохода: нетто (получено минус отправлено)",
|
||||
"tt_earn_show_all": "Показывать сумму дохода для каждого адреса",
|
||||
"tt_earnings_format": "Как отображаются суммы дохода по адресам и итогам",
|
||||
"tt_encrypt": "Зашифровать wallet.dat паролем",
|
||||
"tt_export_all": "Экспортировать все приватные ключи в файл",
|
||||
"tt_export_csv": "Экспортировать историю транзакций в виде таблицы CSV",
|
||||
"tt_export_key": "Экспортировать приватный ключ выбранного адреса",
|
||||
"tt_favorite_opacity": "Непрозрачность кнопки-звёздочки избранного и её золотой обводки в списке адресов (минимум 15 %)",
|
||||
"tt_fetch_prices": "Получить рыночные цены DRGX из API CoinGecko",
|
||||
"tt_font_scale": "Масштабировать весь текст и интерфейс (1.0x = по умолчанию, до 1.5x).",
|
||||
"tt_idle_delay": "Сколько ждать перед началом майнинга",
|
||||
@@ -1986,6 +2093,7 @@
|
||||
"tt_keep_daemon": "Демон будет остановлен при запуске мастера настройки",
|
||||
"tt_language": "Язык интерфейса кошелька",
|
||||
"tt_layout_hotkey": "Горячая клавиша: стрелки влево/вправо для переключения раскладок Баланса",
|
||||
"tt_lite_confirm_pass": "Введите новую парольную фразу ещё раз; она должна совпадать и содержать не менее 8 символов",
|
||||
"tt_lite_copy": "Скопировать показанный секрет в буфер обмена",
|
||||
"tt_lite_decrypt_pass": "Введите пароль, чтобы снять шифрование с кошелька",
|
||||
"tt_lite_encrypt": "Зашифровать кошелёк паролем выше; он блокируется сразу же и требует пароль для разблокировки",
|
||||
@@ -2014,11 +2122,16 @@
|
||||
"tt_low_spec": "Отключить все тяжёлые визуальные эффекты\\nГорячая клавиша: Ctrl+Shift+Down",
|
||||
"tt_merge": "Объединить несколько UTXO в один адрес",
|
||||
"tt_mine_idle": "Автоматически начать майнинг при\\nпростое системы (нет ввода с клавиатуры/мыши)",
|
||||
"tt_mining_est_daily": "Долгосрочное статистическое среднее для вашей текущей доли хешрейта в сети. Реальные награды за майнинг приходят случайно (по Пуассону) — часто подолгу ничего, а затем сразу много, — поэтому это НЕ стабильный ежедневный доход.",
|
||||
"tt_mining_est_daily_fee_fmt": "Показано за вычетом комиссии пула %.1f%%.",
|
||||
"tt_mining_est_daily_nofee": "Показано до вычета комиссии пула (комиссия этого пула неизвестна).",
|
||||
"tt_new_mining_address": "Создаёт прозрачный адрес (R…) для наград за соло-майнинг. Он не принимает платежи — для них сообщайте экранированный адрес.",
|
||||
"tt_noise": "Интенсивность зернистой текстуры (0% = выкл., 100% = максимум)",
|
||||
"tt_open_app_dir": "Открыть папку ObsidianDragon (настройки, темы, логи) в файловом менеджере",
|
||||
"tt_open_data_dir": "Открыть в файловом менеджере папку с данными кошелька и блокчейна",
|
||||
"tt_open_dir": "Нажмите, чтобы открыть в проводнике",
|
||||
"tt_open_log_folder": "Открывает папку с журналами отладки и сбоев",
|
||||
"tt_pin_out_of_date": "Парольная фраза кошелька изменилась после установки этого PIN, поэтому PIN больше не разблокирует кошелёк. Удалите PIN и установите его заново.",
|
||||
"tt_reduce_motion": "Отключить анимированные переходы и плавное изменение баланса для доступности",
|
||||
"tt_remove_encrypt": "Удалить шифрование и хранить кошелёк без защиты",
|
||||
"tt_remove_pin": "Удалить PIN и требовать пароль для разблокировки",
|
||||
@@ -2054,10 +2167,13 @@
|
||||
"tt_theme_effects": "Мерцание, свечение, циклическая смена оттенка по теме",
|
||||
"tt_theme_hotkey": "Горячая клавиша: Ctrl+Влево/Вправо для переключения тем",
|
||||
"tt_tor": "Маршрутизировать подключения демона через сеть Tor для анонимности",
|
||||
"tt_tor_unavailable": "Пока недоступно: этот кошелёк не направляет никакой трафик через Tor. Только для узла можно добавить proxy=127.0.0.1:9050 в DRAGONX.conf и перезапустить узел. Tor должен быть запущен, иначе узел не найдёт других узлов. Запросы цен, обновлений, bootstrap и пула по-прежнему идут напрямую; для них используйте общесистемный VPN/Tor.",
|
||||
"tt_tor_unavailable_lite": "Пока недоступно: этот кошелёк не направляет никакой трафик через Tor. Используйте общесистемный VPN/Tor.",
|
||||
"tt_tx_url": "Базовый URL для просмотра транзакций в обозревателе блоков",
|
||||
"tt_ui_opacity": "Непрозрачность карточек и боковой панели (100% = полностью непрозрачно, ниже = прозрачнее)",
|
||||
"tt_validate": "Проверить, действителен ли адрес DragonX",
|
||||
"tt_verbose": "Записывать подробную диагностику подключений,\\nсостояние демона и информацию о владельце порта\\nна вкладке Консоль",
|
||||
"tt_view_transactions": "Показать транзакции с этим адресом на вкладке «История»",
|
||||
"tt_wallet_size_warn": "Защищённые кошельки растут за счёт данных witness каждой записи — объединение множества записей в один адрес уменьшает его размер. Сначала сделайте резервную копию.",
|
||||
"tt_wallets_button": "Показать файлы кошельков и переключаться между ними",
|
||||
"tt_website": "Открыть сайт DragonX",
|
||||
@@ -2075,6 +2191,7 @@
|
||||
"tx_loading_queued": "Обновление транзакций поставлено в очередь",
|
||||
"tx_loading_refreshing_cached": "Обновление истории кошелька (в кеше: %d)",
|
||||
"tx_loading_scanning_shielded": "Сканирование экранированной истории (%d адресов)",
|
||||
"tx_loading_waits_for_sync": "История обновится, когда узел завершит синхронизацию",
|
||||
"tx_mined": "ДОБЫТА",
|
||||
"tx_progress_balances": "Обновление балансов",
|
||||
"tx_progress_finalizing": "Завершение транзакции",
|
||||
@@ -2106,6 +2223,7 @@
|
||||
"validate_description": "Введите адрес DragonX, чтобы проверить его действительность и принадлежность к этому кошельку.",
|
||||
"validate_invalid": "НЕДЕЙСТВИТЕЛЕН",
|
||||
"validate_is_mine": "Этот кошелёк владеет этим адресом",
|
||||
"validate_mining_type": "Адрес для майнинга (прозрачный)",
|
||||
"validate_not_mine": "Не принадлежит этому кошельку",
|
||||
"validate_ownership": "Принадлежность:",
|
||||
"validate_results": "Результаты:",
|
||||
@@ -2113,7 +2231,6 @@
|
||||
"validate_shielded_type": "Экранированный (z-адрес)",
|
||||
"validate_status": "Статус:",
|
||||
"validate_title": "Проверить адрес",
|
||||
"validate_transparent_type": "Прозрачный (t-адрес)",
|
||||
"validate_type": "Тип:",
|
||||
"validate_valid": "ДЕЙСТВИТЕЛЕН",
|
||||
"validating": "Проверка...",
|
||||
@@ -2123,6 +2240,7 @@
|
||||
"view_all": "Показать всё",
|
||||
"view_details": "Подробнее",
|
||||
"view_on_explorer": "Посмотреть в обозревателе",
|
||||
"view_transactions": "Показать транзакции",
|
||||
"waiting_for_daemon": "Ожидание подключения к daemon...",
|
||||
"wallet": "КОШЕЛЁК",
|
||||
"wallet_degraded_notify": "Ваш кошелёк открыт в режиме с ограниченной функциональностью: существующие средства в безопасности и доступны для расходования, но создание новых адресов и защита средств отключены. Сделайте резервную копию сид-фразы и восстановите из неё кошелёк, чтобы полностью его исправить.",
|
||||
@@ -2231,6 +2349,7 @@
|
||||
"wallets_reveal": "Открыть папку",
|
||||
"wallets_scan_folder": "Сканировать другую папку на наличие кошельков…",
|
||||
"wallets_scanned_folders": "Просканированные папки:",
|
||||
"wallets_scanning": "Поиск в папках…",
|
||||
"wallets_sort_addresses": "Адреса",
|
||||
"wallets_sort_asc": "По возрастанию (сначала старые / меньше / меньший)",
|
||||
"wallets_sort_by": "Сортировка:",
|
||||
|
||||
+150
-31
@@ -25,6 +25,7 @@
|
||||
"about_website": "网站",
|
||||
"acrylic": "亚克力",
|
||||
"add": "添加",
|
||||
"addr_type_mining": "挖矿",
|
||||
"address": "地址",
|
||||
"address_book_add": "添加地址",
|
||||
"address_book_add_new": "添加新地址",
|
||||
@@ -68,6 +69,7 @@
|
||||
"appx_blockchain_maintenance_in_progress": "已有区块链维护操作正在进行中。",
|
||||
"appx_blockchain_rescan_complete": "区块链重新扫描完成",
|
||||
"appx_bootstrap_complete_reconciling": "引导数据完成——正在将你的钱包与新的链数据进行核对。",
|
||||
"appx_bootstrap_reconcile_probe_failed": "无法将你的钱包与引导数据的链数据进行核对:节点一直太忙,无法检查其已有的区块。待节点同步完成后,请在设置中运行重新扫描。",
|
||||
"appx_cancel": "取消",
|
||||
"appx_cleaning_up": "正在清理…",
|
||||
"appx_confirm_your_backup": "确认你的备份",
|
||||
@@ -78,11 +80,15 @@
|
||||
"appx_creating_your_wallet": "正在创建你的钱包…",
|
||||
"appx_daemon_error": "守护进程错误",
|
||||
"appx_daemon_reinstall_in_progress": "守护进程重新安装已在进行中。",
|
||||
"appx_daemon_reinstall_node_busy": "节点仍在运行,因此未重新安装守护进程。请在它停止后重试。",
|
||||
"appx_disconnecting": "正在断开连接…",
|
||||
"appx_done": "完成",
|
||||
"appx_dragonxd_output": "dragonxd 输出",
|
||||
"appx_encrypting_wallet": "正在加密钱包…",
|
||||
"appx_fullnode_lifecycle_unavailable_lite": "全节点生命周期操作在轻量版中不可用",
|
||||
"appx_import_cancelled": "已取消:节点可能仍在扫描。如果该密钥的资金没有显示,请运行“设置 → 重新扫描”。",
|
||||
"appx_import_interrupted": "导入被中断,该密钥可能已在钱包中。如果其资金没有显示,请运行“设置 → 重新扫描”。",
|
||||
"appx_import_refused": "节点拒绝了请求,因此未导入该密钥。",
|
||||
"appx_installing_bundled_daemon": "正在安装捆绑守护进程——节点将停止、更新并重启…",
|
||||
"appx_invalid_payment_uri_prefix": "无效的支付 URI:",
|
||||
"appx_ive_written_it_down": "我已抄写完毕",
|
||||
@@ -99,7 +105,8 @@
|
||||
"appx_no_bundled_daemon_to_install": "此版本没有可安装的捆绑守护进程",
|
||||
"appx_no_embedded_daemon_to_install": "此版本没有可安装的内嵌守护进程",
|
||||
"appx_node_busy_restarting": "节点正忙于重启——请稍后再试。",
|
||||
"appx_node_rebuilding_witness_cache": "节点正在重建其见证缓存",
|
||||
"appx_node_busy_try_later": "节点正忙于完成一项耗时任务(例如重建票据见证)。请在完成后重试;现在停止它可能导致这项工作从头开始。",
|
||||
"appx_node_not_stopped": "节点未能及时停止,因此未做任何更改。请几分钟后重试。",
|
||||
"appx_not_next_word": " — 这不是下一个词",
|
||||
"appx_payment_request_loaded": "支付请求已加载",
|
||||
"appx_pool_miner_connected_and_hashing": "矿池矿工已连接并正在计算哈希。",
|
||||
@@ -120,11 +127,7 @@
|
||||
"appx_simple_background_enabled": "已启用简约背景",
|
||||
"appx_skip": "跳过",
|
||||
"appx_skip_anyway": "仍要跳过",
|
||||
"appx_still_status_prefix": "仍处于“",
|
||||
"appx_still_status_suffix": "”——现在强制退出可能损坏链数据。",
|
||||
"appx_stop_anyway_and_quit": "仍要停止并退出",
|
||||
"appx_stopping_daemon_deleting_blockchain": "正在停止守护进程并删除区块链数据…",
|
||||
"appx_stopping_node_discards_rebuild": "现在停止节点会丢弃正在进行的重建,下次打开钱包时将重新开始(需要几分钟)。你也可以让节点继续运行。",
|
||||
"appx_stopping_pool_miner": "正在停止矿池矿工…",
|
||||
"appx_syncing_pct_block_n_of_n": "正在同步 %.1f%% —— 区块 %d / %d",
|
||||
"appx_tap_words_in_order": "按正确顺序点击这些词,以确认你已保存。",
|
||||
@@ -134,6 +137,9 @@
|
||||
"appx_use_settings_restart_daemon_hint": "请使用 设置 > 重启守护进程 重试",
|
||||
"appx_waiting_for_daemon_to_encrypt_wallet": "正在等待守护进程加密钱包…",
|
||||
"appx_wallet_created_and_backed_up": "钱包已创建并完成备份。",
|
||||
"appx_wallet_op_node_busy": "节点正忙,暂时无法响应。请在完成后重新导入或重新扫描。",
|
||||
"appx_wallet_op_running": "密钥导入或重新扫描仍在进行中。请在完成后重试。",
|
||||
"appx_wallet_op_shutting_down": "钱包正在关闭。",
|
||||
"appx_wallet_open_failed_prefix": "钱包打开失败:",
|
||||
"auto_shield": "自动屏蔽挖矿",
|
||||
"autoshield_by_node": "您的节点会自动为您屏蔽挖出的 coinbase。",
|
||||
@@ -174,21 +180,20 @@
|
||||
"balance_history_collecting": "余额历史——正在收集数据…",
|
||||
"balance_layout": "余额布局",
|
||||
"balance_layout_switched": "布局:%s",
|
||||
"balance_mining_fmt": "挖矿奖励:%.8f",
|
||||
"balance_mining_rate": "挖矿中 %s",
|
||||
"balance_shielded_fmt": "屏蔽:%.8f",
|
||||
"balance_syncing_pct": "同步中 %.1f%%",
|
||||
"balance_transparent_fmt": "透明:%.8f",
|
||||
"baltab_market": "市价",
|
||||
"baltab_market_price_4dp": "市价:$%.4f",
|
||||
"baltab_market_price_8dp": "市价:$%.8f",
|
||||
"baltab_mining_addresses_count": "%d 个挖矿地址",
|
||||
"baltab_mining_amount": "挖矿奖励 %.8f",
|
||||
"baltab_pct_change_24h": "%s%.1f%% 24小时",
|
||||
"baltab_pct_of_total_zaddr": "占总额 %.0f%% · %d 个 Z-addr",
|
||||
"baltab_shielded": "隐蔽",
|
||||
"baltab_shielded_amount": "隐蔽 %.8f",
|
||||
"baltab_t_addresses_count": "%d 个 T-address",
|
||||
"baltab_total_balance": "总余额",
|
||||
"baltab_transparent": "透明",
|
||||
"baltab_transparent_amount": "透明 %.8f",
|
||||
"ban": "封禁",
|
||||
"banned_peers": "已封禁节点",
|
||||
"block": "区块",
|
||||
@@ -238,6 +243,7 @@
|
||||
"change_pass_confirm": "确认新密码:",
|
||||
"change_pass_current": "当前密码短语:",
|
||||
"change_pass_new": "新密码短语:",
|
||||
"change_pass_pin_note": "此钱包设有快速解锁 PIN。更改后它将过时:请在设置中移除 PIN 并重新设置。",
|
||||
"change_pass_title": "更改密码短语",
|
||||
"characters": "字符",
|
||||
"chat": "聊天",
|
||||
@@ -513,7 +519,7 @@
|
||||
"contact_avatar_image_hint": "图片会复制到应用中,即使原文件移动也能保持可用。",
|
||||
"contact_avatar_remove": "移除",
|
||||
"contact_avatar_shielded": "隐蔽",
|
||||
"contact_avatar_transparent": "透明",
|
||||
"contact_chip_mining": "仅限挖矿",
|
||||
"contact_global": "在每个钱包中显示(全局联系人)",
|
||||
"contact_global_badge_tt": "全局联系人——在每个钱包中可见",
|
||||
"contact_global_tt": "开启:无论您加载哪个钱包,此联系人都保持可见。关闭:它仅属于当前钱包。",
|
||||
@@ -633,6 +639,11 @@
|
||||
"download": "下载",
|
||||
"download_bootstrap": "下载引导程序",
|
||||
"dragonx_green": "DragonX(绿色)",
|
||||
"earnings_format": "收益显示格式",
|
||||
"earnings_format_coin": "仅代币",
|
||||
"earnings_format_coin_fiat": "代币+法币",
|
||||
"earnings_format_fiat": "仅法币",
|
||||
"earnings_label": "收益",
|
||||
"edit": "编辑",
|
||||
"empty_wallet_keys_suffix": "个密钥",
|
||||
"empty_wallet_open_manager": "打开钱包管理器",
|
||||
@@ -709,8 +720,8 @@
|
||||
"export_tx_success": "交易导出成功",
|
||||
"export_tx_title": "导出交易到 CSV",
|
||||
"export_viewing_key": "导出查看密钥",
|
||||
"failed_create_mining_address": "创建挖矿地址失败",
|
||||
"failed_create_shielded": "无法创建屏蔽地址",
|
||||
"failed_create_transparent": "无法创建透明地址",
|
||||
"faq": "常见问题",
|
||||
"faq_d_mgmt_1_a": "内置节点会随钱包自动启动,并在适当时机停止,因此通常无需手动管理。高级控制选项(包括重启守护进程)位于 设置 > 节点与安全。",
|
||||
"faq_d_mgmt_1_q": "如何启动或停止节点?",
|
||||
@@ -788,13 +799,13 @@
|
||||
"faq_open_tooltip": "帮助与常见问题",
|
||||
"faq_search_hint": "搜索常见问题…",
|
||||
"faq_title": "帮助与常见问题",
|
||||
"faq_w_addr_1_a": "透明地址(以 R 或 t 开头)的工作方式与大多数币种相同:金额和地址在区块链上是公开的。\n\n隐私地址(z 地址,以 zs 开头)使用零知识加密技术——金额、发送方和接收方都在链上加密。存放在隐私地址中的余额是私密的。",
|
||||
"faq_w_addr_1_a": "屏蔽地址(z 地址,以 zs 开头)是您在 DragonX 上使用的地址。它们采用零知识密码学,金额、发送方和接收方在链上都是加密的,您的余额保持私密。\n\n透明地址(以 R 开头)是挖矿地址。DragonX 是完全私密的链,因此它们只能接收挖矿奖励——网络会拒绝任何发送给它们的付款。到达那里的奖励在被屏蔽之前是公开的,而钱包会自动完成屏蔽。",
|
||||
"faq_w_addr_1_q": "透明地址和隐私地址有什么区别?",
|
||||
"faq_w_addr_2_a": "请尽量使用隐私(z)地址——它们能保护您的余额和支付记录不被公开。DragonX 是一种隐私币,收款默认使用隐私地址。\n\n只有在您使用的服务无法处理隐私地址时,才使用透明地址。",
|
||||
"faq_w_addr_2_a": "收发任何款项都请使用屏蔽(zs)地址——在 DragonX 上只有这种地址可以接收付款。\n\n透明(挖矿)地址仅用于单人挖矿奖励。切勿把它提供给他人用于付款:网络会拒绝该付款。",
|
||||
"faq_w_addr_2_q": "应该使用哪种地址类型?",
|
||||
"faq_w_addr_3_a": "对于隐私地址,重复使用没有问题,也不会泄露历史记录。对于透明地址,每次付款使用新地址可以提升隐私性。可以在“接收”标签页创建新地址。",
|
||||
"faq_w_addr_3_a": "重复使用屏蔽地址没有问题,也不会泄露您的历史记录。您仍可以在“接收”页创建新地址,例如为每个联系人各建一个,以便知道是谁付的款。挖矿地址仅用于挖矿奖励,您很少需要新建。",
|
||||
"faq_w_addr_3_q": "应该重复使用地址,还是新建一个?",
|
||||
"faq_w_addr_4_a": "屏蔽会将币从透明地址转移到隐私地址,使其变为私密。新挖出的币到账时是透明的,会自动为您屏蔽。\n\n您也可以手动屏蔽;屏蔽只是一种特殊交易,因此需要正常的确认时间才能完成。",
|
||||
"faq_w_addr_4_a": "屏蔽是把币从透明(挖矿)地址转移到屏蔽地址,使其变为私密。新挖出的币会到达挖矿地址,并在成熟后自动屏蔽。\n\n您也可以在钱包中手动屏蔽;屏蔽只是一种特殊交易,因此需要正常的确认时间。",
|
||||
"faq_w_addr_4_q": "什么是“屏蔽”?",
|
||||
"faq_w_addr_title": "地址与隐私",
|
||||
"faq_w_bal_1_a": "在钱包同步完成之前,您的余额是不完整的,可能显示为 0——因为它还没有扫描完您的所有交易。同步完成后余额会自动补齐。\n\n请留意窗口底部的状态栏:它显示连接状态和区块高度。同步完成后,余额即为准确值。",
|
||||
@@ -838,11 +849,11 @@
|
||||
"faq_w_seed_4_a": "助记词是主要备份,足以恢复一切。钱包文件的副本是一个便捷的次要备份,还能保留标签和设置。请将所有备份妥善离线私密保管。",
|
||||
"faq_w_seed_4_q": "我还需要备份 wallet.dat 吗?",
|
||||
"faq_w_seed_title": "助记词与备份",
|
||||
"faq_w_send_1_a": "打开“发送”标签页,粘贴收款方地址,输入金额并确认。如果钱包已加密,系统会要求您解锁以完成交易。\n\n发送到隐私地址是私密的;发送到透明地址是公开的。",
|
||||
"faq_w_send_1_a": "打开“发送”标签页,粘贴收款方地址,输入金额并确认。如果钱包已加密,系统会要求您解锁以完成交易。\n\n付款发送到屏蔽(zs)地址,并保持私密。钱包不会向透明(挖矿)地址发送:网络会拒绝向此类地址付款。",
|
||||
"faq_w_send_1_q": "如何发送资金?",
|
||||
"faq_w_send_2_a": "DragonX 的手续费非常低,并且是自动设置的。手续费会在您确认前显示。通常无需手动更改。",
|
||||
"faq_w_send_2_q": "我需要支付多少手续费?",
|
||||
"faq_w_send_3_a": "可以——向隐私(z)地址发送时,您可以附加一条加密备注。只有收款方能够读取它。透明地址不支持备注。",
|
||||
"faq_w_send_3_a": "可以——向隐私(z)地址发送时,您可以附加一条加密备注。只有收款方能够读取它。",
|
||||
"faq_w_send_3_q": "我可以在付款时附加消息吗?",
|
||||
"faq_w_send_4_a": "交易在被打包进区块并获得确认之前处于「待处理」状态。这通常需要一两分钟。屏蔽交易还需要您的钱包已完成同步才能生成证明。\n\n如果一笔发款长时间处于待处理状态,请检查您的钱包是否已连接并同步(见窗口底部的状态栏)。",
|
||||
"faq_w_send_4_q": "我发送了一笔付款,但显示待处理——为什么?",
|
||||
@@ -857,6 +868,7 @@
|
||||
"faq_w_set_4_q": "如何显示法币价格?",
|
||||
"faq_w_set_title": "设置与外观",
|
||||
"favorite_address": "收藏地址",
|
||||
"favorite_opacity": "收藏高亮不透明度",
|
||||
"fee": "手续费",
|
||||
"fee_high": "高",
|
||||
"fee_label": "手续费:",
|
||||
@@ -864,14 +876,9 @@
|
||||
"fee_normal": "普通",
|
||||
"fetch_prices": "获取价格",
|
||||
"file": "文件",
|
||||
"file_save_location": "文件将保存至:~/.config/ObsidianDragon/",
|
||||
"file_save_location": "文件将保存至:",
|
||||
"filter": "筛选...",
|
||||
"font_scale": "字体大小",
|
||||
"force_quit": "强制退出",
|
||||
"force_quit_confirm_msg": "这将立即终止守护进程而不进行正常关闭。\n这可能会损坏区块链索引并需要重新同步。",
|
||||
"force_quit_confirm_title": "强制退出?",
|
||||
"force_quit_warning": "这将立即终止守护进程而不进行正常关闭。可能需要重新同步区块链。",
|
||||
"force_quit_yes": "强制退出",
|
||||
"from": "从",
|
||||
"from_upper": "从",
|
||||
"full_details": "完整详情",
|
||||
@@ -940,6 +947,7 @@
|
||||
"hidden_tag": " (已隐藏)",
|
||||
"hide": "隐藏",
|
||||
"hide_address": "隐藏地址",
|
||||
"hide_earnings": "隐藏收益",
|
||||
"hide_qr": "隐藏二维码",
|
||||
"hide_zero_balances": "隐藏零余额",
|
||||
"history": "历史",
|
||||
@@ -1014,11 +1022,13 @@
|
||||
"lite_birthday_backup": "生日区块:%llu (也请一并备份)",
|
||||
"lite_birthday_hint": "开始扫描的区块高度。如未知请保留 0(完整扫描更慢)。",
|
||||
"lite_birthday_label": "诞生区块",
|
||||
"lite_confirm_label": "确认",
|
||||
"lite_console_backend_commands": "后端命令:",
|
||||
"lite_console_help_passthrough": "其他任何输入都将作为轻钱包控制台命令运行。",
|
||||
"lite_copy": "复制",
|
||||
"lite_could_not_start": "无法启动该操作",
|
||||
"lite_could_not_write": "无法写入 ",
|
||||
"lite_encrypt_offline_warning": "任何拿到钱包文件的人都可以离线尝试密码。请先备份助记词,并使用较长的密码短语。",
|
||||
"lite_encrypt_wallet": "加密钱包",
|
||||
"lite_encryption_removed": "已移除加密",
|
||||
"lite_enter_all_seed_words": "请输入全部 24 个助记词以恢复(已输入 %d 个)",
|
||||
@@ -1026,6 +1036,7 @@
|
||||
"lite_hide_wipe": "隐藏并清除",
|
||||
"lite_import": "导入",
|
||||
"lite_import_key_label": "导入密钥",
|
||||
"lite_key_import_failed": "无法导入密钥——请检查密钥,解锁钱包(如已加密)后重试。",
|
||||
"lite_key_imported": "密钥已导入——运行同步以扫描其历史记录",
|
||||
"lite_lock_failed": "锁定失败",
|
||||
"lite_lock_now": "立即锁定",
|
||||
@@ -1170,9 +1181,43 @@
|
||||
"merge_description": "将多个 UTXO 合并到一个屏蔽地址。这可以帮助减小钱包大小并提高隐私性。",
|
||||
"merge_fee_hint": "此交易的网络手续费。",
|
||||
"merge_funds": "合并资金",
|
||||
"merge_into_one": "合并到一个地址",
|
||||
"merge_keep_each": "保留在各自地址",
|
||||
"merge_keep_each_batches_fmt": "超过 %d 张票据的地址会分多批合并;确认后再运行一次即可合并其余部分。",
|
||||
"merge_keep_each_confirm_fmt": "整合 %d 张票据,涉及 %d 个地址?每个地址保留各自的资金。手续费:约 %s DRGX(每批一次)。",
|
||||
"merge_keep_each_desc": "每个地址的票据会合并回该地址本身,因此您用不同标签分开存放的资金仍保持分开。仅限屏蔽地址——透明资金只能被屏蔽(合并到一个地址)。",
|
||||
"merge_keep_each_none": "每个屏蔽地址都只有一张票据——无需整合。",
|
||||
"merge_keep_each_nothing_submitted": "没有任何整合——节点拒绝了所有合并。",
|
||||
"merge_keep_each_row_fmt": "%s · %d 张票据 · %s DRGX",
|
||||
"merge_keep_each_run_again": "分多批合并的地址现在每批各有一张票据。确认后再运行一次,即可把它们合并为一张。",
|
||||
"merge_keep_each_select": "请至少勾选一个要整合的地址。",
|
||||
"merge_keep_each_some_failed": "部分合并失败——请查看上方列表。",
|
||||
"merge_keep_each_status_done": "%s——完成:已合并 %d 张票据,共 %d 批",
|
||||
"merge_keep_each_status_failed": "%s——失败:%s",
|
||||
"merge_keep_each_status_not_started": "%s——未开始:%s",
|
||||
"merge_keep_each_status_running": "%s——正在合并 %d 张票据(已完成 %d/%d 批)…",
|
||||
"merge_keep_each_status_unconfirmed": "%s——无法确认状态",
|
||||
"merge_keep_each_too_small": "金额太小,无法整合:手续费会耗尽这些资金。",
|
||||
"merge_max_inputs": "每批最大输入数",
|
||||
"merge_no_spendable": "暂无可合并的可用输入。",
|
||||
"merge_progress": "正在合并…这可能需要几分钟。您可以关闭此窗口。",
|
||||
"merge_run_aborted_toast": "节点连接已重置,合并已停止。再次运行以继续。",
|
||||
"merge_run_busy": "正在进行合并。请等待其完成,或先将其停止。",
|
||||
"merge_run_chip_fmt": "合并中 %d/%d",
|
||||
"merge_run_err_timeout": "节点未及时响应;该批次可能仍在队列中。",
|
||||
"merge_run_halted_fmt": "多次失败后合并已停止:%s",
|
||||
"merge_run_migration_pending": "请先完成或取消助记词迁移,再进行合并。",
|
||||
"merge_run_progress_fmt": "正在合并… 第 %d 批(共约 %d 批),已合并 %d 张票据。可以关闭此窗口,合并会在后台继续。",
|
||||
"merge_run_start_failed": "无法开始合并——节点未连接。",
|
||||
"merge_run_status_not_run": "%s——未合并(%d 张票据)",
|
||||
"merge_run_status_waiting": "%s——等待中(%d 张票据)",
|
||||
"merge_run_stop": "停止",
|
||||
"merge_run_stopped_toast": "合并已停止。再次运行以合并其余部分。",
|
||||
"merge_run_stopping": "已发送的批次完成后停止…",
|
||||
"merge_run_wait_batch": "正在间隔各批次,以保持节点响应…",
|
||||
"merge_run_wait_send": "已暂停,等待您的待处理交易完成。",
|
||||
"merge_run_wait_sync": "已暂停:节点仍在同步。",
|
||||
"merge_scope_failed_fmt": "无法读取您的输入:%s",
|
||||
"merge_scope_fmt": "%d 笔透明输入 + %d 笔屏蔽输入 ・ 约 %s DRGX 可用",
|
||||
"merge_scope_loading": "正在检查您的输入…",
|
||||
"merge_send_failed": "合并失败:",
|
||||
@@ -1181,7 +1226,11 @@
|
||||
"merge_src_shielded": "屏蔽",
|
||||
"merge_src_transparent": "透明",
|
||||
"merge_started": "合并操作已开始",
|
||||
"merge_submit_uncertain": "连接中断前节点未确认合并。合并仍可能完成——重新连接后,请先在“历史”中确认,再重新合并。",
|
||||
"merge_title": "合并到地址",
|
||||
"merge_wait_locked": "钱包已锁定。请解锁后继续。",
|
||||
"merge_waiting_node": "正在等待节点…",
|
||||
"merge_where": "资金应转到哪里?",
|
||||
"mig_adopt": "将其设为我的钱包",
|
||||
"mig_adopt_now": "采用助记词钱包",
|
||||
"mig_adopting": "正在安装您的新钱包并重新扫描…",
|
||||
@@ -1233,6 +1282,8 @@
|
||||
"miner_fee": "矿工费",
|
||||
"mining": "挖矿",
|
||||
"mining_active": "活跃",
|
||||
"mining_addr_explainer": "仅接收单人挖矿奖励——网络会拒绝向此地址付款。奖励成熟后会自动屏蔽。",
|
||||
"mining_address": "挖矿地址",
|
||||
"mining_address_copied": "挖矿地址已复制",
|
||||
"mining_all_time": "所有时间",
|
||||
"mining_already_saved": "矿池 URL 已保存",
|
||||
@@ -1246,6 +1297,8 @@
|
||||
"mining_benchmark_testing": "测试中",
|
||||
"mining_benchmark_tooltip": "为此 CPU 找到最佳线程数",
|
||||
"mining_block_copied": "区块哈希已复制",
|
||||
"mining_card_click_to_shield": "点击立即屏蔽已成熟的奖励。",
|
||||
"mining_card_tooltip": "单人挖矿奖励会发送到您的挖矿(透明,R…)地址。这些地址无法接收付款。奖励成熟后会自动屏蔽。",
|
||||
"mining_chart_1m_ago": "1分钟前",
|
||||
"mining_chart_5m_ago": "5分钟前",
|
||||
"mining_chart_now": "现在",
|
||||
@@ -1261,6 +1314,8 @@
|
||||
"mining_est_block": "预计区块",
|
||||
"mining_est_daily": "预计日收益",
|
||||
"mining_est_daily_pool_sub": "粗略的单人挖矿等值,扣除矿池费用前",
|
||||
"mining_est_daily_sub": "平均",
|
||||
"mining_est_daily_sub_net": "平均·净",
|
||||
"mining_filter_all": "全部",
|
||||
"mining_filter_tip_all": "显示所有收益",
|
||||
"mining_filter_tip_pool": "仅显示矿池收益",
|
||||
@@ -1290,6 +1345,7 @@
|
||||
"mining_payout_address": "支付地址",
|
||||
"mining_payout_foreign": "⚠ 此支付地址不在您当前的钱包中——挖矿奖励将进入另一个钱包。如果您切换过钱包,请更新它。",
|
||||
"mining_payout_invalid": "不是有效的 DragonX 地址——启动前请更正,否则挖矿奖励将丢失。",
|
||||
"mining_payout_needs_shielded": "矿池使用普通交易付款,而在 DragonX 上普通交易无法到达挖矿(透明,R…)地址。请使用屏蔽地址(以 zs 开头)作为收款地址。",
|
||||
"mining_payout_tooltip": "接收挖矿奖励的地址",
|
||||
"mining_pool": "矿池",
|
||||
"mining_pool_fee": "费用",
|
||||
@@ -1302,6 +1358,8 @@
|
||||
"mining_refresh": "刷新",
|
||||
"mining_remove": "移除",
|
||||
"mining_reset_defaults": "重置默认值",
|
||||
"mining_rewards": "挖矿奖励",
|
||||
"mining_rewards_unshielded": "尚未屏蔽",
|
||||
"mining_save_payout_address": "保存支付地址",
|
||||
"mining_save_pool_url": "保存矿池 URL",
|
||||
"mining_saved_addresses": "已保存地址:",
|
||||
@@ -1335,10 +1393,10 @@
|
||||
"network_fee": "网络手续费",
|
||||
"network_hashrate": "全网算力",
|
||||
"new": "+ 新建",
|
||||
"new_mining_address": "新建挖矿地址",
|
||||
"new_mining_address_created": "已创建新的挖矿地址",
|
||||
"new_shielded_created": "新屏蔽地址已创建",
|
||||
"new_t_address": "新 T 地址",
|
||||
"new_t_transparent": "新 t 地址(透明)",
|
||||
"new_transparent_created": "新透明地址已创建",
|
||||
"new_z_address": "新 Z 地址",
|
||||
"new_z_shielded": "新 z 地址(屏蔽)",
|
||||
"no_addresses": "未找到地址。请使用上方按钮创建一个。",
|
||||
@@ -1358,7 +1416,19 @@
|
||||
"node_banner_offline_title": "未连接到 DragonX 节点",
|
||||
"node_banner_reconnect": "重新连接",
|
||||
"node_banner_restart": "重启节点",
|
||||
"node_busy_rebuild_desc": "已运行 %s。大型钱包可能需要约一小时,完成前节点无法响应钱包。",
|
||||
"node_busy_rebuild_extending": "正在扩展见证(此步骤节点不报告进度)。",
|
||||
"node_busy_rebuild_reading": "正在读取区块:%ld / %ld",
|
||||
"node_busy_rebuild_title": "节点繁忙:正在重建票据见证",
|
||||
"node_busy_replay_desc": "已运行 %s。节点正在重新应用上次停止前收到的区块。",
|
||||
"node_busy_replay_title": "节点繁忙:正在追赶区块",
|
||||
"node_busy_rpc_desc": "节点正在运行,但已有 %s 忙于其他任务而无法响应钱包,通常是因为见证重建等耗时操作。",
|
||||
"node_busy_rpc_title": "节点繁忙:暂未响应",
|
||||
"node_busy_running": "已运行 %s",
|
||||
"node_security": "节点与安全",
|
||||
"node_starting_busy_desc": "节点可以访问,但正忙,暂未响应。更新后或首次启动时属正常现象,可能需要几分钟。",
|
||||
"node_starting_launch_desc": "正在启动 dragonxd 并等待其上线…",
|
||||
"node_starting_title": "正在启动 DragonX 节点…",
|
||||
"noise": "噪点",
|
||||
"not_connected": "未连接到守护进程...",
|
||||
"not_connected_to_daemon": "未连接到守护进程",
|
||||
@@ -1511,6 +1581,8 @@
|
||||
"ram_wallet_gb": "钱包:%.1f GB",
|
||||
"ram_wallet_mb": "钱包:%.0f MB",
|
||||
"receive": "接收",
|
||||
"receive_not_for_payments": "不用于付款",
|
||||
"receive_use_shielded": "使用屏蔽地址",
|
||||
"received": "已接收",
|
||||
"received_filter": "已接收",
|
||||
"received_label": "已接收",
|
||||
@@ -1525,6 +1597,7 @@
|
||||
"reduce_motion": "减少动画",
|
||||
"refresh": "刷新",
|
||||
"refresh_now": "立即刷新",
|
||||
"refresh_waits_for_sync": "节点仍在同步。同步完成后将刷新钱包数据。",
|
||||
"remove_favorite": "移除收藏",
|
||||
"repair_wallet": "修复钱包",
|
||||
"report_bug": "报告错误",
|
||||
@@ -1539,12 +1612,12 @@
|
||||
"request_select_address": "选择地址...",
|
||||
"request_shielded_addrs": "-- 屏蔽地址 --",
|
||||
"request_title": "请求付款",
|
||||
"request_transparent_addrs": "-- 透明地址 --",
|
||||
"request_uri_copied": "付款 URI 已复制到剪贴板",
|
||||
"rescan": "重新扫描",
|
||||
"rescan_bootstrapped_msg": "您的节点使用了引导程序,因此快照以下的区块不在磁盘上,从创世区块重新扫描将失败。请从快照所包含的高度开始重新扫描,以核对钱包的已花费余额。您的 wallet.dat 和区块链数据不会被删除。",
|
||||
"rescan_detecting": "正在检查您的节点在磁盘上已有哪些区块…",
|
||||
"rescan_from_height": "从区块高度重新扫描:",
|
||||
"rescan_probe_failed": "无法检查您的节点在磁盘上已有哪些区块(节点正忙或请求超时)。如果此节点使用了引导程序,请取消并在一分钟后重试:在使用引导程序的节点上,下面的 -rescan 重启将失败。",
|
||||
"reset_to_defaults": "重置为默认值",
|
||||
"restarting_after_encryption": "加密后重启守护进程...",
|
||||
"restore_address": "恢复地址",
|
||||
@@ -1587,6 +1660,7 @@
|
||||
"sb_net_khs": "网络: %.2f KH/s",
|
||||
"sb_net_mhs": "网络: %.2f MH/s",
|
||||
"sb_no_conf": "未找到 DRAGONX.conf",
|
||||
"sb_node_busy": "节点繁忙",
|
||||
"sb_peers": "节点: %zu",
|
||||
"sb_plaintext_remote_blocked": "拒绝以明文形式向远程主机发送 RPC 凭据。请在 DRAGONX.conf 中添加 rpcallowplaintext=1 以允许此操作,或启用 rpctls=1 使用 TLS。",
|
||||
"sb_rescanning": "重新扫描",
|
||||
@@ -1608,6 +1682,7 @@
|
||||
"screenshot_sweep": "运行截图批处理",
|
||||
"screenshot_sweep_desc": "遍历每个标签页的每一种主题,并将每一个的截图保存到配置目录 screenshots 文件夹下的各标签页子文件夹中(覆盖上一次的遍历)。运行几秒钟。",
|
||||
"screenshot_sweep_full": "完整 UI 遍历",
|
||||
"screenshot_sweep_refused": "完整 UI 遍历被拒绝;请查看调试日志",
|
||||
"search_icons": "搜索图标...",
|
||||
"search_placeholder": "搜索...",
|
||||
"sec_changing_passphrase": "正在更改密码短语…",
|
||||
@@ -1629,15 +1704,19 @@
|
||||
"sec_mode_passphrase": " 密码短语",
|
||||
"sec_not_connected_to_daemon": "未连接到守护进程",
|
||||
"sec_not_connected_to_daemon_pin": "未连接到守护进程",
|
||||
"sec_passphrase_changed_pin_stale": "密码短语已更改;PIN 已过时",
|
||||
"sec_passphrase_changed_successfully": "密码短语更改成功",
|
||||
"sec_pin_changed_successfully": "PIN 更改成功",
|
||||
"sec_pin_removed": "PIN 已移除",
|
||||
"sec_pin_set_successfully": "PIN 设置成功",
|
||||
"sec_pin_stale_msg": "PIN 已过时。请使用密码短语。",
|
||||
"sec_pin_unreadable": "无法读取 PIN 文件。请使用密码短语。",
|
||||
"sec_restart_daemon_for_encryption": "请重启守护进程以使加密生效。",
|
||||
"sec_too_many_attempts_wait": "尝试次数过多。请等待 %.0f 秒…",
|
||||
"sec_total_elapsed_fmt": "总耗时:%d 分 %02d 秒",
|
||||
"sec_unlock_button": "解锁",
|
||||
"sec_unlock_failed_prefix": "解锁失败:",
|
||||
"sec_unlock_node_busy": "The node is busy; try again in a moment.",
|
||||
"sec_unlocking_fmt": "正在解锁%s",
|
||||
"sec_use_passphrase_instead": "改用密码短语",
|
||||
"sec_use_pin_instead": "改用 PIN",
|
||||
@@ -1690,6 +1769,8 @@
|
||||
"send_from_this_address": "从此地址发送",
|
||||
"send_go_to_receive": "前往接收",
|
||||
"send_keep": "保留",
|
||||
"send_mining_addr_blocked": "这是一个挖矿(透明)地址。在 DragonX 上,这类地址只能接收挖矿奖励——网络会拒绝向其付款。请向收款人索取屏蔽地址(以 zs 开头)。",
|
||||
"send_mining_addr_short": "挖矿地址——无法接收付款",
|
||||
"send_network_fee": "网络手续费",
|
||||
"send_no_balance": "无余额",
|
||||
"send_no_recent": "没有最近的发送",
|
||||
@@ -1717,7 +1798,6 @@
|
||||
"send_txid_copied": "交易ID 已复制到剪贴板",
|
||||
"send_txid_label": "TxID:%s",
|
||||
"send_valid_shielded": "有效的屏蔽地址",
|
||||
"send_valid_transparent": "有效的透明地址",
|
||||
"send_wallet_empty": "您的钱包是空的",
|
||||
"send_yes_clear": "是,清除",
|
||||
"sender_balance": "发送方: %.8f → %.8f DRGX",
|
||||
@@ -1780,6 +1860,7 @@
|
||||
"settings_open_log_folder": "打开日志文件夹",
|
||||
"settings_other": "其他",
|
||||
"settings_pin_active": "PIN",
|
||||
"settings_pin_out_of_date": "PIN 已过时",
|
||||
"settings_privacy": "隐私",
|
||||
"settings_quick_unlock_pin": "快速解锁 PIN",
|
||||
"settings_reduce_transparency": "降低透明度",
|
||||
@@ -1845,11 +1926,24 @@
|
||||
"shielded_type": "屏蔽",
|
||||
"shielding_notice": "注意:这将把资金从透明 (T) 地址转移到隐私 (Z) 地址。",
|
||||
"show": "显示",
|
||||
"show_earnings": "显示收益",
|
||||
"show_hidden": "显示已隐藏 (%d)",
|
||||
"show_qr": "显示二维码",
|
||||
"show_qr_code": "显示二维码",
|
||||
"showing_transactions": "显示第 %d–%d 笔,共 %d 笔交易(总计:%zu)",
|
||||
"showing_x_of_y": "显示 %d / %d 个地址",
|
||||
"shutdown_kill_confirm_msg": "节点已长时间没有 CPU 或磁盘活动,可能已卡住。\n强制结束会丢失所有未保存的数据;下次启动可能需要更长时间。",
|
||||
"shutdown_kill_confirm_title": "强制结束节点?",
|
||||
"shutdown_kill_confirm_yes": "强制结束",
|
||||
"shutdown_kill_node": "强制结束节点(不安全)",
|
||||
"shutdown_kill_node_tip": "立即结束节点。所有尚未保存的数据都会丢失,下次启动时会重新处理自上次保存以来的区块。",
|
||||
"shutdown_leave_running": "关闭钱包——保持节点运行",
|
||||
"shutdown_leave_running_tip": "立即关闭钱包。节点会继续独立运行并完成当前操作,不会丢失任何数据。",
|
||||
"shutdown_stop_asking": "正在请求节点停止…",
|
||||
"shutdown_stop_cpu_suffix": " · CPU %d%%",
|
||||
"shutdown_stop_delivered": "节点正在关闭并保存其状态",
|
||||
"shutdown_stop_idle": "已 %d 分钟没有 CPU 或磁盘活动——节点可能已卡住",
|
||||
"shutdown_stop_queued": "节点已收到停止请求,但正忙——它会尽快停止",
|
||||
"simple_background": "简单背景",
|
||||
"slider_off": "关闭",
|
||||
"sort_amount_high": "金额最大",
|
||||
@@ -1857,9 +1951,18 @@
|
||||
"sort_date_newest": "最新优先",
|
||||
"sort_date_oldest": "最早优先",
|
||||
"start_mining": "开始挖矿",
|
||||
"startup_files_preparing": "正在准备钱包文件... %d%%",
|
||||
"startup_files_verifying": "正在验证钱包文件...",
|
||||
"status": "状态",
|
||||
"stop_external": "停止外部守护进程",
|
||||
"stop_mining": "停止挖矿",
|
||||
"stopconfirm_busy_body": "节点已有 %s 忙于其他任务而无法响应钱包,可能是因为见证重建等耗时操作。现在停止它,下次启动时可能需要重做这些工作。\n\n建议:关闭钱包并保持节点运行,它会自行完成。",
|
||||
"stopconfirm_busy_title": "节点正忙",
|
||||
"stopconfirm_rebuild_body": "已运行 %s,大型钱包可能需要约一小时。现在停止节点,要么会等待重建完成,要么下次启动时需要重新进行重建以及区块追赶。\n\n建议:关闭钱包并保持节点运行,它会自行完成。",
|
||||
"stopconfirm_rebuild_title": "节点正在重建票据见证",
|
||||
"stopconfirm_stop_wait": "停止节点并等待",
|
||||
"stopconfirm_walletop_body": "节点正在为密钥导入或重新扫描扫描区块链。现在停止它会提前结束这次扫描,尚未扫描到的资金只有在“设置 → 重新扫描”之后才会显示。\n\n建议:关闭钱包并保持节点运行,它会自行完成扫描。",
|
||||
"stopconfirm_walletop_title": "密钥导入或重新扫描正在进行",
|
||||
"stratum_allowip_hint": "允许来自指定 IP 或 CIDR 的矿工连接(留空 = 仅限本机)",
|
||||
"stratum_expose_warn": "会向您允许的网络开放挖矿端口——仅在受信任的局域网中使用。",
|
||||
"stratum_host": "托管矿池(stratum)",
|
||||
@@ -1906,7 +2009,6 @@
|
||||
"switch_stopnode_title": "停止正在运行的节点?",
|
||||
"switch_stopnode_warn": "已有一个并非由此钱包启动的节点正在运行。",
|
||||
"syncing": "同步中...",
|
||||
"t_address": "T 地址",
|
||||
"t_addresses": "T 地址",
|
||||
"test_connection": "测试",
|
||||
"theme": "主题",
|
||||
@@ -1928,6 +2030,7 @@
|
||||
"tools": "工具",
|
||||
"tools_actions": "工具与操作...",
|
||||
"tools_actions_hdr": "工具与操作",
|
||||
"tor_never_applied_notice": "“使用 Tor”选项从未生效:您的连接并未通过 Tor 路由。该选项已关闭。其他方法请参阅设置中该选项的提示。",
|
||||
"total": "合计",
|
||||
"total_balance_label": "总余额",
|
||||
"transaction_id": "交易 ID",
|
||||
@@ -1941,8 +2044,6 @@
|
||||
"transfer_sent": "转账已发送",
|
||||
"transfer_sent_desc": "您的转账已提交到网络。",
|
||||
"transfer_to": "转账至:",
|
||||
"transparent": "透明",
|
||||
"transparent_address": "透明地址",
|
||||
"try_again": "重试",
|
||||
"tt_addr_url": "在区块浏览器中查看地址的基础 URL",
|
||||
"tt_address_book": "管理已保存的地址以快速发送",
|
||||
@@ -1974,10 +2075,16 @@
|
||||
"tt_debug_expand": "展开调试日志选项",
|
||||
"tt_delete_blockchain": "删除所有区块链数据并重新同步。wallet.dat 和配置将被保留。",
|
||||
"tt_download_bootstrap": "下载区块链引导程序以加速同步\n现有区块数据将被替换",
|
||||
"tt_earn_interval": "收益周期:1H=本小时,1D=今天,1M=本月,1Y=今年",
|
||||
"tt_earn_mode_inflows": "收益模式:收到的金额(含挖矿)",
|
||||
"tt_earn_mode_net": "收益模式:净额(收到减去发送)",
|
||||
"tt_earn_show_all": "为每个地址显示收益数值",
|
||||
"tt_earnings_format": "按地址和总计的收益数值的显示方式",
|
||||
"tt_encrypt": "使用密码加密 wallet.dat",
|
||||
"tt_export_all": "将所有私钥导出到文件",
|
||||
"tt_export_csv": "将交易历史导出为 CSV 电子表格",
|
||||
"tt_export_key": "导出所选地址的私钥",
|
||||
"tt_favorite_opacity": "地址列表中收藏星形按钮及其金色轮廓的不透明度(最低 15%)",
|
||||
"tt_fetch_prices": "从 CoinGecko API 获取 DRGX 市场价格",
|
||||
"tt_font_scale": "缩放所有文本和界面(1.0x = 默认,最大 1.5x)。",
|
||||
"tt_idle_delay": "开始挖矿前等待多长时间",
|
||||
@@ -1986,6 +2093,7 @@
|
||||
"tt_keep_daemon": "运行设置向导时守护进程仍会停止",
|
||||
"tt_language": "钱包界面语言",
|
||||
"tt_layout_hotkey": "快捷键:左/右箭头键切换余额布局",
|
||||
"tt_lite_confirm_pass": "再次输入新的密码短语;两次输入必须一致,且至少 8 个字符",
|
||||
"tt_lite_copy": "将显示的机密复制到剪贴板",
|
||||
"tt_lite_decrypt_pass": "输入你的密码以移除钱包的加密",
|
||||
"tt_lite_encrypt": "用上方的密码加密钱包;加密后立即锁定,需要该密码才能解锁",
|
||||
@@ -2014,11 +2122,16 @@
|
||||
"tt_low_spec": "禁用所有重度视觉效果\\n快捷键:Ctrl+Shift+Down",
|
||||
"tt_merge": "将多个 UTXO 合并到一个地址",
|
||||
"tt_mine_idle": "系统空闲时自动开始挖矿\\n(无键盘/鼠标输入)",
|
||||
"tt_mining_est_daily": "基于您当前在全网算力占比的长期统计平均值。实际挖矿奖励是随机到达的(泊松分布)——常常很长时间没有,然后一次性获得一笔,因此这并不是稳定的每日收入。",
|
||||
"tt_mining_est_daily_fee_fmt": "已扣除 %.1f%% 矿池手续费。",
|
||||
"tt_mining_est_daily_nofee": "未扣除矿池手续费(此矿池手续费未知)。",
|
||||
"tt_new_mining_address": "创建一个用于单人挖矿奖励的透明(R…)地址。它无法接收付款——收款请提供屏蔽地址。",
|
||||
"tt_noise": "颗粒纹理强度(0% = 关闭,100% = 最大)",
|
||||
"tt_open_app_dir": "在文件管理器中打开 ObsidianDragon 文件夹(设置、主题、日志)",
|
||||
"tt_open_data_dir": "在文件管理器中打开包含您钱包和区块链数据的文件夹",
|
||||
"tt_open_dir": "点击在文件管理器中打开",
|
||||
"tt_open_log_folder": "打开包含调试和崩溃日志的文件夹",
|
||||
"tt_pin_out_of_date": "设置此 PIN 后钱包密码短语已更改,因此该 PIN 无法再解锁钱包。请移除 PIN 并重新设置。",
|
||||
"tt_reduce_motion": "禁用动画过渡和余额渐变以提高无障碍性",
|
||||
"tt_remove_encrypt": "移除加密并以未受保护状态存储钱包",
|
||||
"tt_remove_pin": "移除 PIN 并要求密码解锁",
|
||||
@@ -2054,10 +2167,13 @@
|
||||
"tt_theme_effects": "每个主题的闪烁、发光、色调循环",
|
||||
"tt_theme_hotkey": "快捷键:Ctrl+左/右箭头切换主题",
|
||||
"tt_tor": "通过 Tor 网络路由守护进程连接以实现匿名",
|
||||
"tt_tor_unavailable": "暂不可用:此钱包不会通过 Tor 路由任何流量。仅就节点而言,您可以在 DRAGONX.conf 中添加 proxy=127.0.0.1:9050 并重启节点。Tor 必须正在运行,否则节点将找不到其他节点。价格、更新、引导和矿池请求仍会直接连接;如需保护这些请求,请使用系统级 VPN/Tor。",
|
||||
"tt_tor_unavailable_lite": "暂不可用:此钱包不会通过 Tor 路由任何流量。请使用系统级 VPN/Tor。",
|
||||
"tt_tx_url": "在区块浏览器中查看交易的基础 URL",
|
||||
"tt_ui_opacity": "卡片和侧边栏不透明度(100% = 完全不透明,越低越透明)",
|
||||
"tt_validate": "检查 DragonX 地址是否有效",
|
||||
"tt_verbose": "将详细连接诊断、守护进程状态\\n和端口所有者信息记录到控制台选项卡",
|
||||
"tt_view_transactions": "在历史记录标签页中显示涉及此地址的交易",
|
||||
"tt_wallet_size_warn": "屏蔽钱包会随每个票据的见证数据增长——将多个票据合并到一个地址可以缩小体积。请先备份。",
|
||||
"tt_wallets_button": "列出您的钱包文件并在它们之间切换",
|
||||
"tt_website": "打开 DragonX 网站",
|
||||
@@ -2075,6 +2191,7 @@
|
||||
"tx_loading_queued": "已排队的交易刷新",
|
||||
"tx_loading_refreshing_cached": "正在刷新钱包历史(已缓存 %d 条)",
|
||||
"tx_loading_scanning_shielded": "正在扫描屏蔽历史(%d 个地址)",
|
||||
"tx_loading_waits_for_sync": "节点同步完成后将更新历史记录",
|
||||
"tx_mined": "已挖得",
|
||||
"tx_progress_balances": "正在刷新余额",
|
||||
"tx_progress_finalizing": "正在完成交易",
|
||||
@@ -2106,6 +2223,7 @@
|
||||
"validate_description": "输入一个 DragonX 地址来检查它是否有效以及是否属于此钱包。",
|
||||
"validate_invalid": "无效",
|
||||
"validate_is_mine": "此钱包拥有该地址",
|
||||
"validate_mining_type": "挖矿地址(透明)",
|
||||
"validate_not_mine": "不属于此钱包",
|
||||
"validate_ownership": "所有权:",
|
||||
"validate_results": "结果:",
|
||||
@@ -2113,7 +2231,6 @@
|
||||
"validate_shielded_type": "屏蔽(z 地址)",
|
||||
"validate_status": "状态:",
|
||||
"validate_title": "验证地址",
|
||||
"validate_transparent_type": "透明(t 地址)",
|
||||
"validate_type": "类型:",
|
||||
"validate_valid": "有效",
|
||||
"validating": "验证中...",
|
||||
@@ -2123,6 +2240,7 @@
|
||||
"view_all": "查看全部",
|
||||
"view_details": "查看详情",
|
||||
"view_on_explorer": "在浏览器中查看",
|
||||
"view_transactions": "查看交易",
|
||||
"waiting_for_daemon": "等待守护进程连接...",
|
||||
"wallet": "钱包",
|
||||
"wallet_degraded_notify": "您的钱包以受限功能模式打开:现有资金安全且可用,但无法创建新地址或进行屏蔽操作。请备份助记词并用它恢复钱包以完全修复。",
|
||||
@@ -2231,6 +2349,7 @@
|
||||
"wallets_reveal": "打开文件夹",
|
||||
"wallets_scan_folder": "扫描其他文件夹以查找钱包…",
|
||||
"wallets_scanned_folders": "已扫描的文件夹:",
|
||||
"wallets_scanning": "正在扫描文件夹…",
|
||||
"wallets_sort_addresses": "地址",
|
||||
"wallets_sort_asc": "升序(最早/最少/最小优先)",
|
||||
"wallets_sort_by": "排序:",
|
||||
|
||||
+19
-2
@@ -700,6 +700,7 @@ status-pill-bg-alpha = { size = 30 }
|
||||
status-pill-y-offset = { size = 1 }
|
||||
confirmed-threshold = { size = 10 }
|
||||
|
||||
# NOTE: [banners.*] and [layout] are parsed as flat sections (kFlatSections in ui_schema.cpp).
|
||||
# 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]
|
||||
@@ -1097,6 +1098,7 @@ fee-input = { width = 150 }
|
||||
utxo-limit-input = { width = 150 }
|
||||
shield-button = { width = 120, font = "button" }
|
||||
cancel-button = { width = 100, font = "button" }
|
||||
list-max-rows = { size = 7 } # address picker / run rows shown before the list scrolls
|
||||
|
||||
[dialogs.request-payment]
|
||||
width = 500.0
|
||||
@@ -1202,6 +1204,16 @@ connection-dot = { opacity = 1.0 }
|
||||
height = 30
|
||||
padding = [8, 0]
|
||||
|
||||
[components.startup-files-chip]
|
||||
width = { size = 340.0 }
|
||||
height = { size = 46.0 }
|
||||
bottom-margin = { size = 38.0 }
|
||||
rounding = { size = 10.0 }
|
||||
padding-x = { size = 14.0 }
|
||||
bar-height = { size = 3.0 }
|
||||
bar-bottom = { size = 10.0 }
|
||||
show-delay-seconds = { size = 0.4 }
|
||||
|
||||
[components.notifications]
|
||||
width = 350.0
|
||||
height = 60.0
|
||||
@@ -1213,12 +1225,13 @@ pill-margin-y = { size = 3.0 }
|
||||
pill-rounding = { size = 12.0 }
|
||||
padding-x = { size = 12.0 }
|
||||
glass-fill-alpha = { size = 18.0 }
|
||||
accent-fill-alpha = { size = 0.22 } # severity-tint fill opacity (fraction of the accent alpha)
|
||||
glass-border-alpha = { size = 35.0 }
|
||||
progress-bar-height = { size = 2.0 }
|
||||
duration-info = { size = 1.0 }
|
||||
duration-success = { size = 2.5 }
|
||||
duration-warning = { size = 3.5 }
|
||||
duration-error = { size = 4.0 }
|
||||
duration-warning = { size = 7.0 }
|
||||
duration-error = { size = 9.0 }
|
||||
window = { width = 350, height = 60, border-radius = 8, border-width = 1 }
|
||||
viewport-padding = { size = 20.0 }
|
||||
stack-gap = { size = 10.0 }
|
||||
@@ -1339,6 +1352,10 @@ wallet-btn-padding = { size = 24.0 }
|
||||
rpc-label-min-width = { size = 70.0 }
|
||||
rpc-label-width = { size = 85.0 }
|
||||
security-combo-width = { size = 120.0 }
|
||||
# Wrap width of the Security band's "PIN out of date" tooltip (a long sentence in every language).
|
||||
pin-tooltip-wrap-width = { size = 420.0 }
|
||||
# Wrap width of the disabled "Use Tor" checkbox's tooltip (Settings page and the legacy Settings window).
|
||||
tor-tooltip-wrap-width = { size = 420.0 }
|
||||
node-grid-breakpoint = { size = 900.0 }
|
||||
port-input-min-width = { size = 60.0 }
|
||||
port-input-width-ratio = { size = 0.4 }
|
||||
|
||||
@@ -25,6 +25,644 @@ def load_english_keys():
|
||||
|
||||
# All translations for the 158 missing keys
|
||||
TRANSLATIONS = {
|
||||
"merge_where": {
|
||||
"de": "Wohin sollen die Mittel gehen?",
|
||||
"es": "¿Adónde deben ir los fondos?",
|
||||
"fr": "Où doivent aller les fonds ?",
|
||||
"pt": "Para onde devem ir os fundos?",
|
||||
"ru": "Куда направить средства?",
|
||||
"zh": "资金应转到哪里?",
|
||||
"ja": "資金の移動先",
|
||||
"ko": "자금을 어디로 보낼까요?",
|
||||
},
|
||||
"merge_into_one": {
|
||||
"de": "In eine Adresse",
|
||||
"es": "A una sola dirección",
|
||||
"fr": "Vers une seule adresse",
|
||||
"pt": "Para um só endereço",
|
||||
"ru": "На один адрес",
|
||||
"zh": "合并到一个地址",
|
||||
"ja": "1 つのアドレスへ",
|
||||
"ko": "한 주소로",
|
||||
},
|
||||
"merge_keep_each": {
|
||||
"de": "In jeder Adresse belassen",
|
||||
"es": "Mantener en cada dirección",
|
||||
"fr": "Garder dans chaque adresse",
|
||||
"pt": "Manter em cada endereço",
|
||||
"ru": "Оставить в каждом адресе",
|
||||
"zh": "保留在各自地址",
|
||||
"ja": "各アドレスに残す",
|
||||
"ko": "각 주소에 유지",
|
||||
},
|
||||
"merge_keep_each_desc": {
|
||||
"de": "Die Notes jeder Adresse werden in genau diese Adresse zurückgeführt, sodass Mittel, die Sie unter verschiedenen Bezeichnungen getrennt halten, getrennt bleiben. Nur abgeschirmte Adressen – transparente Mittel können nur abgeschirmt werden (In eine Adresse).",
|
||||
"es": "Las notas de cada dirección se fusionan de nuevo en esa misma dirección, así que los fondos que mantienes separados con distintas etiquetas siguen separados. Solo direcciones blindadas: los fondos transparentes solo pueden blindarse (A una sola dirección).",
|
||||
"fr": "Les notes de chaque adresse sont refusionnées dans cette même adresse : les fonds que vous gardez séparés sous des libellés différents restent séparés. Adresses blindées uniquement — les fonds transparents ne peuvent qu'être blindés (Vers une seule adresse).",
|
||||
"pt": "As notas de cada endereço são mescladas de volta nesse mesmo endereço, então os fundos que você mantém separados com rótulos diferentes continuam separados. Só endereços blindados — fundos transparentes só podem ser blindados (Para um só endereço).",
|
||||
"ru": "Заметки каждого адреса объединяются обратно в тот же адрес, поэтому средства, которые вы держите раздельно под разными метками, остаются раздельными. Только экранированные адреса — прозрачные средства можно только экранировать («На один адрес»).",
|
||||
"zh": "每个地址的票据会合并回该地址本身,因此您用不同标签分开存放的资金仍保持分开。仅限屏蔽地址——透明资金只能被屏蔽(合并到一个地址)。",
|
||||
"ja": "各アドレスのノートはそのアドレス自身に統合されるため、ラベルごとに分けている資金は分かれたままです。シールドアドレスのみ対象です。透明アドレスの資金はシールドすることしかできません(1 つのアドレスへ)。",
|
||||
"ko": "각 주소의 노트는 같은 주소로 다시 병합되므로, 다른 라벨로 나눠 둔 자금은 계속 나뉜 상태로 유지됩니다. 차폐 주소만 해당되며, 투명 자금은 차폐만 할 수 있습니다(한 주소로).",
|
||||
},
|
||||
"merge_keep_each_none": {
|
||||
"de": "Jede abgeschirmte Adresse hat bereits nur eine Note – nichts zu konsolidieren.",
|
||||
"es": "Cada dirección blindada ya tiene una sola nota: no hay nada que consolidar.",
|
||||
"fr": "Chaque adresse blindée ne contient déjà qu'une seule note — rien à consolider.",
|
||||
"pt": "Cada endereço blindado já tem uma única nota — nada a consolidar.",
|
||||
"ru": "На каждом экранированном адресе уже одна заметка — объединять нечего.",
|
||||
"zh": "每个屏蔽地址都只有一张票据——无需整合。",
|
||||
"ja": "すべてのシールドアドレスのノートはすでに 1 つです。統合するものはありません。",
|
||||
"ko": "모든 차폐 주소에 노트가 이미 하나뿐입니다. 통합할 것이 없습니다.",
|
||||
},
|
||||
"merge_keep_each_row_fmt": {
|
||||
"de": "%s · %d Notes · %s DRGX",
|
||||
"es": "%s · %d notas · %s DRGX",
|
||||
"fr": "%s · %d notes · %s DRGX",
|
||||
"pt": "%s · %d notas · %s DRGX",
|
||||
"ru": "%s · заметок: %d · %s DRGX",
|
||||
"zh": "%s · %d 张票据 · %s DRGX",
|
||||
"ja": "%s · ノート %d 件 · %s DRGX",
|
||||
"ko": "%s · 노트 %d개 · %s DRGX",
|
||||
},
|
||||
"merge_keep_each_too_small": {
|
||||
"de": "Zu klein zum Konsolidieren: Die Gebühr würde diese Mittel aufbrauchen.",
|
||||
"es": "Demasiado poco para consolidar: la comisión se llevaría estos fondos.",
|
||||
"fr": "Trop faible pour être consolidé : les frais absorberaient ces fonds.",
|
||||
"pt": "Pequeno demais para consolidar: a taxa consumiria esses fundos.",
|
||||
"ru": "Слишком мало для объединения: комиссия съест эти средства.",
|
||||
"zh": "金额太小,无法整合:手续费会耗尽这些资金。",
|
||||
"ja": "少なすぎて統合できません。手数料でこの資金がなくなります。",
|
||||
"ko": "너무 적어서 통합할 수 없습니다. 수수료가 이 자금을 모두 소모합니다.",
|
||||
},
|
||||
"merge_keep_each_batches_fmt": {
|
||||
"de": "Adressen mit mehr als %d Notes werden in mehreren Durchgängen zusammengeführt; führen Sie es nach deren Bestätigung erneut aus, um den Rest zusammenzuführen.",
|
||||
"es": "Las direcciones con más de %d notas se fusionan en varios lotes; vuelve a ejecutarlo cuando se confirmen para fusionar el resto.",
|
||||
"fr": "Les adresses de plus de %d notes sont fusionnées en plusieurs lots ; relancez l'opération une fois ceux-ci confirmés pour fusionner le reste.",
|
||||
"pt": "Endereços com mais de %d notas são mesclados em vários lotes; execute de novo depois que forem confirmados para mesclar o restante.",
|
||||
"ru": "Адреса с более чем %d заметками объединяются в несколько партий; запустите снова после их подтверждения, чтобы объединить остальное.",
|
||||
"zh": "超过 %d 张票据的地址会分多批合并;确认后再运行一次即可合并其余部分。",
|
||||
"ja": "ノートが %d 件を超えるアドレスは複数回に分けて統合されます。承認後にもう一度実行すると残りが統合されます。",
|
||||
"ko": "노트가 %d개를 넘는 주소는 여러 번에 나눠 병합됩니다. 확인된 후 다시 실행하면 나머지가 병합됩니다.",
|
||||
},
|
||||
"merge_keep_each_confirm_fmt": {
|
||||
"de": "%d Notes in %d Adressen konsolidieren? Jede Adresse behält ihre eigenen Mittel. Gebühren: etwa %s DRGX (eine pro Durchgang).",
|
||||
"es": "¿Consolidar %d notas en %d direcciones? Cada dirección conserva sus propios fondos. Comisiones: unos %s DRGX (una por lote).",
|
||||
"fr": "Consolider %d notes dans %d adresses ? Chaque adresse garde ses propres fonds. Frais : environ %s DRGX (un par lot).",
|
||||
"pt": "Consolidar %d notas em %d endereços? Cada endereço mantém seus próprios fundos. Taxas: cerca de %s DRGX (uma por lote).",
|
||||
"ru": "Объединить %d заметок на %d адресах? Каждый адрес сохраняет свои средства. Комиссии: около %s DRGX (по одной на партию).",
|
||||
"zh": "整合 %d 张票据,涉及 %d 个地址?每个地址保留各自的资金。手续费:约 %s DRGX(每批一次)。",
|
||||
"ja": "%d 件のノートを %d 個のアドレスで統合しますか?各アドレスの資金はそのまま残ります。手数料:約 %s DRGX(1 回ごとに 1 件)。",
|
||||
"ko": "노트 %d개를 주소 %d개에서 통합할까요? 각 주소는 자기 자금을 그대로 유지합니다. 수수료: 약 %s DRGX(배치마다 1회).",
|
||||
},
|
||||
"merge_keep_each_select": {
|
||||
"de": "Wählen Sie mindestens eine Adresse zum Konsolidieren aus.",
|
||||
"es": "Marca al menos una dirección para consolidar.",
|
||||
"fr": "Cochez au moins une adresse à consolider.",
|
||||
"pt": "Marque pelo menos um endereço para consolidar.",
|
||||
"ru": "Отметьте хотя бы один адрес для объединения.",
|
||||
"zh": "请至少勾选一个要整合的地址。",
|
||||
"ja": "統合するアドレスを 1 つ以上選んでください。",
|
||||
"ko": "통합할 주소를 하나 이상 선택하세요.",
|
||||
},
|
||||
"merge_keep_each_nothing_submitted": {
|
||||
"de": "Nichts wurde konsolidiert – der Node hat jede Zusammenführung abgelehnt.",
|
||||
"es": "No se consolidó nada: el nodo rechazó todas las fusiones.",
|
||||
"fr": "Rien n'a été consolidé — le nœud a refusé chaque fusion.",
|
||||
"pt": "Nada foi consolidado — o nó recusou todas as mesclagens.",
|
||||
"ru": "Ничего не объединено — узел отклонил все объединения.",
|
||||
"zh": "没有任何整合——节点拒绝了所有合并。",
|
||||
"ja": "何も統合されませんでした。ノードがすべての統合を拒否しました。",
|
||||
"ko": "아무것도 통합되지 않았습니다. 노드가 모든 병합을 거부했습니다.",
|
||||
},
|
||||
"merge_keep_each_some_failed": {
|
||||
"de": "Einige Zusammenführungen sind fehlgeschlagen – siehe Liste oben.",
|
||||
"es": "Algunas fusiones fallaron; consulta la lista de arriba.",
|
||||
"fr": "Certaines fusions ont échoué — voir la liste ci-dessus.",
|
||||
"pt": "Algumas mesclagens falharam — veja a lista acima.",
|
||||
"ru": "Некоторые объединения не удались — см. список выше.",
|
||||
"zh": "部分合并失败——请查看上方列表。",
|
||||
"ja": "一部の統合に失敗しました。上の一覧を確認してください。",
|
||||
"ko": "일부 병합에 실패했습니다. 위 목록을 확인하세요.",
|
||||
},
|
||||
"merge_keep_each_status_not_started": {
|
||||
"de": "%s – nicht gestartet: %s",
|
||||
"es": "%s: no iniciado: %s",
|
||||
"fr": "%s — non démarré : %s",
|
||||
"pt": "%s — não iniciado: %s",
|
||||
"ru": "%s — не запущено: %s",
|
||||
"zh": "%s——未开始:%s",
|
||||
"ja": "%s — 開始されませんでした:%s",
|
||||
"ko": "%s — 시작되지 않음: %s",
|
||||
},
|
||||
"merge_keep_each_status_failed": {
|
||||
"de": "%s – fehlgeschlagen: %s",
|
||||
"es": "%s: falló: %s",
|
||||
"fr": "%s — échec : %s",
|
||||
"pt": "%s — falhou: %s",
|
||||
"ru": "%s — ошибка: %s",
|
||||
"zh": "%s——失败:%s",
|
||||
"ja": "%s — 失敗:%s",
|
||||
"ko": "%s — 실패: %s",
|
||||
},
|
||||
"merge_keep_each_status_done": {
|
||||
"de": "%s – fertig: %d Notes in %d Durchgang/Durchgängen zusammengeführt",
|
||||
"es": "%s: listo: %d notas fusionadas en %d lote(s)",
|
||||
"fr": "%s — terminé : %d notes fusionnées en %d lot(s)",
|
||||
"pt": "%s — concluído: %d notas mescladas em %d lote(s)",
|
||||
"ru": "%s — готово: объединено заметок: %d, партий: %d",
|
||||
"zh": "%s——完成:已合并 %d 张票据,共 %d 批",
|
||||
"ja": "%s — 完了:%d 件のノートを %d 回で統合",
|
||||
"ko": "%s — 완료: 노트 %d개를 %d번에 걸쳐 병합",
|
||||
},
|
||||
"merge_keep_each_status_running": {
|
||||
"de": "%s – %d Notes werden zusammengeführt (%d von %d Durchgängen fertig)…",
|
||||
"es": "%s: fusionando %d notas (%d de %d lotes listos)…",
|
||||
"fr": "%s — fusion de %d notes (%d lots sur %d terminés)…",
|
||||
"pt": "%s — mesclando %d notas (%d de %d lotes concluídos)…",
|
||||
"ru": "%s — объединение %d заметок (готово партий: %d из %d)…",
|
||||
"zh": "%s——正在合并 %d 张票据(已完成 %d/%d 批)…",
|
||||
"ja": "%s — %d 件のノートを統合中(%d / %d 回完了)…",
|
||||
"ko": "%s — 노트 %d개 병합 중(%d/%d 배치 완료)…",
|
||||
},
|
||||
"merge_keep_each_run_again": {
|
||||
"de": "Adressen, die in mehreren Durchgängen zusammengeführt wurden, haben jetzt eine Note pro Durchgang. Führen Sie dies nach deren Bestätigung erneut aus, um sie zu einer zusammenzuführen.",
|
||||
"es": "Las direcciones fusionadas en varios lotes tienen ahora una nota por lote. Vuelve a ejecutarlo cuando se confirmen para fusionarlas en una.",
|
||||
"fr": "Les adresses fusionnées en plusieurs lots contiennent maintenant une note par lot. Relancez l'opération une fois ceux-ci confirmés pour les fusionner en une seule.",
|
||||
"pt": "Endereços mesclados em vários lotes agora têm uma nota por lote. Execute de novo depois que forem confirmados para mesclá-las em uma.",
|
||||
"ru": "На адресах, объединённых в несколько партий, теперь по одной заметке на партию. Запустите снова после их подтверждения, чтобы объединить их в одну.",
|
||||
"zh": "分多批合并的地址现在每批各有一张票据。确认后再运行一次,即可把它们合并为一张。",
|
||||
"ja": "複数回に分けて統合したアドレスには、回数分のノートが残っています。承認後にもう一度実行すると 1 つにまとめられます。",
|
||||
"ko": "여러 번에 나눠 병합된 주소에는 배치마다 노트가 하나씩 남아 있습니다. 확인된 후 다시 실행하면 하나로 병합됩니다.",
|
||||
},
|
||||
"mining_addr_explainer": {
|
||||
"de": "Empfängt nur Solo-Mining-Belohnungen – das Netzwerk lehnt Zahlungen an diese Adresse ab. Belohnungen werden automatisch abgeschirmt, sobald sie gereift sind.",
|
||||
"es": "Solo recibe recompensas de minería en solitario; la red rechaza los pagos a esta dirección. Las recompensas se blindan automáticamente cuando maduran.",
|
||||
"fr": "Ne reçoit que des récompenses de minage en solo — le réseau refuse les paiements vers cette adresse. Les récompenses sont blindées automatiquement une fois arrivées à maturité.",
|
||||
"pt": "Recebe apenas recompensas de mineração solo — a rede rejeita pagamentos para este endereço. As recompensas são blindadas automaticamente quando amadurecem.",
|
||||
"ru": "Принимает только награды за соло-майнинг — сеть отклоняет платежи на этот адрес. Награды экранируются автоматически, когда созревают.",
|
||||
"zh": "仅接收单人挖矿奖励——网络会拒绝向此地址付款。奖励成熟后会自动屏蔽。",
|
||||
"ja": "ソロマイニング報酬のみを受け取ります。ネットワークはこのアドレスへの支払いを拒否します。報酬は成熟すると自動的にシールドされます。",
|
||||
"ko": "솔로 채굴 보상만 받습니다. 네트워크가 이 주소로의 결제를 거부합니다. 보상은 성숙하면 자동으로 차폐됩니다.",
|
||||
},
|
||||
"receive_use_shielded": {
|
||||
"de": "Abgeschirmte Adresse verwenden",
|
||||
"es": "Usar una dirección blindada",
|
||||
"fr": "Utiliser une adresse blindée",
|
||||
"pt": "Usar um endereço blindado",
|
||||
"ru": "Использовать экранированный адрес",
|
||||
"zh": "使用屏蔽地址",
|
||||
"ja": "シールドアドレスを使う",
|
||||
"ko": "차폐 주소 사용",
|
||||
},
|
||||
"receive_not_for_payments": {
|
||||
"de": "Nicht für Zahlungen",
|
||||
"es": "No para pagos",
|
||||
"fr": "Pas pour les paiements",
|
||||
"pt": "Não é para pagamentos",
|
||||
"ru": "Не для платежей",
|
||||
"zh": "不用于付款",
|
||||
"ja": "支払いには使えません",
|
||||
"ko": "결제용이 아님",
|
||||
},
|
||||
"validate_mining_type": {
|
||||
"de": "Mining-Adresse (transparent)",
|
||||
"es": "Dirección de minería (transparente)",
|
||||
"fr": "Adresse de minage (transparente)",
|
||||
"pt": "Endereço de mineração (transparente)",
|
||||
"ru": "Адрес для майнинга (прозрачный)",
|
||||
"zh": "挖矿地址(透明)",
|
||||
"ja": "マイニングアドレス(透明)",
|
||||
"ko": "채굴 주소(투명)",
|
||||
},
|
||||
"send_mining_addr_short": {
|
||||
"de": "Mining-Adresse – kann keine Zahlungen empfangen",
|
||||
"es": "Dirección de minería: no puede recibir pagos",
|
||||
"fr": "Adresse de minage — ne peut pas recevoir de paiements",
|
||||
"pt": "Endereço de mineração — não pode receber pagamentos",
|
||||
"ru": "Адрес для майнинга — не принимает платежи",
|
||||
"zh": "挖矿地址——无法接收付款",
|
||||
"ja": "マイニングアドレス — 支払いを受け取れません",
|
||||
"ko": "채굴 주소 — 결제를 받을 수 없음",
|
||||
},
|
||||
"send_mining_addr_blocked": {
|
||||
"de": "Dies ist eine Mining-Adresse (transparent). Bei DragonX können solche Adressen nur Mining-Belohnungen empfangen – das Netzwerk lehnt Zahlungen an sie ab. Bitten Sie den Empfänger um eine abgeschirmte Adresse (sie beginnt mit zs).",
|
||||
"es": "Esta es una dirección de minería (transparente). En DragonX solo pueden recibir recompensas de minería; la red rechaza los pagos a ellas. Pide al destinatario una dirección blindada (empieza por zs).",
|
||||
"fr": "Ceci est une adresse de minage (transparente). Sur DragonX, elles ne peuvent recevoir que des récompenses de minage — le réseau refuse les paiements vers elles. Demandez au destinataire une adresse blindée (elle commence par zs).",
|
||||
"pt": "Este é um endereço de mineração (transparente). No DragonX eles só podem receber recompensas de mineração — a rede rejeita pagamentos para eles. Peça ao destinatário um endereço blindado (começa com zs).",
|
||||
"ru": "Это адрес для майнинга (прозрачный). В DragonX такие адреса могут получать только награды за майнинг — сеть отклоняет платежи на них. Попросите у получателя экранированный адрес (начинается с zs).",
|
||||
"zh": "这是一个挖矿(透明)地址。在 DragonX 上,这类地址只能接收挖矿奖励——网络会拒绝向其付款。请向收款人索取屏蔽地址(以 zs 开头)。",
|
||||
"ja": "これはマイニング(透明)アドレスです。DragonX ではこのアドレスはマイニング報酬しか受け取れず、ネットワークはこのアドレスへの支払いを拒否します。受取人にシールドアドレス(zs で始まる)を依頼してください。",
|
||||
"ko": "채굴(투명) 주소입니다. DragonX에서 이런 주소는 채굴 보상만 받을 수 있으며, 네트워크가 이 주소로의 결제를 거부합니다. 받는 사람에게 차폐 주소(zs로 시작)를 요청하세요.",
|
||||
},
|
||||
"mining_payout_needs_shielded": {
|
||||
"de": "Pools zahlen mit gewöhnlichen Transaktionen aus, die bei DragonX keine Mining-Adresse (transparent, R…) erreichen können. Verwenden Sie eine abgeschirmte Adresse (sie beginnt mit zs) als Auszahlungsadresse.",
|
||||
"es": "Los pools pagan con transacciones normales, que en DragonX no pueden llegar a una dirección de minería (transparente, R…). Usa una dirección blindada (empieza por zs) como dirección de pago.",
|
||||
"fr": "Les pools paient avec des transactions ordinaires, qui ne peuvent pas atteindre une adresse de minage (transparente, R…) sur DragonX. Utilisez une adresse blindée (elle commence par zs) comme adresse de paiement.",
|
||||
"pt": "Os pools pagam com transações comuns, que no DragonX não conseguem chegar a um endereço de mineração (transparente, R…). Use um endereço blindado (começa com zs) como endereço de pagamento.",
|
||||
"ru": "Пулы выплачивают обычными транзакциями, которые в DragonX не могут дойти до адреса для майнинга (прозрачного, R…). Укажите экранированный адрес (начинается с zs) как адрес для выплат.",
|
||||
"zh": "矿池使用普通交易付款,而在 DragonX 上普通交易无法到达挖矿(透明,R…)地址。请使用屏蔽地址(以 zs 开头)作为收款地址。",
|
||||
"ja": "プールは通常のトランザクションで支払いますが、DragonX ではそれがマイニング(透明、R…)アドレスに届きません。支払い先にはシールドアドレス(zs で始まる)を使用してください。",
|
||||
"ko": "풀은 일반 트랜잭션으로 지급하는데, DragonX에서는 이것이 채굴(투명, R…) 주소에 도달할 수 없습니다. 지급 주소로 차폐 주소(zs로 시작)를 사용하세요.",
|
||||
},
|
||||
"new_mining_address": {
|
||||
"de": "Neue Mining-Adresse",
|
||||
"es": "Nueva dirección de minería",
|
||||
"fr": "Nouvelle adresse de minage",
|
||||
"pt": "Novo endereço de mineração",
|
||||
"ru": "Новый адрес для майнинга",
|
||||
"zh": "新建挖矿地址",
|
||||
"ja": "新しいマイニングアドレス",
|
||||
"ko": "새 채굴 주소",
|
||||
},
|
||||
"tt_new_mining_address": {
|
||||
"de": "Erstellt eine transparente Adresse (R…) für Solo-Mining-Belohnungen. Sie kann keine Zahlungen empfangen – teilen Sie dafür eine abgeschirmte Adresse.",
|
||||
"es": "Crea una dirección transparente (R…) para recompensas de minería en solitario. No puede recibir pagos; para eso comparte una dirección blindada.",
|
||||
"fr": "Crée une adresse transparente (R…) pour les récompenses de minage en solo. Elle ne peut pas recevoir de paiements — partagez une adresse blindée pour cela.",
|
||||
"pt": "Cria um endereço transparente (R…) para recompensas de mineração solo. Ele não pode receber pagamentos — compartilhe um endereço blindado para isso.",
|
||||
"ru": "Создаёт прозрачный адрес (R…) для наград за соло-майнинг. Он не принимает платежи — для них сообщайте экранированный адрес.",
|
||||
"zh": "创建一个用于单人挖矿奖励的透明(R…)地址。它无法接收付款——收款请提供屏蔽地址。",
|
||||
"ja": "ソロマイニング報酬用の透明(R…)アドレスを作成します。支払いは受け取れません。支払いにはシールドアドレスを共有してください。",
|
||||
"ko": "솔로 채굴 보상용 투명(R…) 주소를 만듭니다. 결제는 받을 수 없으니, 결제용으로는 차폐 주소를 공유하세요.",
|
||||
},
|
||||
"mining_address": {
|
||||
"de": "Mining-Adresse",
|
||||
"es": "Dirección de minería",
|
||||
"fr": "Adresse de minage",
|
||||
"pt": "Endereço de mineração",
|
||||
"ru": "Адрес для майнинга",
|
||||
"zh": "挖矿地址",
|
||||
"ja": "マイニングアドレス",
|
||||
"ko": "채굴 주소",
|
||||
},
|
||||
"addr_type_mining": {
|
||||
"de": "Mining",
|
||||
"es": "Minería",
|
||||
"fr": "Minage",
|
||||
"pt": "Mineração",
|
||||
"ru": "Майнинг",
|
||||
"zh": "挖矿",
|
||||
"ja": "マイニング",
|
||||
"ko": "채굴",
|
||||
},
|
||||
"mining_rewards": {
|
||||
"de": "Mining-Belohnungen",
|
||||
"es": "Recompensas de minería",
|
||||
"fr": "Récompenses de minage",
|
||||
"pt": "Recompensas de mineração",
|
||||
"ru": "Награды за майнинг",
|
||||
"zh": "挖矿奖励",
|
||||
"ja": "マイニング報酬",
|
||||
"ko": "채굴 보상",
|
||||
},
|
||||
"mining_rewards_unshielded": {
|
||||
"de": "Noch nicht abgeschirmt",
|
||||
"es": "Aún sin blindar",
|
||||
"fr": "Pas encore blindées",
|
||||
"pt": "Ainda não blindadas",
|
||||
"ru": "Ещё не экранированы",
|
||||
"zh": "尚未屏蔽",
|
||||
"ja": "未シールド",
|
||||
"ko": "아직 차폐되지 않음",
|
||||
},
|
||||
"baltab_mining_addresses_count": {
|
||||
"de": "%d Mining-Adressen",
|
||||
"es": "%d direcciones de minería",
|
||||
"fr": "%d adresses de minage",
|
||||
"pt": "%d endereços de mineração",
|
||||
"ru": "Адресов для майнинга: %d",
|
||||
"zh": "%d 个挖矿地址",
|
||||
"ja": "マイニングアドレス %d 件",
|
||||
"ko": "채굴 주소 %d개",
|
||||
},
|
||||
"baltab_mining_amount": {
|
||||
"de": "Mining-Belohnungen %.8f",
|
||||
"es": "Recompensas de minería %.8f",
|
||||
"fr": "Récompenses de minage %.8f",
|
||||
"pt": "Recompensas de mineração %.8f",
|
||||
"ru": "Награды за майнинг %.8f",
|
||||
"zh": "挖矿奖励 %.8f",
|
||||
"ja": "マイニング報酬 %.8f",
|
||||
"ko": "채굴 보상 %.8f",
|
||||
},
|
||||
"balance_mining_fmt": {
|
||||
"de": "Mining-Belohnungen: %.8f",
|
||||
"es": "Recompensas de minería: %.8f",
|
||||
"fr": "Récompenses de minage : %.8f",
|
||||
"pt": "Recompensas de mineração: %.8f",
|
||||
"ru": "Награды за майнинг: %.8f",
|
||||
"zh": "挖矿奖励:%.8f",
|
||||
"ja": "マイニング報酬:%.8f",
|
||||
"ko": "채굴 보상: %.8f",
|
||||
},
|
||||
"mining_card_tooltip": {
|
||||
"de": "Solo-Mining-Belohnungen gehen an Ihre Mining-Adressen (transparent, R…). Diese Adressen können keine Zahlungen empfangen. Belohnungen werden automatisch abgeschirmt, sobald sie gereift sind.",
|
||||
"es": "Las recompensas de minería en solitario llegan a tus direcciones de minería (transparentes, R…). Esas direcciones no pueden recibir pagos. Las recompensas se blindan automáticamente cuando maduran.",
|
||||
"fr": "Les récompenses de minage en solo arrivent sur vos adresses de minage (transparentes, R…). Ces adresses ne peuvent pas recevoir de paiements. Les récompenses sont blindées automatiquement une fois arrivées à maturité.",
|
||||
"pt": "As recompensas de mineração solo chegam aos seus endereços de mineração (transparentes, R…). Esses endereços não podem receber pagamentos. As recompensas são blindadas automaticamente quando amadurecem.",
|
||||
"ru": "Награды за соло-майнинг поступают на ваши адреса для майнинга (прозрачные, R…). Эти адреса не принимают платежи. Награды экранируются автоматически, когда созревают.",
|
||||
"zh": "单人挖矿奖励会发送到您的挖矿(透明,R…)地址。这些地址无法接收付款。奖励成熟后会自动屏蔽。",
|
||||
"ja": "ソロマイニング報酬はマイニング(透明、R…)アドレスに届きます。これらのアドレスは支払いを受け取れません。報酬は成熟すると自動的にシールドされます。",
|
||||
"ko": "솔로 채굴 보상은 채굴(투명, R…) 주소로 들어옵니다. 이 주소는 결제를 받을 수 없습니다. 보상은 성숙하면 자동으로 차폐됩니다.",
|
||||
},
|
||||
"mining_card_click_to_shield": {
|
||||
"de": "Klicken, um gereifte Belohnungen jetzt abzuschirmen.",
|
||||
"es": "Haz clic para blindar ahora las recompensas maduras.",
|
||||
"fr": "Cliquez pour blinder dès maintenant les récompenses arrivées à maturité.",
|
||||
"pt": "Clique para blindar agora as recompensas amadurecidas.",
|
||||
"ru": "Нажмите, чтобы экранировать созревшие награды сейчас.",
|
||||
"zh": "点击立即屏蔽已成熟的奖励。",
|
||||
"ja": "クリックすると成熟した報酬を今すぐシールドします。",
|
||||
"ko": "클릭하면 성숙한 보상을 지금 차폐합니다.",
|
||||
},
|
||||
"contact_chip_mining": {
|
||||
"de": "Nur Mining",
|
||||
"es": "Solo minería",
|
||||
"fr": "Minage seul",
|
||||
"pt": "Só mineração",
|
||||
"ru": "Только майнинг",
|
||||
"zh": "仅限挖矿",
|
||||
"ja": "マイニング専用",
|
||||
"ko": "채굴 전용",
|
||||
},
|
||||
"failed_create_mining_address": {
|
||||
"de": "Mining-Adresse konnte nicht erstellt werden",
|
||||
"es": "No se pudo crear la dirección de minería",
|
||||
"fr": "Impossible de créer l'adresse de minage",
|
||||
"pt": "Falha ao criar o endereço de mineração",
|
||||
"ru": "Не удалось создать адрес для майнинга",
|
||||
"zh": "创建挖矿地址失败",
|
||||
"ja": "マイニングアドレスを作成できませんでした",
|
||||
"ko": "채굴 주소를 만들지 못했습니다",
|
||||
},
|
||||
"new_mining_address_created": {
|
||||
"de": "Neue Mining-Adresse erstellt",
|
||||
"es": "Nueva dirección de minería creada",
|
||||
"fr": "Nouvelle adresse de minage créée",
|
||||
"pt": "Novo endereço de mineração criado",
|
||||
"ru": "Новый адрес для майнинга создан",
|
||||
"zh": "已创建新的挖矿地址",
|
||||
"ja": "新しいマイニングアドレスを作成しました",
|
||||
"ko": "새 채굴 주소를 만들었습니다",
|
||||
},
|
||||
"appx_node_busy_try_later": {
|
||||
"de": "Der Node ist mit einer langen Aufgabe beschäftigt (etwa dem Neuaufbau der Note-Witnesses). Versuchen Sie es erneut, wenn sie abgeschlossen ist; ein Stoppen jetzt könnte diese Arbeit von vorn beginnen lassen.",
|
||||
"es": "El nodo está ocupado terminando una tarea larga (como reconstruir los testigos de notas). Vuelve a intentarlo cuando termine; detenerlo ahora podría hacer que empiece ese trabajo de nuevo.",
|
||||
"fr": "Le nœud termine une longue tâche (comme la reconstruction des témoins de notes). Réessayez une fois qu'elle est finie ; l'arrêter maintenant pourrait lui faire recommencer ce travail.",
|
||||
"pt": "O nó está ocupado concluindo uma tarefa longa (como reconstruir as testemunhas de notas). Tente novamente quando terminar; pará-lo agora pode fazê-lo recomeçar esse trabalho.",
|
||||
"ru": "Узел занят долгой задачей (например, перестройкой свидетелей заметок). Повторите попытку, когда она завершится; если остановить его сейчас, эту работу, возможно, придётся начать заново.",
|
||||
"zh": "节点正忙于完成一项耗时任务(例如重建票据见证)。请在完成后重试;现在停止它可能导致这项工作从头开始。",
|
||||
"ja": "ノードは長時間の処理(ノートウィットネスの再構築など)を実行中です。完了してからもう一度お試しください。今停止すると、その処理を最初からやり直すことがあります。",
|
||||
"ko": "노드가 긴 작업(노트 위트니스 재구성 등)을 마무리하는 중입니다. 끝난 뒤 다시 시도하세요. 지금 중지하면 그 작업을 처음부터 다시 할 수 있습니다.",
|
||||
},
|
||||
"appx_node_not_stopped": {
|
||||
"de": "Der Node wurde nicht rechtzeitig beendet, daher wurde nichts geändert. Versuchen Sie es in ein paar Minuten erneut.",
|
||||
"es": "El nodo no se detuvo a tiempo, así que no se cambió nada. Vuelve a intentarlo en unos minutos.",
|
||||
"fr": "Le nœud ne s'est pas arrêté à temps, rien n'a donc été modifié. Réessayez dans quelques minutes.",
|
||||
"pt": "O nó não parou a tempo, por isso nada foi alterado. Tente novamente em alguns minutos.",
|
||||
"ru": "Узел не остановился вовремя, поэтому ничего не изменено. Повторите попытку через несколько минут.",
|
||||
"zh": "节点未能及时停止,因此未做任何更改。请几分钟后重试。",
|
||||
"ja": "ノードが時間内に停止しなかったため、何も変更されませんでした。数分後にもう一度お試しください。",
|
||||
"ko": "노드가 제때 중지되지 않아 아무것도 변경되지 않았습니다. 몇 분 후 다시 시도하세요.",
|
||||
},
|
||||
# --- Daemon shutdown hardening: stopping the node on quit (never kill unasked) ---
|
||||
"shutdown_leave_running": {
|
||||
"de": "Wallet schließen – Node weiterlaufen lassen", "es": "Cerrar la cartera y dejar el nodo en marcha",
|
||||
"fr": "Fermer le portefeuille — laisser le nœud actif", "pt": "Fechar a carteira e deixar o nó em execução",
|
||||
"ru": "Закрыть кошелёк — оставить узел работать", "zh": "关闭钱包——保持节点运行",
|
||||
"ja": "ウォレットを閉じる — ノードは実行したまま", "ko": "지갑 닫기 — 노드는 계속 실행"
|
||||
},
|
||||
"shutdown_leave_running_tip": {
|
||||
"de": "Schließt die Wallet sofort. Der Node läuft allein weiter und beendet, was er gerade tut; nichts geht verloren.",
|
||||
"es": "Cierra la cartera ahora. El nodo sigue funcionando por su cuenta y termina lo que está haciendo; no se pierde nada.",
|
||||
"fr": "Ferme le portefeuille maintenant. Le nœud continue de fonctionner seul et termine ce qu'il fait ; rien n'est perdu.",
|
||||
"pt": "Fecha a carteira agora. O nó continua em execução sozinho e termina o que está fazendo; nada é perdido.",
|
||||
"ru": "Закрывает кошелёк сейчас. Узел продолжит работать сам и завершит текущую операцию; ничего не теряется.",
|
||||
"zh": "立即关闭钱包。节点会继续独立运行并完成当前操作,不会丢失任何数据。",
|
||||
"ja": "ウォレットをすぐに閉じます。ノードは単独で実行を続け、処理中の作業を完了します。何も失われません。",
|
||||
"ko": "지갑을 지금 닫습니다. 노드는 혼자 계속 실행되며 진행 중인 작업을 마칩니다. 잃는 것은 없습니다."
|
||||
},
|
||||
"shutdown_kill_node": {
|
||||
"de": "Node erzwungen beenden (unsicher)", "es": "Forzar cierre del nodo (inseguro)",
|
||||
"fr": "Tuer le nœud (risqué)", "pt": "Forçar encerramento do nó (inseguro)",
|
||||
"ru": "Принудительно завершить узел (небезопасно)", "zh": "强制结束节点(不安全)",
|
||||
"ja": "ノードを強制終了(危険)", "ko": "노드 강제 종료(위험)"
|
||||
},
|
||||
"shutdown_kill_node_tip": {
|
||||
"de": "Beendet den Node sofort. Alles, was er noch nicht gespeichert hat, geht verloren, und beim nächsten Start spielt er die Blöcke seit dem letzten Speichern erneut ein.",
|
||||
"es": "Detiene el nodo de inmediato. Se pierde todo lo que no haya guardado, y en el próximo inicio volverá a procesar los bloques desde el último guardado.",
|
||||
"fr": "Arrête le nœud immédiatement. Tout ce qu'il n'a pas enregistré est perdu, et au prochain démarrage il rejouera les blocs depuis son dernier enregistrement.",
|
||||
"pt": "Encerra o nó imediatamente. Tudo o que não foi salvo se perde, e no próximo início ele reprocessa os blocos desde o último salvamento.",
|
||||
"ru": "Немедленно завершает узел. Всё несохранённое будет потеряно, а при следующем запуске он заново обработает блоки с момента последнего сохранения.",
|
||||
"zh": "立即结束节点。所有尚未保存的数据都会丢失,下次启动时会重新处理自上次保存以来的区块。",
|
||||
"ja": "ノードを直ちに終了します。保存されていない内容はすべて失われ、次回の起動時に前回の保存以降のブロックを再処理します。",
|
||||
"ko": "노드를 즉시 종료합니다. 저장되지 않은 내용은 모두 사라지며, 다음 시작 시 마지막 저장 이후의 블록을 다시 처리합니다."
|
||||
},
|
||||
"shutdown_kill_confirm_title": {
|
||||
"de": "Node erzwungen beenden?", "es": "¿Forzar el cierre del nodo?", "fr": "Tuer le nœud ?",
|
||||
"pt": "Forçar o encerramento do nó?", "ru": "Принудительно завершить узел?", "zh": "强制结束节点?",
|
||||
"ja": "ノードを強制終了しますか?", "ko": "노드를 강제 종료할까요?"
|
||||
},
|
||||
"shutdown_kill_confirm_msg": {
|
||||
"de": "Der Node zeigt seit Langem keine CPU- oder Festplattenaktivität und hängt möglicherweise.\nWenn Sie ihn erzwungen beenden, geht alles Ungespeicherte verloren; sein nächster Start kann deutlich länger dauern.",
|
||||
"es": "El nodo lleva mucho tiempo sin actividad de CPU ni de disco y puede estar bloqueado.\nSi fuerzas su cierre se perderá todo lo que no haya guardado; su próximo inicio puede tardar mucho más.",
|
||||
"fr": "Le nœud ne montre plus d'activité processeur ni disque depuis longtemps et est peut-être bloqué.\nLe tuer fait perdre tout ce qu'il n'a pas enregistré ; son prochain démarrage peut être beaucoup plus long.",
|
||||
"pt": "O nó não mostra atividade de CPU nem de disco há muito tempo e pode estar travado.\nForçar o encerramento perde tudo o que não foi salvo; o próximo início pode demorar muito mais.",
|
||||
"ru": "Узел давно не проявляет активности процессора и диска и, возможно, завис.\nПри принудительном завершении всё несохранённое будет потеряно; следующий запуск может занять гораздо больше времени.",
|
||||
"zh": "节点已长时间没有 CPU 或磁盘活动,可能已卡住。\n强制结束会丢失所有未保存的数据;下次启动可能需要更长时间。",
|
||||
"ja": "ノードは長時間 CPU やディスクの活動がなく、停止している可能性があります。\n強制終了すると保存されていない内容はすべて失われ、次回の起動に大幅に時間がかかることがあります。",
|
||||
"ko": "노드가 오랫동안 CPU나 디스크 활동이 없어 멈췄을 수 있습니다.\n강제 종료하면 저장되지 않은 내용이 모두 사라지고, 다음 시작이 훨씬 오래 걸릴 수 있습니다."
|
||||
},
|
||||
"shutdown_kill_confirm_yes": {
|
||||
"de": "Erzwungen beenden", "es": "Forzar cierre", "fr": "Tuer le nœud", "pt": "Forçar encerramento",
|
||||
"ru": "Завершить узел", "zh": "强制结束", "ja": "強制終了", "ko": "강제 종료"
|
||||
},
|
||||
"shutdown_stop_asking": {
|
||||
"de": "Node wird zum Beenden aufgefordert…", "es": "Pidiendo al nodo que se detenga…",
|
||||
"fr": "Demande d'arrêt envoyée au nœud…", "pt": "Pedindo ao nó para parar…",
|
||||
"ru": "Отправка узлу команды остановки…", "zh": "正在请求节点停止…",
|
||||
"ja": "ノードに停止を要求しています…", "ko": "노드에 중지를 요청하는 중…"
|
||||
},
|
||||
"shutdown_stop_queued": {
|
||||
"de": "Der Node hat die Stopp-Anforderung erhalten, ist aber beschäftigt – er stoppt, sobald er kann",
|
||||
"es": "El nodo recibió la orden de detenerse, pero está ocupado; se detendrá en cuanto pueda",
|
||||
"fr": "Le nœud a reçu la demande d'arrêt mais il est occupé — il s'arrêtera dès que possible",
|
||||
"pt": "O nó recebeu o pedido de parada, mas está ocupado; ele vai parar assim que puder",
|
||||
"ru": "Узел получил команду остановки, но занят — он остановится, как только сможет",
|
||||
"zh": "节点已收到停止请求,但正忙——它会尽快停止",
|
||||
"ja": "ノードは停止要求を受け取りましたが処理中です — 可能になり次第停止します",
|
||||
"ko": "노드가 중지 요청을 받았지만 작업 중입니다 — 가능해지면 중지됩니다"
|
||||
},
|
||||
"shutdown_stop_delivered": {
|
||||
"de": "Der Node fährt herunter und speichert seinen Zustand", "es": "El nodo se está cerrando y guardando su estado",
|
||||
"fr": "Le nœud s'arrête et enregistre son état", "pt": "O nó está encerrando e salvando seu estado",
|
||||
"ru": "Узел завершает работу и сохраняет своё состояние", "zh": "节点正在关闭并保存其状态",
|
||||
"ja": "ノードを終了し、状態を保存しています", "ko": "노드가 종료 중이며 상태를 저장하고 있습니다"
|
||||
},
|
||||
"shutdown_stop_cpu_suffix": {
|
||||
"de": " · CPU %d%%", "es": " · CPU %d%%", "fr": " · CPU %d%%", "pt": " · CPU %d%%",
|
||||
"ru": " · ЦП %d%%", "zh": " · CPU %d%%", "ja": " · CPU %d%%", "ko": " · CPU %d%%"
|
||||
},
|
||||
"shutdown_stop_idle": {
|
||||
"de": "Seit %d Min. keine CPU- oder Festplattenaktivität – der Node hängt möglicherweise",
|
||||
"es": "Sin actividad de CPU ni de disco desde hace %d min; puede que el nodo esté bloqueado",
|
||||
"fr": "Aucune activité processeur ni disque depuis %d min — le nœud est peut-être bloqué",
|
||||
"pt": "Sem atividade de CPU ou disco há %d min; o nó pode estar travado",
|
||||
"ru": "Нет активности процессора и диска уже %d мин — возможно, узел завис",
|
||||
"zh": "已 %d 分钟没有 CPU 或磁盘活动——节点可能已卡住",
|
||||
"ja": "%d 分間 CPU やディスクの活動がありません — ノードが停止している可能性があります",
|
||||
"ko": "%d분 동안 CPU나 디스크 활동이 없습니다 — 노드가 멈췄을 수 있습니다"
|
||||
},
|
||||
# --- The node is busy: overlay, banner, status bar ---
|
||||
"node_busy_rebuild_title": {
|
||||
"de": "Node beschäftigt: Note-Witnesses werden neu aufgebaut",
|
||||
"es": "Nodo ocupado: reconstruyendo testigos de notas",
|
||||
"fr": "Nœud occupé : reconstruction des témoins de notes",
|
||||
"pt": "Nó ocupado: reconstruindo testemunhas de notas",
|
||||
"ru": "Узел занят: перестройка свидетелей заметок",
|
||||
"zh": "节点繁忙:正在重建票据见证",
|
||||
"ja": "ノード処理中:ノートウィットネスを再構築しています",
|
||||
"ko": "노드 작업 중: 노트 위트니스 재구성 중",
|
||||
},
|
||||
"node_busy_running": {
|
||||
"de": "Läuft seit %s",
|
||||
"es": "En curso desde hace %s",
|
||||
"fr": "En cours depuis %s",
|
||||
"pt": "Em andamento há %s",
|
||||
"ru": "Выполняется %s",
|
||||
"zh": "已运行 %s",
|
||||
"ja": "経過時間 %s",
|
||||
"ko": "%s 동안 진행 중",
|
||||
},
|
||||
"node_busy_rebuild_desc": {
|
||||
"de": "Läuft seit %s. Bei großen Wallets kann das etwa eine Stunde dauern, und der Node kann der Wallet erst danach antworten.",
|
||||
"es": "En curso desde hace %s. En carteras grandes puede tardar alrededor de una hora, y el nodo no puede responder a la cartera hasta que termine.",
|
||||
"fr": "En cours depuis %s. Pour un gros portefeuille, cela peut prendre environ une heure, et le nœud ne peut pas répondre au portefeuille avant la fin.",
|
||||
"pt": "Em andamento há %s. Em carteiras grandes pode levar cerca de uma hora, e o nó não consegue responder à carteira até terminar.",
|
||||
"ru": "Выполняется %s. Для больших кошельков это может занять около часа, и до окончания узел не может отвечать кошельку.",
|
||||
"zh": "已运行 %s。大型钱包可能需要约一小时,完成前节点无法响应钱包。",
|
||||
"ja": "経過時間 %s。大きなウォレットでは約 1 時間かかることがあり、完了するまでノードはウォレットに応答できません。",
|
||||
"ko": "%s 동안 진행 중입니다. 큰 지갑은 약 1시간이 걸릴 수 있으며, 끝날 때까지 노드가 지갑에 응답할 수 없습니다."
|
||||
},
|
||||
"node_busy_rebuild_reading": {
|
||||
"de": "Blöcke werden gelesen: %ld / %ld", "es": "Leyendo bloques: %ld / %ld", "fr": "Lecture des blocs : %ld / %ld",
|
||||
"pt": "Lendo blocos: %ld / %ld", "ru": "Чтение блоков: %ld / %ld", "zh": "正在读取区块:%ld / %ld",
|
||||
"ja": "ブロックを読み込み中:%ld / %ld", "ko": "블록 읽는 중: %ld / %ld"
|
||||
},
|
||||
"node_busy_rebuild_extending": {
|
||||
"de": "Witnesses werden erweitert (der Node meldet für diesen Schritt keinen Fortschritt).",
|
||||
"es": "Ampliando testigos (el nodo no informa del progreso de este paso).",
|
||||
"fr": "Extension des témoins (le nœud ne signale aucune progression pour cette étape).",
|
||||
"pt": "Estendendo testemunhas (o nó não informa o progresso desta etapa).",
|
||||
"ru": "Расширение свидетелей (узел не сообщает о ходе этого этапа).",
|
||||
"zh": "正在扩展见证(此步骤节点不报告进度)。",
|
||||
"ja": "ウィットネスを拡張しています(この段階ではノードは進捗を報告しません)。",
|
||||
"ko": "위트니스 확장 중(이 단계에서는 노드가 진행 상황을 보고하지 않습니다)."
|
||||
},
|
||||
"node_busy_replay_title": {
|
||||
"de": "Node beschäftigt: Blöcke werden nachgeholt", "es": "Nodo ocupado: poniéndose al día con los bloques",
|
||||
"fr": "Nœud occupé : rattrapage des blocs", "pt": "Nó ocupado: recuperando blocos",
|
||||
"ru": "Узел занят: догоняет блоки", "zh": "节点繁忙:正在追赶区块",
|
||||
"ja": "ノード処理中:ブロックを追いかけています", "ko": "노드 작업 중: 블록 따라잡는 중"
|
||||
},
|
||||
"node_busy_replay_desc": {
|
||||
"de": "Läuft seit %s. Der Node wendet Blöcke erneut an, die er vor dem letzten Beenden empfangen hat.",
|
||||
"es": "En curso desde hace %s. El nodo está volviendo a aplicar bloques que recibió antes de detenerse por última vez.",
|
||||
"fr": "En cours depuis %s. Le nœud réapplique les blocs reçus avant son dernier arrêt.",
|
||||
"pt": "Em andamento há %s. O nó está reaplicando blocos que recebeu antes da última parada.",
|
||||
"ru": "Выполняется %s. Узел повторно применяет блоки, полученные до последней остановки.",
|
||||
"zh": "已运行 %s。节点正在重新应用上次停止前收到的区块。",
|
||||
"ja": "経過時間 %s。ノードは前回停止する前に受信したブロックを再適用しています。",
|
||||
"ko": "%s 동안 진행 중입니다. 노드가 마지막으로 멈추기 전에 받은 블록을 다시 적용하고 있습니다."
|
||||
},
|
||||
"node_busy_rpc_title": {
|
||||
"de": "Node beschäftigt: antwortet noch nicht", "es": "Nodo ocupado: aún no responde",
|
||||
"fr": "Nœud occupé : ne répond pas encore", "pt": "Nó ocupado: ainda não responde",
|
||||
"ru": "Узел занят: пока не отвечает", "zh": "节点繁忙:暂未响应",
|
||||
"ja": "ノード処理中:まだ応答していません", "ko": "노드 작업 중: 아직 응답하지 않음"
|
||||
},
|
||||
"node_busy_rpc_desc": {
|
||||
"de": "Der Node läuft, ist aber seit %s zu beschäftigt, um der Wallet zu antworten – meist wegen eines langen Vorgangs wie dem Neuaufbau der Witnesses.",
|
||||
"es": "El nodo está en marcha, pero lleva %s demasiado ocupado para responder a la cartera, normalmente por una operación larga como la reconstrucción de testigos.",
|
||||
"fr": "Le nœud fonctionne mais est trop occupé pour répondre au portefeuille depuis %s, généralement à cause d'une longue opération comme la reconstruction des témoins.",
|
||||
"pt": "O nó está em execução, mas está ocupado demais para responder à carteira há %s, geralmente por causa de uma operação longa como a reconstrução de testemunhas.",
|
||||
"ru": "Узел работает, но уже %s слишком занят, чтобы отвечать кошельку, обычно из-за долгой операции, например перестройки свидетелей.",
|
||||
"zh": "节点正在运行,但已有 %s 忙于其他任务而无法响应钱包,通常是因为见证重建等耗时操作。",
|
||||
"ja": "ノードは実行中ですが、%s の間ウォレットに応答できないほど処理中です。通常はウィットネスの再構築などの長時間の処理が原因です。",
|
||||
"ko": "노드는 실행 중이지만 %s 동안 지갑에 응답할 수 없을 만큼 바쁩니다. 보통 위트니스 재구성 같은 긴 작업 때문입니다."
|
||||
},
|
||||
"node_starting_title": {
|
||||
"de": "DragonX-Node wird gestartet…", "es": "Iniciando el nodo DragonX…", "fr": "Démarrage du nœud DragonX…",
|
||||
"pt": "Iniciando o nó DragonX…", "ru": "Запуск узла DragonX…", "zh": "正在启动 DragonX 节点…",
|
||||
"ja": "DragonX ノードを起動しています…", "ko": "DragonX 노드 시작 중…"
|
||||
},
|
||||
"node_starting_busy_desc": {
|
||||
"de": "Der Node ist erreichbar, aber beschäftigt und antwortet noch nicht. Das ist nach einem Update oder beim ersten Start normal und kann einige Minuten dauern.",
|
||||
"es": "El nodo está accesible pero ocupado y aún no responde. Es normal tras una actualización o en el primer inicio; puede tardar unos minutos.",
|
||||
"fr": "Le nœud est joignable mais occupé et ne répond pas encore. C'est normal après une mise à jour ou au premier lancement ; cela peut prendre quelques minutes.",
|
||||
"pt": "O nó está acessível, mas ocupado, e ainda não responde. Isso é normal após uma atualização ou na primeira execução; pode levar alguns minutos.",
|
||||
"ru": "Узел доступен, но занят и пока не отвечает. Это нормально после обновления или при первом запуске; может занять несколько минут.",
|
||||
"zh": "节点可以访问,但正忙,暂未响应。更新后或首次启动时属正常现象,可能需要几分钟。",
|
||||
"ja": "ノードには接続できますが処理中のため、まだ応答していません。更新後や初回起動時には正常な状態で、数分かかることがあります。",
|
||||
"ko": "노드에 연결할 수 있지만 바빠서 아직 응답하지 않습니다. 업데이트 후나 처음 실행할 때는 정상이며, 몇 분 걸릴 수 있습니다."
|
||||
},
|
||||
"node_starting_launch_desc": {
|
||||
"de": "dragonxd wird gestartet, warte auf Verbindung…", "es": "Iniciando dragonxd y esperando a que esté en línea…",
|
||||
"fr": "Lancement de dragonxd, en attente de sa disponibilité…", "pt": "Iniciando o dragonxd e aguardando ficar online…",
|
||||
"ru": "Запуск dragonxd и ожидание его готовности…", "zh": "正在启动 dragonxd 并等待其上线…",
|
||||
"ja": "dragonxd を起動し、オンラインになるのを待っています…", "ko": "dragonxd를 시작하고 온라인 상태가 되기를 기다리는 중…"
|
||||
},
|
||||
"sb_node_busy": {
|
||||
"de": "Node beschäftigt", "es": "Nodo ocupado", "fr": "Nœud occupé", "pt": "Nó ocupado",
|
||||
"ru": "Узел занят", "zh": "节点繁忙", "ja": "ノード処理中", "ko": "노드 작업 중"
|
||||
},
|
||||
# --- Quitting while the node is busy ---
|
||||
"stopconfirm_rebuild_title": {
|
||||
"de": "Der Node baut die Note-Witnesses neu auf", "es": "El nodo está reconstruyendo los testigos de notas",
|
||||
"fr": "Le nœud reconstruit les témoins de notes", "pt": "O nó está reconstruindo as testemunhas de notas",
|
||||
"ru": "Узел перестраивает свидетелей заметок", "zh": "节点正在重建票据见证",
|
||||
"ja": "ノードはノートウィットネスを再構築しています", "ko": "노드가 노트 위트니스를 재구성하고 있습니다"
|
||||
},
|
||||
"stopconfirm_rebuild_body": {
|
||||
"de": "Er läuft seit %s, und bei großen Wallets kann das etwa eine Stunde dauern. Wenn Sie den Node jetzt stoppen, wird entweder bis zum Ende des Neuaufbaus gewartet, oder der nächste Start muss den Neuaufbau samt Nachholen wiederholen.\n\nEmpfohlen: Wallet schließen und den Node weiterlaufen lassen. Er beendet die Arbeit selbstständig.",
|
||||
"es": "Lleva %s en curso, y en carteras grandes puede tardar alrededor de una hora. Detener el nodo ahora o bien espera a que termine la reconstrucción, o bien hace que el próximo inicio la repita junto con cualquier puesta al día.\n\nRecomendado: cierra la cartera y deja el nodo en marcha. Terminará por su cuenta.",
|
||||
"fr": "Elle est en cours depuis %s, et pour un gros portefeuille elle peut prendre environ une heure. Arrêter le nœud maintenant attend la fin de la reconstruction, ou oblige son prochain démarrage à la refaire, rattrapage compris.\n\nRecommandé : fermez le portefeuille et laissez le nœud actif. Il terminera seul.",
|
||||
"pt": "Está em andamento há %s, e em carteiras grandes pode levar cerca de uma hora. Parar o nó agora ou espera a reconstrução terminar, ou faz o próximo início refazê-la junto com qualquer recuperação.\n\nRecomendado: feche a carteira e deixe o nó em execução. Ele termina sozinho.",
|
||||
"ru": "Она выполняется уже %s, а для больших кошельков может занять около часа. Если остановить узел сейчас, он либо дождётся окончания перестройки, либо при следующем запуске выполнит её заново вместе с догонянием блоков.\n\nРекомендуется: закройте кошелёк и оставьте узел работать. Он завершит всё сам.",
|
||||
"zh": "已运行 %s,大型钱包可能需要约一小时。现在停止节点,要么会等待重建完成,要么下次启动时需要重新进行重建以及区块追赶。\n\n建议:关闭钱包并保持节点运行,它会自行完成。",
|
||||
"ja": "経過時間は %s で、大きなウォレットでは約 1 時間かかることがあります。今ノードを停止すると、再構築の完了まで待つか、次回の起動時に再構築とブロックの追いつきをやり直すことになります。\n\n推奨:ウォレットを閉じ、ノードは実行したままにしてください。ノードは自動的に完了します。",
|
||||
"ko": "%s 동안 진행 중이며, 큰 지갑은 약 1시간이 걸릴 수 있습니다. 지금 노드를 중지하면 재구성이 끝날 때까지 기다리거나, 다음 시작 시 재구성과 블록 따라잡기를 다시 해야 합니다.\n\n권장: 지갑을 닫고 노드는 계속 실행하세요. 노드가 스스로 마칩니다."
|
||||
},
|
||||
"stopconfirm_busy_title": {
|
||||
"de": "Der Node ist beschäftigt", "es": "El nodo está ocupado", "fr": "Le nœud est occupé",
|
||||
"pt": "O nó está ocupado", "ru": "Узел занят", "zh": "节点正忙", "ja": "ノードは処理中です",
|
||||
"ko": "노드가 작업 중입니다"
|
||||
},
|
||||
"stopconfirm_busy_body": {
|
||||
"de": "Er ist seit %s zu beschäftigt, um der Wallet zu antworten – vermutlich wegen eines langen Vorgangs wie dem Neuaufbau der Witnesses. Wenn Sie ihn jetzt stoppen, muss der nächste Start diese Arbeit eventuell wiederholen.\n\nEmpfohlen: Wallet schließen und den Node weiterlaufen lassen. Er beendet die Arbeit selbstständig.",
|
||||
"es": "Lleva %s demasiado ocupado para responder a la cartera, probablemente por una operación larga como la reconstrucción de testigos. Detenerlo ahora puede hacer que el próximo inicio repita ese trabajo.\n\nRecomendado: cierra la cartera y deja el nodo en marcha. Terminará por su cuenta.",
|
||||
"fr": "Il est trop occupé pour répondre au portefeuille depuis %s, probablement à cause d'une longue opération comme la reconstruction des témoins. L'arrêter maintenant peut obliger son prochain démarrage à refaire ce travail.\n\nRecommandé : fermez le portefeuille et laissez le nœud actif. Il terminera seul.",
|
||||
"pt": "Está ocupado demais para responder à carteira há %s, provavelmente por causa de uma operação longa como a reconstrução de testemunhas. Pará-lo agora pode fazer o próximo início refazer esse trabalho.\n\nRecomendado: feche a carteira e deixe o nó em execução. Ele termina sozinho.",
|
||||
"ru": "Уже %s он слишком занят, чтобы отвечать кошельку, вероятно, из-за долгой операции, например перестройки свидетелей. Если остановить его сейчас, при следующем запуске эту работу, возможно, придётся выполнить заново.\n\nРекомендуется: закройте кошелёк и оставьте узел работать. Он завершит всё сам.",
|
||||
"zh": "节点已有 %s 忙于其他任务而无法响应钱包,可能是因为见证重建等耗时操作。现在停止它,下次启动时可能需要重做这些工作。\n\n建议:关闭钱包并保持节点运行,它会自行完成。",
|
||||
"ja": "%s の間、ウォレットに応答できないほど処理中です。ウィットネスの再構築などの長時間の処理が原因と考えられます。今停止すると、次回の起動時にその処理をやり直すことがあります。\n\n推奨:ウォレットを閉じ、ノードは実行したままにしてください。ノードは自動的に完了します。",
|
||||
"ko": "%s 동안 지갑에 응답할 수 없을 만큼 바빴습니다. 위트니스 재구성 같은 긴 작업 때문일 가능성이 큽니다. 지금 중지하면 다음 시작 시 그 작업을 다시 해야 할 수 있습니다.\n\n권장: 지갑을 닫고 노드는 계속 실행하세요. 노드가 스스로 마칩니다."
|
||||
},
|
||||
"stopconfirm_stop_wait": {
|
||||
"de": "Node stoppen & warten", "es": "Detener el nodo y esperar", "fr": "Arrêter le nœud et attendre",
|
||||
"pt": "Parar o nó e aguardar", "ru": "Остановить узел и подождать", "zh": "停止节点并等待",
|
||||
"ja": "ノードを停止して待つ", "ko": "노드 중지 후 대기"
|
||||
},
|
||||
"appx_daemon_reinstall_node_busy": {
|
||||
"de": "Der Node läuft noch, daher wurde der Daemon nicht neu installiert. Versuchen Sie es erneut, sobald er beendet ist.",
|
||||
"es": "El nodo sigue en marcha, así que no se reinstaló el daemon. Vuelve a intentarlo cuando se haya detenido.",
|
||||
"fr": "Le nœud est toujours actif, le démon n'a donc pas été réinstallé. Réessayez une fois qu'il sera arrêté.",
|
||||
"pt": "O nó ainda está em execução, por isso o daemon não foi reinstalado. Tente novamente quando ele tiver parado.",
|
||||
"ru": "Узел всё ещё работает, поэтому демон не был переустановлен. Повторите попытку, когда он остановится.",
|
||||
"zh": "节点仍在运行,因此未重新安装守护进程。请在它停止后重试。",
|
||||
"ja": "ノードがまだ実行中のため、デーモンは再インストールされませんでした。停止してからもう一度お試しください。",
|
||||
"ko": "노드가 아직 실행 중이라 데몬을 다시 설치하지 않았습니다. 노드가 멈춘 뒤 다시 시도하세요."
|
||||
},
|
||||
# --- Balance tab ---
|
||||
"hide_zero_balances": {
|
||||
"es": "Ocultar saldos 0", "de": "Nullsalden ausblenden", "fr": "Masquer les soldes à 0",
|
||||
|
||||
Executable
+79
@@ -0,0 +1,79 @@
|
||||
#!/usr/bin/env bash
|
||||
# Cross-compile the test suite (ObsidianDragonTests.exe) and the fake daemon (tools/fake_dragonxd)
|
||||
# for Windows with mingw-w64, then run the tests: directly under WSL (Windows interop), else with
|
||||
# wine if installed, else just build.
|
||||
#
|
||||
# scripts/test-windows.sh [--no-run] [--jobs N]
|
||||
#
|
||||
# Uses its own build/windows-tests tree (full-node config), so build/windows is left alone. Needs
|
||||
# the mingw toolchain file that ./build.sh --win-release writes to build/windows/. Dependency sources
|
||||
# already fetched into build/windows/_deps are reused, so nothing is downloaded twice.
|
||||
set -euo pipefail
|
||||
|
||||
repo="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
bd="$repo/build/windows-tests"
|
||||
toolchain="$repo/build/windows/mingw-toolchain.cmake"
|
||||
deps="$repo/build/windows/_deps"
|
||||
run=true
|
||||
jobs=8 # each test TU is large; several parallel mingw builds can exhaust RAM
|
||||
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case "$1" in
|
||||
--no-run) run=false; shift ;;
|
||||
--jobs) jobs="$2"; shift 2 ;;
|
||||
-h|--help) sed -n '2,10p' "$0" | sed 's/^# \{0,1\}//'; exit 0 ;;
|
||||
*) echo "unknown option: $1" >&2; exit 2 ;;
|
||||
esac
|
||||
done
|
||||
|
||||
if [[ ! -f "$toolchain" ]]; then
|
||||
echo "missing $toolchain; run ./build.sh --win-release once to create the mingw toolchain" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
src_args=()
|
||||
for dep in sdl3 json tomlplusplus sqlite3 curl libwebp; do
|
||||
if [[ -d "$deps/$dep-src" ]]; then
|
||||
src_args+=("-DFETCHCONTENT_SOURCE_DIR_${dep^^}=$deps/$dep-src")
|
||||
fi
|
||||
done
|
||||
|
||||
cmake -S "$repo" -B "$bd" \
|
||||
-DCMAKE_TOOLCHAIN_FILE="$toolchain" \
|
||||
-DCMAKE_BUILD_TYPE=Release \
|
||||
-DDRAGONX_USE_SYSTEM_SDL3=OFF \
|
||||
-DDRAGONX_BUILD_LITE=OFF \
|
||||
-DDRAGONX_ENABLE_LITE_BACKEND=OFF \
|
||||
-DBUILD_TESTING=ON \
|
||||
-DDRAGONX_BUILD_FAKE_DAEMON=ON \
|
||||
"${src_args[@]}"
|
||||
cmake --build "$bd" --target ObsidianDragonTests fake_dragonxd -j "$jobs"
|
||||
|
||||
exe="$bd/bin/ObsidianDragonTests.exe"
|
||||
echo "built: $exe"
|
||||
echo "built: $bd/fake_dragonxd/dragonxd.exe"
|
||||
$run || exit 0
|
||||
|
||||
# The fixture path compiled into the binary is a Linux path; hand the Windows process its own.
|
||||
# APPDATA/LOCALAPPDATA point at a sandbox so anything resolving them from the environment stays out
|
||||
# of the real profile. (SHGetFolderPath-based paths ignore the environment; the tests that would
|
||||
# write through those are compiled out on Windows.)
|
||||
export DRAGONX_TEST_FIXTURE_DIR="$repo/tests/fixtures"
|
||||
sandbox="$bd/test-appdata"
|
||||
mkdir -p "$sandbox"
|
||||
export APPDATA="$sandbox" LOCALAPPDATA="$sandbox"
|
||||
# The fake dragonxd.exe plays a "foreign" node (isolated datadir/port) for the never-kill-by-name test.
|
||||
export DRAGONX_TEST_FAKE_DAEMON="$bd/fake_dragonxd/dragonxd.exe"
|
||||
if grep -qi microsoft /proc/version 2>/dev/null && [[ -e /proc/sys/fs/binfmt_misc/WSLInterop ]]; then
|
||||
# /p: translate each to a Windows path for the Windows process
|
||||
export WSLENV="${WSLENV:+$WSLENV:}DRAGONX_TEST_FIXTURE_DIR/p:APPDATA/p:LOCALAPPDATA/p:DRAGONX_TEST_FAKE_DAEMON/p"
|
||||
cd "$bd/bin"
|
||||
exec "$exe"
|
||||
elif command -v wine >/dev/null 2>&1; then
|
||||
export DRAGONX_TEST_FIXTURE_DIR="$(winepath -w "$DRAGONX_TEST_FIXTURE_DIR")"
|
||||
export DRAGONX_TEST_FAKE_DAEMON="$(winepath -w "$DRAGONX_TEST_FAKE_DAEMON")"
|
||||
cd "$bd/bin"
|
||||
exec wine "$exe"
|
||||
else
|
||||
echo "not on WSL and no wine: copy $exe (and tests/fixtures) to Windows to run it" >&2
|
||||
fi
|
||||
+1966
-762
File diff suppressed because it is too large
Load Diff
+2358
-626
File diff suppressed because it is too large
Load Diff
+436
-126
@@ -6,6 +6,8 @@
|
||||
// Split from app.cpp for maintainability.
|
||||
|
||||
#include "app.h"
|
||||
#include "data/pin_vault_policy.h"
|
||||
#include "data/wallet_security_reconcile.h"
|
||||
#include "rpc/rpc_client.h"
|
||||
#include "rpc/rpc_worker.h"
|
||||
#include "rpc/connection.h"
|
||||
@@ -31,6 +33,7 @@
|
||||
#include <algorithm>
|
||||
#include <cstring>
|
||||
#include <ctime>
|
||||
#include <cmath>
|
||||
#include <cstdint>
|
||||
#include <filesystem>
|
||||
#include <fstream>
|
||||
@@ -214,7 +217,7 @@ public:
|
||||
: app_(app), token_(token) {}
|
||||
|
||||
bool isUsingEmbeddedDaemon() const override { return app_.isUsingEmbeddedDaemon(); }
|
||||
void stopEmbeddedDaemon() override { app_.stopEmbeddedDaemon(); }
|
||||
bool stopEmbeddedDaemon() override { return app_.stopEmbeddedDaemon() == daemon::StopOutcome::Exited; }
|
||||
bool startEmbeddedDaemon() override { return app_.startEmbeddedDaemon(); }
|
||||
bool cancelled() const override { return token_.cancelled(); }
|
||||
bool shuttingDown() const override { return app_.isShuttingDown(); }
|
||||
@@ -238,22 +241,13 @@ private:
|
||||
// daemon off the main thread (to avoid stalling the UI), or ask the user to
|
||||
// restart an external daemon. Shared by encryptWalletWithPassphrase() and
|
||||
// processDeferredEncryption(); must be called on the main thread.
|
||||
// Zero (overwrite) then delete a plaintext key export so a full cleartext dump of every private key is
|
||||
// never left readable on disk. Idempotent + error-tolerant (safe on a missing/locked file). (H-02)
|
||||
// Best-effort secure delete (overwrite in place, fsync, unlink) of a plaintext key export, so a full
|
||||
// cleartext dump of every private key is not left readable on disk. Idempotent + error-tolerant
|
||||
// (safe on a missing/locked file). (H-02)
|
||||
void App::scrubAndRemoveExport(const std::string& path)
|
||||
{
|
||||
if (path.empty()) return;
|
||||
std::error_code ec;
|
||||
const auto sz = std::filesystem::file_size(path, ec);
|
||||
if (!ec && sz > 0) {
|
||||
std::fstream scrub(path, std::ios::binary | std::ios::in | std::ios::out);
|
||||
if (scrub) {
|
||||
const std::vector<char> zeros(static_cast<size_t>(sz), 0);
|
||||
scrub.write(zeros.data(), static_cast<std::streamsize>(sz));
|
||||
scrub.flush();
|
||||
}
|
||||
}
|
||||
std::filesystem::remove(path, ec);
|
||||
util::Platform::secureWipeAndRemove(path);
|
||||
}
|
||||
|
||||
// Startup net for H-02: a crash/kill/early-return between exporting the cleartext keys and scrubbing them
|
||||
@@ -274,6 +268,20 @@ void App::sweepStaleDecryptExports()
|
||||
}
|
||||
|
||||
void App::restartDaemonAfterEncryption(const char* taskName, bool announceRestartStatus) {
|
||||
// Both callers have just had encryptwallet succeed. Record it here: the first-run wizard's
|
||||
// deferred path never did, and the reconnect after the restart usually lands in RPC warmup, where
|
||||
// the connect-time getwalletinfo fails and can't correct it.
|
||||
state_.encrypted = true;
|
||||
state_.locked = true;
|
||||
state_.unlocked_until = 0;
|
||||
state_.encryption_state_known = true;
|
||||
// encryptwallet succeeded: the wizard's persisted request is satisfied. Clear it HERE (not only at the next
|
||||
// connect) so a quit before the reconnect can't leave a stale flag (C88).
|
||||
if (settings_ && settings_->getEncryptionPending()) {
|
||||
settings_->setEncryptionPending(false);
|
||||
settings_->save();
|
||||
}
|
||||
|
||||
if (isUsingEmbeddedDaemon()) {
|
||||
if (announceRestartStatus) {
|
||||
// Update connection_status_ so the loading overlay explains why
|
||||
@@ -283,6 +291,17 @@ void App::restartDaemonAfterEncryption(const char* taskName, bool announceRestar
|
||||
// Gate the main-loop reconnect while the daemon is down (so tryConnect can't hit the stopped
|
||||
// node or start a duplicate) and block a concurrent wallet switch/rescan, which check this flag.
|
||||
daemon_restarting_ = true;
|
||||
// Mark the main client disconnected so the update loop's reconnect path (tryConnect, held back
|
||||
// by daemon_restarting_) picks the restarted node up. Connected-but-locked no longer re-runs
|
||||
// the connect sequence on its own. Not onDisconnected(): its state_.clear() would drop the
|
||||
// encrypted/locked flags just set for the lock screen. Abort any in-flight call first:
|
||||
// disconnect() takes the curl lock, which a worker call (say a balance scan queued behind
|
||||
// encryptwallet) holds for its whole transfer, up to the 30 s timeout. open() clears the abort
|
||||
// when tryConnect reconnects.
|
||||
if (rpc_) {
|
||||
rpc_->requestAbort();
|
||||
rpc_->disconnect();
|
||||
}
|
||||
// Give daemon a moment to shut down, then restart
|
||||
// (do this off the main thread to avoid stalling the UI)
|
||||
async_tasks_.submit(taskName, [this](const util::AsyncTaskManager::Token& token) {
|
||||
@@ -292,8 +311,13 @@ void App::restartDaemonAfterEncryption(const char* taskName, bool announceRestar
|
||||
for (int i = 0; i < 20 && !token.cancelled() && !shutting_down_; ++i)
|
||||
std::this_thread::sleep_for(std::chrono::milliseconds(100));
|
||||
if (!token.cancelled() && !shutting_down_) {
|
||||
stopEmbeddedDaemon();
|
||||
if (!token.cancelled() && !shutting_down_) startEmbeddedDaemon();
|
||||
if (stopEmbeddedDaemon() == daemon::StopOutcome::Exited) {
|
||||
if (!token.cancelled() && !shutting_down_) startEmbeddedDaemon();
|
||||
} else if (!shutting_down_) {
|
||||
// Still running: never start a second node over it. The connect loop goes back to it.
|
||||
DEBUG_LOGF("[App] encryption restart: the node did not exit; not restarting it\n");
|
||||
pending_stop_failed_.fetch_or(kStopFailedNotify);
|
||||
}
|
||||
}
|
||||
} catch (...) {}
|
||||
daemon_restarting_ = false; // re-arm reconnect (tryConnect runs from the update loop)
|
||||
@@ -306,6 +330,7 @@ void App::restartDaemonAfterEncryption(const char* taskName, bool announceRestar
|
||||
|
||||
void App::encryptWalletWithPassphrase(const std::string& passphrase) {
|
||||
if (!rpc_ || !rpc_->isConnected()) return;
|
||||
if (refuseIfNodeBusy()) return; // encrypting ends in a node restart
|
||||
encrypt_in_progress_ = true;
|
||||
encrypt_status_ = TR("sec_encrypting_wallet");
|
||||
|
||||
@@ -411,6 +436,8 @@ void App::processDeferredEncryption() {
|
||||
// Finalize PIN settings on main thread
|
||||
if (result.pinProvided) {
|
||||
if (result.pinStored) {
|
||||
pin_vault_stale_ = false;
|
||||
lock_use_pin_ = true;
|
||||
settings_->setPinEnabled(true);
|
||||
settings_->save();
|
||||
ui::Notifications::instance().info(TR("sec_wallet_encrypted_and_pin_set"), 5.0f);
|
||||
@@ -450,8 +477,14 @@ void App::applyUnlockSuccess(const std::string& passphrase, int timeout) {
|
||||
lock_unlock_in_progress_ = false;
|
||||
lock_error_msg_.clear();
|
||||
lock_attempts_ = 0;
|
||||
// Clear the persisted lockout on a successful unlock (guarded so a normal unlock isn't a disk write).
|
||||
if (settings_ && settings_->getLockFailedAttempts() != 0) {
|
||||
settings_->setLockFailedAttempts(0);
|
||||
settings_->setLockoutUntilEpoch(0);
|
||||
settings_->save();
|
||||
}
|
||||
memset(lock_passphrase_buf_, 0, sizeof(lock_passphrase_buf_));
|
||||
last_interaction_ = std::chrono::steady_clock::now();
|
||||
idle_.noteActivity();
|
||||
state_.encrypted = true;
|
||||
state_.locked = false;
|
||||
state_.unlocked_until = std::time(nullptr) + timeout;
|
||||
@@ -466,15 +499,54 @@ void App::applyUnlockFailure(const std::string& errorMessage) {
|
||||
lock_attempts_++;
|
||||
lock_error_msg_ = errorMessage;
|
||||
lock_error_timer_ = 3.0f;
|
||||
lock_timer_armed_at_ = std::chrono::steady_clock::now();
|
||||
memset(lock_passphrase_buf_, 0, sizeof(lock_passphrase_buf_));
|
||||
const float baseDelay = ui::schema::UI().drawElement("security", "lockout-base-delay").sizeOr(2.0f);
|
||||
const int maxAttempts = (int)ui::schema::UI().drawElement("security", "max-attempts-before-lockout").sizeOr(5.0f);
|
||||
if (lock_attempts_ >= maxAttempts) {
|
||||
lock_lockout_timer_ = baseDelay * (float)(1 << std::min(lock_attempts_ - maxAttempts, 8));
|
||||
}
|
||||
// Persist the attempt count + lockout expiry (keyed to this wallet) so killing and relaunching the
|
||||
// app can't reset the escalating lockout. The PIN/passphrase itself is never stored.
|
||||
if (settings_) {
|
||||
settings_->setLockStateWallet(settings_->getActiveWalletFile());
|
||||
settings_->setLockFailedAttempts(lock_attempts_);
|
||||
settings_->setLockoutUntilEpoch(lock_lockout_timer_ > 0.0f
|
||||
? (long long)std::time(nullptr) + (long long)std::ceil(lock_lockout_timer_) : 0);
|
||||
settings_->save();
|
||||
}
|
||||
DEBUG_LOGF("[App] Wallet unlock failed (attempt %d)\n", lock_attempts_);
|
||||
}
|
||||
|
||||
// Restore the persisted lockout at the start of a lock-screen session so a kill-and-relaunch can't reset
|
||||
// the escalating failure curve. Only applies when the persisted state is for the active wallet.
|
||||
void App::restoreLockoutState() {
|
||||
if (!settings_) return;
|
||||
if (settings_->getLockStateWallet() != settings_->getActiveWalletFile()) return;
|
||||
lock_attempts_ = std::max(0, std::min(settings_->getLockFailedAttempts(), 64));
|
||||
long long remaining = settings_->getLockoutUntilEpoch() - (long long)std::time(nullptr);
|
||||
// Cap the restored countdown so a forward clock change can't create an absurd wait. The attempt count
|
||||
// still persists, so rolling the clock back only buys a short window before the next failure re-arms
|
||||
// the full curve.
|
||||
const float baseDelay = ui::schema::UI().drawElement("security", "lockout-base-delay").sizeOr(2.0f);
|
||||
const long long maxLock = (long long)(baseDelay * (float)(1 << 8));
|
||||
if (remaining > maxLock) remaining = maxLock;
|
||||
if (remaining > 0) {
|
||||
lock_lockout_timer_ = (float)remaining;
|
||||
lock_timer_armed_at_ = std::chrono::steady_clock::now();
|
||||
}
|
||||
}
|
||||
|
||||
// Failure that is NOT a wrong credential (busy node, not-encrypted, unknown error): surface the
|
||||
// message and clear the buffer, but do not bump lock_attempts_ or arm the lockout.
|
||||
void App::applyUnlockNotCounted(const std::string& msg) {
|
||||
lock_unlock_in_progress_ = false;
|
||||
lock_error_msg_ = msg;
|
||||
lock_error_timer_ = 3.0f;
|
||||
lock_timer_armed_at_ = std::chrono::steady_clock::now();
|
||||
memset(lock_passphrase_buf_, 0, sizeof(lock_passphrase_buf_));
|
||||
}
|
||||
|
||||
void App::unlockWallet(const std::string& passphrase, int timeout) {
|
||||
if (!rpc_ || !rpc_->isConnected() || !worker_) return;
|
||||
lock_unlock_in_progress_ = true;
|
||||
@@ -484,17 +556,39 @@ void App::unlockWallet(const std::string& passphrase, int timeout) {
|
||||
auto* r = (fast_rpc_ && fast_rpc_->isConnected()) ? fast_rpc_.get() : rpc_.get();
|
||||
w->post([this, r, passphrase = std::string(passphrase), timeout]() mutable -> rpc::RPCWorker::MainCb {
|
||||
std::string err_msg;
|
||||
WalletSecurityRpcAdapter rpcAdapter(r);
|
||||
bool ok = rpcAdapter.unlockWallet(passphrase, timeout, err_msg);
|
||||
rpc::UnlockFailure cls = rpc::UnlockFailure::Other;
|
||||
bool ok = false;
|
||||
try {
|
||||
rpc::RPCClient::TraceScope trace("Security / Unlock");
|
||||
r->call("walletpassphrase", {passphrase, timeout});
|
||||
ok = true;
|
||||
} catch (const std::exception& e) {
|
||||
err_msg = e.what();
|
||||
cls = rpc::classifyUnlockFailure(e);
|
||||
}
|
||||
std::string cachePassphrase = passphrase;
|
||||
util::SecureVault::secureZero(passphrase.data(), passphrase.size());
|
||||
|
||||
return [this, ok, err_msg, timeout, passphrase = std::move(cachePassphrase)]() mutable {
|
||||
return [this, ok, cls, err_msg, timeout, passphrase = std::move(cachePassphrase)]() mutable {
|
||||
if (ok) {
|
||||
applyUnlockSuccess(passphrase, timeout);
|
||||
} else {
|
||||
DEBUG_LOGF("[App] Passphrase unlock RPC error: %s\n", err_msg.c_str());
|
||||
applyUnlockFailure(TR("incorrect_passphrase"));
|
||||
switch (cls) {
|
||||
case rpc::UnlockFailure::WrongPassphrase:
|
||||
applyUnlockFailure(TR("incorrect_passphrase"));
|
||||
break;
|
||||
case rpc::UnlockFailure::NodeBusy:
|
||||
applyUnlockNotCounted(TR("sec_unlock_node_busy"));
|
||||
break;
|
||||
case rpc::UnlockFailure::NotEncrypted:
|
||||
applyUnlockNotCounted(TR("sec_unlock_node_busy"));
|
||||
refreshWalletEncryptionState();
|
||||
break;
|
||||
case rpc::UnlockFailure::Other:
|
||||
applyUnlockNotCounted(std::string(TR("sec_unlock_failed_prefix")) + err_msg);
|
||||
break;
|
||||
}
|
||||
}
|
||||
util::SecureVault::secureZero(passphrase.data(), passphrase.size());
|
||||
};
|
||||
@@ -521,7 +615,7 @@ void App::lockWallet() {
|
||||
if (ok) {
|
||||
state_.locked = true;
|
||||
state_.unlocked_until = 0;
|
||||
resetTransactionHistoryCacheSession();
|
||||
lockTransactionHistoryCacheSession(); // keeps the history's scan progress
|
||||
lock_failure_warned_ = false;
|
||||
DEBUG_LOGF("[App] Wallet locked\n");
|
||||
} else {
|
||||
@@ -542,13 +636,19 @@ void App::changePassphrase(const std::string& oldPass, const std::string& newPas
|
||||
if (!rpc_ || !rpc_->isConnected() || !worker_) return;
|
||||
encrypt_in_progress_ = true;
|
||||
encrypt_status_ = TR("sec_changing_passphrase");
|
||||
// The vault this change applies to, and whether it changes the passphrase at all. The success
|
||||
// callback decides about the vault with the state at THAT time (C87).
|
||||
const std::string vaultPath = vault_ ? vault_->getVaultPath() : std::string();
|
||||
const bool samePass = (oldPass == newPass);
|
||||
|
||||
auto* w = (fast_worker_ && fast_worker_->isRunning()) ? fast_worker_.get() : worker_.get();
|
||||
auto* r = (fast_rpc_ && fast_rpc_->isConnected()) ? fast_rpc_.get() : rpc_.get();
|
||||
w->post([this,
|
||||
r,
|
||||
oldPass = std::string(oldPass),
|
||||
newPass = std::string(newPass)]() mutable -> rpc::RPCWorker::MainCb {
|
||||
newPass = std::string(newPass),
|
||||
vaultPath,
|
||||
samePass]() mutable -> rpc::RPCWorker::MainCb {
|
||||
bool ok = false;
|
||||
std::string err_msg;
|
||||
try {
|
||||
@@ -564,7 +664,9 @@ void App::changePassphrase(const std::string& oldPass, const std::string& newPas
|
||||
return [this,
|
||||
ok,
|
||||
err_msg,
|
||||
newPass = std::move(cacheNewPass)]() mutable {
|
||||
newPass = std::move(cacheNewPass),
|
||||
vaultPath,
|
||||
samePass]() mutable {
|
||||
encrypt_in_progress_ = false;
|
||||
if (ok) {
|
||||
encrypt_status_.clear();
|
||||
@@ -573,8 +675,21 @@ void App::changePassphrase(const std::string& oldPass, const std::string& newPas
|
||||
memset(change_new_pass_buf_, 0, sizeof(change_new_pass_buf_));
|
||||
memset(change_confirm_buf_, 0, sizeof(change_confirm_buf_));
|
||||
unlockTransactionHistoryCacheWithPassphrase(newPass);
|
||||
storeTransactionHistoryCacheIfAvailable();
|
||||
ui::Notifications::instance().info(TR("sec_passphrase_changed_successfully"));
|
||||
// New key: the old ciphertext is unreadable with it. Write now unless the unlock just did
|
||||
// (keyId() is part of the content signature, so Now skips only if the same key is submitted).
|
||||
storeTransactionHistoryCacheIfAvailable(data::HistoryStoreMode::Now);
|
||||
const bool vaultNow = vault_ && vault_->hasVault();
|
||||
if (data::decideVaultAfterPassphraseChange(true, vaultNow, samePass, vaultPath,
|
||||
vault_ ? vault_->getVaultPath() : std::string()) == data::VaultAfterChange::MarkStale) {
|
||||
// The PIN vault still seals the OLD passphrase. Keep the file (the user removes the PIN
|
||||
// and sets it again), but stop offering the PIN this session and say so.
|
||||
lock_use_pin_ = false;
|
||||
pin_vault_stale_ = true;
|
||||
DEBUG_LOGF("[App] Passphrase changed; the PIN vault is kept but marked out of date\n");
|
||||
ui::Notifications::instance().warning(TR("sec_passphrase_changed_pin_stale"), 10.0f);
|
||||
} else {
|
||||
ui::Notifications::instance().info(TR("sec_passphrase_changed_successfully"));
|
||||
}
|
||||
} else {
|
||||
encrypt_status_ = std::string(TR("sec_failed_prefix")) + err_msg;
|
||||
}
|
||||
@@ -609,14 +724,8 @@ void App::refreshWalletEncryptionState() {
|
||||
state_.unlocked_until = until;
|
||||
state_.locked = (until == 0);
|
||||
state_.encryption_state_known = true;
|
||||
// Wallet is encrypted — any pending deferred-encryption request has now been
|
||||
// satisfied (however it completed). Clear the persisted flag (W2-2).
|
||||
if (settings_ && settings_->getEncryptionPending()) {
|
||||
settings_->setEncryptionPending(false);
|
||||
settings_->save();
|
||||
}
|
||||
if (state_.locked) {
|
||||
resetTransactionHistoryCacheSession();
|
||||
lockTransactionHistoryCacheSession();
|
||||
} else if (state_.transactions.empty()) {
|
||||
loadTransactionHistoryCacheIfAvailable();
|
||||
} else {
|
||||
@@ -627,18 +736,6 @@ void App::refreshWalletEncryptionState() {
|
||||
state_.locked = false;
|
||||
state_.unlocked_until = 0;
|
||||
state_.encryption_state_known = true;
|
||||
// W2-2: encryption was requested (persisted flag) but the wallet is NOT encrypted,
|
||||
// and no deferred encryption is pending/in-flight — it was lost to a quit/crash or a
|
||||
// failed connect before it applied. Warn (once/session) instead of silently leaving
|
||||
// an unencrypted wallet the user believes is protected. The flag stays set until the
|
||||
// wallet is actually encrypted, so the warning recurs each launch until resolved.
|
||||
if (settings_ && settings_->getEncryptionPending() &&
|
||||
!wallet_security_.hasDeferredEncryption() && !encrypt_in_progress_ &&
|
||||
!encryption_incomplete_warned_) {
|
||||
encryption_incomplete_warned_ = true;
|
||||
ui::Notifications::instance().warning(
|
||||
TR("sec_encryption_did_not_complete"), 30.0f);
|
||||
}
|
||||
if (state_.transactions.empty()) {
|
||||
loadTransactionHistoryCacheIfAvailable();
|
||||
} else {
|
||||
@@ -662,6 +759,29 @@ void App::refreshWalletEncryptionState() {
|
||||
});
|
||||
}
|
||||
|
||||
void App::reconcileEncryptionPending(const services::NetworkRefreshService::WalletEncryptionResult& enc)
|
||||
{
|
||||
if (!settings_) return;
|
||||
using Act = data::EncryptionPendingAction;
|
||||
switch (data::decideEncryptionPending(settings_->getEncryptionPending(), enc.ok, enc.encrypted,
|
||||
wallet_security_.hasDeferredEncryption(), encrypt_in_progress_,
|
||||
encryption_incomplete_warned_)) {
|
||||
case Act::Clear:
|
||||
// The wallet is encrypted, however it got there (deferred, Settings, another client).
|
||||
settings_->setEncryptionPending(false);
|
||||
settings_->save();
|
||||
break;
|
||||
case Act::Warn:
|
||||
// Requested but NOT encrypted, and nothing pending/in flight: lost to a quit/crash or failed connect.
|
||||
// The flag stays set, so this recurs each launch until the wallet is actually encrypted (W2-2).
|
||||
encryption_incomplete_warned_ = true;
|
||||
ui::Notifications::instance().warning(TR("sec_encryption_did_not_complete"), 30.0f);
|
||||
break;
|
||||
case Act::None:
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// ===========================================================================
|
||||
// Auto-lock on idle
|
||||
// ===========================================================================
|
||||
@@ -676,17 +796,16 @@ void App::checkAutoLock() {
|
||||
|| (xmrig_manager_ && xmrig_manager_->isRunning());
|
||||
if (miningActive) {
|
||||
// Keep resetting the idle timer so we don't lock the instant mining stops
|
||||
last_interaction_ = std::chrono::steady_clock::now();
|
||||
idle_.noteActivity();
|
||||
return;
|
||||
}
|
||||
|
||||
int timeout = settings_ ? settings_->getAutoLockTimeout() : 300;
|
||||
if (timeout <= 0) return; // disabled
|
||||
|
||||
auto now = std::chrono::steady_clock::now();
|
||||
float elapsed = std::chrono::duration<float>(now - last_interaction_).count();
|
||||
const double elapsed = idle_.idleSeconds();
|
||||
|
||||
if (elapsed >= (float)timeout) {
|
||||
if (elapsed >= (double)timeout) {
|
||||
// Lite has no daemon `walletlock` — App::lockWallet() early-returns without rpc_, so route
|
||||
// lite through lockLiteWallet() (locks the backend + tears down the chat session so no
|
||||
// decrypted store / unlocked DB key survives the idle lock). In full-node builds
|
||||
@@ -697,6 +816,23 @@ void App::checkAutoLock() {
|
||||
}
|
||||
}
|
||||
|
||||
void App::reconcileUnlockExpiry()
|
||||
{
|
||||
// Full node only: Lite has no daemon timer (unlocked_until stays 0), and its lock is the backend's.
|
||||
if (lite_wallet_ || !state_.connected || !rpc_ || !rpc_->isConnected()) return;
|
||||
if (!data::unlockWindowExpired(state_.encrypted, state_.locked, state_.unlocked_until,
|
||||
static_cast<std::int64_t>(std::time(nullptr)))) return;
|
||||
if (lock_unlock_in_progress_ || encrypt_in_progress_ || pin_in_progress_) return; // an unlock/lock/PIN change decides it
|
||||
const auto now = std::chrono::steady_clock::now();
|
||||
if (now < unlock_reconcile_retry_at_) return;
|
||||
// Back-off, wall-clock independent. refreshEncryptionState() coalesces via Job::Encryption and its apply path
|
||||
// (applyWalletEncryptionResult) is authoritative: unlocked_until == 0 => locked (lock screen + tx-cache
|
||||
// lock); a newer deadline (e.g. a console walletpassphrase, or the lock-screen re-verify) is adopted. If the
|
||||
// daemon clock is skewed and still reports "unlocked", this retries every 30 s, one cheap-ish getwalletinfo.
|
||||
unlock_reconcile_retry_at_ = now + std::chrono::seconds(30);
|
||||
refreshEncryptionState();
|
||||
}
|
||||
|
||||
// ===========================================================================
|
||||
// Mine when idle — auto-start/stop mining based on system idle state
|
||||
// Supports two modes:
|
||||
@@ -726,6 +862,17 @@ void App::checkIdleMining() {
|
||||
// Skip idle mining adjustments while thread benchmark is running
|
||||
if (ui::IsMiningBenchmarkActive()) return;
|
||||
|
||||
// The decisions below work on a seconds scale (the idle delay is tens of seconds or more), so check
|
||||
// at 1 Hz. This used to run every frame: an X11 idle query and, with GPU-aware idle, an nvidia-smi
|
||||
// launch on the UI thread per frame. Steady clock rather than ImGui::GetTime(), whose clock stops
|
||||
// while minimized (update() still runs then).
|
||||
const auto now = std::chrono::steady_clock::now();
|
||||
// ...except while a miner this feature started (or scaled up) is running: it must yield on the first
|
||||
// frame that sees the user back, and frames can be seconds apart when the app idles, so a gated frame
|
||||
// may be the only one carrying that input. The idle query is cheap; the GPU probe has its own cache.
|
||||
if (!(idle_mining_active_ || idle_scaled_to_idle_) && now < idle_mining_next_check_) return;
|
||||
idle_mining_next_check_ = now + std::chrono::seconds(1);
|
||||
|
||||
int idleSec = util::Platform::getSystemIdleSeconds();
|
||||
int delay = settings_->getMineIdleDelay();
|
||||
bool isPool = settings_->getPoolMode() || !supportsSoloMining();
|
||||
@@ -733,26 +880,40 @@ void App::checkIdleMining() {
|
||||
bool threadScaling = settings_->getIdleThreadScaling();
|
||||
int maxThreads = std::max(1, (int)std::thread::hardware_concurrency());
|
||||
|
||||
// GPU-aware idle detection: if enabled, treat GPU utilization >= 10%
|
||||
// as "user active" (e.g. watching a video). Disabled = unrestricted
|
||||
// mode that only looks at keyboard/mouse input.
|
||||
bool gpuBusy = false;
|
||||
if (settings_->getIdleGpuAware()) {
|
||||
int gpuUtil = util::Platform::getGpuUtilization();
|
||||
gpuBusy = (gpuUtil >= 10);
|
||||
}
|
||||
bool systemIdle = (idleSec >= delay) && !gpuBusy;
|
||||
|
||||
// Check if mining is already running (manually started by user)
|
||||
bool miningActive = isPool
|
||||
? (xmrig_manager_ && xmrig_manager_->isRunning())
|
||||
: state_.mining.generate;
|
||||
|
||||
// Thread scaling only adjusts a miner the user started: with none running there is nothing to
|
||||
// decide, or to probe for.
|
||||
if (threadScaling && (!miningActive || mining_toggle_in_progress_.load())) return;
|
||||
|
||||
// GPU-aware idle detection: if enabled, treat GPU utilization >= 10% as "user active" (e.g. watching
|
||||
// a video). Disabled = unrestricted mode that only looks at keyboard/mouse input. The probe can only
|
||||
// matter once input is idle and a decision can change (start/stop mode leaves a miner the user
|
||||
// started alone), so it runs only then, at most every 5 s, with hysteresis (busy at >= 10%, clear
|
||||
// below 5%) so a GPU hovering near 10% can't keep restarting the miner. Unknown (-1) reads as not busy.
|
||||
const bool inputIdle = idleSec >= delay;
|
||||
const bool decisionPossible = threadScaling || !miningActive || idle_mining_active_;
|
||||
bool gpuBusy = false;
|
||||
if (inputIdle && decisionPossible && settings_->getIdleGpuAware()) {
|
||||
if (now >= idle_gpu_next_sample_) {
|
||||
const int gpuUtil = util::Platform::getGpuUtilization();
|
||||
if (gpuUtil >= 10) idle_gpu_busy_ = true;
|
||||
else if (gpuUtil < 5) idle_gpu_busy_ = false;
|
||||
idle_gpu_next_sample_ = now + std::chrono::seconds(5);
|
||||
}
|
||||
gpuBusy = idle_gpu_busy_;
|
||||
} else if (!inputIdle) {
|
||||
idle_gpu_busy_ = false; // input activity decides now; sample afresh once idle again
|
||||
idle_gpu_next_sample_ = {};
|
||||
}
|
||||
bool systemIdle = inputIdle && !gpuBusy;
|
||||
|
||||
if (threadScaling) {
|
||||
// --- Thread scaling mode ---
|
||||
// Mining must already be running (started by user). We just adjust threads.
|
||||
if (!miningActive || mining_toggle_in_progress_.load()) return;
|
||||
|
||||
// Mining is already running (started by user; checked above). We just adjust threads.
|
||||
int activeThreads = settings_->getIdleThreadsActive();
|
||||
int idleThreads = settings_->getIdleThreadsIdle();
|
||||
// Resolve auto values: active defaults to half, idle defaults to all
|
||||
@@ -768,8 +929,7 @@ void App::checkIdleMining() {
|
||||
if (!idle_scaled_to_idle_) {
|
||||
idle_scaled_to_idle_ = true;
|
||||
if (isPool) {
|
||||
stopPoolMining();
|
||||
startPoolMining(idleThreads);
|
||||
retargetPoolMining(idleThreads);
|
||||
} else {
|
||||
startMining(idleThreads);
|
||||
}
|
||||
@@ -780,8 +940,7 @@ void App::checkIdleMining() {
|
||||
if (idle_scaled_to_idle_) {
|
||||
idle_scaled_to_idle_ = false;
|
||||
if (isPool) {
|
||||
stopPoolMining();
|
||||
startPoolMining(activeThreads);
|
||||
retargetPoolMining(activeThreads);
|
||||
} else {
|
||||
startMining(activeThreads);
|
||||
}
|
||||
@@ -789,13 +948,16 @@ void App::checkIdleMining() {
|
||||
} else {
|
||||
// Mining just started while user is active — ensure active
|
||||
// thread count is applied (grid selection may differ).
|
||||
// Pool: compare with the count last REQUESTED (a restart still queued on the control thread
|
||||
// included), not the xmrig API's last report, which lags a restart by seconds.
|
||||
int currentThreads = isPool
|
||||
? xmrig_manager_->getStats().threads_active
|
||||
? services::effectivePoolThreads(pool_threads_target_.load(std::memory_order_relaxed),
|
||||
xmrig_manager_->getRequestedThreads(),
|
||||
xmrig_manager_->getStats().threads_active)
|
||||
: state_.mining.genproclimit;
|
||||
if (currentThreads > 0 && currentThreads != activeThreads) {
|
||||
if (isPool) {
|
||||
stopPoolMining();
|
||||
startPoolMining(activeThreads);
|
||||
retargetPoolMining(activeThreads);
|
||||
} else {
|
||||
startMining(activeThreads);
|
||||
}
|
||||
@@ -838,6 +1000,68 @@ void App::checkIdleMining() {
|
||||
}
|
||||
}
|
||||
|
||||
// ===========================================================================
|
||||
// Mining thread-count picks — coalesced (one save + at most one miner change per gesture)
|
||||
// ===========================================================================
|
||||
|
||||
namespace {
|
||||
double miningSteadySeconds()
|
||||
{
|
||||
return std::chrono::duration<double>(std::chrono::steady_clock::now().time_since_epoch()).count();
|
||||
}
|
||||
} // namespace
|
||||
|
||||
void App::selectMiningThreads(int threads)
|
||||
{
|
||||
if (!settings_) return;
|
||||
threads = std::clamp(threads, 1, std::max(1, (int)std::thread::hardware_concurrency()));
|
||||
if (settings_->getPoolThreads() != threads) { // in memory now; saved once when the pick settles
|
||||
settings_->setPoolThreads(threads);
|
||||
mining_threads_save_pending_ = true;
|
||||
}
|
||||
mining_thread_commit_.select(threads, miningSteadySeconds());
|
||||
}
|
||||
|
||||
void App::tickMiningThreadCommit()
|
||||
{
|
||||
if (shutting_down_ || !settings_) return; // beginShutdown() saves settings itself
|
||||
const double now = miningSteadySeconds();
|
||||
|
||||
// The grid holds the commit while the button is down. The release is noticed HERE, not by the tab, so
|
||||
// a drag that ends while the Mining tab isn't drawn (page switch, focus loss) still commits.
|
||||
bool held = false;
|
||||
if (mining_thread_drag_) {
|
||||
if (ImGui::IsMouseDown(ImGuiMouseButton_Left)) held = true;
|
||||
else { mining_thread_drag_ = false; mining_thread_commit_.release(now); }
|
||||
}
|
||||
|
||||
// The benchmark owns the miner's thread count while it runs (its cancel/finish restore it): keep the
|
||||
// pick in settings, drop the miner change.
|
||||
if (ui::IsMiningBenchmarkActive()) {
|
||||
mining_thread_commit_.cancel();
|
||||
mining_solo_threads_pending_ = 0;
|
||||
if (mining_threads_save_pending_) { mining_threads_save_pending_ = false; settings_->save(); }
|
||||
return;
|
||||
}
|
||||
|
||||
int threads = 0;
|
||||
if (mining_thread_commit_.take(now, held, threads)) {
|
||||
if (mining_threads_save_pending_) { mining_threads_save_pending_ = false; settings_->save(); }
|
||||
retargetPoolMining(threads); // restarts xmrig once, only if it is running
|
||||
mining_solo_threads_pending_ = threads; // setgenerate, once no other toggle is in flight
|
||||
} else if (mining_thread_commit_.pending() && !held) {
|
||||
ui::material::RequestProgressFrame(); // keep frames coming until the debounce deadline
|
||||
}
|
||||
|
||||
// Solo: startMining() drops a call while another setgenerate is in flight, so wait for it (its result
|
||||
// wakes the loop). Applies only while solo mining is on, as before.
|
||||
if (mining_solo_threads_pending_ > 0 && !mining_toggle_in_progress_.load(std::memory_order_relaxed)) {
|
||||
const int t = mining_solo_threads_pending_;
|
||||
mining_solo_threads_pending_ = 0;
|
||||
if (state_.mining.generate) startMining(t);
|
||||
}
|
||||
}
|
||||
|
||||
// ===========================================================================
|
||||
// Restart Setup Wizard (from Settings)
|
||||
// ===========================================================================
|
||||
@@ -851,6 +1075,18 @@ void App::checkIdleMining() {
|
||||
void App::renderLockScreen() {
|
||||
using namespace ui::material;
|
||||
|
||||
// Restore any persisted lockout once per lock-screen session (re-armed on wallet switch).
|
||||
if (!lock_state_restored_) {
|
||||
lock_state_restored_ = true;
|
||||
restoreLockoutState();
|
||||
}
|
||||
|
||||
// Keep frames coming while something on the lock screen is moving without input: the unlock in
|
||||
// flight (its dots), the lockout countdown, an error message about to clear.
|
||||
if (lock_unlock_in_progress_ || lock_lockout_timer_ > 0.0f) {
|
||||
RequestProgressFrame();
|
||||
}
|
||||
|
||||
// Full-window lock overlay: cover the ENTIRE viewport (sidebar included) so the whole screen is
|
||||
// obscured while locked, not just the tab-content area. A borderless, transparent, focused window
|
||||
// over the main viewport (same pattern as the modal overlays; opened from within ##ContentArea,
|
||||
@@ -957,9 +1193,14 @@ void App::renderLockScreen() {
|
||||
cy += ts.y + 20.0f * dp;
|
||||
}
|
||||
|
||||
// Count down only the time since the timers were armed: a failed unlock arms them from an RPC result,
|
||||
// which can land on a frame that ends an idle gap the countdown would otherwise lose at once.
|
||||
const float lockDt = std::min(logic_dt_, std::chrono::duration<float>(
|
||||
std::chrono::steady_clock::now() - lock_timer_armed_at_).count());
|
||||
|
||||
// Lockout timer
|
||||
if (lock_lockout_timer_ > 0.0f) {
|
||||
lock_lockout_timer_ -= ImGui::GetIO().DeltaTime;
|
||||
lock_lockout_timer_ -= lockDt;
|
||||
if (lock_lockout_timer_ < 0) lock_lockout_timer_ = 0;
|
||||
|
||||
char msg[128];
|
||||
@@ -1064,9 +1305,9 @@ void App::renderLockScreen() {
|
||||
lock_screen_was_visible_ = true;
|
||||
}
|
||||
|
||||
// Error message
|
||||
if (!lock_error_msg_.empty() && lock_error_timer_ > 0) {
|
||||
lock_error_timer_ -= ImGui::GetIO().DeltaTime;
|
||||
// Error message — stays visible until the next unlock attempt / mode switch / success, instead of
|
||||
// fading after a few seconds (a failed-unlock reason was often gone before it could be read).
|
||||
if (!lock_error_msg_.empty()) {
|
||||
ImVec2 es = captionFont->CalcTextSizeA(captionFont->LegacySize, FLT_MAX, 0, lock_error_msg_.c_str());
|
||||
dl->AddText(captionFont, captionFont->LegacySize,
|
||||
ImVec2(cardX + (cardW - es.x) * 0.5f, cy), ui::material::Error(),
|
||||
@@ -1112,6 +1353,7 @@ void App::renderLockScreen() {
|
||||
|
||||
// Handle submit (Enter key or button click)
|
||||
if ((submitted || btnClicked) && canSubmit) {
|
||||
lock_error_msg_.clear(); // the prior error clears only when a new attempt actually starts
|
||||
// Derive daemon unlock timeout from auto-lock setting:
|
||||
// 2x the idle timeout so the daemon stays unlocked longer than
|
||||
// the GUI auto-lock, acting as a safety net if the GUI crashes.
|
||||
@@ -1129,22 +1371,30 @@ void App::renderLockScreen() {
|
||||
// Use fast-lane worker for priority unlock.
|
||||
auto* w = (fast_worker_ && fast_worker_->isRunning()) ? fast_worker_.get() : worker_.get();
|
||||
auto* r = (fast_rpc_ && fast_rpc_->isConnected()) ? fast_rpc_.get() : rpc_.get();
|
||||
// The vault this attempt opens: a wallet switch before the answer must not mark the new
|
||||
// wallet's PIN out of date.
|
||||
const std::string vaultPath = vault_ ? vault_->getVaultPath() : std::string();
|
||||
if (w) {
|
||||
w->post([this, r, pin, timeout]() -> rpc::RPCWorker::MainCb {
|
||||
w->post([this, r, pin, timeout, vaultPath]() -> rpc::RPCWorker::MainCb {
|
||||
// Heavy Argon2id derivation runs here (worker thread)
|
||||
std::string passphrase;
|
||||
bool vaultOk = vault_ && vault_->retrieve(pin, passphrase);
|
||||
|
||||
if (!vaultOk) {
|
||||
bool noVault = !vault_ || !vault_->hasVault();
|
||||
return [this, noVault]() {
|
||||
if (noVault) {
|
||||
// Vault file missing — switch to passphrase mode. Not a failed
|
||||
// attempt, so no counter bump / lockout escalation.
|
||||
// A file no PIN can open (zero-filled by a removeVault() whose unlink failed,
|
||||
// truncated, unknown version) counts as no vault: failing it is not a wrong PIN.
|
||||
bool malformed = !noVault && !vault_->isWellFormed();
|
||||
return [this, noVault, malformed]() {
|
||||
if (noVault || malformed) {
|
||||
// Vault file missing or unreadable — switch to passphrase mode. Not a
|
||||
// failed attempt, so no counter bump / lockout escalation.
|
||||
lock_unlock_in_progress_ = false;
|
||||
lock_error_msg_ = TR("pin_not_set");
|
||||
lock_error_msg_ = noVault ? TR("pin_not_set") : TR("sec_pin_unreadable");
|
||||
lock_use_pin_ = false;
|
||||
lock_error_timer_ = 3.0f;
|
||||
lock_error_timer_ = noVault ? 3.0f : 6.0f;
|
||||
lock_timer_armed_at_ = std::chrono::steady_clock::now();
|
||||
if (malformed) DEBUG_LOGF("[App] PIN unlock: the vault file is malformed; no penalty\n");
|
||||
} else {
|
||||
applyUnlockFailure(TR("incorrect_pin"));
|
||||
}
|
||||
@@ -1154,6 +1404,7 @@ void App::renderLockScreen() {
|
||||
// Vault decrypted — now unlock wallet via RPC (also on worker thread)
|
||||
bool rpcOk = false;
|
||||
std::string rpcErr;
|
||||
int rpcCode = 0;
|
||||
try {
|
||||
if (r && r->isConnected()) {
|
||||
r->call("walletpassphrase", {passphrase, timeout});
|
||||
@@ -1161,6 +1412,8 @@ void App::renderLockScreen() {
|
||||
} else {
|
||||
rpcErr = TR("sec_not_connected_to_daemon");
|
||||
}
|
||||
} catch (const rpc::RpcError& e) {
|
||||
rpcErr = e.what(); rpcCode = e.code;
|
||||
} catch (const std::exception& e) {
|
||||
rpcErr = e.what();
|
||||
}
|
||||
@@ -1170,12 +1423,28 @@ void App::renderLockScreen() {
|
||||
applyUnlockSuccess(passphrase, timeout);
|
||||
util::SecureVault::secureZero(passphrase.data(), passphrase.size());
|
||||
};
|
||||
} else if (data::isStaleVaultUnlockFailure(/*vaultDecrypted=*/true, rpcCode)) {
|
||||
// The PIN was right (the vault opened) but the wallet refuses the passphrase it
|
||||
// seals: it was changed after the PIN was set. Not a failed attempt (no lockout)
|
||||
// and the vault is kept; use the passphrase and mark the PIN out of date.
|
||||
util::SecureVault::secureZero(passphrase.data(), passphrase.size());
|
||||
return [this, vaultPath]() {
|
||||
lock_unlock_in_progress_ = false;
|
||||
lock_use_pin_ = false;
|
||||
if (vault_ && vault_->getVaultPath() == vaultPath) pin_vault_stale_ = true;
|
||||
lock_error_msg_ = TR("sec_pin_stale_msg");
|
||||
lock_error_timer_ = 6.0f;
|
||||
lock_timer_armed_at_ = std::chrono::steady_clock::now();
|
||||
memset(lock_passphrase_buf_, 0, sizeof(lock_passphrase_buf_));
|
||||
DEBUG_LOGF("[App] PIN unlock: the wallet refused the vault's passphrase (-14); "
|
||||
"PIN marked out of date, no penalty\n");
|
||||
};
|
||||
} else {
|
||||
// Vault decrypt succeeded but the RPC failed — still a failed unlock,
|
||||
// so route through applyUnlockFailure so the lockout curve applies
|
||||
// (this path previously bumped the counter but skipped the lockout math).
|
||||
// Vault decrypt succeeded (the PIN was correct) and the RPC failed for a
|
||||
// reason other than -14 (busy node, warmup, transport error). That is never
|
||||
// a wrong credential, so it must not count toward the lockout.
|
||||
return [this, rpcErr, passphrase = std::move(passphrase)]() mutable {
|
||||
applyUnlockFailure(std::string(TR("sec_unlock_failed_prefix")) + rpcErr);
|
||||
applyUnlockNotCounted(std::string(TR("sec_unlock_failed_prefix")) + rpcErr);
|
||||
util::SecureVault::secureZero(passphrase.data(), passphrase.size());
|
||||
};
|
||||
}
|
||||
@@ -1225,15 +1494,16 @@ void App::renderEncryptWalletDialog() {
|
||||
|
||||
ImGui::TextUnformatted(TR("wiz_passphrase"));
|
||||
ImGui::PushItemWidth(-1);
|
||||
ImGui::InputText("##enc_pass", encrypt_pass_buf_, sizeof(encrypt_pass_buf_),
|
||||
ImGuiInputTextFlags_Password);
|
||||
static bool s_showEncPass = false;
|
||||
const ImGuiInputTextFlags encPwFlags = s_showEncPass ? 0 : ImGuiInputTextFlags_Password;
|
||||
ImGui::InputText("##enc_pass", encrypt_pass_buf_, sizeof(encrypt_pass_buf_), encPwFlags);
|
||||
ImGui::PopItemWidth();
|
||||
|
||||
ImGui::TextUnformatted(TR("enc_confirm"));
|
||||
ImGui::PushItemWidth(-1);
|
||||
ImGui::InputText("##enc_confirm", encrypt_confirm_buf_, sizeof(encrypt_confirm_buf_),
|
||||
ImGuiInputTextFlags_Password);
|
||||
ImGui::InputText("##enc_confirm", encrypt_confirm_buf_, sizeof(encrypt_confirm_buf_), encPwFlags);
|
||||
ImGui::PopItemWidth();
|
||||
ImGui::Checkbox(TR("enc_show_pass"), &s_showEncPass);
|
||||
|
||||
// Strength meter bar
|
||||
{
|
||||
@@ -1281,12 +1551,19 @@ void App::renderEncryptWalletDialog() {
|
||||
}
|
||||
|
||||
ImGui::Spacing();
|
||||
bool valid = strlen(encrypt_pass_buf_) >= 8 &&
|
||||
strcmp(encrypt_pass_buf_, encrypt_confirm_buf_) == 0;
|
||||
const size_t encPassLen = strlen(encrypt_pass_buf_);
|
||||
const bool encMatch = strcmp(encrypt_pass_buf_, encrypt_confirm_buf_) == 0;
|
||||
bool valid = encPassLen >= 8 && encMatch;
|
||||
// Say WHY the button is disabled, once the user has started typing (was silent before).
|
||||
if (!valid && (encPassLen > 0 || strlen(encrypt_confirm_buf_) > 0)) {
|
||||
const char* why = (encPassLen < 8) ? TR("enc_min_len")
|
||||
: (!encMatch ? TR("enc_mismatch") : "");
|
||||
if (*why) ImGui::TextColored(ImVec4(1.0f, 0.7f, 0.3f, 1.0f), "%s", why);
|
||||
}
|
||||
|
||||
float btnW = (ImGui::GetContentRegionAvail().x - ImGui::GetStyle().ItemSpacing.x) * 0.5f;
|
||||
ImGui::BeginDisabled(!valid || encrypt_in_progress_);
|
||||
if (ui::material::TactileButton(TR("settings_encrypt_wallet"), ImVec2(btnW, 40))) {
|
||||
if (ui::material::TactileButton(TR("settings_encrypt_wallet"), ImVec2(btnW, 40.0f * ui::Layout::dpiScale()))) {
|
||||
std::string pass(encrypt_pass_buf_);
|
||||
enc_dlg_saved_passphrase_ = pass;
|
||||
memset(encrypt_pass_buf_, 0, sizeof(encrypt_pass_buf_));
|
||||
@@ -1298,7 +1575,7 @@ void App::renderEncryptWalletDialog() {
|
||||
ImGui::EndDisabled();
|
||||
|
||||
ImGui::SameLine();
|
||||
if (ui::material::TactileButton(TR("cancel"), ImVec2(btnW, 40))) {
|
||||
if (ui::material::TactileButton(TR("cancel"), ImVec2(btnW, 40.0f * ui::Layout::dpiScale()))) {
|
||||
memset(encrypt_pass_buf_, 0, sizeof(encrypt_pass_buf_));
|
||||
memset(encrypt_confirm_buf_, 0, sizeof(encrypt_confirm_buf_));
|
||||
show_encrypt_dialog_ = false;
|
||||
@@ -1377,7 +1654,7 @@ void App::renderEncryptWalletDialog() {
|
||||
float btnW = (ImGui::GetContentRegionAvail().x - ImGui::GetStyle().ItemSpacing.x) * 0.5f;
|
||||
|
||||
ImGui::BeginDisabled(!pinValid || !hasPassphrase || pin_in_progress_);
|
||||
if (ui::material::TactileButton(TR("settings_set_pin"), ImVec2(btnW, 40))) {
|
||||
if (ui::material::TactileButton(TR("settings_set_pin"), ImVec2(btnW, 40.0f * ui::Layout::dpiScale()))) {
|
||||
pin_in_progress_ = true;
|
||||
enc_dlg_pin_status_.clear();
|
||||
std::string savedPass = enc_dlg_saved_passphrase_;
|
||||
@@ -1391,6 +1668,8 @@ void App::renderEncryptWalletDialog() {
|
||||
if (!pinStr.empty()) util::SecureVault::secureZero(&pinStr[0], pinStr.size());
|
||||
return [this, ok]() {
|
||||
if (ok) {
|
||||
pin_vault_stale_ = false;
|
||||
lock_use_pin_ = true;
|
||||
settings_->setPinEnabled(true);
|
||||
settings_->save();
|
||||
pin_in_progress_ = false;
|
||||
@@ -1418,7 +1697,7 @@ void App::renderEncryptWalletDialog() {
|
||||
ImGui::EndDisabled();
|
||||
|
||||
ImGui::SameLine();
|
||||
if (ui::material::TactileButton(TR("wiz_skip"), ImVec2(btnW, 40))) {
|
||||
if (ui::material::TactileButton(TR("wiz_skip"), ImVec2(btnW, 40.0f * ui::Layout::dpiScale()))) {
|
||||
if (!enc_dlg_saved_passphrase_.empty()) {
|
||||
util::SecureVault::secureZero(&enc_dlg_saved_passphrase_[0],
|
||||
enc_dlg_saved_passphrase_.size());
|
||||
@@ -1454,6 +1733,14 @@ void App::renderEncryptWalletDialog() {
|
||||
DialogWarningHeader(TR("wiz_encrypt_warning"));
|
||||
ImGui::Spacing();
|
||||
|
||||
// This wallet has a PIN: it keeps the old passphrase and goes out of date (C87).
|
||||
if (hasPinVault()) {
|
||||
ImGui::PushStyleColor(ImGuiCol_Text, ImGui::GetStyleColorVec4(ImGuiCol_TextDisabled));
|
||||
ImGui::TextWrapped("%s", TR("change_pass_pin_note"));
|
||||
ImGui::PopStyleColor();
|
||||
ImGui::Spacing();
|
||||
}
|
||||
|
||||
ImGui::TextUnformatted(TR("change_pass_current"));
|
||||
ImGui::PushItemWidth(-1);
|
||||
ImGui::InputText("##chg_old", change_old_pass_buf_, sizeof(change_old_pass_buf_),
|
||||
@@ -1483,8 +1770,9 @@ void App::renderEncryptWalletDialog() {
|
||||
|
||||
// Two-button footer (primary + Cancel) to match the encrypt/decrypt siblings.
|
||||
float btnW = (ImGui::GetContentRegionAvail().x - ImGui::GetStyle().ItemSpacing.x) * 0.5f;
|
||||
ImGui::BeginDisabled(!valid || encrypt_in_progress_);
|
||||
if (ui::material::TactileButton(TR("change_pass_title"), ImVec2(btnW, 40))) {
|
||||
// Not while a PIN operation runs: PIN setup could verify and seal the OLD passphrase.
|
||||
ImGui::BeginDisabled(!valid || encrypt_in_progress_ || pin_in_progress_);
|
||||
if (ui::material::TactileButton(TR("change_pass_title"), ImVec2(btnW, 40.0f * ui::Layout::dpiScale()))) {
|
||||
changePassphrase(std::string(change_old_pass_buf_),
|
||||
std::string(change_new_pass_buf_));
|
||||
}
|
||||
@@ -1493,7 +1781,7 @@ void App::renderEncryptWalletDialog() {
|
||||
ImGui::SameLine();
|
||||
// Cancel does what Esc/close does — dismiss without applying. Buffers are wiped by
|
||||
// the !show_change_passphrase_ cleanup block below.
|
||||
if (ui::material::TactileButton(TR("cancel"), ImVec2(btnW, 40))) {
|
||||
if (ui::material::TactileButton(TR("cancel"), ImVec2(btnW, 40.0f * ui::Layout::dpiScale()))) {
|
||||
show_change_passphrase_ = false;
|
||||
}
|
||||
EndOverlayDialog();
|
||||
@@ -1561,7 +1849,9 @@ void App::renderDecryptWalletDialog() {
|
||||
|
||||
float btnW = (ImGui::GetContentRegionAvail().x - ImGui::GetStyle().ItemSpacing.x) * 0.5f;
|
||||
ImGui::BeginDisabled(!valid || decryptState.inProgress);
|
||||
if (ui::material::TactileButton(TR("settings_remove_encryption"), ImVec2(btnW, 40)) || (enterPressed && valid)) {
|
||||
// Decrypting stops and restarts the node: not while it is busy (refuseIfNodeBusy() only on a click).
|
||||
if ((ui::material::TactileButton(TR("settings_remove_encryption"), ImVec2(btnW, 40.0f * ui::Layout::dpiScale())) || (enterPressed && valid)) &&
|
||||
!refuseIfNodeBusy()) {
|
||||
std::string passphrase(decrypt_pass_buf_);
|
||||
memset(decrypt_pass_buf_, 0, sizeof(decrypt_pass_buf_));
|
||||
wallet_security_workflow_.start(std::chrono::steady_clock::now());
|
||||
@@ -1613,15 +1903,18 @@ void App::renderDecryptWalletDialog() {
|
||||
|
||||
// Continue with step 3
|
||||
worker_->post([this, filePlan]() -> rpc::RPCWorker::MainCb {
|
||||
WalletSecurityDecryptRpcAdapter decryptRpc(rpc_.get(),
|
||||
[this](rpc::RPCClient& client, const char* context) {
|
||||
return sendStopCommandSafely(client, context);
|
||||
});
|
||||
services::WalletSecurityWorkflowExecutor::stopDaemon(decryptRpc);
|
||||
|
||||
// Wait for daemon to fully stop
|
||||
for (int i = 0; i < 30 && !shutting_down_; ++i)
|
||||
std::this_thread::sleep_for(std::chrono::milliseconds(100));
|
||||
// Stop the node and wait for its PROCESS to exit before wallet.dat is
|
||||
// renamed: dragonxd keeps the file open until it exits, which can take
|
||||
// minutes on a large wallet (a fixed 3s wait let the rename run under a
|
||||
// live node). Ours or the one we're connected to; bounded, never killed.
|
||||
const bool stopped = stopEmbeddedDaemon(StopMode::Background, StopScope::Connected) ==
|
||||
daemon::StopOutcome::Exited;
|
||||
if (!stopped) {
|
||||
App::scrubAndRemoveExport(filePlan.exportPath); // nothing will import it
|
||||
return [this]() {
|
||||
wallet_security_workflow_.fail(TR("appx_node_not_stopped"));
|
||||
};
|
||||
}
|
||||
|
||||
return [this, filePlan]() {
|
||||
wallet_security_workflow_.advanceTo(DecryptStep::BackupWallet,
|
||||
@@ -1681,6 +1974,12 @@ void App::renderDecryptWalletDialog() {
|
||||
state_.sync.rescanning = true;
|
||||
state_.sync.rescan_progress = 0.0f;
|
||||
|
||||
// The user chose to remove encryption: any wizard-era "encryption pending" request is moot
|
||||
// (else the next connect would warn that encryption "did not complete") (C88).
|
||||
if (settings_ && settings_->getEncryptionPending()) {
|
||||
settings_->setEncryptionPending(false);
|
||||
settings_->save();
|
||||
}
|
||||
// Clear encryption state early — vault/PIN removed now,
|
||||
// wallet file is already unencrypted
|
||||
services::WalletSecurityWorkflowExecutor::cleanupVaultAndPin([this]() {
|
||||
@@ -1729,9 +2028,8 @@ void App::renderDecryptWalletDialog() {
|
||||
wallet_security_workflow_.finishImport();
|
||||
|
||||
// Force address + peer refresh
|
||||
invalidateAddressValidationCache();
|
||||
addresses_dirty_ = true;
|
||||
transactions_dirty_ = true;
|
||||
invalidateAddressValidationCache(); // marks the addresses dirty
|
||||
markTransactionsDirty();
|
||||
last_tx_block_height_ = -1;
|
||||
|
||||
refreshWalletEncryptionState();
|
||||
@@ -1761,7 +2059,7 @@ void App::renderDecryptWalletDialog() {
|
||||
ImGui::EndDisabled();
|
||||
|
||||
ImGui::SameLine();
|
||||
if (ui::material::TactileButton(TR("cancel"), ImVec2(btnW, 40))) {
|
||||
if (ui::material::TactileButton(TR("cancel"), ImVec2(btnW, 40.0f * ui::Layout::dpiScale()))) {
|
||||
memset(decrypt_pass_buf_, 0, sizeof(decrypt_pass_buf_));
|
||||
show_decrypt_dialog_ = false;
|
||||
}
|
||||
@@ -1876,7 +2174,7 @@ void App::renderDecryptWalletDialog() {
|
||||
ImGui::TextWrapped("%s", TR("decrypt_success_desc"));
|
||||
|
||||
ImGui::Spacing();
|
||||
if (ui::material::TactileButton(TR("close"), ImVec2(-1, 40))) {
|
||||
if (ui::material::TactileButton(TR("close"), ImVec2(-1, 40.0f * ui::Layout::dpiScale()))) {
|
||||
show_decrypt_dialog_ = false;
|
||||
}
|
||||
|
||||
@@ -1893,11 +2191,11 @@ void App::renderDecryptWalletDialog() {
|
||||
|
||||
ImGui::Spacing();
|
||||
float btnW = (ImGui::GetContentRegionAvail().x - ImGui::GetStyle().ItemSpacing.x) * 0.5f;
|
||||
if (ui::material::TactileButton(TR("try_again"), ImVec2(btnW, 40))) {
|
||||
if (ui::material::TactileButton(TR("try_again"), ImVec2(btnW, 40.0f * ui::Layout::dpiScale()))) {
|
||||
wallet_security_workflow_.reset();
|
||||
}
|
||||
ImGui::SameLine();
|
||||
if (ui::material::TactileButton(TR("close"), ImVec2(btnW, 40))) {
|
||||
if (ui::material::TactileButton(TR("close"), ImVec2(btnW, 40.0f * ui::Layout::dpiScale()))) {
|
||||
show_decrypt_dialog_ = false;
|
||||
}
|
||||
}
|
||||
@@ -1961,8 +2259,9 @@ void App::renderPinDialogs() {
|
||||
|
||||
// Two-button footer (primary + Cancel) to match the encrypt/decrypt siblings.
|
||||
float btnW = (ImGui::GetContentRegionAvail().x - ImGui::GetStyle().ItemSpacing.x) * 0.5f;
|
||||
ImGui::BeginDisabled(!valid || pin_in_progress_);
|
||||
if (ui::material::TactileButton(TR("settings_set_pin"), ImVec2(btnW, 40))) {
|
||||
// Not while a passphrase change runs: it could land after the old passphrase is verified.
|
||||
ImGui::BeginDisabled(!valid || pin_in_progress_ || encrypt_in_progress_);
|
||||
if (ui::material::TactileButton(TR("settings_set_pin"), ImVec2(btnW, 40.0f * ui::Layout::dpiScale()))) {
|
||||
pin_in_progress_ = true;
|
||||
pin_status_ = TR("sec_verifying_passphrase");
|
||||
|
||||
@@ -1975,11 +2274,15 @@ void App::renderPinDialogs() {
|
||||
memset(pin_confirm_buf_, 0, sizeof(pin_confirm_buf_));
|
||||
|
||||
if (rpc_ && rpc_->isConnected() && worker_) {
|
||||
worker_->post([this, passphrase, pin]() mutable -> rpc::RPCWorker::MainCb {
|
||||
// C90: decided on the main thread (state_ is main-thread data). An unlocked wallet is verified
|
||||
// with the time it has left and stays unlocked, so the GUI and the daemon keep agreeing.
|
||||
const data::SetPinVerifyPlan verifyPlan = data::planSetPinVerify(
|
||||
state_.isUnlocked(), state_.unlocked_until, static_cast<std::int64_t>(std::time(nullptr)));
|
||||
worker_->post([this, passphrase, pin, verifyPlan]() mutable -> rpc::RPCWorker::MainCb {
|
||||
// Verify passphrase via RPC (worker thread)
|
||||
try {
|
||||
rpc::RPCClient::TraceScope trace("Security / PIN setup");
|
||||
rpc_->call("walletpassphrase", {passphrase, 5});
|
||||
rpc_->call("walletpassphrase", {passphrase, verifyPlan.timeoutSecs});
|
||||
} catch (const std::exception& e) {
|
||||
if (!passphrase.empty()) util::SecureVault::secureZero(&passphrase[0], passphrase.size());
|
||||
if (!pin.empty()) util::SecureVault::secureZero(&pin[0], pin.size());
|
||||
@@ -1995,14 +2298,20 @@ void App::renderPinDialogs() {
|
||||
if (!passphrase.empty()) util::SecureVault::secureZero(&passphrase[0], passphrase.size());
|
||||
if (!pin.empty()) util::SecureVault::secureZero(&pin[0], pin.size());
|
||||
|
||||
// Lock wallet back
|
||||
try {
|
||||
rpc::RPCClient::TraceScope trace("Security / PIN setup");
|
||||
rpc_->call("walletlock");
|
||||
} catch (...) {}
|
||||
// Lock wallet back — only if it was locked before the check (C90).
|
||||
if (verifyPlan.relockAfter) {
|
||||
try {
|
||||
rpc::RPCClient::TraceScope trace("Security / PIN setup");
|
||||
rpc_->call("walletlock");
|
||||
} catch (...) {}
|
||||
}
|
||||
|
||||
return [this, storeOk]() {
|
||||
// Re-read the daemon's lock state either way: it is the truth and self-heals any drift.
|
||||
refreshEncryptionState();
|
||||
if (storeOk) {
|
||||
pin_vault_stale_ = false; // the vault now seals the verified passphrase
|
||||
lock_use_pin_ = true;
|
||||
settings_->setPinEnabled(true);
|
||||
settings_->save();
|
||||
pin_status_.clear();
|
||||
@@ -2025,7 +2334,7 @@ void App::renderPinDialogs() {
|
||||
ImGui::SameLine();
|
||||
// Cancel does what Esc/close does — dismiss without applying. Buffers are wiped by
|
||||
// the !show_pin_setup_ cleanup block below.
|
||||
if (ui::material::TactileButton(TR("cancel"), ImVec2(btnW, 40))) {
|
||||
if (ui::material::TactileButton(TR("cancel"), ImVec2(btnW, 40.0f * ui::Layout::dpiScale()))) {
|
||||
show_pin_setup_ = false;
|
||||
}
|
||||
EndOverlayDialog();
|
||||
@@ -2079,7 +2388,7 @@ void App::renderPinDialogs() {
|
||||
strcmp(pin_buf_, pin_confirm_buf_) == 0;
|
||||
|
||||
ImGui::BeginDisabled(!valid || pin_in_progress_);
|
||||
if (ui::material::TactileButton(TR("settings_change_pin"), ImVec2(-1, 40))) {
|
||||
if (ui::material::TactileButton(TR("settings_change_pin"), ImVec2(-1, 40.0f * ui::Layout::dpiScale()))) {
|
||||
pin_in_progress_ = true;
|
||||
pin_status_ = TR("sec_changing_pin");
|
||||
std::string oldPin(pin_old_buf_);
|
||||
@@ -2144,7 +2453,7 @@ void App::renderPinDialogs() {
|
||||
ImGui::Spacing();
|
||||
bool valid = strlen(pin_old_buf_) >= 4;
|
||||
ImGui::BeginDisabled(!valid || pin_in_progress_);
|
||||
if (ui::material::TactileButton(TR("settings_remove_pin"), ImVec2(-1, 40))) {
|
||||
if (ui::material::TactileButton(TR("settings_remove_pin"), ImVec2(-1, 40.0f * ui::Layout::dpiScale()))) {
|
||||
pin_in_progress_ = true;
|
||||
pin_status_ = TR("sec_verifying_pin");
|
||||
std::string oldPin(pin_old_buf_);
|
||||
@@ -2161,6 +2470,7 @@ void App::renderPinDialogs() {
|
||||
return [this, ok]() {
|
||||
if (ok) {
|
||||
vault_->removeVault();
|
||||
pin_vault_stale_ = false;
|
||||
settings_->setPinEnabled(false);
|
||||
settings_->save();
|
||||
pin_status_.clear();
|
||||
|
||||
+260
-28
@@ -19,6 +19,7 @@
|
||||
#include "ui/notifications.h"
|
||||
#include "ui/sidebar.h"
|
||||
#include "ui/windows/send_tab.h"
|
||||
#include "ui/windows/receive_tab.h"
|
||||
#include "ui/windows/wallets_dialog.h"
|
||||
#include "ui/windows/export_transactions_dialog.h"
|
||||
#include "ui/windows/export_all_keys_dialog.h"
|
||||
@@ -33,8 +34,15 @@
|
||||
#include "ui/windows/address_transfer_dialog.h"
|
||||
#include "ui/windows/key_export_dialog.h"
|
||||
#include "ui/windows/contacts_tab.h"
|
||||
#include "ui/windows/chat_tab.h"
|
||||
#include "ui/windows/folder_picker.h"
|
||||
#include "ui/pages/settings_page.h"
|
||||
#include "chat/chat_protocol.h"
|
||||
#include "chat/chat_identity.h" // deriveChatIdentityFromSecret / ChatIdentityStatus (sweep identity)
|
||||
#include "chat/chat_crypto.h" // ChatKeyPair / wipeChatKeyPair
|
||||
#include "util/i18n.h"
|
||||
#include "util/platform.h"
|
||||
#include "util/private_temp_dir.h"
|
||||
#include "wallet/wallet_capabilities.h"
|
||||
|
||||
#include "imgui.h"
|
||||
@@ -94,28 +102,30 @@ const char* kDemoZAddr =
|
||||
const char* kDemoTxid = "b647077c471fdd2877d35c9d8b70e3c785547fae618458b4068d913ee3d1dc4f";
|
||||
|
||||
// Contacts-view sweep: seed a few demo contacts (Z + T types, some global) so the Cards/List/Table
|
||||
// modes render with data, and restore the real book afterward. Uses sweepSetEntries (no disk write),
|
||||
// so the user's persisted address book is never touched even if the sweep is interrupted.
|
||||
std::vector<data::AddressBookEntry> s_contactsSweepBackup;
|
||||
// modes render with data, and restore the real book afterward. sweepBegin() snapshots the real book on
|
||||
// its first call only (this setup re-runs every frame) and latches save()/load(), so the user's
|
||||
// persisted address book is never touched, even if the sweep is interrupted.
|
||||
void seedSweepContacts(App& a)
|
||||
{
|
||||
s_contactsSweepBackup = a.addressBook().entries();
|
||||
data::AddressBookEntry e1("drgx pool payout address", kDemoZAddr, "mining pool payouts"); // Z, global
|
||||
e1.avatar = "icon:account_balance"; // icon avatar (verifies the icon-badge render path)
|
||||
data::AddressBookEntry e2("exchange deposit", "t1DemoTransparentAddressForUiSweep00000", ""); // T
|
||||
data::AddressBookEntry e2("mining rig", "t1DemoTransparentAddressForUiSweep00000", ""); // T (mining only)
|
||||
// Scope to the active wallet so it's visible (the tab hides contacts not in the active wallet);
|
||||
// non-empty hash also keeps it non-global -> no globe badge, so the list shows a global/non-global mix.
|
||||
e2.scope = a.activeWalletIdentityHash();
|
||||
data::AddressBookEntry e3("cold savings",
|
||||
"zs1sweepdemocoldsavingsaddressforuicapture0000000000000000000000000000", "long-term storage"); // Z, global
|
||||
a.addressBook().sweepSetEntries({ e1, e2, e3 });
|
||||
a.addressBook().sweepBegin({ e1, e2, e3 });
|
||||
}
|
||||
// The contacts view mode is restored by Settings::sweepEnd() at sweep end.
|
||||
void restoreSweepContacts(App& a)
|
||||
{
|
||||
a.addressBook().sweepSetEntries(s_contactsSweepBackup);
|
||||
s_contactsSweepBackup.clear();
|
||||
if (a.settings()) a.settings()->setContactsViewMode(0);
|
||||
a.addressBook().sweepEnd();
|
||||
}
|
||||
|
||||
// The full sweep's sandbox: a fresh owner-only dir under the temp dir. Its demo wallet files live in
|
||||
// <sandbox>/DRAGONX (and DRAGONX-many, extra-wallets, picker-demo), never in the real datadir or config dir.
|
||||
const char* kSweepSandboxPrefix = "obsidiandragon-sweep-";
|
||||
} // namespace
|
||||
|
||||
std::string App::screenshotDir() const
|
||||
@@ -131,6 +141,11 @@ std::string App::screenshotFullDir() const
|
||||
// ── Demo data (full sweep only): make every data-dependent screen render offline ─────────────
|
||||
void App::installDemoWalletData()
|
||||
{
|
||||
// Persistence latches first: until clearDemoWalletData(), nothing the demo surfaces put into
|
||||
// Settings or the wallet index (or, via seedSweepContacts, the address book) can reach disk.
|
||||
if (settings_) settings_->sweepBegin();
|
||||
wallet_index_.sweepBegin();
|
||||
|
||||
// Snapshot the state_ fields we mutate (WalletState isn't copy-assignable — reference aliases).
|
||||
auto& s = sweep_state_snapshot_;
|
||||
s.connected = state_.connected; s.warming_up = state_.warming_up;
|
||||
@@ -145,11 +160,16 @@ void App::installDemoWalletData()
|
||||
s.transactions = state_.transactions;
|
||||
s.market_price_usd = state_.market.price_usd;
|
||||
s.market_change_24h = state_.market.change_24h;
|
||||
s.wizard_phase = wizard_phase_;
|
||||
s.lite_firstrun_dismissed = lite_firstrun_dismissed_;
|
||||
s.valid = true;
|
||||
|
||||
applyHealthyDemoState();
|
||||
state_.sync.blocks = state_.sync.headers = 3124322;
|
||||
state_.sync.verification_progress = 1.0; state_.sync.syncing = false;
|
||||
state_.sync.tip_known = true; // a healthy synced node knows the peer-derived tip (so the no-peers
|
||||
state_.sync.building_witnesses = false; state_.sync.rescanning = false; // banner only shows on its target
|
||||
lite_firstrun_dismissed_ = true; // the demo "has" a wallet — don't overlay the lite welcome prompt
|
||||
// Legacy (pre-seed) wallet so the Settings "Migrate to seed" button glows in the sweep.
|
||||
wallet_seed_status_ = WalletSeedStatus::NoMnemonic;
|
||||
|
||||
@@ -200,6 +220,7 @@ void App::installDemoWalletData()
|
||||
tx("deadbeef000000000000000000000000000000000000000000000000000000000000", "receive", 0.50000000,
|
||||
1751290000, 0, "zs1demosecondaryshieldedaddressforuisweep0000000000000000000000000", "pending"),
|
||||
};
|
||||
state_.touchTransactions();
|
||||
|
||||
// Demo portfolio groups so the Market tab's row styles render with real-looking data. Snapshot
|
||||
// the user's real portfolio first; setPortfolio* only mutate memory (save() is never called
|
||||
@@ -227,7 +248,7 @@ void App::installDemoWalletData()
|
||||
settings_->setPortfolioStyle(0);
|
||||
}
|
||||
|
||||
seedChatDemoData();
|
||||
sweep_chat_identity_fabricated_ = seedChatDemoData();
|
||||
}
|
||||
|
||||
void App::applyHealthyDemoState()
|
||||
@@ -245,6 +266,46 @@ void App::applyHealthyDemoState()
|
||||
wizard_phase_ = WizardPhase::None;
|
||||
}
|
||||
|
||||
// Re-fabricate an identity-only demo chat identity (no messages — the demo store is untouched by
|
||||
// clearIdentity) so a chat-identity sweep target's teardown restores the populated chat UI. Mirrors the
|
||||
// identity half of seedChatDemoData(); never clobbers a real identity (only runs when none is held).
|
||||
void App::sweepEnsureDemoChatIdentity()
|
||||
{
|
||||
if (!chat::hushChatFeatureEnabledAtBuild()) return;
|
||||
if (chat_service_.hasIdentity()) return;
|
||||
chat::ChatKeyPair keys;
|
||||
const std::string demoSecret = generateChatLocalId("demo-chat:", 24); // random, per-run
|
||||
if (chat::deriveChatIdentityFromSecret(demoSecret, keys).status == chat::ChatIdentityStatus::Ready)
|
||||
chat_service_.setIdentity(keys);
|
||||
chat::wipeChatKeyPair(keys);
|
||||
}
|
||||
|
||||
// Refuse a full sweep while any real workflow it would fake is in flight: its surfaces overwrite the
|
||||
// migration, wallet-switch, security-workflow and seed-backup state in memory, and its teardown resets it.
|
||||
const char* App::fullSweepBlockedReason() const
|
||||
{
|
||||
if (settings_ && (settings_->getSeedMigrationPending() || settings_->getSeedMigrationSweepSubmitted() ||
|
||||
!settings_->getSeedMigrationSweepTxid().empty() ||
|
||||
!settings_->getSeedMigrationSweepOpid().empty()))
|
||||
return "a migrate-to-seed is pending (seed_migration_* settings)";
|
||||
if (show_seed_migration_ || seed_migration_step_ != SeedMigrationStep::Intro || seed_migration_in_flight_)
|
||||
return "the migrate-to-seed dialog is open";
|
||||
if (wallet_switch_phase_.load() != 0 || wallet_switch_dialog_open_.load() ||
|
||||
!pending_switch_wallet_file_.empty())
|
||||
return "a wallet switch is in progress";
|
||||
if (wallet_security_workflow_.inProgress() || wallet_security_workflow_.importActive())
|
||||
return "an encryption workflow is in progress";
|
||||
if (encrypt_in_progress_ || pin_in_progress_ || lock_unlock_in_progress_)
|
||||
return "an encrypt, PIN or lock/unlock operation is in progress";
|
||||
if (show_seed_backup_ || !seed_backup_phrase_.empty())
|
||||
return "the seed-phrase backup dialog is open";
|
||||
if (bootstrap_ || bootstrap_downloading_)
|
||||
return "a bootstrap is in progress";
|
||||
if (long_lane_.active() || import_in_progress_ || runtime_rescan_active_)
|
||||
return "a key import or rescan is in progress";
|
||||
return nullptr;
|
||||
}
|
||||
|
||||
void App::clearDemoWalletData()
|
||||
{
|
||||
auto& s = sweep_state_snapshot_;
|
||||
@@ -260,12 +321,72 @@ void App::clearDemoWalletData()
|
||||
state_.totalBalance = s.totalBalance; state_.unconfirmedBalance = s.unconfirmedBalance;
|
||||
state_.addresses = s.addresses; state_.z_addresses = s.z_addresses; state_.t_addresses = s.t_addresses;
|
||||
state_.transactions = s.transactions;
|
||||
state_.touchTransactions();
|
||||
state_.market.price_usd = s.market_price_usd;
|
||||
state_.market.change_24h = s.market_change_24h;
|
||||
state_.market.price_history = g_marketHistorySnapshot;
|
||||
g_marketHistorySnapshot.clear();
|
||||
wizard_phase_ = s.wizard_phase; // applyHealthyDemoState() dismissed it
|
||||
lite_firstrun_dismissed_ = s.lite_firstrun_dismissed;
|
||||
s = SweepStateSnapshot{}; // invalidate
|
||||
|
||||
// Sandbox: close what shows it, then delete it (error_code overloads only; at worst temp debris,
|
||||
// e.g. a file a Windows probe thread still holds).
|
||||
ui::WalletsDialog::hide();
|
||||
ui::FolderPicker::close();
|
||||
ui::WalletsDialog::setSweepDataDir({});
|
||||
if (!sweep_sandbox_.empty()) {
|
||||
if (!util::removePrivateTempDir(sweep_sandbox_, kSweepSandboxPrefix))
|
||||
DEBUG_LOGF("[Sweep] could not fully remove the sandbox %s\n", sweep_sandbox_.c_str());
|
||||
sweep_sandbox_.clear();
|
||||
}
|
||||
|
||||
// Migrate-to-seed state the mig() surfaces faked. Safe to reset: the preflight refused the sweep
|
||||
// while a real migration was pending or its dialog was open.
|
||||
show_seed_migration_ = false;
|
||||
seed_migration_step_ = SeedMigrationStep::Intro;
|
||||
if (!seed_migration_seed_.empty())
|
||||
sodium_memzero(&seed_migration_seed_[0], seed_migration_seed_.size());
|
||||
seed_migration_seed_.clear();
|
||||
seed_migration_dest_.clear();
|
||||
seed_migration_sweep_txid_.clear();
|
||||
seed_migration_status_.clear();
|
||||
seed_migration_precheck_ = SeedMigrationPrecheck::Pending;
|
||||
seed_migration_precheck_started_ = false;
|
||||
seed_migration_backed_up_ = false;
|
||||
seed_migration_understood_ = false;
|
||||
seed_migration_confirm_discard_ = false;
|
||||
seed_migration_confirm_sweep_ = false;
|
||||
seed_migration_balance_ = 0.0;
|
||||
seed_migration_balance_loaded_ = false;
|
||||
seed_migration_nofunds_confirmed_ = false;
|
||||
seed_migration_sweep_confs_ = 0;
|
||||
seed_migration_legacy_remaining_ = -1.0;
|
||||
|
||||
// Chat: drop the demo conversations (in-memory only). If the sweep had to fabricate the demo
|
||||
// identity, drop it too and re-arm provisioning, or chat would run on that throwaway key for the rest
|
||||
// of the session. No real identity existed, so the DB was never unlocked under it.
|
||||
if (chat::hushChatFeatureEnabledAtBuild()) {
|
||||
for (const char* cid : {"demo-1", "demo-2", "demo-3"})
|
||||
chat_service_.store().eraseConversation(cid);
|
||||
if (sweep_chat_identity_fabricated_) {
|
||||
chat_service_.clearIdentity();
|
||||
chat_service_.store().clear();
|
||||
chat_seen_watermark_.clear();
|
||||
ui::ResetChatTab();
|
||||
chat_db_.lock();
|
||||
chat_identity_provisioned_ = false;
|
||||
chat_identity_fetch_in_flight_ = false;
|
||||
chat_identity_unavailable_ = false;
|
||||
++chat_session_generation_; // discard any identity fetch that was in flight
|
||||
}
|
||||
}
|
||||
sweep_chat_identity_fabricated_ = false;
|
||||
|
||||
// Address book and wallet index: back to the real data (no disk write).
|
||||
addressBook().sweepEnd();
|
||||
wallet_index_.sweepEnd();
|
||||
|
||||
// Restore the user's real portfolio (demo groups were in-memory only).
|
||||
if (g_pfSnapshotValid && settings_) {
|
||||
settings_->setPortfolioEntries(g_pfSnapshot);
|
||||
@@ -273,6 +394,10 @@ void App::clearDemoWalletData()
|
||||
}
|
||||
g_pfSnapshot.clear();
|
||||
g_pfSnapshotValid = false;
|
||||
|
||||
// LAST: restore the latched settings and flush once if a save was suppressed. Anything restored
|
||||
// after this point would be flushed with its demo value.
|
||||
if (settings_) settings_->sweepEnd();
|
||||
}
|
||||
|
||||
// ── Catalog ──────────────────────────────────────────────────────────────────────────────────
|
||||
@@ -370,6 +495,13 @@ void App::buildSweepCatalog()
|
||||
add("modal-validate-address", ui::NavPage::Receive,
|
||||
[](App&) { ui::ValidateAddressDialog::show(); },
|
||||
[](App&) { ui::ValidateAddressDialog::hide(); });
|
||||
add("modal-validate-mining", ui::NavPage::Receive,
|
||||
[](App&) { ui::ValidateAddressDialog::sweepShowMiningResult("t1DemoTransparentAddressForUiSweep00000"); },
|
||||
[](App&) { ui::ValidateAddressDialog::hide(); });
|
||||
// A mining (transparent) address selected on Receive: no payment request, no QR — the explainer instead.
|
||||
add("receive-mining-address", ui::NavPage::Receive,
|
||||
[](App& a) { ui::SweepSelectReceiveAddress(&a, "t1DemoTransparentAddressForUiSweep00000"); },
|
||||
[](App& a) { ui::SweepSelectReceiveAddress(&a, nullptr); });
|
||||
add("modal-address-label", ui::NavPage::Overview,
|
||||
[](App& a) { ui::AddressLabelDialog::show(&a, kDemoZAddr, true); },
|
||||
[](App&) { ui::AddressLabelDialog::hide(); });
|
||||
@@ -393,6 +525,10 @@ void App::buildSweepCatalog()
|
||||
add("modal-merge", ui::NavPage::Send,
|
||||
[](App&) { ui::ShieldDialog::showMerge(); },
|
||||
[](App&) { ui::ShieldDialog::hide(); });
|
||||
// Consolidate (wallet-size preset): keeps each address's funds in that address by default.
|
||||
add("modal-consolidate", ui::NavPage::Send,
|
||||
[](App&) { ui::ShieldDialog::showConsolidate(); },
|
||||
[](App&) { ui::ShieldDialog::hide(); });
|
||||
// z->t transfer so the (converted) deshielding DialogWarningHeader renders.
|
||||
add("modal-transfer", ui::NavPage::Overview,
|
||||
[](App& a) {
|
||||
@@ -443,7 +579,13 @@ void App::buildSweepCatalog()
|
||||
a.show_seed_backup_ = true; a.seed_backup_fetch_started_ = true;
|
||||
a.seed_backup_loading_ = false; a.seed_backup_no_mnemonic_ = false;
|
||||
a.seed_backup_status_.clear();
|
||||
if (a.seed_backup_phrase_.empty()) a.seed_backup_phrase_ = kDemoMnemonic;
|
||||
// Always the demo phrase: anything else here could be a real mnemonic, and it would be
|
||||
// written into this surface's screenshots for every skin.
|
||||
if (a.seed_backup_phrase_ != kDemoMnemonic) {
|
||||
if (!a.seed_backup_phrase_.empty())
|
||||
sodium_memzero(&a.seed_backup_phrase_[0], a.seed_backup_phrase_.size());
|
||||
a.seed_backup_phrase_ = kDemoMnemonic;
|
||||
}
|
||||
},
|
||||
[](App& a) {
|
||||
a.show_seed_backup_ = false; a.seed_backup_fetch_started_ = false;
|
||||
@@ -484,7 +626,7 @@ void App::buildSweepCatalog()
|
||||
a.seed_migration_precheck_started_ = true;
|
||||
});
|
||||
mig("modal-migrate-showseed", SeedMigrationStep::ShowSeed,
|
||||
[](App& a) { a.seed_migration_seed_ = kDemoMnemonic; a.seed_migration_backed_up_ = false; });
|
||||
[](App& a) { a.seed_migration_seed_ = kDemoMnemonic; a.seed_migration_backed_up_ = false; a.seed_migration_understood_ = false; });
|
||||
mig("modal-migrate-sweep", SeedMigrationStep::Sweep,
|
||||
[](App& a) { a.seed_migration_balance_ = 15.75000000; a.seed_migration_balance_loaded_ = true; });
|
||||
mig("modal-migrate-nofunds", SeedMigrationStep::Sweep,
|
||||
@@ -497,12 +639,13 @@ void App::buildSweepCatalog()
|
||||
mig("modal-migrate-error", SeedMigrationStep::Error,
|
||||
[](App& a) { a.seed_migration_status_ = "Example error: the daemon did not respond."; });
|
||||
|
||||
// Multi-wallet: the wallet-files list. Drop a couple of demo wallet files in the (throwaway)
|
||||
// datadir + cache their metadata so the list renders populated.
|
||||
// Multi-wallet: the wallet-files list. Drop a couple of demo wallet files in the sandbox's
|
||||
// datadir + cache their metadata (latched, never saved) so the list renders populated.
|
||||
add("modal-wallets", ui::NavPage::Settings,
|
||||
[](App& a) {
|
||||
if (a.sweep_sandbox_.empty()) return;
|
||||
std::error_code ec;
|
||||
const std::string dd = util::Platform::getDragonXDataDir();
|
||||
const std::string dd = a.sweep_sandbox_ + "/DRAGONX";
|
||||
fs::create_directories(dd, ec);
|
||||
if (!fs::exists(dd + "/wallet.dat", ec))
|
||||
std::ofstream(dd + "/wallet.dat", std::ios::binary) << std::string(122880, '\0');
|
||||
@@ -517,26 +660,26 @@ void App::buildSweepCatalog()
|
||||
a.walletIndex().upsert(e1);
|
||||
a.walletIndex().upsert(e2);
|
||||
// An out-of-datadir wallet (in an extra folder) so the Import action shows too.
|
||||
const std::string extra = util::Platform::getConfigDir() + "extra-wallets";
|
||||
const std::string extra = a.sweep_sandbox_ + "/extra-wallets";
|
||||
fs::create_directories(extra, ec);
|
||||
if (!fs::exists(extra + "/my-wallet.dat", ec))
|
||||
std::ofstream(extra + "/my-wallet.dat", std::ios::binary) << std::string(80000, '\0');
|
||||
a.walletIndex().addExtraFolder(extra);
|
||||
if (a.settings()) a.settings()->setActiveWalletFile("wallet.dat");
|
||||
ui::WalletsDialog::setSweepDataDir(dd);
|
||||
ui::WalletsDialog::show(&a);
|
||||
},
|
||||
[](App&) { ui::WalletsDialog::hide(); });
|
||||
|
||||
// Many wallets — exercises the viewport-cap path: the card can't fit all rows, so the list
|
||||
// must scroll internally while the create/scan/footer controls stay pinned (esp. at 150%).
|
||||
// Teardown removes the extra files it dropped so the plain modal-wallets surface stays a
|
||||
// clean 3-wallet reference (both surfaces share the one throwaway datadir).
|
||||
static const char* kManyExtra[] = {"wallet-cold.dat", "wallet-trading.dat", "wallet-mining.dat",
|
||||
"wallet-donations.dat", "wallet-2023.dat", "wallet-payroll.dat"};
|
||||
// Its own sandbox datadir keeps the plain modal-wallets surface a clean 3-wallet reference;
|
||||
// nothing is ever deleted here (the whole sandbox goes at sweep end).
|
||||
add("modal-wallets-many", ui::NavPage::Settings,
|
||||
[](App& a) {
|
||||
if (a.sweep_sandbox_.empty()) return;
|
||||
std::error_code ec;
|
||||
const std::string dd = util::Platform::getDragonXDataDir();
|
||||
const std::string dd = a.sweep_sandbox_ + "/DRAGONX-many";
|
||||
fs::create_directories(dd, ec);
|
||||
const char* names[] = {"wallet.dat", "wallet-savings.dat", "wallet-cold.dat",
|
||||
"wallet-trading.dat", "wallet-mining.dat", "wallet-donations.dat",
|
||||
@@ -550,14 +693,10 @@ void App::buildSweepCatalog()
|
||||
a.walletIndex().upsert(e);
|
||||
}
|
||||
if (a.settings()) a.settings()->setActiveWalletFile("wallet.dat");
|
||||
ui::WalletsDialog::setSweepDataDir(dd);
|
||||
ui::WalletsDialog::show(&a);
|
||||
},
|
||||
[](App& a) {
|
||||
ui::WalletsDialog::hide();
|
||||
std::error_code ec;
|
||||
const std::string dd = util::Platform::getDragonXDataDir();
|
||||
for (const char* n : kManyExtra) fs::remove(dd + "/" + n, ec);
|
||||
});
|
||||
[](App&) { ui::WalletsDialog::hide(); });
|
||||
}
|
||||
|
||||
// First-run wizard — one per meaningful phase (full-node only; blocks all other UI while shown).
|
||||
@@ -588,6 +727,64 @@ void App::buildSweepCatalog()
|
||||
[](App& a) { a.state_.connected = false; a.state_.encryption_state_known = false; },
|
||||
[](App& a) { a.applyHealthyDemoState(); });
|
||||
|
||||
// Restore the healthy synced demo sync-state (applyHealthyDemoState leaves state_.sync alone).
|
||||
auto resetDemoSync = [](App& a) {
|
||||
a.applyHealthyDemoState();
|
||||
a.state_.sync.syncing = false; a.state_.sync.building_witnesses = false;
|
||||
a.state_.sync.rescanning = false; a.state_.sync.verification_progress = 1.0;
|
||||
a.state_.sync.tip_known = true;
|
||||
a.state_.sync.blocks = a.state_.sync.headers = 3124322;
|
||||
};
|
||||
// Balance tab while the node is still catching up (connected, tip known, verifying) → the shared
|
||||
// "catching up — your funds are safe" strip above whichever layout renders (P4).
|
||||
add("overview-syncing", ui::NavPage::Overview,
|
||||
[](App& a) {
|
||||
a.applyHealthyDemoState();
|
||||
a.state_.sync.tip_known = true;
|
||||
a.state_.sync.syncing = true;
|
||||
a.state_.sync.building_witnesses = false;
|
||||
a.state_.sync.rescanning = false;
|
||||
a.state_.sync.verification_progress = 0.62;
|
||||
a.state_.sync.blocks = 1934000; a.state_.sync.headers = 3124322;
|
||||
},
|
||||
resetDemoSync);
|
||||
// Full node connected to the daemon but with zero network peers → the node-status banner's honest
|
||||
// detail (P2). Full-node only: the Lite build has no node, so there this renders as a plain Overview.
|
||||
add("overview-no-peers", ui::NavPage::Overview,
|
||||
[](App& a) {
|
||||
a.applyHealthyDemoState();
|
||||
a.state_.sync.tip_known = false; // no peer-derived tip → NoPeers banner
|
||||
a.state_.sync.syncing = false;
|
||||
a.state_.sync.blocks = 3124322; a.state_.sync.headers = 3124322;
|
||||
a.connection_status_ = "Connected";
|
||||
a.connection_in_progress_ = false; // else the evaluator treats it as "connecting", not no-peers
|
||||
a.sweep_show_node_banner_ = true; // allow the banner (suppressed on every other target)
|
||||
},
|
||||
[resetDemoSync](App& a) { a.sweep_show_node_banner_ = false; resetDemoSync(a); });
|
||||
|
||||
// Chat with no derived identity yet — the states that previously ALL said "unlock your wallet" (P3).
|
||||
// Setup clears the fabricated demo identity (clearIdentity wipes only the key; the demo messages stay
|
||||
// in the store); teardown re-fabricates an identity so the populated chat UI returns for later targets.
|
||||
add("chat-identity-preparing", ui::NavPage::Chat,
|
||||
[](App& a) {
|
||||
a.chat_service_.clearIdentity();
|
||||
a.chat_identity_unavailable_ = false;
|
||||
a.chat_identity_fetch_in_flight_ = false;
|
||||
},
|
||||
[](App& a) { a.chat_identity_unavailable_ = false; a.sweepEnsureDemoChatIdentity(); });
|
||||
add("chat-identity-unavailable", ui::NavPage::Chat,
|
||||
[](App& a) {
|
||||
a.chat_service_.clearIdentity();
|
||||
a.chat_identity_unavailable_ = true; // view-only / legacy wallet with no exportable secret
|
||||
a.chat_identity_fetch_in_flight_ = false;
|
||||
},
|
||||
[](App& a) { a.chat_identity_unavailable_ = false; a.sweepEnsureDemoChatIdentity(); });
|
||||
|
||||
// A mining (transparent) address as the recipient: shown as unpayable, Send disabled.
|
||||
add("send-mining-recipient", ui::NavPage::Send,
|
||||
[](App& a) { ui::SweepSetSendRecipient(&a, "R9NXAVJezHiBnT3ijTpg3JUZre7PxhJWti"); },
|
||||
[](App& a) { ui::SweepSetSendRecipient(&a, nullptr); });
|
||||
|
||||
// Send-confirm popup (state lives in send_tab statics → driven via a debug hook).
|
||||
add("popup-send-confirm", ui::NavPage::Send,
|
||||
[](App& a) { ui::SweepShowSendConfirm(&a, true); },
|
||||
@@ -683,13 +880,15 @@ void App::buildSweepCatalog()
|
||||
// list shows sub-folders + wallet files, then open the wallets dialog + the picker over it.
|
||||
add("modal-folder-picker", ui::NavPage::Settings,
|
||||
[](App& a) {
|
||||
if (a.sweep_sandbox_.empty()) return;
|
||||
std::error_code ec;
|
||||
const std::string demo = util::Platform::getConfigDir() + "picker-demo";
|
||||
const std::string demo = a.sweep_sandbox_ + "/picker-demo";
|
||||
for (const char* sub : {"Documents", "Downloads", "Backups", "wallet-archive"})
|
||||
fs::create_directories(demo + "/" + sub, ec);
|
||||
for (const char* f : {"wallet-cold.dat", "wallet-2023.dat"})
|
||||
if (!fs::exists(demo + "/" + f, ec))
|
||||
std::ofstream(demo + "/" + f, std::ios::binary) << std::string(66000, '\0');
|
||||
ui::WalletsDialog::setSweepDataDir(a.sweep_sandbox_ + "/DRAGONX");
|
||||
ui::WalletsDialog::show(&a);
|
||||
ui::FolderPicker::open(demo, [](const std::string&) {});
|
||||
},
|
||||
@@ -699,7 +898,24 @@ void App::buildSweepCatalog()
|
||||
// ── State machine ───────────────────────────────────────────────────────────────────────────
|
||||
void App::startSweepImpl(bool full)
|
||||
{
|
||||
#if !DRAGONX_ENABLE_UI_SWEEP
|
||||
// Dev tool only (CMake DRAGONX_ENABLE_UI_SWEEP, OFF in every release); an undefined macro counts as 0.
|
||||
(void)full;
|
||||
DEBUG_LOGF("[Sweep] disabled in this build\n");
|
||||
return;
|
||||
#else
|
||||
if (screenshot_sweep_active_) return;
|
||||
// Sentinel: a release binary must not contain this literal (strings <bin> | grep -c ... == 0).
|
||||
DEBUG_LOGF("[Sweep] DRAGONX_UI_SWEEP_COMPILED_IN\n");
|
||||
|
||||
// The full sweep fakes wallet, migration, switch and security state: never over a real workflow.
|
||||
if (full) {
|
||||
if (const char* why = fullSweepBlockedReason()) {
|
||||
DEBUG_LOGF("[Sweep] full sweep refused: %s\n", why);
|
||||
ui::Notifications::instance().warning(TR("screenshot_sweep_refused"));
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
sweep_skins_.clear();
|
||||
for (const auto& sk : ui::schema::SkinManager::instance().available())
|
||||
@@ -717,6 +933,21 @@ void App::startSweepImpl(bool full)
|
||||
std::string sweepOnlyStr = sweepOnly ? sweepOnly : "";
|
||||
if (!sweepOnlyStr.empty()) sweep_skins_.assign(1, std::string("dark"));
|
||||
|
||||
if (full) {
|
||||
// A fresh private sandbox for the demo wallet files: never the real datadir or config dir.
|
||||
std::error_code tec;
|
||||
const fs::path tmp = fs::temp_directory_path(tec);
|
||||
sweep_sandbox_ = tec ? std::string() : util::makePrivateTempDir(tmp, kSweepSandboxPrefix);
|
||||
if (sweep_sandbox_.empty()) {
|
||||
DEBUG_LOGF("[Sweep] full sweep refused: could not create a private sandbox under %s\n",
|
||||
tmp.string().c_str());
|
||||
ui::Notifications::instance().warning(TR("screenshot_sweep_refused"));
|
||||
return;
|
||||
}
|
||||
ui::WalletsDialog::setSweepDataDir(sweep_sandbox_ + "/DRAGONX");
|
||||
DEBUG_LOGF("[Sweep] sandbox %s\n", sweep_sandbox_.c_str());
|
||||
}
|
||||
|
||||
sweep_full_ = full;
|
||||
if (full) { capture_mode_ = true; installDemoWalletData(); }
|
||||
buildSweepCatalog();
|
||||
@@ -742,6 +973,7 @@ void App::startSweepImpl(bool full)
|
||||
ui::Notifications::instance().info(full ? "Full UI sweep running…" : "Screenshot sweep running…");
|
||||
DEBUG_LOGF("[Sweep] %s -> %s (%d skins x %d surfaces)\n", full ? "FULL" : "tabs",
|
||||
sweep_dir_.c_str(), (int)sweep_skins_.size(), (int)sweep_targets_.size());
|
||||
#endif
|
||||
}
|
||||
|
||||
void App::startScreenshotSweep() { startSweepImpl(false); }
|
||||
|
||||
+84
-42
@@ -73,10 +73,11 @@ WizardUiState s_wizardUi;
|
||||
void App::restartWizard()
|
||||
{
|
||||
if (!supportsFullNodeLifecycleActions()) {
|
||||
ui::Notifications::instance().warning("Lite wallet lifecycle requests are available from Settings as dry-run readiness checks");
|
||||
ui::Notifications::instance().warning(TR("toast_wizard_lite_unavailable"));
|
||||
return;
|
||||
}
|
||||
|
||||
if (refuseIfNodeBusy()) return; // the wizard stops the node
|
||||
DEBUG_LOGF("[App] Restarting setup wizard — stopping daemon...\n");
|
||||
|
||||
// Reset crash counter for fresh wizard attempt
|
||||
@@ -86,6 +87,7 @@ void App::restartWizard()
|
||||
|
||||
// Disconnect RPC
|
||||
if (rpc_ && rpc_->isConnected()) {
|
||||
abortMaintenanceCalls(); // a long call holds the curl lock disconnect() needs
|
||||
rpc_->disconnect();
|
||||
}
|
||||
onDisconnected("Wizard restart");
|
||||
@@ -356,13 +358,9 @@ void App::renderFirstRunWizard() {
|
||||
if (lang.first == wiz_cur_lang) { wiz_language_index = idx; break; }
|
||||
idx++;
|
||||
}
|
||||
// Apply loaded settings to runtime so visuals match slider values
|
||||
ui::effects::setLowSpecMode(wiz_low_spec);
|
||||
ui::effects::ImGuiAcrylic::ApplyBlurAmount(wiz_blur_amount);
|
||||
ui::effects::ImGuiAcrylic::SetUIOpacity(wiz_ui_opacity);
|
||||
ui::effects::ThemeEffects::instance().setEnabled(wiz_theme_effects);
|
||||
ui::effects::ThemeEffects::instance().setReducedTransparency(!wiz_theme_effects);
|
||||
ui::ConsoleTab::s_scanline_enabled = wiz_scanline;
|
||||
// Apply the loaded settings to the runtime (same low-spec-aware path as startup) so the
|
||||
// visuals match the slider values
|
||||
applyVisualSettings();
|
||||
wiz_appearance_init = true;
|
||||
}
|
||||
|
||||
@@ -537,6 +535,9 @@ void App::renderFirstRunWizard() {
|
||||
settings_->setScanlineEnabled(wiz_scanline);
|
||||
settings_->setLowSpecMode(wiz_low_spec);
|
||||
settings_->save();
|
||||
// Make the runtime match what was just saved, both ways: unticking a low-spec that was
|
||||
// loaded rather than ticked here has no snapshot, so the branch above pushes nothing.
|
||||
applyVisualSettings();
|
||||
}
|
||||
ImGui::PopStyleVar();
|
||||
ImGui::SameLine();
|
||||
@@ -799,21 +800,22 @@ void App::renderFirstRunWizard() {
|
||||
// Daemon status indicator
|
||||
{
|
||||
bool daemonUp = isEmbeddedDaemonRunning();
|
||||
const std::string& dStatus = getDaemonStatus();
|
||||
ImU32 dotCol = daemonUp ? IM_COL32(76, 175, 80, 200) // green
|
||||
: IM_COL32(120, 120, 120, 160); // gray
|
||||
if (dStatus.find("Stopping") != std::string::npos)
|
||||
dotCol = IM_COL32(255, 167, 38, 200); // orange
|
||||
// Key the dot + label off the daemon state ENUM, not an English substring of the status
|
||||
// text — the substring match silently failed under every non-English language.
|
||||
const bool daemonStopping = daemon_controller_ &&
|
||||
daemon_controller_->state() == daemon::EmbeddedDaemon::State::Stopping;
|
||||
ImU32 dotCol = daemonStopping ? IM_COL32(255, 167, 38, 230) // orange
|
||||
: daemonUp ? IM_COL32(76, 175, 80, 230) // green
|
||||
: IM_COL32(120, 120, 120, 180); // gray
|
||||
float dotR = 3.5f * dp;
|
||||
dl->AddCircleFilled(ImVec2(cx + dotR, cy + captionFont->LegacySize * 0.5f),
|
||||
dotR, dotCol);
|
||||
const char* label = daemonUp ? (dStatus.find("Stopping") != std::string::npos
|
||||
? TR("bootstrap_daemon_stopping")
|
||||
: TR("bootstrap_daemon_running"))
|
||||
: TR("bootstrap_daemon_stopped");
|
||||
const char* label = daemonStopping ? TR("bootstrap_daemon_stopping")
|
||||
: daemonUp ? TR("bootstrap_daemon_running")
|
||||
: TR("bootstrap_daemon_stopped");
|
||||
dl->AddText(captionFont, captionFont->LegacySize,
|
||||
ImVec2(cx + dotR * 2.0f + 6.0f * dp, cy),
|
||||
(dimCol & 0x00FFFFFF) | IM_COL32(0,0,0,140), label);
|
||||
(dimCol & 0x00FFFFFF) | IM_COL32(0,0,0,220), label);
|
||||
cy += captionFont->LegacySize + 6.0f * dp;
|
||||
}
|
||||
|
||||
@@ -825,9 +827,12 @@ void App::renderFirstRunWizard() {
|
||||
float cancelBX = rightX + (colW - cancelW) * 0.5f;
|
||||
ImGui::SetCursorScreenPos(ImVec2(cancelBX, cy));
|
||||
ImGui::PushStyleVar(ImGuiStyleVar_FrameRounding, 8.0f * dp);
|
||||
// Disabled while the old chain data is being replaced (not cancellable).
|
||||
ImGui::BeginDisabled(!prog.cancellable);
|
||||
if (ui::material::TactileButton(TR("cancel"), ImVec2(cancelW, cancelH))) {
|
||||
bootstrap_->cancel();
|
||||
bootstrap_->requestCancel(); // a no-op once the old chain data is being replaced
|
||||
}
|
||||
ImGui::EndDisabled();
|
||||
ImGui::PopStyleVar();
|
||||
cy += cancelH;
|
||||
|
||||
@@ -858,14 +863,19 @@ void App::renderFirstRunWizard() {
|
||||
ui::material::Error(), TR("wiz_download_failed"));
|
||||
cy += bodyFont->LegacySize + 8.0f * dp;
|
||||
|
||||
// Wrap the (often long) error within the card instead of letting it clip off the edge.
|
||||
dl->AddText(captionFont, captionFont->LegacySize, ImVec2(cx, cy), textCol,
|
||||
errMsg.c_str());
|
||||
cy += captionFont->LegacySize + 16.0f * dp;
|
||||
errMsg.c_str(), nullptr, contentW);
|
||||
ImVec2 es = captionFont->CalcTextSizeA(captionFont->LegacySize, FLT_MAX, contentW, errMsg.c_str());
|
||||
cy += es.y + 16.0f * dp;
|
||||
|
||||
// Retry / Skip
|
||||
// Retry / (Try mirror) / Skip. Offer the mirror unless the last attempt already used it.
|
||||
const std::string mirrorUrl = std::string(util::Bootstrap::kMirrorUrl) + "/" + util::Bootstrap::kZipName;
|
||||
const bool showMirror = (wizard_bootstrap_url_ != mirrorUrl);
|
||||
float btnW2 = 120.0f * dp;
|
||||
float btnH2 = 40.0f * dp;
|
||||
float totalBW = btnW2 * 2 + 12.0f * dp;
|
||||
const int nBtn = showMirror ? 3 : 2;
|
||||
float totalBW = btnW2 * nBtn + 12.0f * dp * (nBtn - 1);
|
||||
float bx = rightX + (colW - totalBW) * 0.5f;
|
||||
|
||||
ImGui::SetCursorScreenPos(ImVec2(bx, cy));
|
||||
@@ -874,19 +884,38 @@ void App::renderFirstRunWizard() {
|
||||
ImGui::PushStyleColor(ImGuiCol_Text, ImGui::ColorConvertU32ToFloat4(ui::material::OnPrimary()));
|
||||
ImGui::PushStyleVar(ImGuiStyleVar_FrameRounding, 8.0f * dp);
|
||||
ImGui::BeginDisabled(!supportsFullNodeLifecycleActions());
|
||||
if (ui::material::TactileButton(TR("retry"), ImVec2(btnW2, btnH2))) {
|
||||
if (ui::material::TactileButton(TR("retry"), ImVec2(btnW2, btnH2)) && !refuseIfNodeBusy()) {
|
||||
// Stop embedded daemon before bootstrap to avoid chain data corruption
|
||||
stopDaemonForBootstrap();
|
||||
bootstrap_ = std::make_unique<util::Bootstrap>();
|
||||
std::string dataDir = util::Platform::getDragonXDataDir();
|
||||
bootstrap_->start(dataDir);
|
||||
if (wizard_bootstrap_url_.empty()) bootstrap_->start(dataDir);
|
||||
else bootstrap_->start(dataDir, wizard_bootstrap_url_); // keep the user's chosen source
|
||||
wizard_phase_ = WizardPhase::BootstrapInProgress;
|
||||
}
|
||||
ImGui::EndDisabled();
|
||||
ImGui::PopStyleVar();
|
||||
ImGui::PopStyleColor(3);
|
||||
|
||||
ImGui::SetCursorScreenPos(ImVec2(bx + btnW2 + 12.0f * dp, cy));
|
||||
float nextBx = bx + btnW2 + 12.0f * dp;
|
||||
if (showMirror) {
|
||||
ImGui::SetCursorScreenPos(ImVec2(nextBx, cy));
|
||||
ImGui::PushStyleVar(ImGuiStyleVar_FrameRounding, 8.0f * dp);
|
||||
ImGui::BeginDisabled(!supportsFullNodeLifecycleActions());
|
||||
if (ui::material::TactileButton(TR("bootstrap_try_mirror"), ImVec2(btnW2, btnH2)) && !refuseIfNodeBusy()) {
|
||||
stopDaemonForBootstrap();
|
||||
bootstrap_ = std::make_unique<util::Bootstrap>();
|
||||
bootstrap_->start(util::Platform::getDragonXDataDir(), mirrorUrl);
|
||||
wizard_bootstrap_url_ = mirrorUrl;
|
||||
wizard_phase_ = WizardPhase::BootstrapInProgress;
|
||||
}
|
||||
ImGui::EndDisabled();
|
||||
if (ImGui::IsItemHovered()) ui::material::Tooltip(TR("bootstrap_use_mirror_tooltip"));
|
||||
ImGui::PopStyleVar();
|
||||
nextBx += btnW2 + 12.0f * dp;
|
||||
}
|
||||
|
||||
ImGui::SetCursorScreenPos(ImVec2(nextBx, cy));
|
||||
ImGui::PushStyleVar(ImGuiStyleVar_FrameRounding, 8.0f * dp);
|
||||
if (ui::material::TactileButton(TR("wiz_skip"), ImVec2(btnW2, btnH2))) {
|
||||
wizard_phase_ = WizardPhase::EncryptOffer;
|
||||
@@ -974,9 +1003,12 @@ void App::renderFirstRunWizard() {
|
||||
}
|
||||
}
|
||||
wizard_stop_status_ = TR("wiz_daemon_waiting_stop");
|
||||
for (int i = 0; i < 60 && !token.cancelled(); i++) {
|
||||
// Stopped means the PROCESS is gone, not just its RPC port: dragonxd unbinds RPC
|
||||
// early in its shutdown and holds the datadir lock until it exits.
|
||||
for (int i = 0; i < 120 && !token.cancelled(); i++) {
|
||||
std::this_thread::sleep_for(std::chrono::seconds(1));
|
||||
if (!daemon::EmbeddedDaemon::isRpcPortInUse()) {
|
||||
if (!daemon::EmbeddedDaemon::isRpcPortInUse() &&
|
||||
!daemon::EmbeddedDaemon::isDaemonProcessRunning()) {
|
||||
wizard_stop_status_ = TR("wiz_daemon_stopped_ok");
|
||||
wizard_stopping_external_ = false;
|
||||
return;
|
||||
@@ -1007,6 +1039,15 @@ void App::renderFirstRunWizard() {
|
||||
cy += bsSize.y + 8.0f * dp;
|
||||
}
|
||||
|
||||
// Honest size/time hint (no number is known until the download HEADs the remote file).
|
||||
{
|
||||
const char* hintText = TR("wiz_bootstrap_size_hint");
|
||||
ImVec2 hs = captionFont->CalcTextSizeA(captionFont->LegacySize, FLT_MAX, contentW, hintText);
|
||||
dl->AddText(captionFont, captionFont->LegacySize, ImVec2(cx, cy),
|
||||
(dimCol & 0x00FFFFFF) | IM_COL32(0, 0, 0, 200), hintText, nullptr, contentW);
|
||||
cy += hs.y + 8.0f * dp;
|
||||
}
|
||||
|
||||
// Trust warning
|
||||
{
|
||||
float warnOpacity = S.drawElement("screens.first-run", "trust-warning").opacity;
|
||||
@@ -1024,21 +1065,20 @@ void App::renderFirstRunWizard() {
|
||||
// Daemon status indicator (subtle, before buttons)
|
||||
{
|
||||
bool daemonUp = isEmbeddedDaemonRunning();
|
||||
const std::string& dStatus = getDaemonStatus();
|
||||
ImU32 dotCol = daemonUp ? IM_COL32(76, 175, 80, 200)
|
||||
: IM_COL32(120, 120, 120, 160);
|
||||
if (dStatus.find("Stopping") != std::string::npos)
|
||||
dotCol = IM_COL32(255, 167, 38, 200);
|
||||
const bool daemonStopping = daemon_controller_ &&
|
||||
daemon_controller_->state() == daemon::EmbeddedDaemon::State::Stopping;
|
||||
ImU32 dotCol = daemonStopping ? IM_COL32(255, 167, 38, 230)
|
||||
: daemonUp ? IM_COL32(76, 175, 80, 230)
|
||||
: IM_COL32(120, 120, 120, 180);
|
||||
float dotR = 3.5f * dp;
|
||||
dl->AddCircleFilled(ImVec2(cx + dotR, cy + captionFont->LegacySize * 0.5f),
|
||||
dotR, dotCol);
|
||||
const char* label = daemonUp ? (dStatus.find("Stopping") != std::string::npos
|
||||
? TR("bootstrap_daemon_stopping")
|
||||
: TR("bootstrap_daemon_running"))
|
||||
: TR("bootstrap_daemon_stopped");
|
||||
const char* label = daemonStopping ? TR("bootstrap_daemon_stopping")
|
||||
: daemonUp ? TR("bootstrap_daemon_running")
|
||||
: TR("bootstrap_daemon_stopped");
|
||||
dl->AddText(captionFont, captionFont->LegacySize,
|
||||
ImVec2(cx + dotR * 2.0f + 6.0f * dp, cy),
|
||||
(dimCol & 0x00FFFFFF) | IM_COL32(0,0,0,140), label);
|
||||
(dimCol & 0x00FFFFFF) | IM_COL32(0,0,0,220), label);
|
||||
cy += captionFont->LegacySize + 10.0f * dp;
|
||||
}
|
||||
|
||||
@@ -1058,12 +1098,13 @@ void App::renderFirstRunWizard() {
|
||||
ImGui::PushStyleColor(ImGuiCol_Text, ImGui::ColorConvertU32ToFloat4(ui::material::OnPrimary()));
|
||||
ImGui::PushStyleVar(ImGuiStyleVar_FrameRounding, 8.0f * dp);
|
||||
ImGui::BeginDisabled(!supportsFullNodeLifecycleActions());
|
||||
if (ui::material::TactileButton(TR("download"), ImVec2(dlBtnW, btnH2))) {
|
||||
if (ui::material::TactileButton(TR("download"), ImVec2(dlBtnW, btnH2)) && !refuseIfNodeBusy()) {
|
||||
// Stop embedded daemon before bootstrap to avoid chain data corruption
|
||||
stopDaemonForBootstrap();
|
||||
bootstrap_ = std::make_unique<util::Bootstrap>();
|
||||
std::string dataDir = util::Platform::getDragonXDataDir();
|
||||
bootstrap_->start(dataDir);
|
||||
wizard_bootstrap_url_.clear(); // default source; Retry won't force the mirror
|
||||
wizard_phase_ = WizardPhase::BootstrapInProgress;
|
||||
}
|
||||
ImGui::EndDisabled();
|
||||
@@ -1077,17 +1118,18 @@ void App::renderFirstRunWizard() {
|
||||
ImGui::PushStyleColor(ImGuiCol_Text, ImGui::ColorConvertU32ToFloat4(ui::material::OnSurface()));
|
||||
ImGui::PushStyleVar(ImGuiStyleVar_FrameRounding, 8.0f * dp);
|
||||
ImGui::BeginDisabled(!supportsFullNodeLifecycleActions());
|
||||
if (ui::material::TactileButton(TR("bootstrap_mirror"), ImVec2(mirrorW, btnH2))) {
|
||||
if (ui::material::TactileButton(TR("bootstrap_use_mirror"), ImVec2(mirrorW, btnH2)) && !refuseIfNodeBusy()) {
|
||||
stopDaemonForBootstrap();
|
||||
bootstrap_ = std::make_unique<util::Bootstrap>();
|
||||
std::string dataDir = util::Platform::getDragonXDataDir();
|
||||
std::string mirrorUrl = std::string(util::Bootstrap::kMirrorUrl) + "/" + util::Bootstrap::kZipName;
|
||||
bootstrap_->start(dataDir, mirrorUrl);
|
||||
wizard_bootstrap_url_ = mirrorUrl; // Retry on the failed card keeps this source
|
||||
wizard_phase_ = WizardPhase::BootstrapInProgress;
|
||||
}
|
||||
ImGui::EndDisabled();
|
||||
if (ImGui::IsItemHovered()) {
|
||||
ui::material::Tooltip(TR("bootstrap_mirror_tooltip"));
|
||||
ui::material::Tooltip(TR("bootstrap_use_mirror_tooltip"));
|
||||
}
|
||||
ImGui::PopStyleVar();
|
||||
ImGui::PopStyleColor(3);
|
||||
@@ -1394,7 +1436,7 @@ void App::renderFirstRunWizard() {
|
||||
(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.
|
||||
// connect in App::reconcileEncryptionPending (W2-2). Saved with the wizard state below.
|
||||
settings_->setEncryptionPending(true);
|
||||
|
||||
// Clear sensitive buffers
|
||||
|
||||
@@ -132,6 +132,55 @@ bool ChatDatabase::append(const ChatMessage& message)
|
||||
return sqlite3_changes(db_) > 0;
|
||||
}
|
||||
|
||||
int ChatDatabase::appendBatch(const std::vector<ChatMessage>& messages)
|
||||
{
|
||||
if (!key_ready_ || !ensureOpen()) return 0;
|
||||
if (messages.empty()) return 0;
|
||||
|
||||
struct Row { std::string dedup; std::vector<unsigned char> nonce, cipher; };
|
||||
std::vector<Row> rows;
|
||||
rows.reserve(messages.size());
|
||||
for (const auto& m : messages) {
|
||||
if (isTombstoned(m)) continue; // locally deleted — don't re-persist
|
||||
try {
|
||||
std::string plain = serialize(m); // strict json.dump() may throw on non-UTF-8 body
|
||||
std::vector<unsigned char> nonce, cipher;
|
||||
const bool ok = encrypt(plain, nonce, cipher);
|
||||
if (!plain.empty()) sodium_memzero(&plain[0], plain.size());
|
||||
if (!ok) continue;
|
||||
rows.push_back({ dedupHash(m.txid, m.payload_position), std::move(nonce), std::move(cipher) });
|
||||
} catch (const std::exception&) {
|
||||
continue; // skip only this message; never open a txn with bad data
|
||||
}
|
||||
}
|
||||
if (rows.empty()) return 0;
|
||||
|
||||
if (!exec("BEGIN")) return 0; // match deleteMessages() in-file style
|
||||
sqlite3_stmt* stmt = nullptr;
|
||||
if (sqlite3_prepare_v2(db_,
|
||||
"INSERT OR IGNORE INTO chat_messages (wallet_tag, dedup_hash, nonce, payload) "
|
||||
"VALUES (?, ?, ?, ?)",
|
||||
-1, &stmt, nullptr) != SQLITE_OK) {
|
||||
exec("ROLLBACK");
|
||||
return 0;
|
||||
}
|
||||
int inserted = 0;
|
||||
bool ok = true;
|
||||
for (const auto& r : rows) {
|
||||
sqlite3_reset(stmt);
|
||||
sqlite3_clear_bindings(stmt);
|
||||
sqlite3_bind_text(stmt, 1, wallet_tag_.c_str(), -1, SQLITE_TRANSIENT);
|
||||
sqlite3_bind_text(stmt, 2, r.dedup.c_str(), -1, SQLITE_TRANSIENT);
|
||||
sqlite3_bind_blob(stmt, 3, r.nonce.data(), static_cast<int>(r.nonce.size()), SQLITE_TRANSIENT);
|
||||
sqlite3_bind_blob(stmt, 4, r.cipher.data(), static_cast<int>(r.cipher.size()), SQLITE_TRANSIENT);
|
||||
if (sqlite3_step(stmt) != SQLITE_DONE) { ok = false; break; }
|
||||
if (sqlite3_changes(db_) > 0) ++inserted;
|
||||
}
|
||||
sqlite3_finalize(stmt);
|
||||
if (!exec(ok ? "COMMIT" : "ROLLBACK")) return 0;
|
||||
return ok ? inserted : 0;
|
||||
}
|
||||
|
||||
bool ChatDatabase::upsert(const ChatMessage& message)
|
||||
{
|
||||
if (!key_ready_ || !ensureOpen()) return false;
|
||||
|
||||
@@ -44,6 +44,12 @@ public:
|
||||
// Returns true if newly inserted; false on duplicate or while locked.
|
||||
bool append(const ChatMessage& message);
|
||||
|
||||
// Persist many messages in ONE transaction (INSERT OR IGNORE, same dedup key as append()).
|
||||
// Serializes+encrypts every message BEFORE opening the transaction, so a non-UTF-8 body is
|
||||
// skipped without leaving a transaction open. Preserves input order (load() relies on rowid).
|
||||
// Returns the number of rows actually inserted. No-op (returns 0) while locked or on empty input.
|
||||
int appendBatch(const std::vector<ChatMessage>& messages);
|
||||
|
||||
// Persist-or-overwrite one message by its (txid+position) dedup key. Unlike append(), this
|
||||
// updates an existing row's payload — used for outgoing echoes whose delivery status changes
|
||||
// (Sending → Sent/Failed). Returns true on success; false while locked / on error.
|
||||
|
||||
@@ -26,7 +26,9 @@ struct ChatMessage {
|
||||
std::string body; // decrypted plaintext (Message) or request text (ContactRequest)
|
||||
std::int64_t timestamp = 0; // tx time in seconds; set by the ingesting caller
|
||||
std::size_t payload_position = 0; // together with txid, the dedup key
|
||||
ChatDelivery delivery = ChatDelivery::Sent; // outgoing only
|
||||
ChatDelivery delivery = ChatDelivery::Sent; // outgoing only. Sent = broadcast to the network (not
|
||||
// necessarily confirmed/delivered yet).
|
||||
std::string fail_reason; // outgoing Failed only; in-memory, NOT persisted
|
||||
};
|
||||
|
||||
} // namespace dragonx::chat
|
||||
|
||||
@@ -16,11 +16,13 @@ ChatService::~ChatService() {
|
||||
void ChatService::setIdentity(const ChatKeyPair& keys) {
|
||||
identity_ = keys;
|
||||
has_identity_ = true;
|
||||
undecryptable_.clear(); // another identity may decrypt them
|
||||
}
|
||||
|
||||
void ChatService::clearIdentity() {
|
||||
wipeChatKeyPair(identity_);
|
||||
has_identity_ = false;
|
||||
undecryptable_.clear();
|
||||
}
|
||||
|
||||
int ChatService::ingest(const std::vector<HushChatTransactionMetadata>& metadata,
|
||||
@@ -36,7 +38,11 @@ int ChatService::ingest(const std::vector<HushChatTransactionMetadata>& metadata
|
||||
const std::string myPubKey = chatIdentityPublicKeyHex(identity_);
|
||||
|
||||
int added = 0;
|
||||
std::vector<ChatMessage> fresh; // persisted once after the loop (one transaction)
|
||||
for (const auto& meta : metadata) {
|
||||
// Already stored: append would refuse it. Every scan re-sends the whole history, so this skips the
|
||||
// decrypt for all but the new messages.
|
||||
if (store_.contains(meta.txid, meta.payload_position)) continue;
|
||||
// A memo whose sender is our OWN identity is something we sent (only we hold our key). The local
|
||||
// echo already records it as outgoing — ingesting it as incoming would duplicate it as a phantom
|
||||
// "from peer" message. (This also collapses same-seed self-chat, where the "peer" wallet shares
|
||||
@@ -78,11 +84,23 @@ int ChatService::ingest(const std::vector<HushChatTransactionMetadata>& metadata
|
||||
message.body = meta.payload_memo; // plaintext request text
|
||||
} else {
|
||||
message.kind = ChatMessageKind::Message;
|
||||
// Keyed by the decrypt's inputs, not only (txid, position): another scan path that pairs a
|
||||
// different header with this payload gets its own try.
|
||||
const std::string failKey = ChatStore::messageKey(meta.txid, meta.payload_position) + "|" +
|
||||
meta.sender_public_key_hex + "|" + meta.secretstream_header_hex;
|
||||
if (undecryptable_.count(failKey)) continue;
|
||||
++decrypt_attempts_;
|
||||
std::string plaintext;
|
||||
const ChatCryptoStatus status = decryptIncoming(
|
||||
identity_, meta.sender_public_key_hex, meta.secretstream_header_hex,
|
||||
meta.payload_memo, plaintext);
|
||||
if (status != ChatCryptoStatus::Ok) continue; // drop undecryptable silently
|
||||
if (status != ChatCryptoStatus::Ok) { // drop undecryptable silently, and don't try it again
|
||||
if (status != ChatCryptoStatus::SodiumInitFailed) { // not a property of the message
|
||||
if (undecryptable_.size() >= 4096) undecryptable_.clear();
|
||||
undecryptable_.insert(failKey);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
message.body = std::move(plaintext);
|
||||
}
|
||||
|
||||
@@ -90,23 +108,26 @@ int ChatService::ingest(const std::vector<HushChatTransactionMetadata>& metadata
|
||||
// session loadFromDatabase() repopulates the store, so re-scanning the chain re-ingests but
|
||||
// the store dedup prevents a duplicate write.
|
||||
if (store_.append(message)) {
|
||||
if (db_) db_->append(message);
|
||||
fresh.push_back(message);
|
||||
++added;
|
||||
// Every ingested message is incoming — report its cid so the caller can notify without
|
||||
// relying on a seen-watermark delta (which block-time vs wall-clock skew can swallow).
|
||||
if (newIncomingCids) newIncomingCids->push_back(message.conversation_id);
|
||||
}
|
||||
}
|
||||
if (db_ && !fresh.empty()) db_->appendBatch(fresh);
|
||||
return added;
|
||||
}
|
||||
|
||||
void ChatService::loadFromDatabase() {
|
||||
if (!db_) return;
|
||||
for (const auto& message : db_->load()) {
|
||||
auto rows = db_->load();
|
||||
store_.reserve(store_.size() + rows.size());
|
||||
for (auto& message : rows) {
|
||||
// Never surface a blocked conversation, even if a prior "delete & block" failed to remove its
|
||||
// rows (defense-in-depth): the ingest guard already drops live scans, this covers the reload path.
|
||||
if (blocked_pred_ && blocked_pred_(message.conversation_id)) continue;
|
||||
store_.append(message);
|
||||
if (blocked_pred_ && blocked_pred_(message.conversation_id)) continue; // evaluate before the move
|
||||
store_.append(std::move(message));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -168,8 +189,8 @@ bool ChatService::recordOutgoingPending(const ChatMessage& message) {
|
||||
return appended;
|
||||
}
|
||||
|
||||
void ChatService::resolveOutgoing(const std::string& txid, ChatDelivery delivery) {
|
||||
const ChatMessage* updated = store_.updateDelivery(txid, delivery);
|
||||
void ChatService::resolveOutgoing(const std::string& txid, ChatDelivery delivery, const std::string& reason) {
|
||||
const ChatMessage* updated = store_.updateDelivery(txid, delivery, reason);
|
||||
if (updated && db_) db_->upsert(*updated); // overwrite the Sending row with the final status
|
||||
}
|
||||
|
||||
|
||||
@@ -14,6 +14,7 @@
|
||||
#include <functional>
|
||||
#include <string>
|
||||
#include <unordered_map>
|
||||
#include <unordered_set>
|
||||
#include <vector>
|
||||
|
||||
namespace dragonx::chat {
|
||||
@@ -94,14 +95,20 @@ public:
|
||||
// quit), then resolveOutgoing() upserts the final status once the broadcast completes. A stray
|
||||
// persisted Sending (crash mid-broadcast) loads back as Sent.
|
||||
bool recordOutgoingPending(const ChatMessage& message);
|
||||
void resolveOutgoing(const std::string& txid, ChatDelivery delivery);
|
||||
void resolveOutgoing(const std::string& txid, ChatDelivery delivery, const std::string& reason = {});
|
||||
|
||||
const ChatStore& store() const { return store_; }
|
||||
ChatStore& store() { return store_; }
|
||||
|
||||
std::uint64_t decryptAttempts() const { return decrypt_attempts_; } // tests / diagnostics
|
||||
|
||||
private:
|
||||
ChatKeyPair identity_{};
|
||||
bool has_identity_ = false;
|
||||
// Decrypt failures, keyed by what the decrypt reads (bounded). A rescan re-sends every message ever
|
||||
// received, so without it each poll re-ran the crypto for every undecryptable memo.
|
||||
std::unordered_set<std::string> undecryptable_;
|
||||
std::uint64_t decrypt_attempts_ = 0;
|
||||
ChatStore store_;
|
||||
ChatDatabase* db_ = nullptr; // optional; not owned
|
||||
std::function<bool(const std::string&)> blocked_pred_; // true => cid is blocked (drop its messages)
|
||||
|
||||
+31
-2
@@ -6,8 +6,12 @@
|
||||
|
||||
namespace dragonx::chat {
|
||||
|
||||
std::string ChatStore::messageKey(const std::string& txid, std::size_t payloadPosition) {
|
||||
return txid + ":" + std::to_string(payloadPosition);
|
||||
}
|
||||
|
||||
std::string ChatStore::dedupKey(const ChatMessage& message) {
|
||||
return message.txid + ":" + std::to_string(message.payload_position);
|
||||
return messageKey(message.txid, message.payload_position);
|
||||
}
|
||||
|
||||
bool ChatStore::append(const ChatMessage& message) {
|
||||
@@ -17,6 +21,28 @@ bool ChatStore::append(const ChatMessage& message) {
|
||||
return true;
|
||||
}
|
||||
|
||||
bool ChatStore::append(ChatMessage&& message) {
|
||||
if (!seen_.insert(dedupKey(message)).second) return false; // reads txid/payload_position before the move
|
||||
messages_.push_back(std::move(message));
|
||||
++revision_;
|
||||
return true;
|
||||
}
|
||||
|
||||
void ChatStore::reserve(std::size_t n) {
|
||||
messages_.reserve(n);
|
||||
seen_.reserve(n);
|
||||
}
|
||||
|
||||
std::unordered_map<std::string, std::int64_t> ChatStore::latestTimestampByConversation() const {
|
||||
std::unordered_map<std::string, std::int64_t> out;
|
||||
for (const auto& message : messages_) {
|
||||
auto it = out.find(message.conversation_id);
|
||||
if (it == out.end()) out.emplace(message.conversation_id, static_cast<std::int64_t>(message.timestamp));
|
||||
else if (static_cast<std::int64_t>(message.timestamp) > it->second) it->second = static_cast<std::int64_t>(message.timestamp);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
std::vector<ChatMessage> ChatStore::conversation(const std::string& conversationId) const {
|
||||
std::vector<ChatMessage> out;
|
||||
for (const auto& message : messages_) {
|
||||
@@ -35,10 +61,12 @@ std::vector<ChatMessage> ChatStore::conversation(const std::string& conversation
|
||||
return out;
|
||||
}
|
||||
|
||||
const ChatMessage* ChatStore::updateDelivery(const std::string& txid, ChatDelivery delivery) {
|
||||
const ChatMessage* ChatStore::updateDelivery(const std::string& txid, ChatDelivery delivery,
|
||||
const std::string& reason) {
|
||||
for (auto& message : messages_) {
|
||||
if (message.txid == txid) {
|
||||
message.delivery = delivery;
|
||||
message.fail_reason = (delivery == ChatDelivery::Failed) ? reason : std::string();
|
||||
++revision_;
|
||||
return &message;
|
||||
}
|
||||
@@ -86,6 +114,7 @@ std::vector<ChatMessage> ChatStore::eraseConversation(const std::string& convers
|
||||
void ChatStore::clear() {
|
||||
messages_.clear();
|
||||
seen_.clear();
|
||||
txid_index_.clear(); // drop the History-badge txid copies too (revision_ below invalidates the memo)
|
||||
++revision_;
|
||||
}
|
||||
|
||||
|
||||
+39
-2
@@ -8,6 +8,7 @@
|
||||
#include <cstdint>
|
||||
#include <functional>
|
||||
#include <string>
|
||||
#include <unordered_map>
|
||||
#include <unordered_set>
|
||||
#include <vector>
|
||||
|
||||
@@ -19,19 +20,34 @@ class ChatStore {
|
||||
public:
|
||||
// Returns true if newly inserted, false if a duplicate was ignored.
|
||||
bool append(const ChatMessage& message);
|
||||
// Same, but moves the message into the store (dedup key is computed before the move).
|
||||
bool append(ChatMessage&& message);
|
||||
// Pre-size the message vector + dedup set for a bulk load of n additional messages.
|
||||
void reserve(std::size_t n);
|
||||
// Max timestamp per conversation in ONE pass over messages_. Semantically equal to
|
||||
// conversation(cid).back().timestamp (conversation() sorts ascending) without the per-cid copy+sort.
|
||||
std::unordered_map<std::string, std::int64_t> latestTimestampByConversation() const;
|
||||
|
||||
// The dedup key of a message: "<txid>:<payload_position>".
|
||||
static std::string messageKey(const std::string& txid, std::size_t payloadPosition);
|
||||
// A message with this (txid, position) is in the store (append would refuse it).
|
||||
bool contains(const std::string& txid, std::size_t payloadPosition) const {
|
||||
return seen_.count(messageKey(txid, payloadPosition)) != 0;
|
||||
}
|
||||
|
||||
// Messages in a conversation, in insertion order.
|
||||
std::vector<ChatMessage> conversation(const std::string& conversationId) const;
|
||||
|
||||
// Update an outgoing echo's delivery status by its local txid. Returns a pointer to the updated
|
||||
// message (for the caller to persist), or nullptr if no message has that txid.
|
||||
const ChatMessage* updateDelivery(const std::string& txid, ChatDelivery delivery);
|
||||
const ChatMessage* updateDelivery(const std::string& txid, ChatDelivery delivery,
|
||||
const std::string& reason = {});
|
||||
|
||||
// Distinct conversation ids, in first-seen order.
|
||||
std::vector<std::string> conversationIds() const;
|
||||
|
||||
// The set of on-chain txids that carried a chat message (sent or received, messages + contact
|
||||
// requests) — used by the History tab to badge / filter chat transactions. O(messages), no copies.
|
||||
// requests). Builds a fresh COPY every call (O(messages) allocations) — prefer hasChatTxid() on hot paths.
|
||||
std::unordered_set<std::string> chatTxids() const {
|
||||
std::unordered_set<std::string> out;
|
||||
out.reserve(messages_.size());
|
||||
@@ -40,6 +56,15 @@ public:
|
||||
return out;
|
||||
}
|
||||
|
||||
// True if an on-chain txid carried a chat message (History tab "Message" badge / Chat filter). Backed by a
|
||||
// txid index rebuilt lazily when revision() moved (once per store mutation, O(messages)); O(1) on an empty
|
||||
// store and per lookup. One txid may back several messages; a from-scratch rebuild handles erase correctly.
|
||||
bool hasChatTxid(const std::string& txid) const {
|
||||
if (messages_.empty() || txid.empty()) return false;
|
||||
if (txid_index_rev_ != revision_) rebuildTxidIndex();
|
||||
return txid_index_.count(txid) != 0;
|
||||
}
|
||||
|
||||
// Remove every message in a conversation from the in-memory view and return the removed messages
|
||||
// (so the caller can delete/tombstone their persisted rows). Re-appends are prevented by the ingest
|
||||
// guard (blocked-cid predicate / DB tombstone), not here.
|
||||
@@ -63,9 +88,21 @@ public:
|
||||
private:
|
||||
static std::string dedupKey(const ChatMessage& message);
|
||||
|
||||
void rebuildTxidIndex() const {
|
||||
txid_index_.clear();
|
||||
txid_index_.reserve(messages_.size());
|
||||
for (const auto& m : messages_)
|
||||
if (!m.txid.empty()) txid_index_.insert(m.txid);
|
||||
txid_index_rev_ = revision_;
|
||||
}
|
||||
|
||||
std::vector<ChatMessage> messages_;
|
||||
std::unordered_set<std::string> seen_;
|
||||
std::uint64_t revision_ = 0;
|
||||
// Lazy memo for hasChatTxid(): valid iff txid_index_rev_ == revision_. revision_ is monotonic and never reset,
|
||||
// so append/updateDelivery/eraseConversation/clear all invalidate it (~0 = never built). Main thread only.
|
||||
mutable std::unordered_set<std::string> txid_index_;
|
||||
mutable std::uint64_t txid_index_rev_ = ~std::uint64_t{0};
|
||||
};
|
||||
|
||||
} // namespace dragonx::chat
|
||||
|
||||
+69
-8
@@ -15,6 +15,7 @@
|
||||
#include <algorithm>
|
||||
#include <type_traits>
|
||||
|
||||
#include "../util/coalescing_writer.h"
|
||||
#include "../util/logger.h"
|
||||
#include "../util/platform.h"
|
||||
|
||||
@@ -176,7 +177,8 @@ bool Settings::load(const std::string& path)
|
||||
for (const auto& c : j["blocked_chat_convs"])
|
||||
if (c.is_object() && c.contains("cid") && c["cid"].is_string())
|
||||
blocked_chat_convs_.push_back({c["cid"].get<std::string>(),
|
||||
c.value("name", std::string())});
|
||||
(c.contains("name") && c["name"].is_string())
|
||||
? c["name"].get<std::string>() : std::string()});
|
||||
}
|
||||
// Chat-tab customization (re-clamped through the setters so hand-edited JSON stays in range).
|
||||
loadScalar(j, "chat_emoji_color", chat_emoji_color_);
|
||||
@@ -193,13 +195,17 @@ bool Settings::load(const std::string& path)
|
||||
loadScalar(j, "blur_multiplier", blur_multiplier_);
|
||||
loadScalar(j, "noise_opacity", noise_opacity_);
|
||||
loadScalar(j, "gradient_background", gradient_background_);
|
||||
// Migrate legacy reduced_transparency bool -> ui_opacity float
|
||||
if (j.contains("ui_opacity")) {
|
||||
ui_opacity_ = j["ui_opacity"].get<float>();
|
||||
} else if (j.contains("reduced_transparency") && j["reduced_transparency"].get<bool>()) {
|
||||
// Migrate legacy reduced_transparency bool -> ui_opacity float (type-tolerant: a hand-edited or
|
||||
// inf->null re-saved value must not quarantine the whole settings file).
|
||||
if (j.contains("ui_opacity") && j["ui_opacity"].is_number()) {
|
||||
ui_opacity_ = std::clamp(j["ui_opacity"].get<float>(), 0.3f, 1.0f); // matches setUIOpacity()
|
||||
} else if (j.contains("reduced_transparency") && j["reduced_transparency"].is_boolean()
|
||||
&& j["reduced_transparency"].get<bool>()) {
|
||||
ui_opacity_ = 1.0f; // legacy: reduced = fully opaque
|
||||
}
|
||||
loadScalar(j, "window_opacity", window_opacity_);
|
||||
loadScalar(j, "favorite_opacity", favorite_opacity_);
|
||||
favorite_opacity_ = std::clamp(favorite_opacity_, 0.15f, 1.0f); // matches setFavoriteOpacity()
|
||||
if (j.contains("balance_layout")) {
|
||||
if (j["balance_layout"].is_string())
|
||||
balance_layout_ = j["balance_layout"].get<std::string>();
|
||||
@@ -236,6 +242,18 @@ bool Settings::load(const std::string& path)
|
||||
for (const auto& a : j["favorite_addresses"])
|
||||
if (a.is_string()) favorite_addresses_.insert(a.get<std::string>());
|
||||
}
|
||||
if (j.contains("earnings_addresses") && j["earnings_addresses"].is_array()) {
|
||||
earnings_addresses_.clear();
|
||||
for (const auto& a : j["earnings_addresses"])
|
||||
if (a.is_string()) earnings_addresses_.insert(a.get<std::string>());
|
||||
}
|
||||
loadScalar(j, "earnings_interval", earnings_interval_);
|
||||
earnings_interval_ = std::clamp(earnings_interval_, 0, 3);
|
||||
loadScalar(j, "earnings_mode", earnings_mode_);
|
||||
earnings_mode_ = std::clamp(earnings_mode_, 0, 1);
|
||||
loadScalar(j, "earnings_format", earnings_format_);
|
||||
earnings_format_ = std::clamp(earnings_format_, 0, 2);
|
||||
loadScalar(j, "earnings_show_all", earnings_show_all_);
|
||||
if (j.contains("address_meta") && j["address_meta"].is_object()) {
|
||||
address_meta_.clear();
|
||||
for (auto& [addr, meta] : j["address_meta"].items()) {
|
||||
@@ -253,6 +271,9 @@ bool Settings::load(const std::string& path)
|
||||
}
|
||||
loadScalar(j, "wizard_completed", wizard_completed_);
|
||||
loadScalar(j, "seed_backup_reminded", seed_backup_reminded_);
|
||||
loadScalar(j, "lock_failed_attempts", lock_failed_attempts_);
|
||||
loadScalar(j, "lockout_until_epoch", lockout_until_epoch_);
|
||||
loadScalar(j, "lock_state_wallet", lock_state_wallet_);
|
||||
loadScalar(j, "large_wallet_warned", large_wallet_warned_);
|
||||
if (j.contains("empty_wallet_warning_acked") && j["empty_wallet_warning_acked"].is_array()) {
|
||||
empty_wallet_warning_acked_.clear();
|
||||
@@ -267,6 +288,7 @@ bool Settings::load(const std::string& path)
|
||||
loadScalar(j, "seed_migration_temp_dir", seed_migration_temp_dir_);
|
||||
loadScalar(j, "seed_migration_sweep_txid", seed_migration_sweep_txid_);
|
||||
loadScalar(j, "seed_migration_sweep_opid", seed_migration_sweep_opid_);
|
||||
loadScalar(j, "seed_migration_sweep_submitted", seed_migration_sweep_submitted_);
|
||||
loadScalar(j, "auto_lock_timeout", auto_lock_timeout_);
|
||||
loadScalar(j, "unlock_duration", unlock_duration_);
|
||||
loadScalar(j, "pin_enabled", pin_enabled_);
|
||||
@@ -419,7 +441,7 @@ bool Settings::load(const std::string& path)
|
||||
entry.showSparkline = e["show_sparkline"].get<bool>();
|
||||
if (e.contains("sparkline_interval") && e["sparkline_interval"].is_number_integer())
|
||||
entry.sparklineInterval = e["sparkline_interval"].get<int>();
|
||||
entry.scope = e.value("scope", "");
|
||||
if (e.contains("scope") && e["scope"].is_string()) entry.scope = e["scope"].get<std::string>();
|
||||
if (e.contains("grid_col") && e["grid_col"].is_number_integer())
|
||||
entry.gridCol = e["grid_col"].get<int>();
|
||||
if (e.contains("grid_row") && e["grid_row"].is_number_integer())
|
||||
@@ -462,6 +484,26 @@ bool Settings::load(const std::string& path)
|
||||
}
|
||||
}
|
||||
|
||||
int Settings::reattachLegacyPortfolioScopes(const std::string& scopeId, const std::string& identityHash,
|
||||
const std::set<std::string>& walletAddresses, bool singleWallet)
|
||||
{
|
||||
if (scopeId.empty()) return 0;
|
||||
int moved = 0;
|
||||
for (auto& e : portfolio_entries_) {
|
||||
if (!e.isLegacyScope()) continue; // global stays global; "w:" ids are already stable
|
||||
bool mine = singleWallet || (!identityHash.empty() && e.scope == identityHash);
|
||||
if (!mine) {
|
||||
for (const auto& a : e.addresses) {
|
||||
if (walletAddresses.count(a)) { mine = true; break; }
|
||||
}
|
||||
}
|
||||
if (!mine) continue; // ambiguous: leave legacy (fail-open visible)
|
||||
e.scope = scopeId;
|
||||
++moved;
|
||||
}
|
||||
return moved;
|
||||
}
|
||||
|
||||
bool Settings::save()
|
||||
{
|
||||
if (settings_path_.empty()) {
|
||||
@@ -472,6 +514,8 @@ bool Settings::save()
|
||||
|
||||
bool Settings::save(const std::string& path)
|
||||
{
|
||||
// UI sweep: nothing is written until sweepEnd(), which restores the real values and saves once.
|
||||
if (sweep_active_) { sweep_suppressed_save_ = true; return true; }
|
||||
json j;
|
||||
|
||||
j["theme"] = theme_;
|
||||
@@ -515,6 +559,7 @@ bool Settings::save(const std::string& path)
|
||||
j["gradient_background"] = gradient_background_;
|
||||
j["ui_opacity"] = ui_opacity_;
|
||||
j["window_opacity"] = window_opacity_;
|
||||
j["favorite_opacity"] = favorite_opacity_;
|
||||
j["balance_layout"] = balance_layout_; // saved as string ID
|
||||
j["portfolio_style"] = portfolio_style_;
|
||||
j["contacts_view_mode"] = contacts_view_mode_;
|
||||
@@ -532,6 +577,13 @@ bool Settings::save(const std::string& path)
|
||||
j["favorite_addresses"] = json::array();
|
||||
for (const auto& addr : favorite_addresses_)
|
||||
j["favorite_addresses"].push_back(addr);
|
||||
j["earnings_addresses"] = json::array();
|
||||
for (const auto& addr : earnings_addresses_)
|
||||
j["earnings_addresses"].push_back(addr);
|
||||
j["earnings_interval"] = earnings_interval_;
|
||||
j["earnings_mode"] = earnings_mode_;
|
||||
j["earnings_format"] = earnings_format_;
|
||||
j["earnings_show_all"] = earnings_show_all_;
|
||||
{
|
||||
json meta_obj = json::object();
|
||||
for (const auto& [addr, m] : address_meta_) {
|
||||
@@ -547,6 +599,9 @@ bool Settings::save(const std::string& path)
|
||||
}
|
||||
j["wizard_completed"] = wizard_completed_;
|
||||
j["seed_backup_reminded"] = seed_backup_reminded_;
|
||||
j["lock_failed_attempts"] = lock_failed_attempts_;
|
||||
j["lockout_until_epoch"] = lockout_until_epoch_;
|
||||
j["lock_state_wallet"] = lock_state_wallet_;
|
||||
j["large_wallet_warned"] = large_wallet_warned_;
|
||||
j["empty_wallet_warning_acked"] = json::array();
|
||||
for (const auto& w : empty_wallet_warning_acked_)
|
||||
@@ -559,6 +614,7 @@ bool Settings::save(const std::string& path)
|
||||
j["seed_migration_temp_dir"] = seed_migration_temp_dir_;
|
||||
j["seed_migration_sweep_txid"] = seed_migration_sweep_txid_;
|
||||
j["seed_migration_sweep_opid"] = seed_migration_sweep_opid_;
|
||||
j["seed_migration_sweep_submitted"] = seed_migration_sweep_submitted_;
|
||||
j["auto_lock_timeout"] = auto_lock_timeout_;
|
||||
j["unlock_duration"] = unlock_duration_;
|
||||
j["pin_enabled"] = pin_enabled_;
|
||||
@@ -655,8 +711,13 @@ bool Settings::save(const std::string& path)
|
||||
// Atomic + durable: write to a temp file, fsync, then rename over the real file.
|
||||
// A crash mid-write can no longer truncate settings.json (which would silently
|
||||
// reset every preference on the next launch). Owner-only (0600) — it carries the
|
||||
// lite-server list and address metadata.
|
||||
return util::Platform::writeFileAtomically(path, j.dump(4), /*restrictPermissions=*/true);
|
||||
// lite-server list and address metadata. With a writer attached the same atomic write
|
||||
// runs on its background thread (queued, latest-wins); after a critical setter
|
||||
// (durable_next_save_) this waits until the file is on disk, as the old save did.
|
||||
const bool ok = util::persistFileAtomically(writer_, durable_next_save_, path, j.dump(4),
|
||||
/*restrictPermissions=*/true);
|
||||
if (ok) durable_next_save_ = false;
|
||||
return ok;
|
||||
} catch (const std::exception& e) {
|
||||
DEBUG_LOGF("Failed to save settings: %s\n", e.what());
|
||||
return false;
|
||||
|
||||
+132
-13
@@ -12,6 +12,7 @@
|
||||
#include <vector>
|
||||
|
||||
namespace dragonx {
|
||||
namespace util { class CoalescingWriter; }
|
||||
namespace config {
|
||||
|
||||
/**
|
||||
@@ -39,10 +40,16 @@ public:
|
||||
|
||||
/**
|
||||
* @brief Save settings to default location
|
||||
* @return true if saved successfully
|
||||
* With a file writer attached (the App's), the JSON snapshot is taken here, on the caller's thread,
|
||||
* and the atomic write runs on the writer's thread: returns true once queued. After a fund/security-
|
||||
* critical setter (see durable_next_save_) it waits until the file is on disk, like the old save().
|
||||
* @return true if saved (or queued) successfully
|
||||
*/
|
||||
bool save();
|
||||
|
||||
/** @brief Route saves through a background writer (not owned; must outlive this). Null = synchronous. */
|
||||
void setFileWriter(util::CoalescingWriter* writer) { writer_ = writer; }
|
||||
|
||||
/**
|
||||
* @brief Save settings to specific path
|
||||
* @param path Path to settings file
|
||||
@@ -93,10 +100,21 @@ public:
|
||||
bool showSparkline = false; // show a price-trend sparkline (live-market bases only)
|
||||
int sparklineInterval = 4; // 0=minute 1=hour 2=day 3=week 4=month (default month: a real curve
|
||||
// from the daily series, vs the young in-session minute buffer)
|
||||
// Per-wallet visibility: "" (shown in every wallet — legacy/global) or a wallet-identity
|
||||
// hash (shown only when that wallet is active). New entries are tagged with the current
|
||||
// wallet so a portfolio built for wallet A doesn't clutter wallet B.
|
||||
// Per-wallet visibility: "" (shown in every wallet - legacy/global) or the active wallet's STABLE scope
|
||||
// id ("w:"+hex, App::activeWalletScopeId(), persisted in wallets.json, never derived from the address
|
||||
// set). Older builds stored the address-set identity hash here, which drifted whenever an address was
|
||||
// added; such a legacy scope fails open (stays visible) until reattachLegacyPortfolioScopes() moves it
|
||||
// to the stable id. New entries are tagged with the current wallet so a portfolio built for wallet A
|
||||
// doesn't clutter wallet B.
|
||||
std::string scope;
|
||||
bool isGlobal() const { return scope.empty(); }
|
||||
bool isLegacyScope() const { return !scope.empty() && scope.rfind("w:", 0) != 0; }
|
||||
bool visibleInWallet(const std::string& scopeId) const {
|
||||
if (isGlobal()) return true;
|
||||
if (scopeId.empty()) return false; // wallet not resolved yet: never another wallet's groups
|
||||
if (isLegacyScope()) return true; // fail open until migrated
|
||||
return scope == scopeId;
|
||||
}
|
||||
// Legacy dashboard-grid placement (deprecated by the row layout; kept for back-compat so an
|
||||
// older build's saved positions aren't dropped, but no longer used by the renderer).
|
||||
int gridCol = -1;
|
||||
@@ -194,6 +212,10 @@ public:
|
||||
|
||||
bool getUseTor() const { return use_tor_; }
|
||||
void setUseTor(bool tor) { use_tor_ = tor; }
|
||||
// "Use Tor" was never implemented: no build routed any traffic through Tor. Clears a stored opt-in
|
||||
// and reports whether there was one, so App::init can tell that user once (the cleared key is the
|
||||
// "already told" flag).
|
||||
bool consumeLegacyUseTor() { const bool was = use_tor_; use_tor_ = false; return was; }
|
||||
|
||||
// Fees
|
||||
bool getAllowCustomFees() const { return allow_custom_fees_; }
|
||||
@@ -239,6 +261,11 @@ public:
|
||||
float getNoiseOpacity() const { return noise_opacity_; }
|
||||
void setNoiseOpacity(float v) { noise_opacity_ = v; }
|
||||
|
||||
// Opacity multiplier for the favorite gold visuals (row outline + star button) in the
|
||||
// Balance address list. 1.0 = theme default (unchanged); floor 0.15 so it can't vanish.
|
||||
float getFavoriteOpacity() const { return favorite_opacity_; }
|
||||
void setFavoriteOpacity(float v) { favorite_opacity_ = std::max(0.15f, std::min(1.0f, v)); }
|
||||
|
||||
// Gradient background mode (use gradient variant of theme background)
|
||||
bool getGradientBackground() const { return gradient_background_; }
|
||||
void setGradientBackground(bool v) { gradient_background_ = v; }
|
||||
@@ -294,6 +321,22 @@ public:
|
||||
void unfavoriteAddress(const std::string& addr) { favorite_addresses_.erase(addr); }
|
||||
int getFavoriteAddressCount() const { return (int)favorite_addresses_.size(); }
|
||||
|
||||
// Inline period-earnings (Balance tab). interval: 0=Hour 1=Day 2=Month 3=Year; mode: 0=Inflows
|
||||
// 1=Net; format: 0=DRGX 1=DRGX+fiat 2=Fiat. show-all toggles the per-address earnings column for
|
||||
// every address at once; earnings_addresses_ is the opt-in set shown when show-all is off.
|
||||
int getEarningsInterval() const { return earnings_interval_; }
|
||||
void setEarningsInterval(int v) { earnings_interval_ = (v < 0 ? 0 : (v > 3 ? 3 : v)); }
|
||||
int getEarningsMode() const { return earnings_mode_; }
|
||||
void setEarningsMode(int v) { earnings_mode_ = (v < 0 ? 0 : (v > 1 ? 1 : v)); }
|
||||
int getEarningsFormat() const { return earnings_format_; }
|
||||
void setEarningsFormat(int v) { earnings_format_ = (v < 0 ? 0 : (v > 2 ? 2 : v)); }
|
||||
bool getEarningsShowAll() const { return earnings_show_all_; }
|
||||
void setEarningsShowAll(bool v) { earnings_show_all_ = v; }
|
||||
const std::set<std::string>& getEarningsAddresses() const { return earnings_addresses_; }
|
||||
bool isAddressEarningsShown(const std::string& addr) const { return earnings_addresses_.count(addr) > 0; }
|
||||
void showAddressEarnings(const std::string& addr) { earnings_addresses_.insert(addr); }
|
||||
void hideAddressEarnings(const std::string& addr) { earnings_addresses_.erase(addr); }
|
||||
|
||||
// Address metadata (labels, icons, custom ordering)
|
||||
struct AddressMeta {
|
||||
std::string label;
|
||||
@@ -344,12 +387,21 @@ public:
|
||||
|
||||
// First-run wizard
|
||||
bool getWizardCompleted() const { return wizard_completed_; }
|
||||
void setWizardCompleted(bool v) { wizard_completed_ = v; }
|
||||
void setWizardCompleted(bool v) { wizard_completed_ = v; durable_next_save_ = true; }
|
||||
|
||||
// Whether the one-time "back up your seed phrase" reminder has already been shown.
|
||||
bool getSeedBackupReminded() const { return seed_backup_reminded_; }
|
||||
void setSeedBackupReminded(bool v) { seed_backup_reminded_ = v; }
|
||||
|
||||
// Unlock lockout state, persisted across restarts so the failed-attempt curve can't be reset by
|
||||
// killing the app. Keyed by the wallet file it applies to; the PIN/passphrase is never stored.
|
||||
int getLockFailedAttempts() const { return lock_failed_attempts_; }
|
||||
void setLockFailedAttempts(int v) { lock_failed_attempts_ = v; durable_next_save_ = true; }
|
||||
long long getLockoutUntilEpoch() const { return lockout_until_epoch_; }
|
||||
void setLockoutUntilEpoch(long long v) { lockout_until_epoch_ = v; durable_next_save_ = true; }
|
||||
std::string getLockStateWallet() const { return lock_state_wallet_; }
|
||||
void setLockStateWallet(const std::string& v) { lock_state_wallet_ = v; durable_next_save_ = true; }
|
||||
|
||||
// One-time nudge when wallet.dat grows past the bloat threshold (re-armed if it shrinks back).
|
||||
bool getLargeWalletWarned() const { return large_wallet_warned_; }
|
||||
void setLargeWalletWarned(bool v) { large_wallet_warned_ = v; }
|
||||
@@ -366,7 +418,7 @@ public:
|
||||
// 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; }
|
||||
void setEncryptionPending(bool v) { encryption_pending_ = v; durable_next_save_ = true; }
|
||||
|
||||
// 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.
|
||||
@@ -377,25 +429,31 @@ public:
|
||||
// the datadir; defaults to the daemon's own default. Used at launch to know which wallet we're
|
||||
// on before connect + to scope per-wallet data.
|
||||
std::string getActiveWalletFile() const { return active_wallet_file_; }
|
||||
void setActiveWalletFile(const std::string& v) { active_wallet_file_ = v; }
|
||||
void setActiveWalletFile(const std::string& v) { active_wallet_file_ = v; durable_next_save_ = true; }
|
||||
|
||||
// Pending "migrate to a seed wallet" state (Phase 1 created the wallet; a later sweep/adopt
|
||||
// step consumes it). dest = the new wallet's sweep-target z-address; tempDir = its datadir.
|
||||
bool getSeedMigrationPending() const { return seed_migration_pending_; }
|
||||
void setSeedMigrationPending(bool v) { seed_migration_pending_ = v; }
|
||||
void setSeedMigrationPending(bool v) { seed_migration_pending_ = v; durable_next_save_ = true; }
|
||||
std::string getSeedMigrationDest() const { return seed_migration_dest_; }
|
||||
void setSeedMigrationDest(const std::string& v) { seed_migration_dest_ = v; }
|
||||
void setSeedMigrationDest(const std::string& v) { seed_migration_dest_ = v; durable_next_save_ = true; }
|
||||
std::string getSeedMigrationTempDir() const { return seed_migration_temp_dir_; }
|
||||
void setSeedMigrationTempDir(const std::string& v) { seed_migration_temp_dir_ = v; }
|
||||
void setSeedMigrationTempDir(const std::string& v) { seed_migration_temp_dir_ = v; durable_next_save_ = true; }
|
||||
// The sweep transaction id, persisted once the sweep is submitted — non-empty means the
|
||||
// migration is past the sweep, so a resume goes to the confirm/adopt stage (not sweep again).
|
||||
std::string getSeedMigrationSweepTxid() const { return seed_migration_sweep_txid_; }
|
||||
void setSeedMigrationSweepTxid(const std::string& v) { seed_migration_sweep_txid_ = v; }
|
||||
void setSeedMigrationSweepTxid(const std::string& v) { seed_migration_sweep_txid_ = v; durable_next_save_ = true; }
|
||||
// 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; }
|
||||
void setSeedMigrationSweepOpid(const std::string& v) { seed_migration_sweep_opid_ = v; durable_next_save_ = true; }
|
||||
// Set TRUE synchronously when a sweep is broadcast; survives the stale-opid resume fallback so the
|
||||
// Sweep step knows a ~0 legacy balance may be an UNCONFIRMED in-flight sweep (funds not yet safe)
|
||||
// and must NOT short-circuit to adopt/swap wallet.dat. Cleared on confirmed-adopt / reset / a
|
||||
// failed submit. See [[decideSeedMigrationResume]] and App::renderSeedMigrationDialog (fund-safety).
|
||||
bool getSeedMigrationSweepSubmitted() const { return seed_migration_sweep_submitted_; }
|
||||
void setSeedMigrationSweepSubmitted(bool v) { seed_migration_sweep_submitted_ = v; durable_next_save_ = true; }
|
||||
|
||||
// Security — auto-lock timeout (seconds; 0 = disabled)
|
||||
int getAutoLockTimeout() const { return auto_lock_timeout_; }
|
||||
@@ -407,7 +465,7 @@ public:
|
||||
|
||||
// Security — PIN unlock enabled
|
||||
bool getPinEnabled() const { return pin_enabled_; }
|
||||
void setPinEnabled(bool v) { pin_enabled_ = v; }
|
||||
void setPinEnabled(bool v) { pin_enabled_ = v; durable_next_save_ = true; }
|
||||
|
||||
// Daemon — keep running in background when closing the app
|
||||
bool getKeepDaemonRunning() const { return keep_daemon_running_; }
|
||||
@@ -446,6 +504,14 @@ public:
|
||||
// User-defined portfolio entries (Market tab). "All funds" is implicit, not stored here.
|
||||
const std::vector<PortfolioEntry>& getPortfolioEntries() const { return portfolio_entries_; }
|
||||
void setPortfolioEntries(const std::vector<PortfolioEntry>& entries) { portfolio_entries_ = entries; }
|
||||
// Re-attach groups stuck on a legacy (address-set-hash) scope to the stable wallet id `scopeId`.
|
||||
// A legacy group is claimed when ANY of: its scope == identityHash (the current address-set hash, exact
|
||||
// match); at least one of its own addresses is in `walletAddresses` (groups carry their addresses, so
|
||||
// this attributes correctly in multi-wallet installs); or `singleWallet` (only one known wallet, so
|
||||
// attribution is unambiguous - same rule contacts use). Anything else stays legacy (fail-open visible).
|
||||
// Idempotent. Does NOT save: the caller saves once if the return value is > 0. Returns #entries moved.
|
||||
int reattachLegacyPortfolioScopes(const std::string& scopeId, const std::string& identityHash,
|
||||
const std::set<std::string>& walletAddresses, bool singleWallet);
|
||||
|
||||
// Lite servers the user has hidden from the Network tab (kept by URL, shown via a toggle).
|
||||
const std::set<std::string>& getLiteHiddenServers() const { return lite_hidden_servers_; }
|
||||
@@ -573,8 +639,51 @@ public:
|
||||
bool needsUpgradeSave() const { return needs_upgrade_save_; }
|
||||
void clearUpgradeSave() { needs_upgrade_save_ = false; }
|
||||
|
||||
// UI-sweep ONLY. sweepBegin() snapshots the values the full sweep's demo surfaces change: the active
|
||||
// wallet file, the contacts view mode, and the pool URL/worker (the Mining tab defaults an empty
|
||||
// worker to the first DEMO z-address). Until sweepEnd(), save() returns true without writing, so
|
||||
// neither those nor the demo portfolio can reach disk, even through the shutdown save if the app
|
||||
// quits mid-sweep. sweepEnd() restores the snapshot and, only if a save was suppressed, saves once
|
||||
// so a real change made meanwhile still lands. The caller restores everything else it changed
|
||||
// (the portfolio) BEFORE sweepEnd(). Both are idempotent.
|
||||
void sweepBegin() {
|
||||
if (sweep_active_) return;
|
||||
sweep_active_wallet_file_ = active_wallet_file_;
|
||||
sweep_contacts_view_mode_ = contacts_view_mode_;
|
||||
sweep_pool_url_ = pool_url_;
|
||||
sweep_pool_worker_ = pool_worker_;
|
||||
sweep_suppressed_save_ = false;
|
||||
sweep_active_ = true;
|
||||
}
|
||||
void sweepEnd() {
|
||||
if (!sweep_active_) return;
|
||||
sweep_active_ = false;
|
||||
active_wallet_file_ = sweep_active_wallet_file_;
|
||||
contacts_view_mode_ = sweep_contacts_view_mode_;
|
||||
pool_url_ = sweep_pool_url_;
|
||||
pool_worker_ = sweep_pool_worker_;
|
||||
if (sweep_suppressed_save_) {
|
||||
sweep_suppressed_save_ = false;
|
||||
save();
|
||||
}
|
||||
}
|
||||
bool sweepActive() const { return sweep_active_; }
|
||||
|
||||
private:
|
||||
// UI-sweep latch (see sweepBegin()).
|
||||
bool sweep_active_ = false;
|
||||
bool sweep_suppressed_save_ = false;
|
||||
std::string sweep_active_wallet_file_;
|
||||
int sweep_contacts_view_mode_ = 0;
|
||||
std::string sweep_pool_url_, sweep_pool_worker_;
|
||||
|
||||
std::string settings_path_;
|
||||
util::CoalescingWriter* writer_ = nullptr; // null -> synchronous saves (tests, tools, early startup)
|
||||
// Armed by the fund/security-critical setters (seed-migration stage, encryption_pending, pin_enabled,
|
||||
// active_wallet_file, wizard_completed): the next save() waits until the file is on disk before it
|
||||
// returns, so e.g. the sweep-submitted marker is durable before the sweep is broadcast. Cleared by a
|
||||
// successful save. A NEW setter for crash-critical state must arm it too.
|
||||
bool durable_next_save_ = false;
|
||||
|
||||
// Settings values
|
||||
std::string theme_ = "dragonx";
|
||||
@@ -606,6 +715,7 @@ private:
|
||||
int acrylic_quality_ = 2;
|
||||
float blur_multiplier_ = 0.10f;
|
||||
float noise_opacity_ = 0.5f;
|
||||
float favorite_opacity_ = 1.0f; // Favorite gold outline/star opacity multiplier (0.15–1.0, 1.0 = theme default)
|
||||
bool gradient_background_ = false;
|
||||
#ifdef _WIN32
|
||||
float ui_opacity_ = 0.50f; // Card/sidebar opacity (0.3–1.0, 1.0 = opaque)
|
||||
@@ -627,9 +737,17 @@ private:
|
||||
bool console_auto_focus_ = false; // focus the command input when the Console tab is opened (opt-in)
|
||||
std::set<std::string> hidden_addresses_;
|
||||
std::set<std::string> favorite_addresses_;
|
||||
std::set<std::string> earnings_addresses_; // per-address earnings column opt-in (when show-all off)
|
||||
int earnings_interval_ = 1; // 0=Hour 1=Day 2=Month 3=Year
|
||||
int earnings_mode_ = 0; // 0=Inflows 1=Net
|
||||
int earnings_format_ = 1; // 0=DRGX 1=DRGX+fiat 2=Fiat
|
||||
bool earnings_show_all_ = false;
|
||||
std::map<std::string, AddressMeta> address_meta_;
|
||||
bool wizard_completed_ = false;
|
||||
bool seed_backup_reminded_ = false;
|
||||
int lock_failed_attempts_ = 0; // persisted unlock-failure count (lockout curve)
|
||||
long long lockout_until_epoch_ = 0; // unix time the current lockout expires (0 = none)
|
||||
std::string lock_state_wallet_; // wallet file the above two apply to
|
||||
bool large_wallet_warned_ = false;
|
||||
std::set<std::string> empty_wallet_warning_acked_; // wallet files whose empty-wallet warning was dismissed
|
||||
bool encryption_pending_ = false;
|
||||
@@ -640,6 +758,7 @@ private:
|
||||
std::string seed_migration_temp_dir_;
|
||||
std::string seed_migration_sweep_txid_;
|
||||
std::string seed_migration_sweep_opid_;
|
||||
bool seed_migration_sweep_submitted_ = false;
|
||||
int auto_lock_timeout_ = 900; // 15 minutes
|
||||
int unlock_duration_ = 600; // 10 minutes
|
||||
bool pin_enabled_ = false;
|
||||
|
||||
@@ -0,0 +1,207 @@
|
||||
#pragma once
|
||||
|
||||
// Is the node busy with something that stopping it would throw away? Tracks dragonxd's own log markers,
|
||||
// in order, for two independent states:
|
||||
// - rebuilding: a witness-cache rebuild (BuildWitnessCache). It holds cs_main for up to an hour on a
|
||||
// big wallet, its second phase logs nothing and ignores shutdown, and a node killed mid-rebuild redoes
|
||||
// it (after replaying everything since its last flush) on the next start;
|
||||
// - replaying: the startup replay ("Activating best chain..."). Stopping there is safe — the daemon
|
||||
// checks for shutdown between steps and flushes — it just takes a moment.
|
||||
// A rebuild can start in the middle of a replay, so its end must not clear `replaying`, and the state is
|
||||
// order-based: it never compares timestamps (POSIX nodes log to stdout without them). Pure and
|
||||
// header-only, so the test target can feed it lines.
|
||||
|
||||
#include <cstdint>
|
||||
#include <cstdio>
|
||||
#include <string>
|
||||
|
||||
namespace dragonx {
|
||||
namespace daemon {
|
||||
|
||||
/**
|
||||
* "YYYY-MM-DD HH:MM:SS ..." (dragonxd's debug.log prefix, which is UTC) -> seconds since the epoch, or 0
|
||||
* when the line has no such prefix. Days-from-civil arithmetic, not mktime(): mktime reads local time.
|
||||
*/
|
||||
inline std::int64_t parseDaemonLogUtc(const std::string& line)
|
||||
{
|
||||
int y = 0, mo = 0, d = 0, h = 0, mi = 0, s = 0;
|
||||
if (line.size() < 19 || std::sscanf(line.c_str(), "%4d-%2d-%2d %2d:%2d:%2d", &y, &mo, &d, &h, &mi, &s) != 6)
|
||||
return 0;
|
||||
if (mo < 1 || mo > 12 || d < 1 || d > 31 || h > 23 || mi > 59 || s > 60) return 0;
|
||||
// Howard Hinnant's days_from_civil.
|
||||
const int yy = y - (mo <= 2 ? 1 : 0);
|
||||
const int era = (yy >= 0 ? yy : yy - 399) / 400;
|
||||
const unsigned yoe = static_cast<unsigned>(yy - era * 400);
|
||||
const unsigned doy = (153u * static_cast<unsigned>(mo + (mo > 2 ? -3 : 9)) + 2u) / 5u + static_cast<unsigned>(d) - 1u;
|
||||
const unsigned doe = yoe * 365u + yoe / 4u - yoe / 100u + doy;
|
||||
const std::int64_t days = static_cast<std::int64_t>(era) * 146097 + static_cast<std::int64_t>(doe) - 719468;
|
||||
return days * 86400 + h * 3600 + mi * 60 + s;
|
||||
}
|
||||
|
||||
class DaemonBusyTracker {
|
||||
public:
|
||||
// Feed raw output: any chunking, a line split across two chunks is reassembled.
|
||||
void feed(const std::string& chunk)
|
||||
{
|
||||
for (char c : chunk) {
|
||||
if (c == '\n') {
|
||||
feedLine(carry_);
|
||||
carry_.clear();
|
||||
} else if (c != '\r') {
|
||||
carry_.push_back(c);
|
||||
if (carry_.size() > 64 * 1024) carry_.clear(); // not a log line; don't grow without bound
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void feedLine(const std::string& line)
|
||||
{
|
||||
// A new daemon process, or the end of the last one: forget everything.
|
||||
if (contains(line, "DragonX version ") || contains(line, "Hush version ") || contains(line, "Shutdown: done")) {
|
||||
reset();
|
||||
return;
|
||||
}
|
||||
if (contains(line, "init message: Activating best chain")) {
|
||||
replaying_ = true;
|
||||
replayStartUtc_ = parseDaemonLogUtc(line);
|
||||
lastMarker_ = line;
|
||||
return;
|
||||
}
|
||||
if (contains(line, "init message: Full Node Done Loading") || contains(line, "init message: Done loading")) {
|
||||
replaying_ = false;
|
||||
lastMarker_ = line;
|
||||
return;
|
||||
}
|
||||
const size_t read = line.find("Reading blocks for witness rebuild: ");
|
||||
if (read != std::string::npos || contains(line, "Building Witnesses for block ")) {
|
||||
if (!rebuilding_) rebuildStartUtc_ = parseDaemonLogUtc(line);
|
||||
rebuilding_ = true;
|
||||
if (read != std::string::npos) {
|
||||
long done = 0, total = 0;
|
||||
if (std::sscanf(line.c_str() + read, "Reading blocks for witness rebuild: %ld / %ld", &done, &total) == 2) {
|
||||
readDone_ = done;
|
||||
readTotal_ = total;
|
||||
}
|
||||
}
|
||||
lastMarker_ = line;
|
||||
return;
|
||||
}
|
||||
// The rebuild's end. UpdateTip is logged under cs_main, so one after a rebuild marker means the
|
||||
// rebuild has released it (exceptions and non-bulk paths print no end line).
|
||||
if (rebuilding_ && (contains(line, "note witness cache(s) to height") ||
|
||||
contains(line, "aborting witness rebuild") ||
|
||||
contains(line, "aborted during witness rebuild") ||
|
||||
contains(line, "aborting building witnesses") ||
|
||||
contains(line, "UpdateTip: "))) {
|
||||
rebuilding_ = false;
|
||||
readDone_ = readTotal_ = 0;
|
||||
rebuildStartUtc_ = 0;
|
||||
lastMarker_ = line;
|
||||
}
|
||||
}
|
||||
|
||||
void reset()
|
||||
{
|
||||
rebuilding_ = replaying_ = false;
|
||||
readDone_ = readTotal_ = 0;
|
||||
rebuildStartUtc_ = replayStartUtc_ = 0;
|
||||
lastMarker_.clear();
|
||||
}
|
||||
|
||||
bool rebuilding() const { return rebuilding_; }
|
||||
bool replaying() const { return replaying_; }
|
||||
bool busy() const { return rebuilding_ || replaying_; }
|
||||
long readDone() const { return readDone_; } // the last Phase-1 "reading blocks" progress, 0 if none
|
||||
long readTotal() const { return readTotal_; }
|
||||
std::int64_t rebuildStartUtc() const { return rebuildStartUtc_; } // 0: unknown (no timestamps)
|
||||
std::int64_t replayStartUtc() const { return replayStartUtc_; }
|
||||
const std::string& lastMarker() const { return lastMarker_; }
|
||||
|
||||
private:
|
||||
static bool contains(const std::string& s, const char* needle) { return s.find(needle) != std::string::npos; }
|
||||
|
||||
std::string carry_;
|
||||
bool rebuilding_ = false;
|
||||
bool replaying_ = false;
|
||||
long readDone_ = 0;
|
||||
long readTotal_ = 0;
|
||||
std::int64_t rebuildStartUtc_ = 0;
|
||||
std::int64_t replayStartUtc_ = 0;
|
||||
std::string lastMarker_;
|
||||
};
|
||||
|
||||
/**
|
||||
* The "keep the node running?" guard on quit. Prompt only when this quit would stop the node and it is
|
||||
* rebuilding witnesses, or has been alive but too busy to answer RPC for a while (a rebuild that logged
|
||||
* no marker). A replay alone never prompts: stopping it is safe. No daemon version suppresses the prompt
|
||||
* (v1.3.0 does not checkpoint a witness rebuild; nothing survives a kill).
|
||||
*/
|
||||
inline bool shouldConfirmStop(bool stopsDaemon, const DaemonBusyTracker& tracker, bool rpcBusyWhileAlive)
|
||||
{
|
||||
return stopsDaemon && (tracker.rebuilding() || rpcBusyWhileAlive);
|
||||
}
|
||||
|
||||
// Retry schedule after failing to find a pid's banner in debug.log: re-scan fast during the fresh-node grace
|
||||
// window, then back off 4 s doubling to a 60 s cap. `backoff` is the caller's running state (0 until grace ends).
|
||||
inline double nextExternalLogRetryDelay(double ageSinceMiss, double& backoff,
|
||||
double graceWindow = 15.0, double fast = 2.0,
|
||||
double base = 4.0, double cap = 60.0)
|
||||
{
|
||||
if (ageSinceMiss < graceWindow) return fast;
|
||||
backoff = (backoff < base) ? base : (backoff * 2.0 < cap ? backoff * 2.0 : cap);
|
||||
return backoff;
|
||||
}
|
||||
|
||||
/**
|
||||
* Where to start feeding a DaemonBusyTracker from the last `window` of the debug.log of a node someone else
|
||||
* started at `processStartUtc` (UTC seconds). Only lines that process wrote count: a node started with
|
||||
* -printtoconsole leaves an old debug.log behind, whose last run may have died mid-rebuild. Returns the
|
||||
* offset of that process's startup banner in the window; 0 when its banner is older than the window
|
||||
* (`windowStartsMidFile`) and the window's first stamped line postdates the process start; npos when the
|
||||
* log isn't that process's.
|
||||
*/
|
||||
inline size_t externalLogFeedOffset(const std::string& window, bool windowStartsMidFile, std::int64_t processStartUtc)
|
||||
{
|
||||
size_t lastBanner = std::string::npos;
|
||||
std::int64_t lastBannerAt = 0, firstStamp = 0;
|
||||
size_t pos = 0;
|
||||
while (pos < window.size()) {
|
||||
size_t end = window.find('\n', pos);
|
||||
if (end == std::string::npos) end = window.size();
|
||||
const std::string line = window.substr(pos, end - pos);
|
||||
const std::int64_t at = parseDaemonLogUtc(line);
|
||||
if (at > 0 && firstStamp == 0) firstStamp = at;
|
||||
if (line.find("DragonX version ") != std::string::npos || line.find("Hush version ") != std::string::npos) {
|
||||
lastBanner = pos;
|
||||
lastBannerAt = at;
|
||||
}
|
||||
pos = end + 1;
|
||||
}
|
||||
if (processStartUtc <= 0) return std::string::npos;
|
||||
const std::int64_t since = processStartUtc - 5; // the log's whole seconds vs the process's start time
|
||||
if (lastBanner != std::string::npos) return (lastBannerAt > 0 && lastBannerAt >= since) ? lastBanner : std::string::npos;
|
||||
if (windowStartsMidFile && firstStamp > 0 && firstStamp >= since) return 0;
|
||||
return std::string::npos;
|
||||
}
|
||||
|
||||
// What the "node is starting / not answering" overlay should say, most specific first.
|
||||
enum class DaemonStatusKind {
|
||||
Rebuilding, // its own log says it's rebuilding note witnesses
|
||||
Replaying, // its own log says it's catching up on blocks it saved before (startup replay)
|
||||
InitPhase, // a recognisable init phase line (loading the block index, verifying, ...)
|
||||
Busy, // it answers the connection but not the request (reply timeout / queue full)
|
||||
Launching, // nothing bound to the RPC port yet
|
||||
};
|
||||
|
||||
inline DaemonStatusKind chooseDaemonStatus(bool hasInitPhaseLine, bool reachableButBusy,
|
||||
const DaemonBusyTracker& tracker)
|
||||
{
|
||||
if (tracker.rebuilding()) return DaemonStatusKind::Rebuilding;
|
||||
if (tracker.replaying()) return DaemonStatusKind::Replaying;
|
||||
if (hasInitPhaseLine) return DaemonStatusKind::InitPhase;
|
||||
if (reachableButBusy) return DaemonStatusKind::Busy;
|
||||
return DaemonStatusKind::Launching;
|
||||
}
|
||||
|
||||
} // namespace daemon
|
||||
} // namespace dragonx
|
||||
@@ -41,10 +41,10 @@ void DaemonController::syncSettings(const config::Settings* settings)
|
||||
daemon_->setWalletFile(walletFile);
|
||||
}
|
||||
|
||||
bool DaemonController::start(const config::Settings* settings)
|
||||
bool DaemonController::start(const config::Settings* settings, int lockWaitPolls)
|
||||
{
|
||||
syncSettings(settings);
|
||||
return daemon_->start();
|
||||
return daemon_->start("", lockWaitPolls);
|
||||
}
|
||||
|
||||
void DaemonController::stop(int waitMs)
|
||||
@@ -52,6 +52,11 @@ void DaemonController::stop(int waitMs)
|
||||
daemon_->stop(waitMs);
|
||||
}
|
||||
|
||||
void DaemonController::markStopRequested()
|
||||
{
|
||||
daemon_->markStopRequested();
|
||||
}
|
||||
|
||||
bool DaemonController::isRunning() const
|
||||
{
|
||||
return daemon_->isRunning();
|
||||
@@ -94,11 +99,18 @@ double DaemonController::memoryUsageMB() const
|
||||
return daemon_ ? daemon_->getMemoryUsageMB() : 0.0;
|
||||
}
|
||||
|
||||
std::function<double()> DaemonController::memoryProbe() const
|
||||
{
|
||||
return daemon_ ? daemon_->memoryProbe() : std::function<double()>{};
|
||||
}
|
||||
|
||||
std::vector<std::string> DaemonController::recentLines(std::size_t count) const
|
||||
{
|
||||
return daemon_ ? daemon_->getRecentLines(count) : std::vector<std::string>{};
|
||||
}
|
||||
|
||||
std::size_t DaemonController::outputEnd() const { return daemon_ ? daemon_->getOutputEnd() : 0; }
|
||||
|
||||
std::string DaemonController::outputSince(std::size_t& offset) const
|
||||
{
|
||||
return daemon_ ? daemon_->getOutputSince(offset) : std::string{};
|
||||
|
||||
+239
-10
@@ -1,9 +1,12 @@
|
||||
#pragma once
|
||||
|
||||
#include "embedded_daemon.h"
|
||||
#include "stop_delivery.h"
|
||||
|
||||
#include <algorithm>
|
||||
#include <cstddef>
|
||||
#include <deque>
|
||||
#include <functional>
|
||||
#include <memory>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
@@ -61,17 +64,21 @@ public:
|
||||
class LifecycleRuntime {
|
||||
public:
|
||||
virtual ~LifecycleRuntime() = default;
|
||||
virtual void stopDaemonWithPolicy() = 0;
|
||||
// Stop the node and wait (bounded, never killing it) for it to exit. False: it is still running
|
||||
// (or we stopped waiting), and nothing that needs it gone may run.
|
||||
virtual bool stopDaemonWithPolicy() = 0;
|
||||
virtual bool startDaemon() = 0;
|
||||
virtual int deleteBlockchainData() = 0;
|
||||
virtual void resetOutputOffset() = 0;
|
||||
virtual void requestRpcStopAndDisconnect(const char* context, const char* reason) = 0;
|
||||
// Disconnect and stop the node off this thread; whoever needs it gone waits for the process to exit.
|
||||
virtual void beginBackgroundStop(const char* reason) = 0;
|
||||
};
|
||||
|
||||
struct LifecycleExecutionResult {
|
||||
bool completed = false;
|
||||
bool cancelled = false;
|
||||
bool stopped = false;
|
||||
bool stopFailed = false; // the node didn't exit: the operation was cancelled before changing anything
|
||||
bool started = false;
|
||||
int deletedItems = 0;
|
||||
};
|
||||
@@ -88,8 +95,9 @@ public:
|
||||
void setStateCallback(StateCallback callback);
|
||||
void syncSettings(const config::Settings* settings);
|
||||
|
||||
bool start(const config::Settings* settings);
|
||||
bool start(const config::Settings* settings, int lockWaitPolls = 3);
|
||||
void stop(int waitMs);
|
||||
void markStopRequested();
|
||||
|
||||
bool isRunning() const;
|
||||
bool externalDaemonDetected() const;
|
||||
@@ -99,8 +107,10 @@ public:
|
||||
int crashCount() const;
|
||||
int lastBlockHeight() const;
|
||||
double memoryUsageMB() const;
|
||||
std::function<double()> memoryProbe() const; // EmbeddedDaemon::memoryProbe(); empty without a daemon
|
||||
std::vector<std::string> recentLines(std::size_t count) const;
|
||||
std::string outputSince(std::size_t& offset) const;
|
||||
std::size_t outputEnd() const;
|
||||
|
||||
void resetCrashCount();
|
||||
void setRescanOnNextStart(bool enabled);
|
||||
@@ -191,22 +201,24 @@ public:
|
||||
|
||||
switch (decision.operation) {
|
||||
case LifecycleOperation::BootstrapStop:
|
||||
// The download takes minutes; the bootstrap worker waits for the process to exit before it
|
||||
// touches chain data.
|
||||
if (decision.wasRunning) {
|
||||
runtime.requestRpcStopAndDisconnect("Bootstrap daemon stop", "Bootstrap");
|
||||
runtime.beginBackgroundStop("Bootstrap");
|
||||
result.stopped = true;
|
||||
}
|
||||
result.completed = true;
|
||||
return result;
|
||||
case LifecycleOperation::ManualRestart:
|
||||
if (decision.wasRunning) {
|
||||
runtime.stopDaemonWithPolicy();
|
||||
result.stopped = true;
|
||||
}
|
||||
break;
|
||||
case LifecycleOperation::Rescan:
|
||||
case LifecycleOperation::RepairWallet:
|
||||
case LifecycleOperation::DeleteBlockchainData:
|
||||
runtime.stopDaemonWithPolicy();
|
||||
if (decision.operation == LifecycleOperation::ManualRestart && !decision.wasRunning) break;
|
||||
// Never start a second node, or delete chain data, under one that hasn't exited.
|
||||
if (!runtime.stopDaemonWithPolicy()) {
|
||||
result.stopFailed = true;
|
||||
return result;
|
||||
}
|
||||
result.stopped = true;
|
||||
break;
|
||||
}
|
||||
@@ -233,6 +245,223 @@ public:
|
||||
ShutdownDecision shutdownDecision(bool keepDaemonRunning,
|
||||
bool stopExternalDaemon) const;
|
||||
|
||||
// ------------------------------------------------------------------------------------------------
|
||||
// Stopping the node: deliver "stop" until it lands, then wait for the process to exit. Never kill it
|
||||
// on our own: a node that is busy (a witness rebuild can hold it for an hour) or flushing would lose
|
||||
// everything since its last save and replay it on the next start. Only an explicit user "Kill",
|
||||
// offered once the node has gone idle, ends it by force; "Leave running" lets the wallet close while
|
||||
// the node finishes on its own.
|
||||
// ------------------------------------------------------------------------------------------------
|
||||
|
||||
using StopUserChoice = daemon::StopUserChoice;
|
||||
using StopOutcome = daemon::StopOutcome;
|
||||
|
||||
struct StopPolicy {
|
||||
bool owned = true; // we launched it: watch the process, signal it, offer Kill
|
||||
bool gracefulSignal = false; // POSIX: a group SIGTERM is a stop channel (dragonxd treats it as stop)
|
||||
double maxWaitSec = 0.0; // stop waiting (never kill) after this long; 0 = no limit
|
||||
bool userChoices = false; // the shutdown screen offers "Leave running" / "Kill"
|
||||
bool abandonOnShutdown = false; // stop waiting once the app starts shutting down (background callers)
|
||||
double idleOfferSec = 600.0; // delivered + no CPU/I-O progress for this long: offer Kill
|
||||
};
|
||||
// A bounded stop that has no way to reach the node (a node we don't own refused our credentials, or our
|
||||
// own did with every source and there is no signal) gives up after this long rather than its full budget.
|
||||
static constexpr double kUndeliverableGraceSec = 5.0;
|
||||
|
||||
// What the stop loop reports for display.
|
||||
struct StopStatus {
|
||||
bool delivered = false; // stop accepted (or the node is shutting down, or was signalled)
|
||||
bool heard = false; // the node has the request (possibly still queued)
|
||||
bool signalled = false; // a graceful signal (POSIX SIGTERM) went out
|
||||
double elapsedSec = 0.0;
|
||||
double cpuPercent = -1.0; // < 0: unknown
|
||||
double idleSec = 0.0;
|
||||
bool killOffered = false;
|
||||
bool rebuildActive = false;
|
||||
StopSendResult lastSend = StopSendResult::Other;
|
||||
int attempts = 0;
|
||||
};
|
||||
|
||||
class StopRuntime {
|
||||
public:
|
||||
virtual ~StopRuntime() = default;
|
||||
virtual double nowSec() = 0;
|
||||
virtual bool processAlive() = 0;
|
||||
virtual StopSendResult trySendStop() = 0;
|
||||
virtual bool nextCredentials() = 0; // switch to the next stop credentials; false when none left
|
||||
virtual bool sendGracefulSignal() = 0; // POSIX group SIGTERM; false where there is none (Windows)
|
||||
virtual double cpuSeconds() = 0; // cumulative CPU of the node; < 0 if unknown
|
||||
virtual double ioBytes() = 0; // cumulative I/O bytes of the node; < 0 if unknown
|
||||
virtual bool rebuildActive() = 0; // a witness rebuild is running: never offer Kill
|
||||
virtual StopUserChoice userChoice() = 0;
|
||||
virtual void offerForceKill(bool offered) = 0;
|
||||
virtual void forceKill() = 0;
|
||||
virtual void setStatus(const StopStatus& status) = 0;
|
||||
};
|
||||
|
||||
/**
|
||||
* "Making progress" = >= 0.05 cores of CPU or >= 1 MB of I/O over the last >= 60s. A flush that
|
||||
* fsyncs a big wallet is mostly I/O at low CPU; a rebuild is mostly CPU. Idle time counts from the
|
||||
* first sample. With no readings at all (unknown), it never reports idle, so Kill is never offered.
|
||||
*/
|
||||
class StopProgressTracker {
|
||||
public:
|
||||
static constexpr double kWindowSec = 60.0;
|
||||
static constexpr double kMinCores = 0.05;
|
||||
static constexpr double kMinIoBytes = 1024.0 * 1024.0;
|
||||
|
||||
void add(double t, double cpuSec, double ioBytes)
|
||||
{
|
||||
if (cpuSec >= 0 || ioBytes >= 0) measurable_ = true;
|
||||
if (samples_.empty()) lastProgressAt_ = t;
|
||||
samples_.push_back({t, cpuSec, ioBytes});
|
||||
// Keep the newest sample that is at least a full window old as the comparison base.
|
||||
while (samples_.size() > 2 && samples_[1].t <= t - kWindowSec) samples_.pop_front();
|
||||
const Sample& base = samples_.front();
|
||||
const double span = t - base.t;
|
||||
if (span >= kWindowSec) {
|
||||
const bool cpuMoved = cpuSec >= 0 && base.cpu >= 0 && (cpuSec - base.cpu) / span >= kMinCores;
|
||||
const bool ioMoved = ioBytes >= 0 && base.io >= 0 && (ioBytes - base.io) >= kMinIoBytes;
|
||||
if (cpuMoved || ioMoved) lastProgressAt_ = t;
|
||||
}
|
||||
}
|
||||
double idleSec(double t) const { return measurable_ ? std::max(0.0, t - lastProgressAt_) : 0.0; }
|
||||
// CPU over the most recent sample interval (100 = one core), for display; < 0 unknown.
|
||||
double cpuPercent() const
|
||||
{
|
||||
if (samples_.size() < 2) return -1.0;
|
||||
const Sample& a = samples_[samples_.size() - 2];
|
||||
const Sample& b = samples_.back();
|
||||
if (a.cpu < 0 || b.cpu < 0 || b.t <= a.t) return -1.0;
|
||||
return std::max(0.0, (b.cpu - a.cpu) / (b.t - a.t) * 100.0);
|
||||
}
|
||||
|
||||
private:
|
||||
struct Sample { double t, cpu, io; };
|
||||
std::deque<Sample> samples_;
|
||||
double lastProgressAt_ = 0.0;
|
||||
bool measurable_ = false;
|
||||
};
|
||||
|
||||
enum class StopWaitDecision { Done, Wait, GiveUp, Detach, OfferForceKill, ForceKill };
|
||||
|
||||
struct StopWaitSample {
|
||||
bool processAlive = true;
|
||||
bool delivered = false;
|
||||
double elapsedSec = 0.0;
|
||||
double idleSec = 0.0;
|
||||
bool rebuildActive = false;
|
||||
StopUserChoice choice = StopUserChoice::None;
|
||||
bool killOffered = false; // the Kill offer is on screen right now
|
||||
bool shuttingDown = false; // the app began shutting down / the task was cancelled
|
||||
bool undeliverable = false; // no stop delivered, none will be sent, and no signal to send
|
||||
};
|
||||
|
||||
static inline StopWaitDecision evaluateStopWait(const StopPolicy& p, const StopWaitSample& s)
|
||||
{
|
||||
if (!s.processAlive) return StopWaitDecision::Done;
|
||||
if (p.userChoices && s.choice == StopUserChoice::Detach) return StopWaitDecision::Detach;
|
||||
if (p.userChoices && p.owned && s.choice == StopUserChoice::Kill && s.killOffered)
|
||||
return StopWaitDecision::ForceKill;
|
||||
if (p.abandonOnShutdown && s.shuttingDown) return StopWaitDecision::GiveUp;
|
||||
if (p.maxWaitSec > 0 && s.elapsedSec >= p.maxWaitSec) return StopWaitDecision::GiveUp;
|
||||
if (p.maxWaitSec > 0 && s.undeliverable && s.elapsedSec >= kUndeliverableGraceSec)
|
||||
return StopWaitDecision::GiveUp;
|
||||
if (p.userChoices && p.owned && s.delivered && !s.rebuildActive && s.idleSec >= p.idleOfferSec)
|
||||
return StopWaitDecision::OfferForceKill;
|
||||
return StopWaitDecision::Wait;
|
||||
}
|
||||
|
||||
static constexpr int kStopTickMs = 250;
|
||||
|
||||
static inline StopOutcome executeDaemonStop(const StopPolicy& policy, StopRuntime& rt,
|
||||
LifecycleTaskContext& task)
|
||||
{
|
||||
if (!rt.processAlive()) return StopOutcome::Exited; // already gone: not a single RPC call
|
||||
|
||||
const double t0 = rt.nowSec();
|
||||
StopDeliveryPolicy delivery(policy.owned);
|
||||
StopProgressTracker progress;
|
||||
bool signalled = false;
|
||||
bool offered = false;
|
||||
auto withdrawOffer = [&] {
|
||||
if (offered) { offered = false; rt.offerForceKill(false); }
|
||||
};
|
||||
|
||||
for (;;) {
|
||||
double now = rt.nowSec() - t0;
|
||||
if (delivery.shouldSend(now)) {
|
||||
const StopSendResult r = delivery.record(rt.trySendStop(), now);
|
||||
if (r == StopSendResult::Auth && policy.owned) {
|
||||
// Our own node refusing our credentials means they're stale (e.g. a regenerated
|
||||
// .cookie): try the next source; out of sources, the graceful signal below remains.
|
||||
if (rt.nextCredentials()) delivery.retryNow(rt.nowSec() - t0);
|
||||
else delivery.giveUp();
|
||||
}
|
||||
// POSIX: a group SIGTERM bypasses the RPC queue altogether (dragonxd's handler sets the same
|
||||
// flag as stop). Send it as soon as an attempt shows stop didn't get through.
|
||||
if (policy.gracefulSignal && !signalled && !delivery.delivered() &&
|
||||
r != StopSendResult::Queued && rt.sendGracefulSignal()) {
|
||||
signalled = true;
|
||||
delivery.markDelivered();
|
||||
}
|
||||
}
|
||||
if (!rt.processAlive()) {
|
||||
withdrawOffer();
|
||||
return StopOutcome::Exited;
|
||||
}
|
||||
|
||||
now = rt.nowSec() - t0;
|
||||
progress.add(now, rt.cpuSeconds(), rt.ioBytes());
|
||||
StopWaitSample s;
|
||||
s.delivered = delivery.delivered();
|
||||
s.elapsedSec = now;
|
||||
s.idleSec = progress.idleSec(now);
|
||||
s.rebuildActive = rt.rebuildActive();
|
||||
s.choice = rt.userChoice();
|
||||
s.killOffered = offered;
|
||||
s.shuttingDown = task.cancelled() || task.shuttingDown();
|
||||
s.undeliverable = delivery.finished() && !delivery.delivered() && !signalled &&
|
||||
!(policy.gracefulSignal && policy.owned);
|
||||
|
||||
switch (evaluateStopWait(policy, s)) {
|
||||
case StopWaitDecision::Done:
|
||||
withdrawOffer();
|
||||
return StopOutcome::Exited;
|
||||
case StopWaitDecision::Detach:
|
||||
withdrawOffer();
|
||||
return StopOutcome::Detached;
|
||||
case StopWaitDecision::ForceKill:
|
||||
withdrawOffer();
|
||||
rt.forceKill();
|
||||
return StopOutcome::Killed;
|
||||
case StopWaitDecision::GiveUp:
|
||||
withdrawOffer();
|
||||
return StopOutcome::StillRunning;
|
||||
case StopWaitDecision::OfferForceKill:
|
||||
if (!offered) { offered = true; rt.offerForceKill(true); }
|
||||
break;
|
||||
case StopWaitDecision::Wait:
|
||||
withdrawOffer(); // progress resumed (or a rebuild started): the node isn't stuck after all
|
||||
break;
|
||||
}
|
||||
|
||||
StopStatus st;
|
||||
st.delivered = delivery.delivered();
|
||||
st.heard = delivery.heard();
|
||||
st.signalled = signalled;
|
||||
st.elapsedSec = now;
|
||||
st.cpuPercent = progress.cpuPercent();
|
||||
st.idleSec = s.idleSec;
|
||||
st.killOffered = offered;
|
||||
st.rebuildActive = s.rebuildActive;
|
||||
st.lastSend = delivery.last();
|
||||
st.attempts = delivery.attempts();
|
||||
rt.setStatus(st);
|
||||
task.sleepForMs(kStopTickMs);
|
||||
}
|
||||
}
|
||||
|
||||
private:
|
||||
std::unique_ptr<EmbeddedDaemon> daemon_;
|
||||
};
|
||||
|
||||
+729
-176
File diff suppressed because it is too large
Load Diff
+247
-17
@@ -4,15 +4,23 @@
|
||||
|
||||
#pragma once
|
||||
|
||||
#include <algorithm>
|
||||
#include <cstdint>
|
||||
#include <cstdio>
|
||||
#include <cstdlib>
|
||||
#include <string>
|
||||
#include <functional>
|
||||
#include <memory>
|
||||
#include <atomic>
|
||||
#include <thread>
|
||||
#include <mutex>
|
||||
#include <condition_variable>
|
||||
#include <chrono>
|
||||
#include <set>
|
||||
#include <vector>
|
||||
|
||||
#include "output_window.h"
|
||||
|
||||
#ifdef _WIN32
|
||||
#include <windows.h>
|
||||
#else
|
||||
@@ -50,16 +58,72 @@ public:
|
||||
/**
|
||||
* @brief Start the embedded dragonxd daemon
|
||||
* @param binary_path Path to dragonxd binary (auto-detect if empty)
|
||||
* @return true if process started successfully
|
||||
* @return true if process started successfully (or is already running); false while a stop
|
||||
* session (beginStop() .. endStop()) is still in progress
|
||||
*/
|
||||
bool start(const std::string& binary_path = "");
|
||||
static constexpr int kDatadirLockWaitMaxPolls = 3; // ~300ms total, breaks early on exit
|
||||
|
||||
/** @param lockWaitPolls max 100ms datadir-lock waits (0 = single check, never sleeps; for the UI thread) */
|
||||
bool start(const std::string& binary_path = "", int lockWaitPolls = kDatadirLockWaitMaxPolls);
|
||||
|
||||
/**
|
||||
* @brief Stop the running daemon
|
||||
* @brief Stop the daemon process this object launched (never any other dragonxd)
|
||||
* @param wait_ms Maximum milliseconds to wait for graceful shutdown
|
||||
* @return true if it exited on its own (or there was nothing to stop), false if it had to be
|
||||
* force-killed — then its wallet/chain files may not have been closed cleanly
|
||||
*/
|
||||
void stop(int wait_ms = 5000);
|
||||
|
||||
bool stop(int wait_ms = 5000);
|
||||
|
||||
/**
|
||||
* @brief Record that we are about to ask the daemon to stop (e.g. over RPC)
|
||||
*
|
||||
* Call it before sending the stop. If the daemon then exits before stop() runs, the monitor
|
||||
* records a clean stop instead of a crash (crash_count_ drives auto-restart). stop() sets it
|
||||
* too; start() clears it.
|
||||
*/
|
||||
void markStopRequested() { stop_requested_ = true; }
|
||||
|
||||
// --- A stop session, driven by DaemonController::executeDaemonStop through the app's StopRuntime ---
|
||||
|
||||
/** Marks the stop as requested and stops the monitor, so the session is the only reaper.
|
||||
* @return true if a process this object launched is still alive; then finish with endStop(). */
|
||||
bool beginStop();
|
||||
/** Polls our own process (reaping it on POSIX) and drains its output; false once it has exited. */
|
||||
bool pollOwnedProcess();
|
||||
/** POSIX: SIGTERM our process group (dragonxd treats it like RPC stop). Windows has no such channel. */
|
||||
bool signalGracefulStop();
|
||||
/** Ends our own process by force (SIGKILL / TerminateProcess) and reaps it. Explicit user choice only. */
|
||||
void forceKillOwnedProcess();
|
||||
struct ResourceUsage {
|
||||
double cpuSeconds = -1.0; // cumulative; < 0 if unknown
|
||||
double ioBytes = -1.0; // cumulative storage I/O; < 0 if unknown
|
||||
};
|
||||
/** CPU and I/O used so far by our process (its whole process group on Linux). */
|
||||
ResourceUsage ownedResourceUsage() const;
|
||||
/** Finishes a stop session. The process is gone: release everything. Still running: keepWatching
|
||||
* resumes the monitor (its exit is still seen, and booked as a clean stop); otherwise let it go —
|
||||
* the user chose to leave it running. */
|
||||
void endStop(bool keepWatching);
|
||||
|
||||
/**
|
||||
* @brief Parse a /proc/<pid>/stat line: process group (field 5) and utime+stime ticks (fields 14+15)
|
||||
*
|
||||
* comm (field 2) is parenthesised and may itself contain spaces and ')', so fields are read from
|
||||
* after the LAST ')'.
|
||||
*/
|
||||
static bool parseProcStatLine(const std::string& line, long& pgrp, unsigned long long& cpuTicks)
|
||||
{
|
||||
const size_t close = line.rfind(')');
|
||||
if (close == std::string::npos) return false;
|
||||
unsigned long long utime = 0, stime = 0;
|
||||
// state ppid pgrp session tty_nr tpgid flags minflt cminflt majflt cmajflt utime stime
|
||||
if (std::sscanf(line.c_str() + close + 1, " %*c %*d %ld %*d %*d %*d %*u %*u %*u %*u %*u %llu %llu",
|
||||
&pgrp, &utime, &stime) != 3)
|
||||
return false;
|
||||
cpuTicks = utime + stime;
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* @brief Check if daemon process is running
|
||||
*/
|
||||
@@ -73,8 +137,18 @@ public:
|
||||
/**
|
||||
* @brief Get daemon process memory usage (RSS) in MB
|
||||
* @return Memory usage in MB, or 0 if unavailable
|
||||
*
|
||||
* Synchronous (Linux scans /proc, macOS forks `ps`): UI code uses memoryProbe() through a
|
||||
* util::BackgroundSampler instead.
|
||||
*/
|
||||
double getMemoryUsageMB() const;
|
||||
|
||||
/**
|
||||
* @brief A self-contained RSS probe (MB) for another thread. Snapshots what it needs on the calling
|
||||
* thread (the pid; on Windows the value itself: one cheap call on the owned handle); the callable
|
||||
* touches no EmbeddedDaemon state. Empty when there is no process.
|
||||
*/
|
||||
std::function<double()> memoryProbe() const;
|
||||
|
||||
/**
|
||||
* @brief Get last error message
|
||||
@@ -93,17 +167,25 @@ public:
|
||||
|
||||
/**
|
||||
* @brief Get new output since a given offset (thread-safe).
|
||||
* Returns the new text and updates offset to the current size.
|
||||
* Returns the new text and advances offset. Offsets are absolute positions in everything ever
|
||||
* appended (start from 0), so trimming old output never shifts them: a reader that falls behind a
|
||||
* trim only loses the trimmed part, not output that is still buffered.
|
||||
*/
|
||||
std::string getOutputSince(size_t& offset) const {
|
||||
std::lock_guard<std::mutex> lk(output_mutex_);
|
||||
if (offset >= process_output_.size()) {
|
||||
offset = process_output_.size();
|
||||
return {};
|
||||
}
|
||||
std::string result = process_output_.substr(offset);
|
||||
offset = process_output_.size();
|
||||
return result;
|
||||
return outputSince(process_output_, output_total_, offset);
|
||||
}
|
||||
|
||||
/** getOutputSince() on a buffer holding the LAST buffer.size() of `total` bytes ever appended. */
|
||||
static std::string outputSince(const std::string& buffer, size_t total, size_t& offset)
|
||||
{
|
||||
return outputWindowSince(buffer, total, offset);
|
||||
}
|
||||
|
||||
/** Absolute end of everything ever appended (a valid getOutputSince() offset). */
|
||||
size_t getOutputEnd() const {
|
||||
std::lock_guard<std::mutex> lk(output_mutex_);
|
||||
return output_total_;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -245,6 +327,121 @@ public:
|
||||
*/
|
||||
static bool isDaemonProcessRunning();
|
||||
|
||||
/**
|
||||
* @brief Does a /proc entry (its comm + its exe link's basename) belong to a dragonxd? (Linux)
|
||||
*
|
||||
* comm alone misses a node in its final shutdown phase: Shutdown() renames the main thread to
|
||||
* "hush-shutoff" while the process still holds the datadir lock. The exe link keeps the binary
|
||||
* name, with " (deleted)" appended once the binary was replaced underneath it (reinstall/update).
|
||||
*/
|
||||
static bool isDaemonProcessIdentity(const std::string& comm, std::string exeName)
|
||||
{
|
||||
static const std::string kDeleted = " (deleted)";
|
||||
if (exeName.size() > kDeleted.size() &&
|
||||
exeName.compare(exeName.size() - kDeleted.size(), kDeleted.size(), kDeleted) == 0)
|
||||
exeName.erase(exeName.size() - kDeleted.size());
|
||||
return comm == "dragonxd" || exeName == "dragonxd";
|
||||
}
|
||||
|
||||
// ---- Ownership record --------------------------------------------------------------------------
|
||||
// <datadir>/obsidiandragon-dragonxd.pid names the node this wallet launched, so a later session that
|
||||
// finds it still running (after "Leave running") knows it is ours: it may stop it gracefully and wait
|
||||
// for it, never kill it. The start token (when that process started) defeats PID reuse and makes a
|
||||
// record from before a reboot stale.
|
||||
struct OwnershipRecord {
|
||||
long pid = 0;
|
||||
std::string startToken;
|
||||
std::string exePath;
|
||||
};
|
||||
static std::string formatOwnershipRecord(const OwnershipRecord& r)
|
||||
{
|
||||
return "pid=" + std::to_string(r.pid) + "\nstart=" + r.startToken + "\nexe=" + r.exePath + "\n";
|
||||
}
|
||||
static bool parseOwnershipRecord(const std::string& text, OwnershipRecord& out)
|
||||
{
|
||||
OwnershipRecord r;
|
||||
size_t pos = 0;
|
||||
while (pos < text.size()) {
|
||||
size_t end = text.find('\n', pos);
|
||||
if (end == std::string::npos) end = text.size();
|
||||
std::string line = text.substr(pos, end - pos);
|
||||
pos = end + 1;
|
||||
if (!line.empty() && line.back() == '\r') line.pop_back();
|
||||
if (line.rfind("pid=", 0) == 0) r.pid = std::strtol(line.c_str() + 4, nullptr, 10);
|
||||
else if (line.rfind("start=", 0) == 0) r.startToken = line.substr(6);
|
||||
else if (line.rfind("exe=", 0) == 0) r.exePath = line.substr(4);
|
||||
}
|
||||
if (r.pid <= 0 || r.startToken.empty()) return false;
|
||||
out = r;
|
||||
return true;
|
||||
}
|
||||
/** Linux /proc/<pid>/stat: the process state and its start time (clock ticks after boot, field 22). */
|
||||
static bool parseProcStatStart(const std::string& line, char& state, unsigned long long& startTicks)
|
||||
{
|
||||
const size_t close = line.rfind(')');
|
||||
if (close == std::string::npos) return false;
|
||||
// state ppid pgrp session tty_nr tpgid flags minflt cminflt majflt cmajflt utime stime cutime cstime
|
||||
// priority nice num_threads itrealvalue starttime
|
||||
return std::sscanf(line.c_str() + close + 1,
|
||||
" %c %*d %*d %*d %*d %*d %*u %*u %*u %*u %*u %*u %*u %*d %*d %*d %*d %*d %*d %llu",
|
||||
&state, &startTicks) == 2;
|
||||
}
|
||||
/**
|
||||
* dragonxd shrinks a large debug.log at startup (it keeps the tail), so the file can become smaller
|
||||
* than where we were reading. Resume at THIS run's startup banner — the first banner line stamped at or
|
||||
* after `launchUtc` (UTC) — not at 0, which would replay the previous run's lines (old rebuild markers,
|
||||
* old corruption errors) as if they were new. No such banner yet: skip to the end (it follows).
|
||||
*/
|
||||
static size_t shrinkResumeOffset(const std::string& content, std::int64_t launchUtc);
|
||||
|
||||
/** When process `pid` started, as an opaque token; "" if it isn't running (or is a zombie). */
|
||||
static std::string processStartToken(long pid);
|
||||
/** When process `pid` started, in UTC seconds; 0 if unknown. */
|
||||
static std::int64_t processStartUtc(long pid);
|
||||
/** Is process `pid` a dragonxd (by name / executable)? */
|
||||
static bool processIsDaemon(long pid);
|
||||
static std::string ownershipRecordPath(); // <datadir>/obsidiandragon-dragonxd.pid
|
||||
static void writeOwnershipRecord(const std::string& path, const OwnershipRecord& r);
|
||||
/** The record at `path`, if its process is still running; a stale or unreadable record is deleted. */
|
||||
static bool liveOwnershipRecord(const std::string& path, OwnershipRecord* out = nullptr);
|
||||
|
||||
/**
|
||||
* Does this DRAGONX.conf text set `key` on an active line ("key=value", spaces allowed around '=';
|
||||
* '#' starts a comment)? Used before adding a flag of our own: a command-line flag overrides the conf,
|
||||
* so the user's own setting must win.
|
||||
*/
|
||||
static bool confSetsKey(const std::string& confText, const std::string& key)
|
||||
{
|
||||
size_t pos = 0;
|
||||
while (pos < confText.size()) {
|
||||
size_t end = confText.find('\n', pos);
|
||||
if (end == std::string::npos) end = confText.size();
|
||||
std::string line = confText.substr(pos, end - pos);
|
||||
pos = end + 1;
|
||||
const size_t hash = line.find('#');
|
||||
if (hash != std::string::npos) line.erase(hash);
|
||||
const size_t eq = line.find('=');
|
||||
if (eq == std::string::npos) continue;
|
||||
size_t b = line.find_first_not_of(" \t\r");
|
||||
size_t e = line.find_last_not_of(" \t\r", eq == 0 ? 0 : eq - 1);
|
||||
if (b == std::string::npos || b >= eq || e == std::string::npos || e < b) continue;
|
||||
if (line.compare(b, e - b + 1, key) == 0) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Drop the wallet's own -dbcache= pin from `args` when the user's DRAGONX.conf sets dbcache (a
|
||||
* command-line flag overrides the conf, so the user's own value must win). Pure; no logging.
|
||||
*/
|
||||
static void dropPinnedDbcacheIfConfSets(std::vector<std::string>& args, const std::string& conf)
|
||||
{
|
||||
if (!confSetsKey(conf, "dbcache")) return;
|
||||
args.erase(std::remove_if(args.begin(), args.end(),
|
||||
[](const std::string& a) { return a.rfind("-dbcache=", 0) == 0; }),
|
||||
args.end());
|
||||
}
|
||||
|
||||
/** Decision returned by evaluateDatadirLockGate(): whether start() may spawn now. */
|
||||
struct StartLockGateDecision {
|
||||
bool proceed = true; // false => bail before spawning
|
||||
@@ -278,10 +475,27 @@ public:
|
||||
int getCrashCount() const { return crash_count_.load(); }
|
||||
/** Reset crash counter (call on successful connection or manual restart) */
|
||||
void resetCrashCount() { crash_count_ = 0; }
|
||||
/** Unexpected exits since this object was created; never reset (unlike the crash count), so a
|
||||
* reader can tell a new crash from an error it has already reported. */
|
||||
std::uint64_t getUnexpectedExitCount() const { return unexpected_exits_.load(); }
|
||||
|
||||
/** Monitor poll interval: stay `fast` for the first 30 s after the thread starts and for 3 s after any
|
||||
* output; otherwise double per idle tick up to `cap`. Any output resets to `fast`. Pure. */
|
||||
static std::chrono::milliseconds nextMonitorInterval(std::chrono::milliseconds cur, bool bytesRead,
|
||||
std::chrono::milliseconds sinceStart, std::chrono::milliseconds sinceLastData,
|
||||
std::chrono::milliseconds fast, std::chrono::milliseconds cap)
|
||||
{
|
||||
using ms = std::chrono::milliseconds;
|
||||
if (bytesRead || sinceStart < ms(30000) || sinceLastData < ms(3000)) return fast;
|
||||
const ms next = cur * 2;
|
||||
return next > cap ? cap : (next < fast ? fast : next);
|
||||
}
|
||||
|
||||
private:
|
||||
void setState(State s, const std::string& message = "");
|
||||
void monitorProcess();
|
||||
void requestMonitorStop(); // set should_stop_ under monitor_mutex_ and wake the monitor
|
||||
size_t outputTotalSnapshot() const; // output_total_ under output_mutex_ (monitor idle detection)
|
||||
void drainOutput(); // read any pending stdout into process_output_
|
||||
void appendOutput(const char* data, size_t len); // append + trim (caller holds output_mutex_)
|
||||
bool startProcess(const std::string& binary_path, const std::vector<std::string>& args);
|
||||
@@ -292,30 +506,46 @@ private:
|
||||
mutable std::mutex error_mutex_; // protects last_error_ (written by main + monitor threads)
|
||||
mutable std::mutex output_mutex_; // protects process_output_
|
||||
std::string process_output_;
|
||||
size_t output_total_ = 0; // bytes ever appended (the absolute end of process_output_)
|
||||
StateCallback state_callback_;
|
||||
|
||||
// Process handle
|
||||
#ifdef _WIN32
|
||||
HANDLE process_handle_ = nullptr;
|
||||
HANDLE stdout_read_ = nullptr; // pipe handle (Linux-style pipe, unused when tailing debug.log)
|
||||
std::string debug_log_path_; // path to daemon's debug.log for output tailing
|
||||
size_t debug_log_offset_ = 0; // read offset into debug.log
|
||||
std::string launch_cmd_; // full command line used to launch daemon (for diagnostics)
|
||||
std::string launch_binary_; // binary path used (for diagnostics)
|
||||
std::string launch_workdir_; // working directory used (for diagnostics)
|
||||
#else
|
||||
pid_t process_pid_ = 0;
|
||||
int stdout_fd_ = -1;
|
||||
int stdout_fd_ = -1; // the child's stdout+stderr (crash diagnostics; the log itself is debug.log)
|
||||
void tailDebugLog();
|
||||
|
||||
// Waits up to timeout_ms for `pid` to exit, draining its output; true once reaped. Only valid
|
||||
// after monitor_thread_ is joined, so that stop() is the sole reaper.
|
||||
bool reapWithin(pid_t pid, int timeout_ms, int& status);
|
||||
#endif
|
||||
|
||||
|
||||
// The node's output is its <datadir>/debug.log (timestamped; it outlives us if the node is left running).
|
||||
std::string debug_log_path_;
|
||||
size_t debug_log_offset_ = 0; // read offset into debug.log
|
||||
std::int64_t launch_utc_ = 0; // when we launched it (to find this run's banner after a log shrink)
|
||||
|
||||
std::thread monitor_thread_;
|
||||
std::atomic<bool> should_stop_{false};
|
||||
std::mutex monitor_mutex_; // pairs with monitor_cv_ so stop wakes the monitor at once
|
||||
std::condition_variable monitor_cv_;
|
||||
std::atomic<bool> stop_requested_{false}; // a stop was asked for: the next exit is not a crash
|
||||
// Serializes start/beginStop/stop/endStop over monitor_thread_, should_stop_ and stop_requested_ (two
|
||||
// threads joining one std::thread is undefined). Never taken by the monitor thread or the state callback.
|
||||
std::mutex lifecycle_mutex_;
|
||||
std::set<std::string> debug_categories_;
|
||||
int max_connections_ = 0; // 0 = daemon default
|
||||
bool stratum_enabled_ = false; // -stratum: host a RandomX pool (v1.3.0+; older daemons ignore it)
|
||||
std::string stratum_allowip_; // -stratumallowip subnet (empty = daemon default: loopback only)
|
||||
std::string wallet_file_; // -wallet=<name> for the active wallet; empty/"wallet.dat" = default
|
||||
std::atomic<int> crash_count_{0}; // consecutive crash counter
|
||||
std::atomic<std::uint64_t> unexpected_exits_{0}; // all unexpected exits (see getUnexpectedExitCount)
|
||||
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> salvage_on_next_start_{false}; // -salvagewallet flag for next start
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
// DragonX Wallet - ImGui Edition
|
||||
// Shared absolute-offset output ring helpers (daemon debug.log feed, xmrig console feed).
|
||||
|
||||
#pragma once
|
||||
|
||||
#include <cstddef>
|
||||
#include <string>
|
||||
|
||||
namespace dragonx {
|
||||
namespace daemon {
|
||||
|
||||
// getOutputSince() over a buffer holding the LAST buffer.size() of `total` bytes ever appended.
|
||||
inline std::string outputWindowSince(const std::string& buffer, std::size_t total, std::size_t& offset)
|
||||
{
|
||||
const std::size_t base = total - buffer.size(); // absolute position of buffer[0]
|
||||
if (offset < base) offset = base; // trimmed before read: resume at what's left
|
||||
if (offset >= total) {
|
||||
offset = total;
|
||||
return {};
|
||||
}
|
||||
std::string result = buffer.substr(offset - base);
|
||||
offset = total;
|
||||
return result;
|
||||
}
|
||||
|
||||
// Append `n` bytes, advance absolute `total`, and trim to ~cap at a newline once size exceeds 1.25x cap.
|
||||
// The `nl < size-1` guard keeps a lone trailing newline from erasing the whole buffer.
|
||||
inline void appendBounded(std::string& buffer, std::size_t& total, const char* data, std::size_t n, std::size_t cap)
|
||||
{
|
||||
buffer.append(data, n);
|
||||
total += n;
|
||||
if (buffer.size() > cap + cap / 4) {
|
||||
const std::size_t trim_to = buffer.size() - cap;
|
||||
const std::size_t nl = buffer.find('\n', trim_to);
|
||||
if (nl != std::string::npos && nl < buffer.size() - 1) buffer.erase(0, nl + 1);
|
||||
else buffer.erase(0, trim_to);
|
||||
}
|
||||
}
|
||||
|
||||
} // namespace daemon
|
||||
} // namespace dragonx
|
||||
@@ -17,6 +17,21 @@ namespace daemon {
|
||||
|
||||
namespace {
|
||||
|
||||
// Asks the isolated node to stop over its own RPC (port + throwaway credentials, never the main node's)
|
||||
// and waits for it to exit. Without the RPC stop it was only ever force-killed, holding the wallet.dat
|
||||
// it had just created. Returns false if it still had to be force-killed.
|
||||
bool stopIsolatedNode(EmbeddedDaemon& node, rpc::RPCClient& cli, int port, const std::string& user,
|
||||
const std::string& pass, int waitMs)
|
||||
{
|
||||
node.markStopRequested();
|
||||
// connect() also succeeds against a node still in warmup, which accepts stop too.
|
||||
if (cli.isConnected() || cli.connect("127.0.0.1", std::to_string(port), user, pass, /*useTls=*/false)) {
|
||||
try { cli.call("stop", nlohmann::json::array(), 10L); } catch (const std::exception&) {}
|
||||
}
|
||||
cli.disconnect();
|
||||
return node.stop(waitMs);
|
||||
}
|
||||
|
||||
// Random alphanumeric token for the isolated node's throwaway RPC credentials (libsodium CSPRNG;
|
||||
// sodium_init() has already run at app startup for the chat crypto).
|
||||
std::string randomToken(int n)
|
||||
@@ -113,7 +128,7 @@ SeedWalletResult SeedWalletCreator::create(bool keepDatadir,
|
||||
}
|
||||
if (!ready) {
|
||||
r.error = "The isolated node did not become ready in time.";
|
||||
temp.stop(20000);
|
||||
stopIsolatedNode(temp, cli, port, user, pass, 20000); // datadir is thrown away: a forced stop is harmless
|
||||
fs::remove_all(base, ec);
|
||||
return r;
|
||||
}
|
||||
@@ -146,18 +161,24 @@ SeedWalletResult SeedWalletCreator::create(bool keepDatadir,
|
||||
}
|
||||
}
|
||||
|
||||
// 7. Stop the isolated node (graceful; it flushes its tiny empty chain quickly). Adopt later copies
|
||||
// this wallet.dat, so it must have been closed cleanly: a node that had to be force-killed
|
||||
// fails the creation (nothing has been swept into it yet, so nothing is lost by retrying).
|
||||
if (!stopIsolatedNode(temp, cli, port, user, pass, 60000) && r.ok) {
|
||||
r.ok = false;
|
||||
r.error = "The isolated node had to be force-stopped, so its new wallet can't be trusted. "
|
||||
"Please try again.";
|
||||
}
|
||||
|
||||
// W1-2: never hand back a live seed on a failure path. If the mnemonic was exported but a
|
||||
// later step failed (empty address, or z_getnewaddress threw), the caller discards this
|
||||
// result without wiping it, which would leave the seed resident. Success keeps it deliberately.
|
||||
// later step failed (empty address, z_getnewaddress threw, or an unclean stop), the caller
|
||||
// discards this result without wiping it, which would leave the seed resident. Success keeps
|
||||
// it deliberately.
|
||||
if (!r.ok && !r.seedPhrase.empty()) {
|
||||
sodium_memzero(&r.seedPhrase[0], r.seedPhrase.size());
|
||||
r.seedPhrase.clear();
|
||||
}
|
||||
|
||||
// 7. Stop the isolated node (graceful; it flushes its tiny empty chain quickly).
|
||||
cli.disconnect();
|
||||
temp.stop(20000);
|
||||
|
||||
// 8. Keep the temp wallet for a later sweep/adopt step, or scrub it. tempDatadir is the
|
||||
// migration root `base`; the new wallet.dat lives in <base>/DRAGONX.
|
||||
r.tempDatadir = base;
|
||||
|
||||
@@ -0,0 +1,155 @@
|
||||
#pragma once
|
||||
|
||||
// Delivering "stop" to dragonxd: what one attempt achieved, and when to send another. Pure and
|
||||
// header-only so the test target can drive it without the RPC client.
|
||||
//
|
||||
// Why retrying matters: dragonxd's HTTP server has 8 workers and a 16-deep queue. While a long
|
||||
// operation holds cs_main, the GUI's own abandoned polls can fill every slot, and each new request is
|
||||
// refused at once with "Work queue depth exceeded" until a slot frees. The old code made one or two
|
||||
// attempts, gated on a getinfo probe that needs cs_main, so stop was never delivered and the node was
|
||||
// then killed.
|
||||
|
||||
#include "rpc/rpc_failure.h"
|
||||
|
||||
namespace dragonx {
|
||||
namespace daemon {
|
||||
|
||||
// What the user picked on the shutdown screen while the node was stopping.
|
||||
enum class StopUserChoice { None, Detach, Kill };
|
||||
|
||||
// How a stop session ended. The wallet never kills the node unless the user chose Kill.
|
||||
enum class StopOutcome {
|
||||
Exited, // the process is gone
|
||||
StillRunning, // we stopped waiting (time budget, app shutdown, cancellation); never killed
|
||||
Detached, // the user chose to leave it running
|
||||
Killed, // the user chose Kill
|
||||
};
|
||||
|
||||
inline const char* stopOutcomeName(StopOutcome o)
|
||||
{
|
||||
switch (o) {
|
||||
case StopOutcome::Exited: return "Exited";
|
||||
case StopOutcome::StillRunning: return "StillRunning";
|
||||
case StopOutcome::Detached: return "Detached";
|
||||
case StopOutcome::Killed: return "Killed";
|
||||
}
|
||||
return "Exited";
|
||||
}
|
||||
|
||||
enum class StopSendResult {
|
||||
Accepted, // HTTP 200: the daemon ran stop
|
||||
Queued, // reply timeout: it reached the daemon and waits for a worker; it will run
|
||||
QueueFull, // "Work queue depth exceeded": refused before queueing (holds no slot)
|
||||
NotListening, // refused / connect timeout: RPC isn't bound (not up yet, or already unbound)
|
||||
ShuttingDown, // 503 / "Unhandled request": the daemon is already shutting down
|
||||
Auth, // 401: our credentials are wrong or stale
|
||||
Other,
|
||||
};
|
||||
|
||||
inline const char* stopSendResultName(StopSendResult r)
|
||||
{
|
||||
switch (r) {
|
||||
case StopSendResult::Accepted: return "Accepted";
|
||||
case StopSendResult::Queued: return "Queued";
|
||||
case StopSendResult::QueueFull: return "QueueFull";
|
||||
case StopSendResult::NotListening: return "NotListening";
|
||||
case StopSendResult::ShuttingDown: return "ShuttingDown";
|
||||
case StopSendResult::Auth: return "Auth";
|
||||
case StopSendResult::Other: return "Other";
|
||||
}
|
||||
return "Other";
|
||||
}
|
||||
|
||||
// What a stop call's failure kind (RpcFailureKind::None = HTTP 200) means for delivery.
|
||||
inline StopSendResult stopSendResultFromKind(rpc::RpcFailureKind kind)
|
||||
{
|
||||
using K = rpc::RpcFailureKind;
|
||||
switch (kind) {
|
||||
case K::None: return StopSendResult::Accepted;
|
||||
case K::ReplyTimeout: return StopSendResult::Queued;
|
||||
case K::QueueFull: return StopSendResult::QueueFull;
|
||||
case K::NotListening:
|
||||
case K::ConnectTimeout: return StopSendResult::NotListening;
|
||||
case K::ShuttingDown:
|
||||
case K::Unhandled: return StopSendResult::ShuttingDown;
|
||||
case K::Auth: return StopSendResult::Auth;
|
||||
default: return StopSendResult::Other;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* When to (re)send stop. Times are seconds on the caller's clock.
|
||||
*
|
||||
* - Accepted / ShuttingDown: delivered, never resend.
|
||||
* - Queued: the daemon has it. Resend at most every 30s: each resend takes another queue slot, and
|
||||
* concurrent stops race on the miner threads (stop's GenerateBitcoins(false)).
|
||||
* - QueueFull / NotListening / Other: not delivered, cheap to retry: again after 1.5s.
|
||||
* - NotListening AFTER the daemon had heard us means it unbound its listener to shut down: delivered.
|
||||
* - Auth: give up for a node we don't own. For our own node the caller may switch credentials and
|
||||
* retry at once (retryNow), or give up (giveUp).
|
||||
*/
|
||||
class StopDeliveryPolicy {
|
||||
public:
|
||||
static constexpr double kRetrySec = 1.5;
|
||||
static constexpr double kQueuedResendSec = 30.0;
|
||||
|
||||
explicit StopDeliveryPolicy(bool owned) : owned_(owned) {}
|
||||
|
||||
bool shouldSend(double now) const { return !finished_ && now >= nextSendAt_; }
|
||||
|
||||
// Records one attempt; returns it as interpreted (a refusal after we were heard reads as ShuttingDown).
|
||||
StopSendResult record(StopSendResult r, double now)
|
||||
{
|
||||
++attempts_;
|
||||
if (r == StopSendResult::NotListening && heard_) r = StopSendResult::ShuttingDown;
|
||||
switch (r) {
|
||||
case StopSendResult::Accepted:
|
||||
heard_ = true;
|
||||
markDelivered();
|
||||
break;
|
||||
case StopSendResult::ShuttingDown:
|
||||
markDelivered();
|
||||
break;
|
||||
case StopSendResult::Queued:
|
||||
heard_ = true;
|
||||
nextSendAt_ = now + kQueuedResendSec;
|
||||
break;
|
||||
case StopSendResult::Auth:
|
||||
authRejected_ = true;
|
||||
if (!owned_) finished_ = true;
|
||||
nextSendAt_ = now + kRetrySec;
|
||||
break;
|
||||
case StopSendResult::QueueFull:
|
||||
case StopSendResult::NotListening:
|
||||
case StopSendResult::Other:
|
||||
nextSendAt_ = now + kRetrySec;
|
||||
break;
|
||||
}
|
||||
last_ = r;
|
||||
return r;
|
||||
}
|
||||
|
||||
void markDelivered() { delivered_ = finished_ = true; } // also for a graceful signal (POSIX SIGTERM)
|
||||
void retryNow(double now) { nextSendAt_ = now; }
|
||||
void giveUp() { finished_ = true; }
|
||||
|
||||
bool delivered() const { return delivered_; } // the daemon is stopping (or was signalled)
|
||||
bool heard() const { return heard_; } // it has (or ran) our request
|
||||
bool finished() const { return finished_; } // no more attempts
|
||||
bool authRejected() const { return authRejected_; }
|
||||
int attempts() const { return attempts_; }
|
||||
StopSendResult last() const { return last_; }
|
||||
|
||||
private:
|
||||
bool owned_ = true;
|
||||
bool delivered_ = false;
|
||||
bool heard_ = false;
|
||||
bool finished_ = false;
|
||||
bool authRejected_ = false;
|
||||
int attempts_ = 0;
|
||||
double nextSendAt_ = 0.0;
|
||||
StopSendResult last_ = StopSendResult::Other;
|
||||
};
|
||||
|
||||
} // namespace daemon
|
||||
} // namespace dragonx
|
||||
@@ -173,9 +173,10 @@ bool XmrigManager::generateConfig(const Config& cfg, const std::string& outPath)
|
||||
|
||||
int hw = (int)std::thread::hardware_concurrency();
|
||||
if (hw < 1) hw = 1;
|
||||
// Use explicit thread count (not just a hint)
|
||||
threads_ = (cfg.threads > 0) ? cfg.threads : std::max(1, hw / 2);
|
||||
if (threads_ > hw) threads_ = hw;
|
||||
// Use explicit thread count (not just a hint). One store: the UI thread reads it (getRequestedThreads).
|
||||
int t = (cfg.threads > 0) ? cfg.threads : std::max(1, hw / 2);
|
||||
if (t > hw) t = hw;
|
||||
threads_ = t;
|
||||
|
||||
json j;
|
||||
j["autosave"] = false;
|
||||
@@ -324,7 +325,7 @@ bool XmrigManager::start(const Config& cfg) {
|
||||
return false;
|
||||
}
|
||||
DEBUG_LOGF("[INFO] XmrigManager: config written to %s (API port %d, threads %d)\n",
|
||||
cfgPath.c_str(), api_port_, threads_);
|
||||
cfgPath.c_str(), api_port_, threads_.load());
|
||||
|
||||
// Spawn process
|
||||
if (!startProcess(binary, cfgPath, threads_)) {
|
||||
@@ -355,12 +356,6 @@ void XmrigManager::stop(int wait_ms) {
|
||||
// Try graceful termination first
|
||||
TerminateProcess(process_handle_, 0);
|
||||
WaitForSingleObject(process_handle_, wait_ms);
|
||||
CloseHandle(process_handle_);
|
||||
process_handle_ = nullptr;
|
||||
}
|
||||
if (stdout_read_) {
|
||||
CloseHandle(stdout_read_);
|
||||
stdout_read_ = nullptr;
|
||||
}
|
||||
#else
|
||||
if (process_pid_ > 0) {
|
||||
@@ -385,15 +380,29 @@ void XmrigManager::stop(int wait_ms) {
|
||||
}
|
||||
process_pid_ = 0;
|
||||
}
|
||||
#endif
|
||||
|
||||
if (monitor_thread_.joinable())
|
||||
monitor_thread_.join();
|
||||
|
||||
// Close the pipe only after the monitor thread has finished with it (it
|
||||
// drains stdout); closing earlier let read() hit a closed/reused fd.
|
||||
#ifdef _WIN32
|
||||
if (process_handle_) {
|
||||
CloseHandle(process_handle_);
|
||||
process_handle_ = nullptr;
|
||||
}
|
||||
if (stdout_read_) {
|
||||
CloseHandle(stdout_read_);
|
||||
stdout_read_ = nullptr;
|
||||
}
|
||||
#else
|
||||
if (stdout_fd_ >= 0) {
|
||||
close(stdout_fd_);
|
||||
stdout_fd_ = -1;
|
||||
}
|
||||
#endif
|
||||
|
||||
if (monitor_thread_.joinable())
|
||||
monitor_thread_.join();
|
||||
|
||||
state_ = State::Stopped;
|
||||
DEBUG_LOGF("[INFO] XmrigManager: stopped\n");
|
||||
}
|
||||
@@ -474,6 +483,7 @@ double XmrigManager::getMemoryUsageMB() const {
|
||||
}
|
||||
|
||||
void XmrigManager::drainOutput() {
|
||||
std::lock_guard<std::mutex> io_lk(io_mutex_);
|
||||
if (!stdout_read_) return;
|
||||
char buf[4096];
|
||||
DWORD avail = 0;
|
||||
@@ -564,7 +574,7 @@ double XmrigManager::getMemoryUsageMB() const {
|
||||
#ifdef __APPLE__
|
||||
// macOS: use ps to read RSS for xmrig process
|
||||
char cmd[128];
|
||||
snprintf(cmd, sizeof(cmd), "ps -o rss= -p %d 2>/dev/null", process_pid_);
|
||||
snprintf(cmd, sizeof(cmd), "ps -o rss= -p %d 2>/dev/null", (int)process_pid_.load(std::memory_order_relaxed));
|
||||
FILE* fp = popen(cmd, "r");
|
||||
if (!fp) return 0.0;
|
||||
char line[64];
|
||||
@@ -577,7 +587,7 @@ double XmrigManager::getMemoryUsageMB() const {
|
||||
return mb;
|
||||
#else
|
||||
char path[64];
|
||||
snprintf(path, sizeof(path), "/proc/%d/statm", process_pid_);
|
||||
snprintf(path, sizeof(path), "/proc/%d/statm", (int)process_pid_.load(std::memory_order_relaxed));
|
||||
FILE* fp = fopen(path, "r");
|
||||
if (!fp) return 0.0;
|
||||
long dummy = 0, pages = 0;
|
||||
@@ -591,6 +601,7 @@ double XmrigManager::getMemoryUsageMB() const {
|
||||
}
|
||||
|
||||
void XmrigManager::drainOutput() {
|
||||
std::lock_guard<std::mutex> io_lk(io_mutex_);
|
||||
if (stdout_fd_ < 0) return;
|
||||
char buf[4096];
|
||||
while (true) {
|
||||
@@ -612,17 +623,7 @@ void XmrigManager::drainOutput() {
|
||||
|
||||
void XmrigManager::appendOutput(const char* data, size_t len) {
|
||||
// Caller must hold output_mutex_
|
||||
static constexpr size_t MAX_OUTPUT = 1024 * 1024; // 1 MB cap
|
||||
process_output_.append(data, len);
|
||||
if (process_output_.size() > MAX_OUTPUT) {
|
||||
// Trim from the front at a newline boundary
|
||||
size_t cut = process_output_.size() - MAX_OUTPUT;
|
||||
auto pos = process_output_.find('\n', cut);
|
||||
if (pos != std::string::npos)
|
||||
process_output_.erase(0, pos + 1);
|
||||
else
|
||||
process_output_.erase(0, cut);
|
||||
}
|
||||
appendBounded(process_output_, output_total_, data, len, 1024 * 1024); // 1 MB cap (1.25x hysteresis)
|
||||
}
|
||||
|
||||
std::vector<std::string> XmrigManager::getRecentLines(int maxLines) const {
|
||||
@@ -711,9 +712,8 @@ void XmrigManager::monitorProcess() {
|
||||
// ============================================================================
|
||||
|
||||
void XmrigManager::pollStats() {
|
||||
// No-op on UI thread — stats are fetched by the monitor thread.
|
||||
// Just drain stdout so log lines stay fresh.
|
||||
drainOutput();
|
||||
// No-op on UI thread — stats and the stdout drain both run on the monitor
|
||||
// thread (a second reader here raced stop() closing the fd).
|
||||
}
|
||||
|
||||
void XmrigManager::fetchStatsHttp() {
|
||||
@@ -739,18 +739,23 @@ void XmrigManager::fetchStatsHttp() {
|
||||
headers = curl_slist_append(headers, authHeader.c_str());
|
||||
|
||||
curl_easy_setopt(curl, CURLOPT_URL, url);
|
||||
curl_easy_setopt(curl, CURLOPT_NOPROXY, "*"); // 127.0.0.1 miner API: never via an env proxy
|
||||
curl_easy_setopt(curl, CURLOPT_HTTPHEADER, headers);
|
||||
curl_easy_setopt(curl, CURLOPT_WRITEFUNCTION, curlWriteCb);
|
||||
curl_easy_setopt(curl, CURLOPT_WRITEDATA, &responseData);
|
||||
curl_easy_setopt(curl, CURLOPT_TIMEOUT_MS, 2000L); // 2s timeout — generous for loaded system
|
||||
curl_easy_setopt(curl, CURLOPT_CONNECTTIMEOUT_MS, 1000L); // 1s connect timeout
|
||||
curl_easy_setopt(curl, CURLOPT_NOSIGNAL, 1L); // Avoid signal issues in threads
|
||||
curl_easy_setopt(curl, CURLOPT_NOPROGRESS, 0L);
|
||||
curl_easy_setopt(curl, CURLOPT_XFERINFOFUNCTION, &XmrigManager::abortCb);
|
||||
curl_easy_setopt(curl, CURLOPT_XFERINFODATA, this);
|
||||
|
||||
CURLcode res = curl_easy_perform(curl);
|
||||
curl_slist_free_all(headers);
|
||||
curl_easy_cleanup(curl);
|
||||
|
||||
if (res != CURLE_OK) {
|
||||
if (should_stop_.load()) return; // aborted by stop(): not a failure
|
||||
static int s_fail_count = 0;
|
||||
if (++s_fail_count <= 5 || s_fail_count % 30 == 0) {
|
||||
DEBUG_LOGF("[WARN] XmrigManager::pollStats: curl failed (%d): %s url=%s\n",
|
||||
@@ -845,6 +850,9 @@ void XmrigManager::fetchPoolApiStats() {
|
||||
// pool.dragonx.cc sits behind Cloudflare and 403s odd User-Agents.
|
||||
curl_easy_setopt(curl, CURLOPT_USERAGENT, "Mozilla/5.0 (compatible; ObsidianDragon)");
|
||||
curl_easy_setopt(curl, CURLOPT_ACCEPT_ENCODING, "");
|
||||
curl_easy_setopt(curl, CURLOPT_NOPROGRESS, 0L);
|
||||
curl_easy_setopt(curl, CURLOPT_XFERINFOFUNCTION, &XmrigManager::abortCb);
|
||||
curl_easy_setopt(curl, CURLOPT_XFERINFODATA, this);
|
||||
|
||||
CURLcode res = curl_easy_perform(curl);
|
||||
curl_easy_cleanup(curl);
|
||||
|
||||
+13
-12
@@ -11,6 +11,8 @@
|
||||
#include <vector>
|
||||
#include <cstdint>
|
||||
|
||||
#include "output_window.h"
|
||||
|
||||
#ifdef _WIN32
|
||||
#include <winsock2.h>
|
||||
#include <windows.h>
|
||||
@@ -106,13 +108,7 @@ public:
|
||||
*/
|
||||
std::string getOutputSince(size_t& offset) const {
|
||||
std::lock_guard<std::mutex> lk(output_mutex_);
|
||||
if (offset >= process_output_.size()) {
|
||||
offset = process_output_.size();
|
||||
return {};
|
||||
}
|
||||
std::string result = process_output_.substr(offset);
|
||||
offset = process_output_.size();
|
||||
return result;
|
||||
return outputWindowSince(process_output_, output_total_, offset);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -161,6 +157,9 @@ private:
|
||||
// Set last_error_ under error_mutex_ (writers run on both the main thread and the monitor thread).
|
||||
void setLastError(std::string e) { std::lock_guard<std::mutex> lk(error_mutex_); last_error_ = std::move(e); }
|
||||
void fetchStatsHttp(); // Blocking HTTP call — runs on monitor thread only
|
||||
// curl xfer-info callback: abort an in-flight stats fetch once stop() is requested.
|
||||
static int abortCb(void* p, int64_t, int64_t, int64_t, int64_t)
|
||||
{ return static_cast<const XmrigManager*>(p)->should_stop_.load() ? 1 : 0; }
|
||||
void fetchPoolApiStats(); // Fetch pool-side stats (hashrate) from pool HTTP API
|
||||
|
||||
std::atomic<State> state_{State::Stopped};
|
||||
@@ -169,23 +168,25 @@ private:
|
||||
|
||||
mutable std::mutex output_mutex_;
|
||||
std::string process_output_;
|
||||
std::size_t output_total_ = 0; // bytes ever appended (absolute end; guarded by output_mutex_, kept across restarts)
|
||||
|
||||
// xmrig HTTP API credentials (random per session)
|
||||
int api_port_ = 0;
|
||||
std::string api_token_;
|
||||
int threads_ = 0; // Thread count for mining
|
||||
std::atomic<int> threads_{0}; // Thread count for mining (written on the mining-control thread, read by the UI)
|
||||
std::string pool_host_; // Pool hostname for stats API
|
||||
PoolStats stats_;
|
||||
mutable std::mutex stats_mutex_;
|
||||
|
||||
// Process handles
|
||||
#ifdef _WIN32
|
||||
HANDLE process_handle_ = nullptr;
|
||||
HANDLE stdout_read_ = nullptr;
|
||||
std::atomic<HANDLE> process_handle_{nullptr};
|
||||
std::atomic<HANDLE> stdout_read_{nullptr};
|
||||
#else
|
||||
pid_t process_pid_ = 0;
|
||||
int stdout_fd_ = -1;
|
||||
std::atomic<pid_t> process_pid_{0};
|
||||
std::atomic<int> stdout_fd_{-1};
|
||||
#endif
|
||||
std::mutex io_mutex_; // serializes drainOutput(); lock order io_mutex_ -> output_mutex_
|
||||
|
||||
std::thread monitor_thread_;
|
||||
std::atomic<bool> should_stop_{false};
|
||||
|
||||
@@ -31,6 +31,7 @@ std::string AddressBook::getDefaultPath()
|
||||
|
||||
bool AddressBook::load()
|
||||
{
|
||||
if (sweep_active_) return true; // UI sweep: keep the demo list and the real-book snapshot
|
||||
file_path_ = getDefaultPath();
|
||||
|
||||
std::ifstream file(file_path_);
|
||||
@@ -79,6 +80,7 @@ bool AddressBook::load()
|
||||
|
||||
bool AddressBook::save()
|
||||
{
|
||||
if (sweep_active_) return true; // UI sweep: demo contacts never reach disk
|
||||
if (file_path_.empty()) {
|
||||
file_path_ = getDefaultPath();
|
||||
}
|
||||
|
||||
+20
-3
@@ -130,10 +130,25 @@ public:
|
||||
std::uint64_t revision() const { return revision_; }
|
||||
|
||||
/**
|
||||
* @brief UI-sweep ONLY: replace the in-memory entries WITHOUT persisting to disk, so the sweep can
|
||||
* seed demo contacts and restore the real book without a disk write. Do not use outside the sweep.
|
||||
* @brief UI-sweep ONLY: show `demo` instead of the real book. The first call snapshots the real
|
||||
* entries; later calls (the sweep re-runs its setup every frame) only swap the demo list. While
|
||||
* active, save() and load() return true without touching disk or the snapshot, so nothing (an
|
||||
* add/update/remove, reattachLegacyScopes) can persist demo contacts. Do not use outside the sweep.
|
||||
*/
|
||||
void sweepSetEntries(std::vector<AddressBookEntry> e) { entries_ = std::move(e); ++revision_; }
|
||||
void sweepBegin(std::vector<AddressBookEntry> demo) {
|
||||
if (!sweep_active_) { sweep_backup_ = std::move(entries_); sweep_active_ = true; }
|
||||
entries_ = std::move(demo);
|
||||
++revision_;
|
||||
}
|
||||
/** @brief UI-sweep ONLY: put the real book back (no disk write). Idempotent. */
|
||||
void sweepEnd() {
|
||||
if (!sweep_active_) return;
|
||||
entries_ = std::move(sweep_backup_);
|
||||
sweep_backup_.clear();
|
||||
sweep_active_ = false;
|
||||
++revision_;
|
||||
}
|
||||
bool sweepActive() const { return sweep_active_; }
|
||||
|
||||
/**
|
||||
* @brief Check if empty
|
||||
@@ -144,6 +159,8 @@ private:
|
||||
std::vector<AddressBookEntry> entries_;
|
||||
std::uint64_t revision_ = 0;
|
||||
std::string file_path_;
|
||||
std::vector<AddressBookEntry> sweep_backup_; // the real book while a UI sweep shows demo contacts
|
||||
bool sweep_active_ = false;
|
||||
};
|
||||
|
||||
} // namespace data
|
||||
|
||||
@@ -0,0 +1,126 @@
|
||||
#pragma once
|
||||
// Pure period-earnings aggregation for the Balance tab's inline earnings figures. Header-only and
|
||||
// dependency-free (beyond TransactionInfo + the std lib) so it can be unit-tested without the GUI.
|
||||
//
|
||||
// It sums wallet INFLOWS (received + mined) over the CURRENT calendar period (hour / day / month /
|
||||
// year), optionally NET of sends, both per receiving address and wallet-wide. Values are "as seen"
|
||||
// (no confirmation/maturity gate), matching the balances the wallet already shows.
|
||||
//
|
||||
// Attribution is best-effort: inflows attribute to TransactionInfo::address (our receiving address);
|
||||
// in Net mode a send debits TransactionInfo::from_address when present (our source address). A send
|
||||
// with no from_address still counts in the wallet TOTAL but not in any per-address figure, so the
|
||||
// per-address numbers can be less complete than the total in Net mode.
|
||||
#include <cstddef>
|
||||
#include <cstdint>
|
||||
#include <ctime>
|
||||
#include <cmath>
|
||||
#include <map>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
#include "data/wallet_state.h" // TransactionInfo
|
||||
|
||||
namespace dragonx {
|
||||
namespace data {
|
||||
|
||||
enum class EarningsInterval { Hour = 0, Day = 1, Month = 2, Year = 3 };
|
||||
enum class EarningsMode { Inflows = 0, Net = 1 };
|
||||
|
||||
// Start (Unix seconds, local calendar) of the current period containing `nowSec` for `interval`:
|
||||
// Hour = top of the current clock hour; Day = local midnight; Month/Year = the 1st of the local
|
||||
// calendar month / Jan 1 of the local year. DST quirks follow std::mktime (as LocalDayStartMemo does).
|
||||
inline std::int64_t earningsPeriodStart(std::int64_t nowSec, EarningsInterval interval) {
|
||||
std::time_t t = (std::time_t)nowSec;
|
||||
std::tm lt{};
|
||||
#if defined(_WIN32)
|
||||
localtime_s(<, &t);
|
||||
#else
|
||||
localtime_r(&t, <);
|
||||
#endif
|
||||
lt.tm_sec = 0;
|
||||
lt.tm_min = 0; // all intervals zero out seconds + minutes
|
||||
if (interval != EarningsInterval::Hour) lt.tm_hour = 0; // Day/Month/Year
|
||||
if (interval == EarningsInterval::Month || interval == EarningsInterval::Year) lt.tm_mday = 1;
|
||||
if (interval == EarningsInterval::Year) lt.tm_mon = 0; // January
|
||||
lt.tm_isdst = -1; // let mktime resolve DST for the normalized time
|
||||
return (std::int64_t)std::mktime(<);
|
||||
}
|
||||
|
||||
struct EarningsResult {
|
||||
double total = 0.0; // wallet-wide earnings for the period (mode applied)
|
||||
std::map<std::string, double> perAddress; // per receiving (Inflows) / source-debited (Net) address
|
||||
int inflowCount = 0; // inflow txs counted in the period
|
||||
};
|
||||
|
||||
// Sum earnings over the current `interval` period. Inflows = received + mined (>= 0). Net also
|
||||
// subtracts sends (so the total can be negative). `nowSec` is passed in for testability.
|
||||
inline EarningsResult computeEarnings(const std::vector<TransactionInfo>& txs, std::int64_t nowSec,
|
||||
EarningsInterval interval, EarningsMode mode) {
|
||||
EarningsResult r;
|
||||
const std::int64_t start = earningsPeriodStart(nowSec, interval);
|
||||
const bool net = (mode == EarningsMode::Net);
|
||||
double totalIn = 0.0, totalOut = 0.0;
|
||||
for (const auto& tx : txs) {
|
||||
if (tx.timestamp < start) continue;
|
||||
if (tx.type == "receive" || tx.type == "mined") {
|
||||
const double a = std::fabs(tx.amount);
|
||||
totalIn += a;
|
||||
r.inflowCount++;
|
||||
if (!tx.address.empty()) r.perAddress[tx.address] += a;
|
||||
} else if (net && tx.type == "send") {
|
||||
const double a = std::fabs(tx.amount);
|
||||
totalOut += a;
|
||||
if (!tx.from_address.empty()) r.perAddress[tx.from_address] -= a;
|
||||
}
|
||||
}
|
||||
r.total = net ? (totalIn - totalOut) : totalIn;
|
||||
return r;
|
||||
}
|
||||
|
||||
// Convenience: the earnings for a single address in a result (0 if the address earned nothing).
|
||||
inline double earningsForAddress(const EarningsResult& r, const std::string& address) {
|
||||
auto it = r.perAddress.find(address);
|
||||
return (it != r.perAddress.end()) ? it->second : 0.0;
|
||||
}
|
||||
|
||||
// Every input the result depends on: equal keys => the cached result is still valid. `periodStart` is
|
||||
// computed by the caller (earningsPeriodStart) so a period roll-over invalidates the cache.
|
||||
struct EarningsKey {
|
||||
std::uint64_t txRevision = 0; // WalletState::tx_revision (bumped by every write to transactions)
|
||||
std::size_t txCount = 0; // belt: a writer that skips the bump but changes the row count
|
||||
EarningsInterval interval = EarningsInterval::Day;
|
||||
EarningsMode mode = EarningsMode::Inflows;
|
||||
std::int64_t periodStart = 0;
|
||||
bool operator==(const EarningsKey& o) const {
|
||||
return txRevision == o.txRevision && txCount == o.txCount && interval == o.interval &&
|
||||
mode == o.mode && periodStart == o.periodStart;
|
||||
}
|
||||
};
|
||||
|
||||
// Revision-keyed cache (mirrors ui::MiningEarningsCache): the O(transactions) pass reruns only when an
|
||||
// input changes, plus a staleness safety net for a future writer that skips the revision bump.
|
||||
class EarningsCache {
|
||||
public:
|
||||
static constexpr std::int64_t kMaxAgeMs = 2000;
|
||||
const EarningsResult& get(const EarningsKey& key, const std::vector<TransactionInfo>& txs,
|
||||
std::int64_t nowSec, std::int64_t nowMs) {
|
||||
if (!valid_ || !(key_ == key) || (nowMs - computedAtMs_) > kMaxAgeMs) {
|
||||
result_ = computeEarnings(txs, nowSec, key.interval, key.mode);
|
||||
key_ = key;
|
||||
valid_ = true;
|
||||
computedAtMs_ = nowMs;
|
||||
computes_++;
|
||||
}
|
||||
return result_;
|
||||
}
|
||||
int computeCount() const { return computes_; } // tests
|
||||
private:
|
||||
bool valid_ = false;
|
||||
EarningsKey key_;
|
||||
std::int64_t computedAtMs_ = 0;
|
||||
EarningsResult result_;
|
||||
int computes_ = 0;
|
||||
};
|
||||
|
||||
} // namespace data
|
||||
} // namespace dragonx
|
||||
@@ -4,6 +4,7 @@
|
||||
|
||||
#pragma once
|
||||
|
||||
#include <cstddef>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
@@ -38,6 +39,25 @@ struct ExchangeInfo {
|
||||
*/
|
||||
const std::vector<ExchangeInfo>& getExchangeRegistry();
|
||||
|
||||
/**
|
||||
* @brief Index of the selected exchange/pair in `reg`, resolved by saved NAME.
|
||||
* Matches the exchange name (any exchange when selEx is empty) THEN the pair display name, like the chart
|
||||
* fetch always has. Not found => (0,0), found=false (the registry's first pair - the app's default).
|
||||
* Single source of truth for the Market tab chips and App::refreshExchangeChart so they cannot diverge.
|
||||
*/
|
||||
struct ExchangeSelection { int exchangeIdx = 0; int pairIdx = 0; bool found = false; };
|
||||
inline ExchangeSelection resolveExchangeSelection(const std::vector<ExchangeInfo>& reg,
|
||||
const std::string& selEx, const std::string& selPair)
|
||||
{
|
||||
for (std::size_t ei = 0; ei < reg.size(); ++ei) {
|
||||
if (!selEx.empty() && reg[ei].name != selEx) continue;
|
||||
for (std::size_t pi = 0; pi < reg[ei].pairs.size(); ++pi)
|
||||
if (reg[ei].pairs[pi].displayName == selPair)
|
||||
return {static_cast<int>(ei), static_cast<int>(pi), true};
|
||||
}
|
||||
return {};
|
||||
}
|
||||
|
||||
/**
|
||||
* @brief Parse a CoinGecko /coins/{id}/tickers JSON body into the exchange registry.
|
||||
*
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
#pragma once
|
||||
|
||||
#include <string>
|
||||
|
||||
// Pure decisions for the PIN quick-unlock vault (C87), kept free of App/UI/RPC state so they are
|
||||
// unit-testable. The vault seals the wallet passphrase under the PIN. None of these paths deletes
|
||||
// it: a vault whose passphrase the wallet no longer accepts is only marked out of date for the
|
||||
// session (no lockout penalty), and the user removes it and sets the PIN again. The vault may hold
|
||||
// the only copy of a passphrase that still opens an older wallet backup.
|
||||
namespace dragonx {
|
||||
namespace data {
|
||||
|
||||
// What a completed walletpassphrasechange means for the active wallet's PIN vault.
|
||||
enum class VaultAfterChange {
|
||||
Keep, // no vault, the change failed or kept the same passphrase, or the vault is another wallet's
|
||||
MarkStale, // the vault seals the OLD passphrase: stop offering the PIN this session and warn (keep the file)
|
||||
};
|
||||
|
||||
// Decided on the main thread when the change completes, not when it is sent, so a vault created
|
||||
// while the change was in flight is covered too. `pathAtDispatch` is the vault path captured when
|
||||
// the change was sent and `pathNow` the active vault path now: a wallet switch in between makes
|
||||
// them differ, and the other wallet's vault is left alone.
|
||||
inline VaultAfterChange decideVaultAfterPassphraseChange(bool changeSucceeded,
|
||||
bool vaultExistsNow,
|
||||
bool samePassphrase,
|
||||
const std::string& pathAtDispatch,
|
||||
const std::string& pathNow) {
|
||||
if (!changeSucceeded || !vaultExistsNow || samePassphrase) return VaultAfterChange::Keep;
|
||||
if (pathAtDispatch.empty() || pathAtDispatch != pathNow) return VaultAfterChange::Keep;
|
||||
return VaultAfterChange::MarkStale;
|
||||
}
|
||||
|
||||
// dragonxd's RPC_WALLET_PASSPHRASE_INCORRECT ("The wallet passphrase entered was incorrect.").
|
||||
constexpr int kRpcWalletPassphraseIncorrect = -14;
|
||||
|
||||
// A PIN unlock whose vault opened (so the PIN was right) but whose walletpassphrase call the daemon
|
||||
// refused with -14: the vault holds a passphrase the wallet no longer accepts (it was changed after
|
||||
// the PIN was set, e.g. with dragonx-cli walletpassphrasechange). Not a wrong-PIN attempt, so no
|
||||
// lockout penalty; the PIN is marked out of date and the vault is kept.
|
||||
inline bool isStaleVaultUnlockFailure(bool vaultDecrypted, int rpcCode) {
|
||||
return vaultDecrypted && rpcCode == kRpcWalletPassphraseIncorrect;
|
||||
}
|
||||
|
||||
} // namespace data
|
||||
} // namespace dragonx
|
||||
@@ -34,4 +34,12 @@ inline MigrationResume decideSeedMigrationResume(bool pending,
|
||||
return MigrationResume::SweepGate;
|
||||
}
|
||||
|
||||
// At the Sweep step with a ~0 legacy balance, may we offer the direct "no funds -> adopt" short-cut
|
||||
// (swap wallet.dat immediately)? Only when NO sweep has been submitted — a genuinely empty wallet.
|
||||
// If a sweep WAS submitted, a ~0 balance is ambiguous: the sweep tx may be unconfirmed in the mempool
|
||||
// and could revert on a drop/reorg, so adopting now can strand the funds in the moved-aside backup.
|
||||
// In that case adopt must go through the confirmation-gated path (>=1 conf) instead — never swap
|
||||
// wallet.dat on an unconfirmed sweep. (fund-safety: audit)
|
||||
inline bool sweepGateMayAdoptEmptyWallet(bool sweepSubmitted) { return !sweepSubmitted; }
|
||||
|
||||
} // namespace dragonx
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
// DragonX Wallet - ImGui Edition
|
||||
// Copyright 2024-2026 The Hush Developers
|
||||
// Released under the GPLv3
|
||||
|
||||
#include "transaction_csv.h"
|
||||
|
||||
#include <cstdio>
|
||||
#include <ctime>
|
||||
|
||||
namespace dragonx {
|
||||
namespace data {
|
||||
|
||||
std::string escapeCsvField(const std::string& field)
|
||||
{
|
||||
// Neutralize spreadsheet formula injection: a field beginning with '=', '+', '-', '@',
|
||||
// tab, or CR is interpreted as a formula by Excel/LibreOffice, letting an attacker-supplied
|
||||
// memo/address execute on open. Prefix such fields with a single quote so they render as text.
|
||||
std::string safe = field;
|
||||
if (!safe.empty()) {
|
||||
const char c0 = safe.front();
|
||||
if (c0 == '=' || c0 == '+' || c0 == '-' || c0 == '@' || c0 == '\t' || c0 == '\r')
|
||||
safe.insert(safe.begin(), '\'');
|
||||
}
|
||||
if (safe.find(',') != std::string::npos ||
|
||||
safe.find('"') != std::string::npos ||
|
||||
safe.find('\n') != std::string::npos) {
|
||||
std::string escaped;
|
||||
escaped.reserve(safe.size() + 4);
|
||||
escaped += '"';
|
||||
for (char c : safe) {
|
||||
if (c == '"') escaped += "\"\"";
|
||||
else escaped += c;
|
||||
}
|
||||
escaped += '"';
|
||||
return escaped;
|
||||
}
|
||||
return safe;
|
||||
}
|
||||
|
||||
std::string formatTransactionsCsv(const std::vector<TransactionInfo>& txs)
|
||||
{
|
||||
std::string out;
|
||||
out.reserve(64 + txs.size() * 200);
|
||||
out += "Date,Type,Amount,Address,TXID,Confirmations,Memo\n";
|
||||
for (const auto& tx : txs) {
|
||||
std::time_t t = static_cast<std::time_t>(tx.timestamp);
|
||||
std::tm tmv{};
|
||||
#ifdef _WIN32
|
||||
const bool okTm = localtime_s(&tmv, &t) == 0;
|
||||
#else
|
||||
const bool okTm = localtime_r(&t, &tmv) != nullptr;
|
||||
#endif
|
||||
char datebuf[32] = "";
|
||||
if (okTm) std::strftime(datebuf, sizeof(datebuf), "%Y-%m-%d %H:%M:%S", &tmv);
|
||||
char amt[48];
|
||||
std::snprintf(amt, sizeof(amt), "%.8f", tx.amount);
|
||||
out += datebuf; out += ',';
|
||||
out += escapeCsvField(tx.type); out += ',';
|
||||
out += amt; out += ',';
|
||||
out += escapeCsvField(tx.address); out += ',';
|
||||
out += escapeCsvField(tx.txid); out += ',';
|
||||
out += std::to_string(tx.confirmations); out += ',';
|
||||
out += escapeCsvField(tx.memo); out += '\n';
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
} // namespace data
|
||||
} // namespace dragonx
|
||||
@@ -0,0 +1,23 @@
|
||||
// DragonX Wallet - ImGui Edition
|
||||
// Copyright 2024-2026 The Hush Developers
|
||||
// Released under the GPLv3
|
||||
|
||||
#pragma once
|
||||
|
||||
#include "wallet_state.h"
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
namespace dragonx {
|
||||
namespace data {
|
||||
|
||||
// CSV-escape one field: neutralises spreadsheet formula injection (leading = + - @ \t \r gets a
|
||||
// ' prefix) and quotes fields containing , " or \n.
|
||||
std::string escapeCsvField(const std::string& field);
|
||||
|
||||
// "Date,Type,Amount,Address,TXID,Confirmations,Memo\n" + one line per transaction
|
||||
// (local time, 8-decimals amount).
|
||||
std::string formatTransactionsCsv(const std::vector<TransactionInfo>& txs);
|
||||
|
||||
} // namespace data
|
||||
} // namespace dragonx
|
||||
@@ -9,6 +9,7 @@
|
||||
|
||||
#include <algorithm>
|
||||
#include <cstdio>
|
||||
#include <cstring>
|
||||
#include <filesystem>
|
||||
#include <utility>
|
||||
|
||||
@@ -110,8 +111,25 @@ std::string associatedDataForWallet(const std::string& walletHash)
|
||||
return std::string("obsidian-dragon-tx-history-v1:") + walletHash;
|
||||
}
|
||||
|
||||
// Non-reversible 64-bit tag of a derived key (BLAKE2b keyed by the key, over a fixed label). Only used to
|
||||
// notice that the key changed; nothing about the key can be recovered from it, and it never leaves memory.
|
||||
std::uint64_t computeKeyId(const std::array<unsigned char, 32>& key)
|
||||
{
|
||||
static const unsigned char kLabel[] = "obsidian-dragon-tx-history-keyid";
|
||||
unsigned char out[crypto_generichash_BYTES_MIN]; // 16, libsodium's minimum; the first 8 are used
|
||||
crypto_generichash(out, sizeof(out), kLabel, sizeof(kLabel) - 1, key.data(), key.size());
|
||||
std::uint64_t id = 0;
|
||||
std::memcpy(&id, out, sizeof(id));
|
||||
return id;
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
TransactionHistoryCache::SnapshotJob::~SnapshotJob()
|
||||
{
|
||||
sodium_memzero(key.data(), key.size());
|
||||
}
|
||||
|
||||
TransactionHistoryCache::TransactionHistoryCache()
|
||||
: TransactionHistoryCache(defaultDatabasePath())
|
||||
{
|
||||
@@ -170,6 +188,7 @@ std::string TransactionHistoryCache::walletIdentityHash(const std::string& walle
|
||||
|
||||
bool TransactionHistoryCache::ensureOpen()
|
||||
{
|
||||
std::lock_guard<std::recursive_mutex> dbLock(db_mutex_);
|
||||
if (db_) return true;
|
||||
|
||||
try {
|
||||
@@ -212,29 +231,91 @@ bool TransactionHistoryCache::ensureOpen()
|
||||
bool TransactionHistoryCache::unlockWithPassphrase(const std::string& walletIdentity,
|
||||
const std::string& passphrase)
|
||||
{
|
||||
if (walletIdentity.empty() || passphrase.empty() || !ensureOpen()) return false;
|
||||
if (walletIdentity.empty() || passphrase.empty()) return false;
|
||||
|
||||
// Held across the KDF on purpose: "this wallet's salt row exists" and "this wallet is the unlocked one"
|
||||
// must be atomic with respect to a writer job's prune (writeSnapshot keeps the unlocked wallet), or a job
|
||||
// for the previous wallet could delete the salt this key is being derived from. The UI thread only waits
|
||||
// here for a write already in flight, never for the queue.
|
||||
std::lock_guard<std::recursive_mutex> dbLock(db_mutex_);
|
||||
if (!ensureOpen()) return false;
|
||||
|
||||
std::string walletHash = walletIdentityHash(walletIdentity);
|
||||
std::vector<unsigned char> salt = getOrCreateSalt(walletHash);
|
||||
if (salt.empty()) return false;
|
||||
|
||||
if (!deriveKey(passphrase, salt)) return false;
|
||||
unlocked_wallet_hash_ = std::move(walletHash);
|
||||
key_ready_ = true;
|
||||
std::array<unsigned char, kKeyBytes> derived{};
|
||||
if (!deriveKey(passphrase, salt, derived)) return false;
|
||||
{
|
||||
std::lock_guard<std::mutex> keyLock(key_mutex_);
|
||||
key_ = derived;
|
||||
key_id_ = computeKeyId(derived);
|
||||
unlocked_wallet_hash_ = std::move(walletHash);
|
||||
key_ready_ = true;
|
||||
}
|
||||
sodium_memzero(derived.data(), derived.size());
|
||||
return true;
|
||||
}
|
||||
|
||||
bool TransactionHistoryCache::unlockUnencrypted(const std::string& walletIdentity)
|
||||
{
|
||||
if (walletIdentity.empty()) return false;
|
||||
std::lock_guard<std::recursive_mutex> dbLock(db_mutex_);
|
||||
if (!ensureOpen()) return false;
|
||||
std::string walletHash = walletIdentityHash(walletIdentity);
|
||||
std::vector<unsigned char> salt = getOrCreateSalt(walletHash);
|
||||
if (salt.empty()) return false;
|
||||
std::array<unsigned char, kKeyBytes> derived{};
|
||||
if (!deriveKeyFast(walletHash, salt, derived)) return false;
|
||||
{
|
||||
std::lock_guard<std::mutex> keyLock(key_mutex_);
|
||||
key_ = derived;
|
||||
key_id_ = computeKeyId(derived);
|
||||
unlocked_wallet_hash_ = std::move(walletHash);
|
||||
key_ready_ = true;
|
||||
}
|
||||
sodium_memzero(derived.data(), derived.size());
|
||||
return true;
|
||||
}
|
||||
|
||||
void TransactionHistoryCache::lockKey()
|
||||
{
|
||||
std::lock_guard<std::mutex> keyLock(key_mutex_);
|
||||
if (key_ready_) sodium_memzero(key_.data(), key_.size());
|
||||
key_ready_ = false;
|
||||
unlocked_wallet_hash_.clear();
|
||||
// key_id_ is kept on purpose: re-unlocking with the same passphrase yields the same id.
|
||||
}
|
||||
|
||||
bool TransactionHistoryCache::hasKey() const
|
||||
{
|
||||
std::lock_guard<std::mutex> keyLock(key_mutex_);
|
||||
return key_ready_;
|
||||
}
|
||||
|
||||
bool TransactionHistoryCache::isUnlockedFor(const std::string& walletIdentity) const
|
||||
{
|
||||
return key_ready_ && !walletIdentity.empty() &&
|
||||
unlocked_wallet_hash_ == walletIdentityHash(walletIdentity);
|
||||
if (walletIdentity.empty()) return false;
|
||||
const std::string hash = walletIdentityHash(walletIdentity); // outside the lock
|
||||
std::lock_guard<std::mutex> keyLock(key_mutex_);
|
||||
return key_ready_ && unlocked_wallet_hash_ == hash;
|
||||
}
|
||||
|
||||
bool TransactionHistoryCache::captureKey(const std::string& walletIdentity,
|
||||
std::array<unsigned char, 32>& keyOut) const
|
||||
{
|
||||
if (walletIdentity.empty()) return false;
|
||||
const std::string hash = walletIdentityHash(walletIdentity);
|
||||
std::lock_guard<std::mutex> keyLock(key_mutex_);
|
||||
if (!key_ready_ || unlocked_wallet_hash_ != hash) return false;
|
||||
keyOut = key_;
|
||||
return true;
|
||||
}
|
||||
|
||||
std::uint64_t TransactionHistoryCache::keyId() const
|
||||
{
|
||||
std::lock_guard<std::mutex> keyLock(key_mutex_);
|
||||
return key_id_;
|
||||
}
|
||||
|
||||
TransactionHistoryCache::LoadResult TransactionHistoryCache::load(
|
||||
@@ -243,6 +324,7 @@ TransactionHistoryCache::LoadResult TransactionHistoryCache::load(
|
||||
const std::string& currentTipHash)
|
||||
{
|
||||
LoadResult result;
|
||||
std::lock_guard<std::recursive_mutex> dbLock(db_mutex_); // serialised against the writer's INSERT/prune
|
||||
if (!isUnlockedFor(walletIdentity) || !ensureOpen()) return result;
|
||||
|
||||
std::string walletHash = walletIdentityHash(walletIdentity);
|
||||
@@ -284,6 +366,29 @@ TransactionHistoryCache::LoadResult TransactionHistoryCache::load(
|
||||
}
|
||||
}
|
||||
}
|
||||
// Sent-sweep watermark (optional). Drop it unless W/A are self-consistent and W is not above the
|
||||
// stored tip (a replay after an unclean stop would otherwise use a stale watermark); the anchor
|
||||
// hash is re-verified against the live chain on the next sweep regardless.
|
||||
if (payload.contains("sent_sweep") && payload["sent_sweep"].is_object()) {
|
||||
const auto& s = payload["sent_sweep"];
|
||||
SentSweepState sw;
|
||||
sw.height = s.value("height", -1);
|
||||
sw.anchorHeight = s.value("anchor_height", -1);
|
||||
sw.anchorHash = s.value("anchor_hash", std::string());
|
||||
sw.fullFailures = s.value("full_failures", 0);
|
||||
sw.fullRetryDelaySec = static_cast<long>(s.value("full_retry_delay_s", std::int64_t{0}));
|
||||
sw.daemonId = s.value("daemon_id", std::string());
|
||||
sw.lastFullTime = s.value("last_full_time", std::int64_t{0});
|
||||
sw.lastFullHeight = s.value("last_full_height", -1);
|
||||
if (s.contains("backlog") && s["backlog"].is_array()) {
|
||||
for (const auto& txid : s["backlog"])
|
||||
if (txid.is_string() && !txid.get<std::string>().empty())
|
||||
sw.backlog.push_back(txid.get<std::string>());
|
||||
}
|
||||
const bool consistent = sw.height >= 0 && sw.anchorHeight > 0 &&
|
||||
sw.anchorHeight <= sw.height && sw.height <= tipHeight;
|
||||
if (consistent) result.sentSweep = std::move(sw);
|
||||
}
|
||||
result.tipHeight = tipHeight;
|
||||
result.tipHash = tipHash;
|
||||
result.updatedAt = updatedAt;
|
||||
@@ -301,35 +406,81 @@ bool TransactionHistoryCache::replace(const std::string& walletIdentity,
|
||||
const std::string& tipHash,
|
||||
const std::vector<TransactionInfo>& transactions,
|
||||
std::time_t updatedAt,
|
||||
const std::unordered_map<std::string, int>& shieldedScanHeights)
|
||||
const std::unordered_map<std::string, int>& shieldedScanHeights,
|
||||
const SentSweepState& sentSweep)
|
||||
{
|
||||
if (!isUnlockedFor(walletIdentity) || !ensureOpen()) return false;
|
||||
// The synchronous form (tests, tools): the same write the background writer does.
|
||||
SnapshotJob job;
|
||||
if (!captureKey(walletIdentity, job.key)) return false;
|
||||
job.walletIdentity = walletIdentity;
|
||||
job.tipHeight = tipHeight;
|
||||
job.tipHash = tipHash;
|
||||
job.transactions = transactions;
|
||||
job.updatedAt = updatedAt;
|
||||
job.shieldedScanHeights = shieldedScanHeights;
|
||||
job.sentSweep = sentSweep;
|
||||
return writeSnapshot(std::move(job));
|
||||
}
|
||||
|
||||
std::string walletHash = walletIdentityHash(walletIdentity);
|
||||
bool TransactionHistoryCache::writeSnapshot(SnapshotJob&& job)
|
||||
{
|
||||
struct WipeKey {
|
||||
std::array<unsigned char, 32>& key;
|
||||
~WipeKey() { sodium_memzero(key.data(), key.size()); }
|
||||
} wipe{job.key};
|
||||
|
||||
// A job without a key would replace the good snapshot with one nobody can read.
|
||||
if (job.walletIdentity.empty() || job.key == std::array<unsigned char, 32>{}) return false;
|
||||
const std::string walletHash = walletIdentityHash(job.walletIdentity);
|
||||
|
||||
// ---- the expensive part (JSON DOM, dump, AEAD), with no lock held ----
|
||||
json payload;
|
||||
payload["schema_version"] = kSchemaVersion;
|
||||
payload["wallet_hash"] = walletHash;
|
||||
payload["tip_height"] = tipHeight;
|
||||
payload["tip_hash"] = tipHash;
|
||||
payload["updated_at"] = static_cast<std::int64_t>(updatedAt);
|
||||
payload["tip_height"] = job.tipHeight;
|
||||
payload["tip_hash"] = job.tipHash;
|
||||
payload["updated_at"] = static_cast<std::int64_t>(job.updatedAt);
|
||||
payload["transactions"] = json::array();
|
||||
for (const auto& transaction : transactions) {
|
||||
for (const auto& transaction : job.transactions) {
|
||||
payload["transactions"].push_back(transactionToJson(transaction));
|
||||
}
|
||||
payload["shielded_scan_heights"] = json::object();
|
||||
for (const auto& [address, height] : shieldedScanHeights) {
|
||||
for (const auto& [address, height] : job.shieldedScanHeights) {
|
||||
if (!address.empty() && height >= 0) {
|
||||
payload["shielded_scan_heights"][address] = height;
|
||||
}
|
||||
}
|
||||
|
||||
// Sent-sweep watermark, only when it is self-consistent (0 < A <= W <= tip). An unknown key means
|
||||
// W = -1 on load, which is the pre-watermark behaviour, so older builds read a new payload safely.
|
||||
const SentSweepState& sw = job.sentSweep;
|
||||
if (sw.height >= 0 && sw.anchorHeight > 0 && sw.anchorHeight <= sw.height && sw.height <= job.tipHeight) {
|
||||
json s;
|
||||
s["height"] = sw.height;
|
||||
s["anchor_height"] = sw.anchorHeight;
|
||||
s["anchor_hash"] = sw.anchorHash;
|
||||
s["backlog"] = json::array();
|
||||
for (const auto& txid : sw.backlog)
|
||||
if (!txid.empty()) s["backlog"].push_back(txid);
|
||||
s["full_failures"] = sw.fullFailures;
|
||||
s["full_retry_delay_s"] = static_cast<std::int64_t>(sw.fullRetryDelaySec);
|
||||
s["daemon_id"] = sw.daemonId;
|
||||
s["last_full_time"] = sw.lastFullTime;
|
||||
s["last_full_height"] = sw.lastFullHeight;
|
||||
payload["sent_sweep"] = std::move(s);
|
||||
}
|
||||
|
||||
std::string plainText = payload.dump();
|
||||
std::vector<unsigned char> nonce;
|
||||
std::vector<unsigned char> cipherText;
|
||||
bool encrypted = encryptPayload(walletHash, plainText, nonce, cipherText);
|
||||
const bool encrypted = encryptPayload(job.key, walletHash, plainText, nonce, cipherText);
|
||||
sodium_memzero(plainText.data(), plainText.size());
|
||||
if (!encrypted) return false;
|
||||
|
||||
// ---- one autocommit INSERT OR REPLACE (WAL: a crash leaves the previous snapshot), then the prune ----
|
||||
std::lock_guard<std::recursive_mutex> dbLock(db_mutex_);
|
||||
if (!ensureOpen()) return false;
|
||||
|
||||
Statement statement(db_,
|
||||
"INSERT OR REPLACE INTO transaction_history_snapshots "
|
||||
"(wallet_hash, schema_version, tip_height, tip_hash, updated_at, nonce, ciphertext) "
|
||||
@@ -338,14 +489,22 @@ bool TransactionHistoryCache::replace(const std::string& walletIdentity,
|
||||
|
||||
if (!bindText(statement.handle, 1, walletHash)) return false;
|
||||
sqlite3_bind_int(statement.handle, 2, kSchemaVersion);
|
||||
sqlite3_bind_int(statement.handle, 3, std::max(0, tipHeight));
|
||||
if (!bindText(statement.handle, 4, tipHash)) return false;
|
||||
sqlite3_bind_int64(statement.handle, 5, static_cast<sqlite3_int64>(updatedAt));
|
||||
sqlite3_bind_int(statement.handle, 3, std::max(0, job.tipHeight));
|
||||
if (!bindText(statement.handle, 4, job.tipHash)) return false;
|
||||
sqlite3_bind_int64(statement.handle, 5, static_cast<sqlite3_int64>(job.updatedAt));
|
||||
if (!bindBlob(statement.handle, 6, nonce)) return false;
|
||||
if (!bindBlob(statement.handle, 7, cipherText)) return false;
|
||||
|
||||
if (sqlite3_step(statement.handle) != SQLITE_DONE) return false;
|
||||
pruneOtherWallets(walletHash); // bound DB growth — drop stale-hash snapshots/salts
|
||||
if (sqlite3_step(statement.handle) != SQLITE_DONE) {
|
||||
DEBUG_LOGF("Transaction history cache write failed: %s\n", sqlite3_errmsg(db_));
|
||||
return false;
|
||||
}
|
||||
std::string liveHash;
|
||||
{
|
||||
std::lock_guard<std::mutex> keyLock(key_mutex_);
|
||||
liveHash = unlocked_wallet_hash_;
|
||||
}
|
||||
pruneOtherWallets(walletHash, liveHash); // bound DB growth — drop stale-hash snapshots/salts
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -357,6 +516,7 @@ void TransactionHistoryCache::clearWallet(const std::string& walletIdentity)
|
||||
|
||||
int TransactionHistoryCache::snapshotCount()
|
||||
{
|
||||
std::lock_guard<std::recursive_mutex> dbLock(db_mutex_);
|
||||
if (!ensureOpen()) return 0;
|
||||
Statement statement(db_, "SELECT COUNT(*) FROM transaction_history_snapshots");
|
||||
if (!statement.handle || sqlite3_step(statement.handle) != SQLITE_ROW) return 0;
|
||||
@@ -417,8 +577,23 @@ std::vector<unsigned char> TransactionHistoryCache::getOrCreateSalt(const std::s
|
||||
return salt;
|
||||
}
|
||||
|
||||
bool TransactionHistoryCache::deriveKeyFast(const std::string& walletHash,
|
||||
const std::vector<unsigned char>& salt,
|
||||
std::array<unsigned char, 32>& keyOut)
|
||||
{
|
||||
if (salt.size() != crypto_pwhash_SALTBYTES) return false;
|
||||
static const unsigned char kLabel[] = "obsidian-dragon-tx-history-unenc-v1";
|
||||
// keyed BLAKE2b over label||walletHash, keyed by the per-wallet salt -> 32-byte key.
|
||||
std::string msg(reinterpret_cast<const char*>(kLabel), sizeof(kLabel) - 1);
|
||||
msg += walletHash;
|
||||
return crypto_generichash(keyOut.data(), keyOut.size(),
|
||||
reinterpret_cast<const unsigned char*>(msg.data()), msg.size(),
|
||||
salt.data(), salt.size()) == 0;
|
||||
}
|
||||
|
||||
bool TransactionHistoryCache::deriveKey(const std::string& passphrase,
|
||||
const std::vector<unsigned char>& salt)
|
||||
const std::vector<unsigned char>& salt,
|
||||
std::array<unsigned char, 32>& keyOut)
|
||||
{
|
||||
if (salt.size() != crypto_pwhash_SALTBYTES) return false;
|
||||
unsigned char derived[kKeyBytes];
|
||||
@@ -429,17 +604,17 @@ bool TransactionHistoryCache::deriveKey(const std::string& passphrase,
|
||||
crypto_pwhash_MEMLIMIT_INTERACTIVE,
|
||||
crypto_pwhash_ALG_ARGON2ID13);
|
||||
if (result != 0) return false;
|
||||
std::copy(derived, derived + sizeof(derived), key_.begin());
|
||||
std::copy(derived, derived + sizeof(derived), keyOut.begin());
|
||||
sodium_memzero(derived, sizeof(derived));
|
||||
return true;
|
||||
}
|
||||
|
||||
bool TransactionHistoryCache::encryptPayload(const std::string& walletHash,
|
||||
bool TransactionHistoryCache::encryptPayload(const std::array<unsigned char, 32>& key,
|
||||
const std::string& walletHash,
|
||||
const std::string& plainText,
|
||||
std::vector<unsigned char>& nonce,
|
||||
std::vector<unsigned char>& cipherText) const
|
||||
std::vector<unsigned char>& cipherText)
|
||||
{
|
||||
if (!key_ready_) return false;
|
||||
nonce.resize(crypto_aead_xchacha20poly1305_ietf_NPUBBYTES);
|
||||
randombytes_buf(nonce.data(), nonce.size());
|
||||
|
||||
@@ -450,7 +625,7 @@ bool TransactionHistoryCache::encryptPayload(const std::string& walletHash,
|
||||
cipherText.data(), &cipherLength,
|
||||
reinterpret_cast<const unsigned char*>(plainText.data()), plainText.size(),
|
||||
reinterpret_cast<const unsigned char*>(associatedData.data()), associatedData.size(),
|
||||
nullptr, nonce.data(), key_.data());
|
||||
nullptr, nonce.data(), key.data());
|
||||
if (result != 0) return false;
|
||||
cipherText.resize(static_cast<std::size_t>(cipherLength));
|
||||
return true;
|
||||
@@ -461,10 +636,16 @@ bool TransactionHistoryCache::decryptPayload(const std::string& walletHash,
|
||||
const std::vector<unsigned char>& cipherText,
|
||||
std::string& plainText) const
|
||||
{
|
||||
if (!key_ready_ || nonce.size() != crypto_aead_xchacha20poly1305_ietf_NPUBBYTES ||
|
||||
if (nonce.size() != crypto_aead_xchacha20poly1305_ietf_NPUBBYTES ||
|
||||
cipherText.size() < crypto_aead_xchacha20poly1305_ietf_ABYTES) {
|
||||
return false;
|
||||
}
|
||||
std::array<unsigned char, kKeyBytes> key{};
|
||||
{
|
||||
std::lock_guard<std::mutex> keyLock(key_mutex_);
|
||||
if (!key_ready_) return false;
|
||||
key = key_;
|
||||
}
|
||||
|
||||
std::string associatedData = associatedDataForWallet(walletHash);
|
||||
std::vector<unsigned char> plain(cipherText.size() - crypto_aead_xchacha20poly1305_ietf_ABYTES);
|
||||
@@ -473,7 +654,8 @@ bool TransactionHistoryCache::decryptPayload(const std::string& walletHash,
|
||||
plain.data(), &plainLength, nullptr,
|
||||
cipherText.data(), cipherText.size(),
|
||||
reinterpret_cast<const unsigned char*>(associatedData.data()), associatedData.size(),
|
||||
nonce.data(), key_.data());
|
||||
nonce.data(), key.data());
|
||||
sodium_memzero(key.data(), key.size());
|
||||
if (result != 0) return false;
|
||||
|
||||
plainText.assign(reinterpret_cast<const char*>(plain.data()), static_cast<std::size_t>(plainLength));
|
||||
@@ -506,6 +688,7 @@ bool TransactionHistoryCache::readSnapshot(const std::string& walletHash,
|
||||
|
||||
void TransactionHistoryCache::clearWalletByHash(const std::string& walletHash)
|
||||
{
|
||||
std::lock_guard<std::recursive_mutex> dbLock(db_mutex_);
|
||||
if (!ensureOpen()) return;
|
||||
Statement statement(db_, "DELETE FROM transaction_history_snapshots WHERE wallet_hash = ?");
|
||||
if (!statement.handle) return;
|
||||
@@ -513,22 +696,28 @@ void TransactionHistoryCache::clearWalletByHash(const std::string& walletHash)
|
||||
sqlite3_step(statement.handle);
|
||||
}
|
||||
|
||||
void TransactionHistoryCache::pruneOtherWallets(const std::string& keepWalletHash)
|
||||
void TransactionHistoryCache::pruneOtherWallets(const std::string& keepWalletHash,
|
||||
const std::string& alsoKeepWalletHash)
|
||||
{
|
||||
std::lock_guard<std::recursive_mutex> dbLock(db_mutex_);
|
||||
if (!ensureOpen() || keepWalletHash.empty()) return;
|
||||
const std::string& alsoKeep = alsoKeepWalletHash.empty() ? keepWalletHash : alsoKeepWalletHash;
|
||||
// Table names are hardcoded literals (no injection surface). Prune both the snapshot
|
||||
// blobs and the now-orphaned salt rows so a stale salt can't outlive its ciphertext.
|
||||
for (const char* table : {"transaction_history_snapshots", "transaction_history_keys"}) {
|
||||
const std::string sql = std::string("DELETE FROM ") + table + " WHERE wallet_hash <> ?";
|
||||
const std::string sql = std::string("DELETE FROM ") + table +
|
||||
" WHERE wallet_hash <> ? AND wallet_hash <> ?";
|
||||
Statement statement(db_, sql.c_str());
|
||||
if (!statement.handle) continue;
|
||||
if (!bindText(statement.handle, 1, keepWalletHash)) continue;
|
||||
if (!bindText(statement.handle, 2, alsoKeep)) continue;
|
||||
sqlite3_step(statement.handle);
|
||||
}
|
||||
}
|
||||
|
||||
void TransactionHistoryCache::close()
|
||||
{
|
||||
std::lock_guard<std::recursive_mutex> dbLock(db_mutex_);
|
||||
if (!db_) return;
|
||||
sqlite3_close(db_);
|
||||
db_ = nullptr;
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
#include <array>
|
||||
#include <cstdint>
|
||||
#include <ctime>
|
||||
#include <mutex>
|
||||
#include <string>
|
||||
#include <unordered_map>
|
||||
#include <vector>
|
||||
@@ -14,6 +15,26 @@ struct sqlite3;
|
||||
namespace dragonx {
|
||||
namespace data {
|
||||
|
||||
// Persisted z_listsentbyaddress watermark, so a relaunch runs a windowed sweep instead of a full one.
|
||||
// height (W) = tip of the last successful sweep; anchorHeight (A) = W - margin, the bottom edge the window
|
||||
// covered; anchorHash (H_A) = getblockhash(A) at that time, verified on the next load. backlog = txids found
|
||||
// but not yet enriched (must persist WITH W, or a pure z->z send whose txid lives only in a sweep result is
|
||||
// lost once the window moves past it). The backoff fields keep a relaunch from immediately re-running a full
|
||||
// sweep that keeps failing; daemonId resets the backoff on a version change. (Namespace-scope, not nested, so
|
||||
// it is a complete type for the default argument of replace().)
|
||||
struct SentSweepState {
|
||||
int height = -1;
|
||||
int anchorHeight = -1;
|
||||
std::string anchorHash;
|
||||
std::vector<std::string> backlog;
|
||||
int fullFailures = 0;
|
||||
long fullRetryDelaySec = 0;
|
||||
std::string daemonId;
|
||||
std::int64_t lastFullTime = 0; // when the last successful FULL sweep finished: drives the weekly
|
||||
int lastFullHeight = -1; // safety-net re-sweep that catches changes the per-event guards miss
|
||||
bool valid() const { return height >= 0; }
|
||||
};
|
||||
|
||||
class TransactionHistoryCache {
|
||||
public:
|
||||
struct LoadResult {
|
||||
@@ -24,6 +45,28 @@ public:
|
||||
std::time_t updatedAt = 0;
|
||||
std::vector<TransactionInfo> transactions;
|
||||
std::unordered_map<std::string, int> shieldedScanHeights;
|
||||
SentSweepState sentSweep;
|
||||
};
|
||||
|
||||
// Everything one snapshot write needs, BY VALUE, so it can run on the history writer thread after the UI
|
||||
// state it came from has moved on (and after lockKey() zeroed the live key). Move-only; the private key
|
||||
// copy is wiped by writeSnapshot() and, for a job that never ran (superseded), by the destructor.
|
||||
struct SnapshotJob {
|
||||
std::string walletIdentity;
|
||||
std::array<unsigned char, 32> key{};
|
||||
int tipHeight = 0;
|
||||
std::string tipHash;
|
||||
std::vector<TransactionInfo> transactions;
|
||||
std::time_t updatedAt = 0;
|
||||
std::unordered_map<std::string, int> shieldedScanHeights;
|
||||
SentSweepState sentSweep;
|
||||
|
||||
SnapshotJob() = default;
|
||||
SnapshotJob(const SnapshotJob&) = delete;
|
||||
SnapshotJob& operator=(const SnapshotJob&) = delete;
|
||||
SnapshotJob(SnapshotJob&&) = default;
|
||||
SnapshotJob& operator=(SnapshotJob&&) = default;
|
||||
~SnapshotJob();
|
||||
};
|
||||
|
||||
TransactionHistoryCache();
|
||||
@@ -40,9 +83,17 @@ public:
|
||||
|
||||
bool ensureOpen();
|
||||
bool unlockWithPassphrase(const std::string& walletIdentity, const std::string& passphrase);
|
||||
// Unlock an UNENCRYPTED wallet's cache without Argon2id: the "secret" is derivable from the DB, so a
|
||||
// cheap domain-separated BLAKE2b KDF is security-equivalent and avoids a ~50-100 ms UI-thread stall.
|
||||
bool unlockUnencrypted(const std::string& walletIdentity);
|
||||
void lockKey();
|
||||
bool hasKey() const { return key_ready_; }
|
||||
bool hasKey() const;
|
||||
bool isUnlockedFor(const std::string& walletIdentity) const;
|
||||
// Copy of the live key if it is unlocked for `walletIdentity` (keyOut untouched otherwise). Caller wipes it.
|
||||
bool captureKey(const std::string& walletIdentity, std::array<unsigned char, 32>& keyOut) const;
|
||||
// Non-reversible id of the key last installed. Survives lockKey(), so re-unlocking with the same
|
||||
// passphrase gives the same id and a passphrase change gives another. 0 = never unlocked.
|
||||
std::uint64_t keyId() const;
|
||||
|
||||
LoadResult load(const std::string& walletIdentity,
|
||||
int currentTipHeight,
|
||||
@@ -52,7 +103,11 @@ public:
|
||||
const std::string& tipHash,
|
||||
const std::vector<TransactionInfo>& transactions,
|
||||
std::time_t updatedAt,
|
||||
const std::unordered_map<std::string, int>& shieldedScanHeights = {});
|
||||
const std::unordered_map<std::string, int>& shieldedScanHeights = {},
|
||||
const SentSweepState& sentSweep = SentSweepState{});
|
||||
// Thread-safe snapshot write (the history writer thread; replace() wraps it): JSON + AEAD with no lock
|
||||
// held, then INSERT OR REPLACE + prune under the DB lock. Always wipes job.key. False for a keyless job.
|
||||
bool writeSnapshot(SnapshotJob&& job);
|
||||
void clearWallet(const std::string& walletIdentity);
|
||||
int snapshotCount();
|
||||
|
||||
@@ -60,12 +115,17 @@ private:
|
||||
bool exec(const char* sql);
|
||||
bool createSchema();
|
||||
std::vector<unsigned char> getOrCreateSalt(const std::string& walletHash);
|
||||
bool deriveKey(const std::string& passphrase,
|
||||
const std::vector<unsigned char>& salt);
|
||||
bool encryptPayload(const std::string& walletHash,
|
||||
const std::string& plainText,
|
||||
std::vector<unsigned char>& nonce,
|
||||
std::vector<unsigned char>& cipherText) const;
|
||||
static bool deriveKeyFast(const std::string& walletHash,
|
||||
const std::vector<unsigned char>& salt,
|
||||
std::array<unsigned char, 32>& keyOut); // BLAKE2b, label-separated
|
||||
static bool deriveKey(const std::string& passphrase,
|
||||
const std::vector<unsigned char>& salt,
|
||||
std::array<unsigned char, 32>& keyOut);
|
||||
static bool encryptPayload(const std::array<unsigned char, 32>& key,
|
||||
const std::string& walletHash,
|
||||
const std::string& plainText,
|
||||
std::vector<unsigned char>& nonce,
|
||||
std::vector<unsigned char>& cipherText);
|
||||
bool decryptPayload(const std::string& walletHash,
|
||||
const std::vector<unsigned char>& nonce,
|
||||
const std::vector<unsigned char>& cipherText,
|
||||
@@ -79,14 +139,21 @@ private:
|
||||
void clearWalletByHash(const std::string& walletHash);
|
||||
// Delete snapshot + salt rows for every wallet hash except the live one, bounding the
|
||||
// DB so generating a new address (which changes the hash) doesn't orphan history forever.
|
||||
void pruneOtherWallets(const std::string& keepWalletHash);
|
||||
// Also keeps `alsoKeepWalletHash` (the wallet unlocked now, if any): a delayed writer job for the previous
|
||||
// wallet must not delete the salt of the wallet just switched to.
|
||||
void pruneOtherWallets(const std::string& keepWalletHash, const std::string& alsoKeepWalletHash);
|
||||
void close();
|
||||
|
||||
// Lock order: db_mutex_ then key_mutex_. key_mutex_ is never held across I/O or the KDF. Only the UI
|
||||
// thread changes the key fields; the writer thread reads unlocked_wallet_hash_ (prune) under both locks.
|
||||
mutable std::recursive_mutex db_mutex_; // db_ and every SQL statement
|
||||
mutable std::mutex key_mutex_; // key_, key_ready_, unlocked_wallet_hash_, key_id_
|
||||
sqlite3* db_ = nullptr;
|
||||
std::string database_path_;
|
||||
std::array<unsigned char, 32> key_{};
|
||||
bool key_ready_ = false;
|
||||
std::string unlocked_wallet_hash_;
|
||||
std::uint64_t key_id_ = 0;
|
||||
};
|
||||
|
||||
} // namespace data
|
||||
|
||||
@@ -0,0 +1,145 @@
|
||||
// DragonX Wallet - ImGui Edition
|
||||
// Copyright 2024-2026 The Hush Developers
|
||||
// Released under the GPLv3
|
||||
|
||||
#include "transaction_history_store_policy.h"
|
||||
|
||||
#include <cstring>
|
||||
|
||||
namespace dragonx {
|
||||
namespace data {
|
||||
|
||||
namespace {
|
||||
|
||||
constexpr std::uint64_t kOffset = 0xcbf29ce484222325ULL;
|
||||
constexpr std::uint64_t kMul = 0x9E3779B97F4A7C15ULL; // odd
|
||||
|
||||
static_assert(sizeof(double) == sizeof(std::uint64_t), "amount bit pattern is hashed as 64 bits");
|
||||
|
||||
// 8 bytes per step: a 5k-row wallet (~1.5 MB of row text) hashes in about a millisecond. Endianness-dependent
|
||||
// on purpose: the value never leaves the process.
|
||||
inline void mixBytes(std::uint64_t& h, const void* data, std::size_t n)
|
||||
{
|
||||
const auto* b = static_cast<const unsigned char*>(data);
|
||||
while (n >= 8) {
|
||||
std::uint64_t w;
|
||||
std::memcpy(&w, b, 8);
|
||||
h = (h ^ w) * kMul;
|
||||
h ^= h >> 32;
|
||||
b += 8;
|
||||
n -= 8;
|
||||
}
|
||||
if (n) {
|
||||
std::uint64_t w = 0;
|
||||
std::memcpy(&w, b, n);
|
||||
h = (h ^ w ^ (static_cast<std::uint64_t>(n) << 56)) * kMul;
|
||||
h ^= h >> 32;
|
||||
}
|
||||
}
|
||||
inline void mixU64(std::uint64_t& h, std::uint64_t v) { mixBytes(h, &v, sizeof(v)); }
|
||||
inline void mixStr(std::uint64_t& h, const std::string& s)
|
||||
{
|
||||
mixU64(h, s.size()); // length first: ("ab","c") != ("a","bc")
|
||||
mixBytes(h, s.data(), s.size());
|
||||
}
|
||||
inline std::uint64_t finalize(std::uint64_t x) // splitmix64 finalizer
|
||||
{
|
||||
x += 0x9e3779b97f4a7c15ULL;
|
||||
x = (x ^ (x >> 30)) * 0xbf58476d1ce4e5b9ULL;
|
||||
x = (x ^ (x >> 27)) * 0x94d049bb133111ebULL;
|
||||
return x ^ (x >> 31);
|
||||
}
|
||||
// The thresholds a stored row is read at: transactions_tab.cpp:788-793 / transaction_details_dialog.cpp:80-85
|
||||
// (0 pending, < 10 counted, >= 100 mature for mined), mining_earnings.cpp:102 (>= 100), the confirmed-tx cache
|
||||
// (>= 10, app_network.cpp:1915) and conflicted (< 0, network_refresh_service.cpp:1470).
|
||||
inline std::uint64_t confirmationBucket(int c)
|
||||
{
|
||||
return c < 0 ? 0 : c == 0 ? 1 : c < 10 ? 2 : c < 100 ? 3 : 4;
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
HistorySnapshotSig historySnapshotSignature(const std::vector<TransactionInfo>& transactions,
|
||||
const std::unordered_set<std::string>& skipTxids,
|
||||
const std::string& walletHash,
|
||||
std::uint64_t keyId,
|
||||
const std::unordered_map<std::string, int>& shieldedScanHeights,
|
||||
int tipHeight,
|
||||
const std::string& tipHash,
|
||||
int sentHeight,
|
||||
int sentAnchorHeight,
|
||||
const std::string& sentAnchorHash,
|
||||
const std::unordered_set<std::string>& sentBacklog)
|
||||
{
|
||||
HistorySnapshotSig sig;
|
||||
std::uint64_t rowSum = 0; // sums: commutative, so row order does not matter; duplicates still count
|
||||
std::uint64_t exactSum = 0;
|
||||
for (const auto& tx : transactions) {
|
||||
if (!skipTxids.empty() && skipTxids.count(tx.txid)) continue;
|
||||
std::uint64_t h = kOffset;
|
||||
mixStr(h, tx.txid);
|
||||
mixStr(h, tx.type);
|
||||
std::uint64_t amountBits = 0;
|
||||
std::memcpy(&amountBits, &tx.amount, sizeof(amountBits));
|
||||
mixU64(h, amountBits);
|
||||
mixU64(h, static_cast<std::uint64_t>(tx.timestamp));
|
||||
mixStr(h, tx.address);
|
||||
mixStr(h, tx.from_address);
|
||||
mixStr(h, tx.memo);
|
||||
std::uint64_t e = h;
|
||||
mixU64(h, confirmationBucket(tx.confirmations));
|
||||
mixU64(e, static_cast<std::uint64_t>(static_cast<std::int64_t>(tx.confirmations)));
|
||||
rowSum += finalize(h);
|
||||
exactSum += finalize(e);
|
||||
++sig.rows;
|
||||
}
|
||||
std::uint64_t backlogSum = 0; // order-independent: a set, so sum is commutative
|
||||
for (const auto& txid : sentBacklog) {
|
||||
if (txid.empty()) continue;
|
||||
std::uint64_t h = kOffset;
|
||||
mixStr(h, txid);
|
||||
backlogSum += finalize(h);
|
||||
}
|
||||
std::uint64_t c = kOffset;
|
||||
mixU64(c, rowSum);
|
||||
mixU64(c, sig.rows);
|
||||
mixStr(c, walletHash);
|
||||
mixU64(c, keyId);
|
||||
mixU64(c, backlogSum);
|
||||
sig.content = finalize(c);
|
||||
|
||||
std::uint64_t heightSum = 0;
|
||||
for (const auto& [address, height] : shieldedScanHeights) {
|
||||
if (address.empty() || height < 0) continue; // exactly what replace() persists
|
||||
std::uint64_t h = kOffset;
|
||||
mixStr(h, address);
|
||||
mixU64(h, static_cast<std::uint64_t>(height));
|
||||
heightSum += finalize(h);
|
||||
}
|
||||
std::uint64_t s = kOffset;
|
||||
mixU64(s, heightSum);
|
||||
mixU64(s, exactSum);
|
||||
mixU64(s, static_cast<std::uint64_t>(static_cast<std::int64_t>(tipHeight)));
|
||||
mixStr(s, tipHash);
|
||||
mixU64(s, static_cast<std::uint64_t>(static_cast<std::int64_t>(sentHeight)));
|
||||
mixU64(s, static_cast<std::uint64_t>(static_cast<std::int64_t>(sentAnchorHeight)));
|
||||
mixStr(s, sentAnchorHash);
|
||||
sig.slow = finalize(s);
|
||||
return sig;
|
||||
}
|
||||
|
||||
HistoryStoreAction decideHistoryStore(const HistoryStoreInput& in)
|
||||
{
|
||||
const bool contentDirty = !in.haveStored || in.stored.content != in.current.content;
|
||||
const bool slowDirty = in.haveStored && in.stored.slow != in.current.slow;
|
||||
if (in.mode == HistoryStoreMode::Now)
|
||||
return (contentDirty || slowDirty) ? HistoryStoreAction::Store : HistoryStoreAction::Skip;
|
||||
if (contentDirty)
|
||||
return in.sinceLastSubmitSec >= kHistoryMinStoreIntervalSec ? HistoryStoreAction::Store
|
||||
: HistoryStoreAction::Defer;
|
||||
if (slowDirty && in.sinceLastSubmitSec >= kHistorySlowStoreIntervalSec) return HistoryStoreAction::Store;
|
||||
return HistoryStoreAction::Skip;
|
||||
}
|
||||
|
||||
} // namespace data
|
||||
} // namespace dragonx
|
||||
@@ -0,0 +1,72 @@
|
||||
// DragonX Wallet - ImGui Edition
|
||||
// Copyright 2024-2026 The Hush Developers
|
||||
// Released under the GPLv3
|
||||
//
|
||||
// Pure decision logic for persisting the tx-history snapshot: what counts as "changed", and when a write is
|
||||
// due. No I/O, no clock, no sodium: unit-tested directly.
|
||||
|
||||
#pragma once
|
||||
|
||||
#include "wallet_state.h"
|
||||
|
||||
#include <cstddef>
|
||||
#include <cstdint>
|
||||
#include <string>
|
||||
#include <unordered_map>
|
||||
#include <unordered_set>
|
||||
#include <vector>
|
||||
|
||||
namespace dragonx {
|
||||
namespace data {
|
||||
|
||||
// 64-bit fingerprints of what a snapshot would contain.
|
||||
struct HistorySnapshotSig {
|
||||
std::uint64_t content = 0; // rows (confirmations by UI bucket) + wallet hash + key id
|
||||
std::uint64_t slow = 0; // shielded scan heights + raw confirmations + tip: drift every block
|
||||
std::size_t rows = 0; // rows that would be persisted (pending sends excluded)
|
||||
};
|
||||
|
||||
// Order-independent over rows. `content` covers the persisted row fields (txid, type, amount bit pattern,
|
||||
// timestamp, address, from_address, memo) with confirmations bucketed at the thresholds the UI and the refresh
|
||||
// read off a stored row (< 0 conflicted, 0 pending, 1-9 counted, >= 10 confirmed, >= 100 mature), plus the
|
||||
// wallet hash and `keyId` (a passphrase change must rewrite: the old ciphertext is unreadable with the new key).
|
||||
// `slow` covers what changes every block: the per-address scan heights (entries replace() would persist), each
|
||||
// row's raw confirmations, and the tip.
|
||||
HistorySnapshotSig historySnapshotSignature(const std::vector<TransactionInfo>& transactions,
|
||||
const std::unordered_set<std::string>& skipTxids,
|
||||
const std::string& walletHash,
|
||||
std::uint64_t keyId,
|
||||
const std::unordered_map<std::string, int>& shieldedScanHeights,
|
||||
int tipHeight,
|
||||
const std::string& tipHash,
|
||||
// Sent-sweep watermark: W/A/H_A move per sweep (folded into `slow`,
|
||||
// written on the slow cadence and always on disconnect/lock/exit),
|
||||
// the enrichment backlog into `content` (written within ~20 s), so an
|
||||
// invalidation that only changes the watermark still reaches disk.
|
||||
int sentHeight = -1,
|
||||
int sentAnchorHeight = -1,
|
||||
const std::string& sentAnchorHash = std::string(),
|
||||
const std::unordered_set<std::string>& sentBacklog = {});
|
||||
|
||||
enum class HistoryStoreMode {
|
||||
Coalesce, // routine refresh: content changes at most every kHistoryMinStoreIntervalSec, slow drift every
|
||||
// kHistorySlowStoreIntervalSec
|
||||
Now // user action / disconnect / lock / exit: store now if anything differs from the last submit
|
||||
};
|
||||
enum class HistoryStoreAction { Skip, Defer, Store };
|
||||
|
||||
constexpr double kHistoryMinStoreIntervalSec = 20.0;
|
||||
constexpr double kHistorySlowStoreIntervalSec = 600.0;
|
||||
|
||||
struct HistoryStoreInput {
|
||||
HistoryStoreMode mode = HistoryStoreMode::Coalesce;
|
||||
bool haveStored = false; // a snapshot of this session (submitted, or the loaded one) is on disk
|
||||
HistorySnapshotSig stored; // its signature
|
||||
HistorySnapshotSig current;
|
||||
double sinceLastSubmitSec = 1e9; // 1e9 = never submitted this session
|
||||
};
|
||||
|
||||
HistoryStoreAction decideHistoryStore(const HistoryStoreInput& input);
|
||||
|
||||
} // namespace data
|
||||
} // namespace dragonx
|
||||
@@ -9,6 +9,7 @@
|
||||
#include <filesystem>
|
||||
#include <fstream>
|
||||
|
||||
#include "../util/coalescing_writer.h"
|
||||
#include "../util/logger.h"
|
||||
#include "../util/platform.h"
|
||||
|
||||
@@ -88,6 +89,7 @@ bool WalletIndex::load()
|
||||
|
||||
bool WalletIndex::save()
|
||||
{
|
||||
if (sweep_active_) return true; // UI sweep: demo entries never reach disk
|
||||
if (file_path_.empty()) file_path_ = getDefaultPath();
|
||||
|
||||
try {
|
||||
@@ -109,7 +111,8 @@ bool WalletIndex::save()
|
||||
j["extraFolders"] = extra_folders_;
|
||||
|
||||
// No secrets here (file names + coarse metadata) but keep it owner-only for consistency.
|
||||
if (!util::Platform::writeFileAtomically(file_path_, j.dump(2), /*restrictPermissions=*/true)) {
|
||||
if (!util::persistFileAtomically(writer_, /*waitForDisk=*/false, file_path_, j.dump(2),
|
||||
/*restrictPermissions=*/true)) {
|
||||
DEBUG_LOGF("Could not write wallet index: %s\n", file_path_.c_str());
|
||||
return false;
|
||||
}
|
||||
|
||||
+32
-1
@@ -5,9 +5,11 @@
|
||||
#pragma once
|
||||
|
||||
#include <string>
|
||||
#include <utility>
|
||||
#include <vector>
|
||||
|
||||
namespace dragonx {
|
||||
namespace util { class CoalescingWriter; }
|
||||
namespace data {
|
||||
|
||||
/**
|
||||
@@ -41,9 +43,12 @@ struct WalletIndexEntry {
|
||||
class WalletIndex {
|
||||
public:
|
||||
bool load();
|
||||
bool save();
|
||||
bool save(); // queued on the writer when one is attached (returns true once queued)
|
||||
static std::string getDefaultPath();
|
||||
|
||||
/** @brief Route save() through a background writer (not owned; must outlive this). Null = synchronous. */
|
||||
void setFileWriter(util::CoalescingWriter* writer) { writer_ = writer; }
|
||||
|
||||
const std::vector<WalletIndexEntry>& entries() const { return entries_; }
|
||||
const std::vector<std::string>& extraFolders() const { return extra_folders_; }
|
||||
|
||||
@@ -60,10 +65,36 @@ public:
|
||||
bool addExtraFolder(const std::string& dir);
|
||||
bool removeExtraFolder(const std::string& dir);
|
||||
|
||||
/**
|
||||
* @brief UI-sweep ONLY: snapshot the entries and extra folders; until sweepEnd(), save() returns
|
||||
* true without writing or queueing. The sweep's demo upserts drop the real entry's scopeId, and
|
||||
* activeWalletScopeId() would then mint and save a new one, hiding the user's "w:"-scoped contacts.
|
||||
*/
|
||||
void sweepBegin() {
|
||||
if (sweep_active_) return;
|
||||
sweep_entries_ = entries_;
|
||||
sweep_extra_folders_ = extra_folders_;
|
||||
sweep_active_ = true;
|
||||
}
|
||||
/** @brief UI-sweep ONLY: restore the snapshot (no disk write). Idempotent. */
|
||||
void sweepEnd() {
|
||||
if (!sweep_active_) return;
|
||||
entries_ = std::move(sweep_entries_);
|
||||
extra_folders_ = std::move(sweep_extra_folders_);
|
||||
sweep_entries_.clear();
|
||||
sweep_extra_folders_.clear();
|
||||
sweep_active_ = false;
|
||||
}
|
||||
bool sweepActive() const { return sweep_active_; }
|
||||
|
||||
private:
|
||||
std::vector<WalletIndexEntry> entries_;
|
||||
std::vector<std::string> extra_folders_;
|
||||
std::string file_path_;
|
||||
util::CoalescingWriter* writer_ = nullptr; // null -> synchronous writes
|
||||
std::vector<WalletIndexEntry> sweep_entries_; // the real index while a UI sweep runs
|
||||
std::vector<std::string> sweep_extra_folders_;
|
||||
bool sweep_active_ = false;
|
||||
};
|
||||
|
||||
} // namespace data
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
#pragma once
|
||||
#include <cstdint>
|
||||
|
||||
namespace dragonx {
|
||||
namespace data {
|
||||
|
||||
enum class EncryptionPendingAction { None, Clear, Warn };
|
||||
|
||||
// What to do with the persisted "encryption requested in the wizard" flag, given ONE parsed getwalletinfo
|
||||
// result. rpcOk must come from the parsed reply (a -28 warm-up failure or a throw is rpcOk=false => None);
|
||||
// never derive it from WalletState (a 15 s timeout forces encrypted=false there).
|
||||
inline EncryptionPendingAction decideEncryptionPending(bool flagSet, bool rpcOk, bool walletEncrypted,
|
||||
bool deferredPending, bool encryptInProgress,
|
||||
bool alreadyWarned)
|
||||
{
|
||||
if (!flagSet || !rpcOk) return EncryptionPendingAction::None;
|
||||
if (walletEncrypted) return EncryptionPendingAction::Clear;
|
||||
if (deferredPending || encryptInProgress || alreadyWarned) return EncryptionPendingAction::None;
|
||||
return EncryptionPendingAction::Warn;
|
||||
}
|
||||
|
||||
// ---- C90 ----
|
||||
constexpr int kSetPinProbeSecs = 5; // the verification unlock used when the wallet is locked
|
||||
|
||||
struct SetPinVerifyPlan {
|
||||
int timeoutSecs; // walletpassphrase timeout for the passphrase check
|
||||
bool relockAfter; // call walletlock afterwards (only if the wallet was locked before)
|
||||
};
|
||||
|
||||
// Set PIN verifies the passphrase with walletpassphrase. If the wallet is unlocked now, re-unlock with the time
|
||||
// it has left (replaces the daemon's timer with the same deadline) and DO NOT relock; otherwise a 5 s probe + relock.
|
||||
inline SetPinVerifyPlan planSetPinVerify(bool guiUnlocked, std::int64_t unlockedUntil, std::int64_t now)
|
||||
{
|
||||
const std::int64_t left = unlockedUntil - now;
|
||||
if (guiUnlocked && left > kSetPinProbeSecs) {
|
||||
const std::int64_t capped = left > 86400 * 7 ? 86400 * 7 : left;
|
||||
return {static_cast<int>(capped), false};
|
||||
}
|
||||
return {kSetPinProbeSecs, true};
|
||||
}
|
||||
|
||||
// The daemon's absolute unlock window has run out but the GUI still says "unlocked". Wall clock, because the
|
||||
// deadline is an absolute unix time. unlockedUntil == 0 means "no deadline known" => never expired here.
|
||||
inline bool unlockWindowExpired(bool encrypted, bool locked, std::int64_t unlockedUntil, std::int64_t now)
|
||||
{
|
||||
return encrypted && !locked && unlockedUntil > 0 && now >= unlockedUntil;
|
||||
}
|
||||
|
||||
} // namespace data
|
||||
} // namespace dragonx
|
||||
@@ -43,6 +43,32 @@ int bestSpendableAddressIndex(const std::vector<AddressInfo>& addresses)
|
||||
return bestIndex;
|
||||
}
|
||||
|
||||
int addressIndexByString(const std::vector<AddressInfo>& addresses, const std::string& address)
|
||||
{
|
||||
if (address.empty()) return -1;
|
||||
for (size_t i = 0; i < addresses.size(); ++i) {
|
||||
if (addresses[i].address == address) return static_cast<int>(i);
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
|
||||
SourceSelection resolveSourceSelection(const std::vector<AddressInfo>& addresses, const std::string& current,
|
||||
bool autoLatched, bool connected)
|
||||
{
|
||||
SourceSelection r;
|
||||
r.address = current;
|
||||
r.autoLatched = autoLatched;
|
||||
r.index = addressIndexByString(addresses, current);
|
||||
if (!autoLatched && connected && !addresses.empty()) {
|
||||
if (current.empty()) { // only ever fill an empty source
|
||||
const int best = bestSpendableAddressIndex(addresses);
|
||||
if (best >= 0) { r.address = addresses[best].address; r.index = best; }
|
||||
}
|
||||
r.autoLatched = true; // one shot, whether or not it picked
|
||||
}
|
||||
return r;
|
||||
}
|
||||
|
||||
std::string TransactionInfo::getTimeString() const
|
||||
{
|
||||
if (timestamp == 0) return "Unknown";
|
||||
|
||||
+71
-3
@@ -9,6 +9,7 @@
|
||||
#include <cstdint>
|
||||
#include <chrono>
|
||||
#include <ctime>
|
||||
#include <cstring>
|
||||
#include <utility>
|
||||
|
||||
#include "exchange_info.h"
|
||||
@@ -41,6 +42,20 @@ struct AddressInfo {
|
||||
std::vector<size_t> sortedSpendableAddressIndices(const std::vector<AddressInfo>& addresses,
|
||||
bool requirePositiveBalance = true);
|
||||
int bestSpendableAddressIndex(const std::vector<AddressInfo>& addresses);
|
||||
// Index of the address whose string equals `address` (exact match), or -1 (empty string / not listed).
|
||||
int addressIndexByString(const std::vector<AddressInfo>& addresses, const std::string& address);
|
||||
|
||||
// The Send tab's source-address resolution, pure. `current` (s_from_address) is the single source of truth:
|
||||
// the one-shot auto-pick (highest confirmed balance) only fills an EMPTY source, and never replaces one the
|
||||
// user - or another tab's "Send from this address" - already set. A source that is not in the list (the list
|
||||
// can be transiently short) is kept as is with index -1; it must NOT trigger a re-pick.
|
||||
struct SourceSelection {
|
||||
std::string address; // what s_from_address must hold afterwards
|
||||
int index = -1; // its row in `addresses`, -1 if not listed
|
||||
bool autoLatched = false; // new value of the one-shot latch
|
||||
};
|
||||
SourceSelection resolveSourceSelection(const std::vector<AddressInfo>& addresses, const std::string& current,
|
||||
bool autoLatched, bool connected);
|
||||
|
||||
/**
|
||||
* @brief Represents a wallet transaction
|
||||
@@ -62,6 +77,22 @@ struct TransactionInfo {
|
||||
bool isMature() const { return confirmations >= 100; }
|
||||
};
|
||||
|
||||
// Exact field-wise equality: decides whether a refreshed list really differs. `amount` is compared
|
||||
// BIT-exactly (like the old History fingerprint): -0.0 and +0.0 render differently ("+-0.0" vs "+0.0").
|
||||
// Must list EVERY TransactionInfo field (tests mutate each one).
|
||||
inline bool sameTransactionInfo(const TransactionInfo& a, const TransactionInfo& b) {
|
||||
return a.timestamp == b.timestamp && a.confirmations == b.confirmations &&
|
||||
std::memcmp(&a.amount, &b.amount, sizeof(double)) == 0 &&
|
||||
a.txid == b.txid && a.type == b.type && a.address == b.address &&
|
||||
a.from_address == b.from_address && a.memo == b.memo;
|
||||
}
|
||||
inline bool sameTransactionList(const std::vector<TransactionInfo>& a, const std::vector<TransactionInfo>& b) {
|
||||
if (a.size() != b.size()) return false;
|
||||
for (std::size_t i = 0; i < a.size(); ++i)
|
||||
if (!sameTransactionInfo(a[i], b[i])) return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* @brief Represents a connected peer
|
||||
*/
|
||||
@@ -115,7 +146,8 @@ struct MiningInfo {
|
||||
|
||||
// History for chart
|
||||
std::vector<double> hashrate_history; // Last N samples
|
||||
static constexpr int MAX_HISTORY = 300; // 5 minutes at 1s intervals
|
||||
std::vector<std::time_t> hashrate_history_t; // wall-clock stamp per sample (lockstep with hashrate_history)
|
||||
static constexpr int MAX_HISTORY = 300; // 5 minutes at ~1s intervals
|
||||
|
||||
// Recent daemon log lines for the mining log panel
|
||||
std::vector<std::string> log_lines;
|
||||
@@ -130,6 +162,7 @@ struct SyncInfo {
|
||||
double verification_progress = 0.0;
|
||||
bool syncing = false;
|
||||
std::string best_blockhash;
|
||||
std::string lite_error; // lite: last sync failure text, "" = none
|
||||
|
||||
// Rescan state (detected from daemon output)
|
||||
bool rescanning = false;
|
||||
@@ -149,7 +182,13 @@ struct SyncInfo {
|
||||
float witness_progress = 0.0f; // 0.0 - 1.0, within the current sub-phase
|
||||
int witness_remaining = 0; // blocks left in the cache walk (0 if unknown / phase 1)
|
||||
|
||||
bool isSynced() const { return !syncing && blocks > 0 && blocks >= headers - 2; }
|
||||
// True only when the daemon reports a positive peer-derived tip (longestchain > 0). A node with
|
||||
// no peers reports longestchain == 0, so `blocks` is then only the LOCAL best height and cannot be
|
||||
// trusted as "at the network tip". isSynced() gates on this so a peerless / cold-started node is
|
||||
// never reported synced (which would enable Send against a stale balance). (audit: peerless-synced)
|
||||
bool tip_known = false;
|
||||
|
||||
bool isSynced() const { return tip_known && !syncing && blocks > 0 && blocks >= headers - 2; }
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -215,6 +254,8 @@ struct PoolMiningState {
|
||||
int64_t uptime_sec = 0;
|
||||
double pool_diff = 0;
|
||||
bool connected = false;
|
||||
std::time_t running_since = 0; // wall-clock the miner was first seen running this session (0 = not running)
|
||||
std::string last_error; // newest xmrig log line that looks like a connection error ("" = none)
|
||||
|
||||
// Memory/thread usage (bytes for memory)
|
||||
int64_t memory_used = 0;
|
||||
@@ -225,7 +266,8 @@ struct PoolMiningState {
|
||||
|
||||
// Hashrate history for chart (mirrors MiningInfo::hashrate_history)
|
||||
std::vector<double> hashrate_history;
|
||||
static constexpr int MAX_HISTORY = 60; // 5 minutes at ~5s intervals
|
||||
std::vector<std::time_t> hashrate_history_t; // wall-clock stamp per sample (lockstep with hashrate_history)
|
||||
static constexpr int MAX_HISTORY = 60; // ~2 min at the ~2s pool poll
|
||||
|
||||
// Recent log lines for the log panel
|
||||
std::vector<std::string> log_lines;
|
||||
@@ -312,8 +354,30 @@ struct WalletState {
|
||||
|
||||
// Timestamps for refresh logic
|
||||
int64_t last_balance_update = 0;
|
||||
// A Core poll that skipped the balance confirmed it is still current (nothing it depends on changed):
|
||||
// the staleness badge counts from the later of the two.
|
||||
int64_t balance_verified_at = 0;
|
||||
// The last balance call that was sent failed (or timed out): the staleness badge then shows at its usual
|
||||
// thresholds even while the scan budget is holding the next one.
|
||||
bool balance_last_attempt_failed = false;
|
||||
int wallet_tx_count = -1; // getwalletinfo's txcount; -1 = unknown
|
||||
int64_t last_address_update = 0; // set when an address-list refresh applies; 0 = never loaded yet
|
||||
int64_t last_tx_update = 0;
|
||||
|
||||
// Change counter for `transactions` (main thread only). Starts at 1 so a UI cache's 0 sentinel never matches;
|
||||
// only ever increases (clear() bumps, never resets) so a wallet switch can't alias an old cache key. EVERY
|
||||
// write to `transactions` (replace, insert/erase, in-place field edit) must use the helpers below or call
|
||||
// touchTransactions() afterwards. UI caches (History tab, ...) key on this, NOT on last_tx_update (a
|
||||
// wall-clock refresh stamp that lite never sets) nor App::tx_dirty_generation_ (a refresh-needed flag).
|
||||
std::uint64_t tx_revision = 1;
|
||||
void touchTransactions() { ++tx_revision; }
|
||||
void replaceTransactions(std::vector<TransactionInfo>&& next) { transactions = std::move(next); touchTransactions(); }
|
||||
// Replace only when the content differs; returns true (and bumps) if it did. Keeps the old list when identical.
|
||||
bool setTransactionsIfChanged(std::vector<TransactionInfo>&& next) {
|
||||
if (sameTransactionList(transactions, next)) return false;
|
||||
replaceTransactions(std::move(next));
|
||||
return true;
|
||||
}
|
||||
int64_t last_peer_update = 0;
|
||||
int64_t last_mining_update = 0;
|
||||
|
||||
@@ -345,6 +409,7 @@ struct WalletState {
|
||||
z_addresses.clear();
|
||||
t_addresses.clear();
|
||||
transactions.clear();
|
||||
touchTransactions(); // never reset: a post-switch revision must differ from every cached key
|
||||
peers.clear();
|
||||
bannedPeers.clear();
|
||||
// W6-1: reset node-level mining state too — the daemon restarts on a wallet switch (mining
|
||||
@@ -356,6 +421,9 @@ struct WalletState {
|
||||
// badge (and any "updated X ago" reader) briefly reports it as current until the first refresh
|
||||
// re-stamps it. All readers treat 0 as "never" (formatTimeAgoShort/timeAgo return "").
|
||||
last_balance_update = last_address_update = last_tx_update = last_peer_update = last_mining_update = 0;
|
||||
balance_verified_at = 0;
|
||||
balance_last_attempt_failed = false;
|
||||
wallet_tx_count = -1;
|
||||
}
|
||||
|
||||
// Rebuild combined addresses list from z/t lists
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
// DragonX Wallet - INCBIN text-resource embedding (CMake-generated). Do NOT edit the generated copy.
|
||||
#include "incbin.h"
|
||||
INCBIN(ui_toml, "@CMAKE_SOURCE_DIR@/res/themes/ui.toml");
|
||||
INCBIN(default_banlist, "@CMAKE_SOURCE_DIR@/res/default_banlist.txt");
|
||||
@@ -0,0 +1,10 @@
|
||||
#pragma once
|
||||
#include <cstdint>
|
||||
// Defined in generated/embedded_text.cpp via INCBIN (.incbin). Data is NOT NUL-terminated;
|
||||
// every consumer passes an explicit size. Size is `unsigned int`.
|
||||
extern "C" {
|
||||
extern const unsigned char g_ui_toml_data[];
|
||||
extern const unsigned int g_ui_toml_size;
|
||||
extern const unsigned char g_default_banlist_data[];
|
||||
extern const unsigned int g_default_banlist_size;
|
||||
}
|
||||
+388
-105
@@ -23,6 +23,7 @@
|
||||
#include "imgui_internal.h"
|
||||
#include "imgui_impl_sdl3.h"
|
||||
#include "ui/effects/imgui_acrylic.h"
|
||||
#include "ui/effects/capture_extent.h"
|
||||
#include "ui/effects/theme_effects.h"
|
||||
#include "util/texture_loader.h"
|
||||
#include "util/platform.h"
|
||||
@@ -48,6 +49,7 @@
|
||||
#ifdef _WIN32
|
||||
#include "platform/windows_backdrop.h"
|
||||
#include <windows.h>
|
||||
#include <io.h>
|
||||
#include <dwmapi.h>
|
||||
#include <shlobj.h>
|
||||
#include <propkey.h>
|
||||
@@ -71,11 +73,13 @@ extern "C" HRESULT __stdcall SHGetPropertyStoreForWindow(HWND hwnd, REFIID riid,
|
||||
#endif
|
||||
#endif
|
||||
|
||||
#include <algorithm>
|
||||
#include <cstdio>
|
||||
#include <cstdarg>
|
||||
#include <cstdlib>
|
||||
#include <cmath>
|
||||
#include <cstdint>
|
||||
#include <cstring>
|
||||
#include <filesystem>
|
||||
#include <fstream>
|
||||
#include <string>
|
||||
@@ -330,6 +334,11 @@ static LRESULT CALLBACK BorderlessWndProc(HWND hwnd, UINT msg,
|
||||
return CallWindowProcW(g_sdlWndProc, hwnd, msg, wParam, lParam);
|
||||
}
|
||||
|
||||
// Caption-button window ops are deferred out of the open ImGui frame and run
|
||||
// after the frame closes (SDL pushes resize events synchronously). (C146)
|
||||
enum class CaptionOp { None, Minimize, Maximize, Restore };
|
||||
static CaptionOp g_pendingCaptionOp = CaptionOp::None;
|
||||
|
||||
// Draw custom window-control buttons (minimize / maximize-restore / close)
|
||||
// on the foreground draw list, and handle title-bar dragging.
|
||||
static void renderBorderlessControls(SDL_Window* window, HWND /*hwnd*/,
|
||||
@@ -376,7 +385,7 @@ static void renderBorderlessControls(SDL_Window* window, HWND /*hwnd*/,
|
||||
float cy = oy + btnH * 0.5f, lx = minX + btnW * 0.5f - iconHalf;
|
||||
fg->AddLine(ImVec2(lx, cy), ImVec2(lx + iconHalf * 2.0f, cy),
|
||||
wcIcon, lineThk);
|
||||
if (hov && click) SDL_MinimizeWindow(window);
|
||||
if (hov && click) g_pendingCaptionOp = CaptionOp::Minimize;
|
||||
}
|
||||
|
||||
// --- Maximize / Restore button ---
|
||||
@@ -398,10 +407,7 @@ static void renderBorderlessControls(SDL_Window* window, HWND /*hwnd*/,
|
||||
ImVec2(cx + iconHalf, cy + iconHalf - dp),
|
||||
wcIcon, 0, 0, lineThk);
|
||||
}
|
||||
if (hov && click) {
|
||||
if (maximized) SDL_RestoreWindow(window);
|
||||
else SDL_MaximizeWindow(window);
|
||||
}
|
||||
if (hov && click) g_pendingCaptionOp = maximized ? CaptionOp::Restore : CaptionOp::Maximize;
|
||||
}
|
||||
|
||||
// --- Close button ---
|
||||
@@ -550,7 +556,7 @@ static void drawWindowBackdrop(dragonx::App& app, ImDrawList* bgDL, ImVec2 p0, I
|
||||
}
|
||||
|
||||
// ── Window-dimension diagnostic tracer ──────────────────────────────────────────────────────
|
||||
// Always-on (writes to stderr → dragonx-debug.log on Windows) so it's visible in release builds,
|
||||
// Always-on (writes to stderr → dragonx-stdout.log on Windows) so it's visible in release builds,
|
||||
// unlike DRAGONX_DEBUG-gated DEBUG_LOGF. Grep the log for "[WINLOG]" to trace how the window size
|
||||
// changes when dragging between monitors of different DPI. Low volume — only fires on window
|
||||
// resize / move / DPI-scale events. TODO: remove once the multi-monitor sizing bug is resolved.
|
||||
@@ -778,6 +784,102 @@ static void installPosixCrashHandler(const std::string& crashLogPath)
|
||||
}
|
||||
#endif // !_WIN32
|
||||
|
||||
// ============================================================================
|
||||
// Frame pacing (P6)
|
||||
// ============================================================================
|
||||
// The main loop renders a frame only when something needs one:
|
||||
// - full rate: the user is interacting (and for kRecentInputMs after the last input event), smooth
|
||||
// scrolling, the screenshot sweep, and a few settle frames after a window-state change or a window
|
||||
// appearing;
|
||||
// - ambient: continuous motion that isn't tied to input (theme-effect animation, the translucent
|
||||
// backdrop, animated avatars, toast fades) at kAmbientFrameMs while the app has focus and
|
||||
// kAmbientFrameUnfocusedMs while it doesn't;
|
||||
// - progress: things that only need to keep visibly moving (spinners and progress bars for work on
|
||||
// other threads, countdowns, a focused text field's caret, the send/refresh/switch/shutdown
|
||||
// states) at kProgressFrameMs / kProgressFrameUnfocusedMs;
|
||||
// - idle: no frames until input, RPC results, or the periodic housekeeping wake.
|
||||
// Hidden or occluded windows get neither ambient nor progress frames.
|
||||
// frameBudgetNs() caps every rate at the display refresh where swap/present doesn't block on vsync
|
||||
// (WSLg, VMs, vsync forced off in the driver), which otherwise spins a core at hundreds of fps.
|
||||
namespace {
|
||||
constexpr Uint32 kAmbientFrameMs = 33; // ~30 fps while the app has focus
|
||||
constexpr Uint32 kAmbientFrameUnfocusedMs = 100; // ~10 fps while another app has focus
|
||||
constexpr Uint32 kProgressFrameMs = 100; // ~10 fps: spinners, progress, caret blink
|
||||
constexpr Uint32 kProgressFrameUnfocusedMs = 250; // ~4 fps
|
||||
constexpr Uint64 kRecentInputMs = 750; // stay at full rate this long after input stops
|
||||
constexpr int kSettleFrames = 3; // full-rate frames after a window-state change
|
||||
|
||||
// Minimum spacing between frame starts: 90% of the display's refresh interval, so a working vsync
|
||||
// never waits here and a non-blocking swap can't outrun the display.
|
||||
Uint64 frameBudgetNs(SDL_Window* window)
|
||||
{
|
||||
float hz = 60.0f;
|
||||
if (const SDL_DisplayMode* dm = SDL_GetCurrentDisplayMode(SDL_GetDisplayForWindow(window))) {
|
||||
if (dm->refresh_rate > 1.0f) hz = dm->refresh_rate;
|
||||
}
|
||||
hz = std::clamp(hz, 30.0f, 240.0f);
|
||||
return static_cast<Uint64>(0.9e9 / hz);
|
||||
}
|
||||
|
||||
bool isInputEvent(Uint32 type)
|
||||
{
|
||||
switch (type) {
|
||||
case SDL_EVENT_KEY_DOWN:
|
||||
case SDL_EVENT_KEY_UP:
|
||||
case SDL_EVENT_TEXT_INPUT:
|
||||
case SDL_EVENT_TEXT_EDITING:
|
||||
case SDL_EVENT_TEXT_EDITING_CANDIDATES:
|
||||
case SDL_EVENT_MOUSE_MOTION:
|
||||
case SDL_EVENT_MOUSE_BUTTON_DOWN:
|
||||
case SDL_EVENT_MOUSE_BUTTON_UP:
|
||||
case SDL_EVENT_MOUSE_WHEEL:
|
||||
case SDL_EVENT_FINGER_DOWN:
|
||||
case SDL_EVENT_FINGER_UP:
|
||||
case SDL_EVENT_FINGER_MOTION:
|
||||
case SDL_EVENT_PEN_DOWN:
|
||||
case SDL_EVENT_PEN_UP:
|
||||
case SDL_EVENT_PEN_MOTION:
|
||||
case SDL_EVENT_GAMEPAD_BUTTON_DOWN:
|
||||
case SDL_EVENT_GAMEPAD_BUTTON_UP:
|
||||
return true;
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// Window-state changes after which the compositor or the acrylic capture may need a few fresh frames.
|
||||
bool isWindowStateEvent(Uint32 type)
|
||||
{
|
||||
switch (type) {
|
||||
case SDL_EVENT_WINDOW_SHOWN:
|
||||
case SDL_EVENT_WINDOW_EXPOSED:
|
||||
case SDL_EVENT_WINDOW_RESTORED:
|
||||
case SDL_EVENT_WINDOW_MAXIMIZED:
|
||||
case SDL_EVENT_WINDOW_RESIZED:
|
||||
case SDL_EVENT_WINDOW_PIXEL_SIZE_CHANGED:
|
||||
case SDL_EVENT_WINDOW_DISPLAY_CHANGED:
|
||||
case SDL_EVENT_WINDOW_DISPLAY_SCALE_CHANGED:
|
||||
case SDL_EVENT_WINDOW_FOCUS_GAINED:
|
||||
case SDL_EVENT_WINDOW_FOCUS_LOST:
|
||||
case SDL_EVENT_WINDOW_OCCLUDED:
|
||||
return true;
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// A held keyboard key keeps ImGui's own key repeat running even where the OS sends no repeat events.
|
||||
// Keyboard only: gamepad keys come from polled analog state, so a drifting stick or a trigger resting
|
||||
// past ImGui's 0.1 threshold would read as held forever (gamepad presses still count as input events).
|
||||
bool anyKeyDown()
|
||||
{
|
||||
for (int k = ImGuiKey_Keyboard_BEGIN; k < ImGuiKey_Keyboard_END; ++k) {
|
||||
if (ImGui::IsKeyDown(static_cast<ImGuiKey>(k))) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
} // namespace
|
||||
|
||||
int main(int argc, char* argv[])
|
||||
{
|
||||
// Ensure ObsidianDragon config directory exists early (before any file I/O)
|
||||
@@ -820,8 +922,10 @@ int main(int argc, char* argv[])
|
||||
// above so the two writers don't interleave/contend on one file.
|
||||
{
|
||||
std::string logPath = (std::filesystem::path(dragonx::util::Platform::getObsidianDragonDir()) / "dragonx-stdout.log").string();
|
||||
freopen(logPath.c_str(), "w", stdout);
|
||||
freopen(logPath.c_str(), "a", stderr);
|
||||
freopen(logPath.c_str(), "w", stdout); // per-run truncation
|
||||
freopen("NUL", "w", stderr); // GUI subsystem may have no valid fd 2 — give it one first
|
||||
_dup2(_fileno(stdout), _fileno(stderr)); // stderr now shares stdout's handle + file offset
|
||||
setvbuf(stderr, nullptr, _IONBF, 0); // keep stderr unbuffered (crash diagnostics)
|
||||
}
|
||||
// Install crash handler for diagnostics
|
||||
SetUnhandledExceptionFilter(CrashHandler);
|
||||
@@ -880,9 +984,16 @@ int main(int argc, char* argv[])
|
||||
// Load saved window size early (before window creation, before App exists).
|
||||
// Settings are loaded again later in app.init(), this is just for the size.
|
||||
int savedWinW = 1200, savedWinH = 720;
|
||||
float earlyFontScale = 1.0f; // consumed below, before the first (and only) font-atlas build
|
||||
bool earlyEmojiColor = false;
|
||||
bool earlyOpaque = false; // opaque start: skip DWM blur/acrylic backdrop (Windows)
|
||||
{
|
||||
dragonx::config::Settings earlySettings;
|
||||
if (earlySettings.load()) {
|
||||
const bool earlyLoaded = earlySettings.load();
|
||||
earlyFontScale = earlySettings.getFontScale(); // defaults when load failed, same as App::init sees
|
||||
earlyEmojiColor = earlySettings.getChatEmojiColor();
|
||||
earlyOpaque = earlySettings.getLowSpecMode() || earlySettings.getWindowOpacity() >= 0.99f;
|
||||
if (earlyLoaded) {
|
||||
int sw = earlySettings.getWindowWidth();
|
||||
int sh = earlySettings.getWindowHeight();
|
||||
if (sw >= 1024 && sh >= 720) {
|
||||
@@ -896,6 +1007,12 @@ int main(int argc, char* argv[])
|
||||
}
|
||||
}
|
||||
|
||||
// Everything the first Typography::load() reads must be final BEFORE InitImGui (DX11 and GL paths below):
|
||||
// ui.toml sizes, the saved font scale (non-flagging setter so the first preFrame does not reload), emoji style.
|
||||
dragonx::App::loadBaseUiSchema();
|
||||
dragonx::ui::Layout::setUserFontScaleVisual(earlyFontScale);
|
||||
dragonx::ui::material::Typography::instance().setColorEmoji(earlyEmojiColor);
|
||||
|
||||
// ---------------------------------------------------------------
|
||||
// Platform-specific window creation and graphics context
|
||||
// ---------------------------------------------------------------
|
||||
@@ -1048,14 +1165,17 @@ int main(int argc, char* argv[])
|
||||
if (hwnd_blur) {
|
||||
// Override SDL's zero-area blur region with a full-window blur.
|
||||
// This gives DWM the signal to blur everything behind our window.
|
||||
HRGN blurRgn = CreateRectRgn(0, 0, -1, -1); // full window
|
||||
DWM_BLURBEHIND bb = {};
|
||||
bb.dwFlags = DWM_BB_ENABLE | DWM_BB_BLURREGION;
|
||||
bb.fEnable = TRUE;
|
||||
bb.hRgnBlur = blurRgn;
|
||||
HRESULT hr_blur = DwmEnableBlurBehindWindow(hwnd_blur, &bb);
|
||||
DeleteObject(blurRgn);
|
||||
DEBUG_LOGF("DwmEnableBlurBehindWindow (full region): 0x%08lx\n", hr_blur);
|
||||
// Skipped when the window starts opaque (SDL's zero-area region stays).
|
||||
if (!earlyOpaque) {
|
||||
HRGN blurRgn = CreateRectRgn(0, 0, -1, -1); // full window
|
||||
DWM_BLURBEHIND bb = {};
|
||||
bb.dwFlags = DWM_BB_ENABLE | DWM_BB_BLURREGION;
|
||||
bb.fEnable = TRUE;
|
||||
bb.hRgnBlur = blurRgn;
|
||||
HRESULT hr_blur = DwmEnableBlurBehindWindow(hwnd_blur, &bb);
|
||||
DeleteObject(blurRgn);
|
||||
DEBUG_LOGF("DwmEnableBlurBehindWindow (full region): 0x%08lx\n", hr_blur);
|
||||
}
|
||||
|
||||
// On Windows 11 22H2+, also enable Acrylic for nicer modern blur
|
||||
auto ver = dragonx::platform::getWindowsVersion();
|
||||
@@ -1073,7 +1193,7 @@ int main(int argc, char* argv[])
|
||||
|
||||
// Acrylic = blurred, semi-transparent desktop view
|
||||
// (NOT Mica which is opaque)
|
||||
DWORD backdrop = 3; // DWMSBT_TRANSIENTWINDOW = Acrylic
|
||||
DWORD backdrop = earlyOpaque ? 1 /*DWMSBT_NONE*/ : 3 /*DWMSBT_TRANSIENTWINDOW = Acrylic*/;
|
||||
HRESULT hr_acr = DwmSetWindowAttribute(hwnd_blur, 38,
|
||||
&backdrop, sizeof(backdrop));
|
||||
DEBUG_LOGF("DWM Acrylic (DWMSBT_TRANSIENTWINDOW): 0x%08lx\n", hr_acr);
|
||||
@@ -1124,8 +1244,9 @@ int main(int argc, char* argv[])
|
||||
|
||||
// Create window with graphics context
|
||||
SDL_GL_SetAttribute(SDL_GL_DOUBLEBUFFER, 1);
|
||||
SDL_GL_SetAttribute(SDL_GL_DEPTH_SIZE, 24);
|
||||
SDL_GL_SetAttribute(SDL_GL_STENCIL_SIZE, 8);
|
||||
// Nothing in the app uses depth/stencil (ImGui + acrylic are color-only): request none.
|
||||
SDL_GL_SetAttribute(SDL_GL_DEPTH_SIZE, 0);
|
||||
SDL_GL_SetAttribute(SDL_GL_STENCIL_SIZE, 0);
|
||||
SDL_GL_SetAttribute(SDL_GL_ALPHA_SIZE, 8);
|
||||
|
||||
SDL_WindowFlags window_flags = (SDL_WindowFlags)(SDL_WINDOW_OPENGL | SDL_WINDOW_RESIZABLE | SDL_WINDOW_HIGH_PIXEL_DENSITY | SDL_WINDOW_TRANSPARENT);
|
||||
@@ -1159,6 +1280,11 @@ int main(int argc, char* argv[])
|
||||
}
|
||||
|
||||
SDL_GL_MakeCurrent(window, gl_context);
|
||||
{ int d = -1, s = -1;
|
||||
const bool okd = SDL_GL_GetAttribute(SDL_GL_DEPTH_SIZE, &d);
|
||||
const bool oks = SDL_GL_GetAttribute(SDL_GL_STENCIL_SIZE, &s);
|
||||
DEBUG_LOGF("GL default framebuffer: depth=%d%s stencil=%d%s\n",
|
||||
d, okd ? "" : " (no attachment)", s, oks ? "" : " (no attachment)"); }
|
||||
SDL_GL_SetSwapInterval(1); // Enable vsync
|
||||
|
||||
bool backdrop_active = false;
|
||||
@@ -1319,6 +1445,11 @@ int main(int argc, char* argv[])
|
||||
|
||||
// Create application instance
|
||||
dragonx::App app;
|
||||
// A quit deferred for the "keep the node running?" prompt brings the window back so it can be seen.
|
||||
app.setRaiseWindowCallback([window]() {
|
||||
SDL_RestoreWindow(window);
|
||||
SDL_RaiseWindow(window);
|
||||
});
|
||||
if (!app.init()) {
|
||||
fprintf(stderr, "Failed to initialize application!\n");
|
||||
#ifdef _WIN32
|
||||
@@ -1345,8 +1476,10 @@ int main(int argc, char* argv[])
|
||||
dragonx::ui::schema::UISchema::instance().reapplyColorsToImGui();
|
||||
}
|
||||
|
||||
// Initialize performance profiler — writes to <ObsidianDragonDir>/perf.log
|
||||
{
|
||||
// Performance profiler — writes to <ObsidianDragonDir>/perf.log. Opt-in (DRAGONX_PERFLOG=1): always
|
||||
// on, it appended a block every 300 frames and was never rotated (tens of MB a day for any wallet
|
||||
// left open).
|
||||
if (const char* perfEnv = std::getenv("DRAGONX_PERFLOG"); perfEnv && *perfEnv && std::strcmp(perfEnv, "0") != 0) {
|
||||
std::string perfPath = (std::filesystem::path(
|
||||
dragonx::util::Platform::getObsidianDragonDir()) / "perf.log").string();
|
||||
dragonx::util::PerfLog::instance().init(perfPath);
|
||||
@@ -1401,6 +1534,20 @@ int main(int argc, char* argv[])
|
||||
if (event->window.windowID != SDL_GetWindowID(ctx->window))
|
||||
return true;
|
||||
|
||||
// Never nest NewFrame() inside an already-open frame — Windows min/max/restore buttons
|
||||
// (renderBorderlessControls) push SDL window events synchronously from inside app.render(). (C146)
|
||||
ImGuiContext* gctx = ImGui::GetCurrentContext();
|
||||
if (!gctx || gctx->WithinFrameScope) return true;
|
||||
|
||||
// Dedupe the RESIZED + PIXEL_SIZE_CHANGED pair: both report the same final pixel size, so render
|
||||
// once. SDL_GetWindowSizeInPixels is a consistent key across both event types (their data1/data2
|
||||
// differ — points vs pixels — and cannot be compared directly).
|
||||
int pw = 0, ph = 0;
|
||||
SDL_GetWindowSizeInPixels(ctx->window, &pw, &ph);
|
||||
static int lastW = 0, lastH = 0;
|
||||
if (pw == lastW && ph == lastH) return true;
|
||||
lastW = pw; lastH = ph;
|
||||
|
||||
// Avoid re-entrancy
|
||||
static bool in_resize_render = false;
|
||||
if (in_resize_render) return true;
|
||||
@@ -1416,6 +1563,8 @@ int main(int argc, char* argv[])
|
||||
SDL_GL_MakeCurrent(ctx->window, ctx->gl_context);
|
||||
ImGui_ImplOpenGL3_NewFrame();
|
||||
#endif
|
||||
// Free/rebuild acrylic targets at the frame boundary (after MakeCurrent on GL: FBOs are per-context).
|
||||
dragonx::ui::effects::ImGuiAcrylic::ReleaseIdleBuffers();
|
||||
ImGui_ImplSDL3_NewFrame();
|
||||
ImGui::NewFrame();
|
||||
|
||||
@@ -1433,7 +1582,9 @@ int main(int argc, char* argv[])
|
||||
|
||||
ImGuiIO& io = ImGui::GetIO();
|
||||
dragonx::ui::effects::ImGuiAcrylic::BeginFrame(
|
||||
(int)io.DisplaySize.x, (int)io.DisplaySize.y);
|
||||
(int)io.DisplaySize.x, (int)io.DisplaySize.y,
|
||||
dragonx::ui::effects::framebufferExtent(io.DisplaySize.x, io.DisplayFramebufferScale.x),
|
||||
dragonx::ui::effects::framebufferExtent(io.DisplaySize.y, io.DisplayFramebufferScale.y));
|
||||
|
||||
ctx->app->update();
|
||||
ctx->app->render();
|
||||
@@ -1475,6 +1626,50 @@ int main(int argc, char* argv[])
|
||||
bool needsRedraw = true; // P6: Start with a full redraw
|
||||
auto lastPeriodicFrame = std::chrono::steady_clock::now();
|
||||
|
||||
// Frame pacing state (P6; see kAmbientFrameMs and friends above main()).
|
||||
Uint64 lastInputMs = 0; // SDL_GetTicks() of the last input-class event
|
||||
Uint64 nextAmbientFrameMs = 0; // when the next ambient frame is due; 0 = none scheduled
|
||||
Uint64 lastFrameStartNs = 0; // software frame cap bookkeeping (SDL_GetTicksNS)
|
||||
int settleFrames = kSettleFrames;
|
||||
auto noteEvent = [&](const SDL_Event& e) {
|
||||
if (isInputEvent(e.type)) {
|
||||
lastInputMs = SDL_GetTicks();
|
||||
app.noteUserInput(); // auto-lock idle clock (C21): real input only, never widget state
|
||||
}
|
||||
else if (isWindowStateEvent(e.type)) settleFrames = kSettleFrames;
|
||||
};
|
||||
|
||||
// Deferred font-scale commit: smooth visual update during Alt+scroll,
|
||||
// atlas rebuild after scrolling stops (~200ms idle).
|
||||
Uint64 fontScaleCommitTick = 0; // 0 = nothing pending
|
||||
// Alt + scroll wheel -> font scale. Returns true if the event was consumed.
|
||||
auto handleAltWheel = [&](const SDL_Event& e) -> bool {
|
||||
if (e.type != SDL_EVENT_MOUSE_WHEEL) return false;
|
||||
if (!(SDL_GetModState() & SDL_KMOD_ALT)) return false;
|
||||
float step = 0.05f * e.wheel.y;
|
||||
float cur = dragonx::ui::Layout::userFontScale();
|
||||
float next = std::max(1.0f, std::min(1.5f, cur + step));
|
||||
if (next != cur) {
|
||||
// Smooth preview — no atlas rebuild yet
|
||||
dragonx::ui::Layout::setUserFontScaleVisual(next);
|
||||
// Schedule atlas rebuild after scrolling stops
|
||||
fontScaleCommitTick = SDL_GetTicks() + 200;
|
||||
}
|
||||
return true;
|
||||
};
|
||||
|
||||
// Headless verification hook (DRAGONX_FULL_SWEEP=1, handled after present), hoisted so the pacing
|
||||
// decision can keep its warm-up frames coming.
|
||||
#if DRAGONX_ENABLE_UI_SWEEP
|
||||
const bool autoSweep = std::getenv("DRAGONX_FULL_SWEEP") != nullptr;
|
||||
#else
|
||||
// The env hook is dead in shipped builds. It must be: with startSweepImpl a no-op, an enabled
|
||||
// autoSweep would quit the app as soon as the warm-up frames ran out.
|
||||
const bool autoSweep = false;
|
||||
#endif
|
||||
int autoSweepFrames = 0;
|
||||
bool autoSweepStarted = false;
|
||||
|
||||
// Per-scale saved window sizes for DPI round-trips.
|
||||
// Each entry maps scale-percentage (100, 125, 150, 200, …) to the
|
||||
// physical pixel size the user had at that scale. When transitioning
|
||||
@@ -1501,6 +1696,7 @@ int main(int argc, char* argv[])
|
||||
int dpiSettleFrames = 0;
|
||||
SDL_DisplayID lastLoggedDisplay = 0; // [WINLOG] throttle: log MOVED only when the display changes
|
||||
Uint64 minimizedLastTickMs = 0; // real-clock tick for the minimized "keep syncing" update
|
||||
Uint64 backgroundSinceMs = 0; // when the window started to look backgrounded (0 = it doesn't)
|
||||
{
|
||||
float s = dragonx::ui::material::Typography::instance().getDpiScale();
|
||||
int w = 0, h = 0;
|
||||
@@ -1538,12 +1734,19 @@ int main(int argc, char* argv[])
|
||||
// above forces redraws while active, so this drains promptly even if the app is otherwise idle.
|
||||
if (dpiSettleFrames > 0) --dpiSettleFrames;
|
||||
|
||||
// P6: Idle rendering — sleep if nothing needs redrawing
|
||||
// P6: Idle rendering — sleep until input, the next ambient frame, or a 200 ms housekeeping tick
|
||||
if (!needsRedraw) {
|
||||
// Wait up to 200ms for user input or wake event
|
||||
int waitMs = 200;
|
||||
if (nextAmbientFrameMs != 0) {
|
||||
const Uint64 nowMs = SDL_GetTicks();
|
||||
waitMs = nextAmbientFrameMs > nowMs
|
||||
? static_cast<int>(std::min<Uint64>(nextAmbientFrameMs - nowMs, 200)) : 0;
|
||||
}
|
||||
SDL_Event waitEvent;
|
||||
if (SDL_WaitEventTimeout(&waitEvent, 200)) {
|
||||
ImGui_ImplSDL3_ProcessEvent(&waitEvent);
|
||||
if (waitMs > 0 && SDL_WaitEventTimeout(&waitEvent, waitMs)) {
|
||||
noteEvent(waitEvent);
|
||||
if (handleAltWheel(waitEvent)) needsRedraw = true;
|
||||
else ImGui_ImplSDL3_ProcessEvent(&waitEvent);
|
||||
if (waitEvent.type == SDL_EVENT_QUIT) {
|
||||
app.beginShutdown();
|
||||
}
|
||||
@@ -1581,11 +1784,12 @@ int main(int argc, char* argv[])
|
||||
lastKnownH = waitEvent.window.data2;
|
||||
}
|
||||
}
|
||||
// Window restored from minimized — trigger immediate data refresh
|
||||
// Window restored. No forced refresh: update() keeps running while minimized, so the data
|
||||
// is current, and refreshNow() wiped the shielded-scan progress and re-scanned every
|
||||
// z-address (SDL also sends RESTORED on un-maximize, so a title-bar double-click did it).
|
||||
if (waitEvent.type == SDL_EVENT_WINDOW_RESTORED &&
|
||||
waitEvent.window.windowID == SDL_GetWindowID(window)) {
|
||||
app.skipDaemonOutputBacklog(); // drop the minimized-period backlog (avoids a spurious "rescan complete" toast)
|
||||
app.refreshNow();
|
||||
}
|
||||
// Handle DPI change that arrived while idle (same logic as poll loop)
|
||||
if (waitEvent.type == SDL_EVENT_WINDOW_DISPLAY_SCALE_CHANGED &&
|
||||
@@ -1608,17 +1812,22 @@ int main(int argc, char* argv[])
|
||||
auto sinceLast = std::chrono::duration_cast<std::chrono::milliseconds>(
|
||||
now - lastPeriodicFrame).count();
|
||||
|
||||
// Immediate triggers: async RPC results or visible notifications
|
||||
bool hasImmediateWork = app.hasPendingRPCResults()
|
||||
// Immediate triggers: background results, visible notifications, work in progress
|
||||
// (also what keeps a hidden or occluded window responsive: ~5 fps while it lasts)
|
||||
bool hasImmediateWork = app.hasPendingBackgroundResults()
|
||||
|| app.hasTransactionSendProgress()
|
||||
|| app.isTransactionRefreshInProgress()
|
||||
|| app.isShuttingDown()
|
||||
|| dragonx::ui::Notifications::instance().hasActive();
|
||||
|
||||
// Ambient frame due (scheduled by the pacing decision at the end of the last frame)
|
||||
const bool ambientDue = nextAmbientFrameMs != 0 && SDL_GetTicks() >= nextAmbientFrameMs;
|
||||
|
||||
// Periodic maintenance: fire refresh timers in app.update()
|
||||
// Low-spec: every 5s; normal: every 2s
|
||||
int periodMs = dragonx::ui::effects::isLowSpecMode() ? 5000 : 2000;
|
||||
|
||||
if (hasImmediateWork || sinceLast >= periodMs) {
|
||||
if (ambientDue || hasImmediateWork || sinceLast >= periodMs) {
|
||||
needsRedraw = true;
|
||||
} else {
|
||||
continue; // Nothing to do — go back to sleep
|
||||
@@ -1626,30 +1835,23 @@ int main(int argc, char* argv[])
|
||||
}
|
||||
}
|
||||
|
||||
// Software frame cap: never start frames faster than the display refreshes. A no-op where
|
||||
// swap/present blocks on vsync; elsewhere it stops the loop spinning a core. The screenshot
|
||||
// sweep switches vsync off on purpose and is exempt. Before the event poll, so the frame still
|
||||
// renders with the freshest input.
|
||||
if (!app.isScreenshotSweeping() && lastFrameStartNs != 0) {
|
||||
const Uint64 budgetNs = frameBudgetNs(window);
|
||||
const Uint64 sinceStartNs = SDL_GetTicksNS() - lastFrameStartNs;
|
||||
if (sinceStartNs < budgetNs) SDL_DelayNS(budgetNs - sinceStartNs);
|
||||
}
|
||||
lastFrameStartNs = SDL_GetTicksNS();
|
||||
|
||||
// Poll remaining events
|
||||
SDL_Event event;
|
||||
// Deferred font-scale commit: smooth visual update during Alt+scroll,
|
||||
// atlas rebuild after scrolling stops (~200ms idle).
|
||||
static Uint64 fontScaleCommitTick = 0; // 0 = nothing pending
|
||||
|
||||
while (SDL_PollEvent(&event)) {
|
||||
// Alt + scroll wheel: smooth font scale adjustment
|
||||
if (event.type == SDL_EVENT_MOUSE_WHEEL) {
|
||||
SDL_Keymod mods = SDL_GetModState();
|
||||
if (mods & SDL_KMOD_ALT) {
|
||||
float step = 0.05f * event.wheel.y;
|
||||
float cur = dragonx::ui::Layout::userFontScale();
|
||||
float next = std::max(1.0f, std::min(1.5f, cur + step));
|
||||
if (next != cur) {
|
||||
// Smooth preview — no atlas rebuild yet
|
||||
dragonx::ui::Layout::setUserFontScaleVisual(next);
|
||||
// Schedule atlas rebuild after scrolling stops
|
||||
fontScaleCommitTick = SDL_GetTicks() + 200;
|
||||
}
|
||||
// Don't pass Alt+scroll to ImGui (would scroll windows)
|
||||
continue;
|
||||
}
|
||||
}
|
||||
noteEvent(event);
|
||||
// Alt + scroll wheel: smooth font scale adjustment (don't pass to ImGui: would scroll windows)
|
||||
if (handleAltWheel(event)) continue;
|
||||
ImGui_ImplSDL3_ProcessEvent(&event);
|
||||
|
||||
if (event.type == SDL_EVENT_QUIT) {
|
||||
@@ -1711,11 +1913,10 @@ int main(int argc, char* argv[])
|
||||
}
|
||||
}
|
||||
}
|
||||
// Window restored from minimized — trigger immediate data refresh
|
||||
// Window restored: no forced refresh (see the wait branch above).
|
||||
if (event.type == SDL_EVENT_WINDOW_RESTORED &&
|
||||
event.window.windowID == SDL_GetWindowID(window)) {
|
||||
app.skipDaemonOutputBacklog(); // drop the minimized-period backlog (avoids a spurious "rescan complete" toast)
|
||||
app.refreshNow();
|
||||
}
|
||||
// Handle DPI/display scale changes (e.g. window dragged to a
|
||||
// different-DPI monitor, or user changes Windows scaling)
|
||||
@@ -1733,6 +1934,22 @@ int main(int argc, char* argv[])
|
||||
}
|
||||
}
|
||||
|
||||
// C134: the window is in the background when it is minimized, hidden, or covered while none of our
|
||||
// windows has focus, for a few seconds with no input meanwhile. Focus loss alone never counts. The
|
||||
// app then polls only the chain state; coming back is immediate.
|
||||
{
|
||||
const Uint64 nowMs = SDL_GetTicks();
|
||||
const SDL_WindowFlags wf = SDL_GetWindowFlags(window);
|
||||
const bool flagged = dragonx::services::windowLooksBackground(
|
||||
(wf & SDL_WINDOW_MINIMIZED) != 0, (wf & SDL_WINDOW_HIDDEN) != 0,
|
||||
(wf & SDL_WINDOW_OCCLUDED) != 0, SDL_GetKeyboardFocus() != nullptr);
|
||||
if (!flagged) backgroundSinceMs = 0;
|
||||
else if (backgroundSinceMs == 0) backgroundSinceMs = nowMs;
|
||||
app.setWindowBackground(dragonx::services::isBackgroundWindow(
|
||||
flagged, backgroundSinceMs != 0 ? (nowMs - backgroundSinceMs) / 1000.0 : 0.0,
|
||||
lastInputMs != 0 ? (nowMs - lastInputMs) / 1000.0 : 1.0e9));
|
||||
}
|
||||
|
||||
// Check if window is minimized
|
||||
if (SDL_GetWindowFlags(window) & SDL_WINDOW_MINIMIZED) {
|
||||
// Keep the wallet syncing while minimized: run the logic update (drains RPC results, ticks the
|
||||
@@ -1773,6 +1990,8 @@ int main(int argc, char* argv[])
|
||||
// Pre-frame: font atlas rebuilds and schema hot-reload must
|
||||
// happen BEFORE NewFrame() because NewFrame() caches font ptrs.
|
||||
app.preFrame();
|
||||
// Acrylic targets are freed/rebuilt here, between frames: nothing recorded can reference them.
|
||||
dragonx::ui::effects::ImGuiAcrylic::ReleaseIdleBuffers();
|
||||
|
||||
// Smooth font-scale: compensate visual font size via FontScaleMain
|
||||
// when the atlas hasn't been rebuilt yet (during slider drag).
|
||||
@@ -1858,10 +2077,14 @@ int main(int argc, char* argv[])
|
||||
ImGuiIO& io = ImGui::GetIO();
|
||||
if (!dragonx::ui::effects::isLowSpecMode()) {
|
||||
dragonx::ui::effects::ImGuiAcrylic::BeginFrame(
|
||||
(int)io.DisplaySize.x, (int)io.DisplaySize.y);
|
||||
(int)io.DisplaySize.x, (int)io.DisplaySize.y,
|
||||
dragonx::ui::effects::framebufferExtent(io.DisplaySize.x, io.DisplayFramebufferScale.x),
|
||||
dragonx::ui::effects::framebufferExtent(io.DisplaySize.y, io.DisplayFramebufferScale.y));
|
||||
}
|
||||
|
||||
// Update theme visual effects timing
|
||||
// Update theme visual effects timing (held still under Reduce Motion)
|
||||
dragonx::ui::effects::ThemeEffects::instance().setMotionFrozen(
|
||||
app.settings() && app.settings()->getReduceMotion());
|
||||
dragonx::ui::effects::ThemeEffects::instance().beginFrame();
|
||||
// Propagate window opacity to theme effects so they fade with the
|
||||
// backdrop while UI elements (cards, text, buttons) stay opaque.
|
||||
@@ -2020,7 +2243,9 @@ int main(int argc, char* argv[])
|
||||
|
||||
// Debug screenshot sweep — grab the finished frame before swap.
|
||||
if (app.wantsScreenshotThisFrame()) {
|
||||
captureFrameToPng(app.screenshotSweepPath(), (int)io.DisplaySize.x, (int)io.DisplaySize.y);
|
||||
captureFrameToPng(app.screenshotSweepPath(),
|
||||
dragonx::ui::effects::framebufferExtent(io.DisplaySize.x, io.DisplayFramebufferScale.x),
|
||||
dragonx::ui::effects::framebufferExtent(io.DisplaySize.y, io.DisplayFramebufferScale.y));
|
||||
app.onScreenshotCaptured();
|
||||
}
|
||||
|
||||
@@ -2053,16 +2278,11 @@ int main(int argc, char* argv[])
|
||||
|
||||
// Headless verification hook: DRAGONX_FULL_SWEEP=1 runs the full UI sweep once the app has
|
||||
// settled, then quits when it finishes. Used to run the sweep without clicking the button.
|
||||
{
|
||||
static const bool autoSweep = std::getenv("DRAGONX_FULL_SWEEP") != nullptr;
|
||||
static int autoFrames = 0;
|
||||
static bool autoStarted = false;
|
||||
if (autoSweep) {
|
||||
if (!autoStarted) {
|
||||
if (++autoFrames >= 90) { app.startFullUiSweep(); autoStarted = true; }
|
||||
} else if (!app.isScreenshotSweeping()) {
|
||||
running = false; // sweep complete
|
||||
}
|
||||
if (autoSweep) {
|
||||
if (!autoSweepStarted) {
|
||||
if (++autoSweepFrames >= 90) { app.startFullUiSweep(); autoSweepStarted = true; }
|
||||
} else if (!app.isScreenshotSweeping()) {
|
||||
running = false; // sweep complete
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2085,41 +2305,99 @@ int main(int argc, char* argv[])
|
||||
// Track last render for idle periodic timer
|
||||
lastPeriodicFrame = std::chrono::steady_clock::now();
|
||||
|
||||
// P6: Determine if next frame needs redraw or can idle
|
||||
// P6: Decide when the next frame is needed: right away (full rate), at the ambient rate, or not
|
||||
// until the next event / housekeeping wake (idle). See the frame-pacing notes above main().
|
||||
{
|
||||
ImGuiContext& g = *GImGui;
|
||||
const Uint64 nowMs = SDL_GetTicks();
|
||||
|
||||
// Detect actual user interaction — NOT WantCaptureMouse/Keyboard
|
||||
// which are just "ImGui has windows that accept input" (always true).
|
||||
bool mouseMoving = g.IO.MouseDelta.x != 0.0f || g.IO.MouseDelta.y != 0.0f;
|
||||
bool mouseClicked = false;
|
||||
// Hidden or occluded windows get no ambient frames (SDL reports occlusion on macOS and
|
||||
// Wayland; elsewhere a covered window usually also lacks focus). Focus is per application:
|
||||
// any of our windows, multi-viewport platform windows included, not just the main one.
|
||||
const SDL_WindowFlags wflags = SDL_GetWindowFlags(window);
|
||||
const bool visible =
|
||||
(wflags & (SDL_WINDOW_HIDDEN | SDL_WINDOW_OCCLUDED | SDL_WINDOW_MINIMIZED)) == 0;
|
||||
const bool focused = SDL_GetKeyboardFocus() != nullptr;
|
||||
|
||||
// Interaction — not WantCaptureMouse/Keyboard, which only mean "ImGui has windows that
|
||||
// accept input" (always true). A focused text field by itself (only the caret moves: the
|
||||
// lock screen's passphrase box, the console input) is ambient below; it used to hold the
|
||||
// loop at the display rate for as long as the field kept focus.
|
||||
const bool textEditing = g.ActiveId != 0 && g.ActiveId == g.InputTextState.ID;
|
||||
bool mouseDown = false;
|
||||
for (int i = 0; i < IM_ARRAYSIZE(g.IO.MouseDown); i++)
|
||||
mouseClicked |= g.IO.MouseDown[i];
|
||||
bool scrolling = g.IO.MouseWheel != 0.0f || g.IO.MouseWheelH != 0.0f;
|
||||
bool typing = g.IO.InputQueueCharacters.Size > 0;
|
||||
bool widgetActive = g.ActiveId != 0 || g.MovingWindow != nullptr;
|
||||
mouseDown |= g.IO.MouseDown[i];
|
||||
const bool uiActive = g.IO.MouseDelta.x != 0.0f || g.IO.MouseDelta.y != 0.0f
|
||||
|| mouseDown
|
||||
|| g.IO.MouseWheel != 0.0f || g.IO.MouseWheelH != 0.0f
|
||||
|| (g.ActiveId != 0 && !textEditing) || g.MovingWindow != nullptr
|
||||
|| g.InputEventsQueue.Size > 0 // input still trickling in
|
||||
|| (focused && anyKeyDown()) // ImGui key repeat
|
||||
|| (lastInputMs != 0 && nowMs - lastInputMs < kRecentInputMs);
|
||||
|
||||
bool uiActive = mouseMoving || mouseClicked || scrolling
|
||||
|| typing || widgetActive;
|
||||
// A window that appeared this frame (a popup hidden for its auto-fit frame, an overlay
|
||||
// dialog's scrim while its card measures and captures its blur) needs a few more frames
|
||||
// to show, even when it opened from an RPC result or a timer rather than input.
|
||||
for (ImGuiWindow* w : g.Windows) {
|
||||
if (w->Active && w->Appearing) { settleFrames = kSettleFrames; break; }
|
||||
}
|
||||
|
||||
// When the background is fully opaque, backdrop doesn't
|
||||
// need continuous frames — let the app idle and save power.
|
||||
float wo2 = app.settings() ? app.settings()->getWindowOpacity() : 1.0f;
|
||||
bool opaqueBackground = dragonx::ui::effects::isLowSpecMode() || wo2 >= 0.99f;
|
||||
bool backdropNeedsFrames = (backdrop_active || app.getGradientTexture() != 0)
|
||||
&& !opaqueBackground;
|
||||
bool animating = app.isShuttingDown()
|
||||
|| app.isWalletSwitchInProgress()
|
||||
|| backdropNeedsFrames
|
||||
|| app.hasTransactionSendProgress()
|
||||
|| app.isTransactionRefreshInProgress()
|
||||
|| dragonx::ui::effects::ThemeEffects::instance().hasActiveAnimation()
|
||||
|| dragonx::ui::Notifications::instance().hasActive()
|
||||
|| dragonx::ui::material::SmoothScrollAnimating()
|
||||
|| dragonx::ui::ConsumeContactsAvatarAnimation();
|
||||
// If nothing is happening, allow the next iteration to idle
|
||||
needsRedraw = uiActive || animating;
|
||||
const bool fullRate = uiActive
|
||||
|| settleFrames > 0
|
||||
|| app.isScreenshotSweeping()
|
||||
|| (autoSweep && !autoSweepStarted)
|
||||
|| dragonx::ui::material::SmoothScrollAnimating();
|
||||
if (settleFrames > 0) --settleFrames;
|
||||
|
||||
// Ambient: continuous motion without input. Theme effects stop requesting frames under
|
||||
// Reduce Motion (their clock is frozen above). The backdrop itself is static; the
|
||||
// translucent window stays on this tier as the conservative choice for compositors.
|
||||
const bool avatarAnimating = dragonx::ui::ConsumeContactsAvatarAnimation(); // consume every frame
|
||||
const bool progressRequested = dragonx::ui::material::ConsumeProgressFrameRequest(); // ditto
|
||||
const float wo2 = app.settings() ? app.settings()->getWindowOpacity() : 1.0f;
|
||||
const bool opaqueBackground = dragonx::ui::effects::isLowSpecMode() || wo2 >= 0.99f;
|
||||
const bool backdropNeedsFrames = (backdrop_active || app.getGradientTexture() != 0)
|
||||
&& !opaqueBackground;
|
||||
const bool reduceMotion = app.settings() && app.settings()->getReduceMotion();
|
||||
// C128: suppress backdrop theme-effect frame requests while a blur overlay (a modal dialog or
|
||||
// the lock screen) covers them -- they aren't visible, and the overlay's own moving elements
|
||||
// request frames via RequestProgressFrame(), so this never freezes anything on screen.
|
||||
const bool effectsAnimating = !reduceMotion
|
||||
&& dragonx::ui::effects::ThemeEffects::instance().hasActiveAnimation()
|
||||
&& !dragonx::ui::material::AnyBlurOverlayActiveLastFrame();
|
||||
const bool ambient = effectsAnimating
|
||||
|| backdropNeedsFrames
|
||||
|| avatarAnimating
|
||||
|| dragonx::ui::Notifications::instance().hasActive();
|
||||
|
||||
// Progress: only needs to keep visibly moving (a spinner, a percentage, dots, a caret).
|
||||
const bool progress = progressRequested
|
||||
|| (textEditing && focused)
|
||||
|| app.isShuttingDown()
|
||||
|| app.isWalletSwitchInProgress()
|
||||
|| app.hasTransactionSendProgress()
|
||||
|| app.isTransactionRefreshInProgress();
|
||||
|
||||
needsRedraw = fullRate;
|
||||
nextAmbientFrameMs = 0;
|
||||
if (!fullRate && visible && (ambient || progress)) {
|
||||
const Uint32 interval = ambient ? (focused ? kAmbientFrameMs : kAmbientFrameUnfocusedMs)
|
||||
: (focused ? kProgressFrameMs : kProgressFrameUnfocusedMs);
|
||||
nextAmbientFrameMs = lastFrameStartNs / 1000000 + interval;
|
||||
}
|
||||
}
|
||||
#ifdef DRAGONX_USE_DX11
|
||||
if (g_pendingCaptionOp != CaptionOp::None) {
|
||||
switch (g_pendingCaptionOp) {
|
||||
case CaptionOp::Minimize: SDL_MinimizeWindow(window); break;
|
||||
case CaptionOp::Maximize: SDL_MaximizeWindow(window); break;
|
||||
case CaptionOp::Restore: SDL_RestoreWindow(window); break;
|
||||
default: break;
|
||||
}
|
||||
g_pendingCaptionOp = CaptionOp::None;
|
||||
needsRedraw = true; // after the pacing block, so it is not overwritten
|
||||
}
|
||||
#endif
|
||||
}
|
||||
|
||||
// Remove resize event watcher
|
||||
@@ -2147,10 +2425,9 @@ int main(int argc, char* argv[])
|
||||
|
||||
// Watchdog: if cleanup takes too long the process lingers without a
|
||||
// window, showing up as a "Background Service" in Task Manager.
|
||||
// Force-exit after timeout — all critical state (settings, daemon
|
||||
// stop) was handled in beginShutdown().
|
||||
// Allow enough time for the daemon to shut down gracefully (up to
|
||||
// 10s in stop()) plus RPC disconnect overhead.
|
||||
// Force-exit after timeout. The daemon stop is already over by the time the render loop exits: the
|
||||
// node has exited, or the user chose to leave it running (it carries on as its own process), so this
|
||||
// only bounds the remaining thread joins and RPC disconnects — it never cuts a node's shutdown short.
|
||||
std::thread([]() {
|
||||
std::this_thread::sleep_for(std::chrono::seconds(15));
|
||||
fflush(stdout);
|
||||
@@ -2158,8 +2435,14 @@ int main(int argc, char* argv[])
|
||||
_Exit(0);
|
||||
}).detach();
|
||||
|
||||
// Land the queued settings/wallets.json writes (incl. the window size saved above) before the joins
|
||||
// in shutdown(); bounded (3 s) so a wedged disk can't eat the watchdog's budget.
|
||||
app.flushPendingWrites();
|
||||
// Final cleanup (daemon already stopped by beginShutdown)
|
||||
app.shutdown();
|
||||
// shutdown()'s fallback branch saves settings again; land that too, BEFORE the single-instance lock
|
||||
// is released below so a relaunch can't read a stale settings.json. _Exit() skips ~App().
|
||||
app.flushPendingWrites();
|
||||
#ifdef DRAGONX_USE_DX11
|
||||
Shutdown(window, dx);
|
||||
#else
|
||||
@@ -2186,7 +2469,11 @@ int main(int argc, char* argv[])
|
||||
|
||||
static bool InitSDL()
|
||||
{
|
||||
if (!SDL_Init(SDL_INIT_VIDEO | SDL_INIT_GAMEPAD)) {
|
||||
// SDL3 otherwise holds timeBeginPeriod(1) for the process lifetime on Windows. Nothing here needs
|
||||
// sub-15 ms waits (vsync pacing, the 200 ms idle wait, >=50 ms thread sleeps), so let the system
|
||||
// timer relax. Ignored on non-Windows platforms; a false return (env override) is harmless.
|
||||
SDL_SetHint(SDL_HINT_TIMER_RESOLUTION, "0");
|
||||
if (!SDL_Init(SDL_INIT_VIDEO)) {
|
||||
fprintf(stderr, "Error: SDL_Init(): %s\n", SDL_GetError());
|
||||
return false;
|
||||
}
|
||||
@@ -2211,8 +2498,6 @@ static bool InitImGui(SDL_Window* window, dragonx::platform::DX11Context& dx)
|
||||
|
||||
// Enable keyboard navigation
|
||||
io.ConfigFlags |= ImGuiConfigFlags_NavEnableKeyboard;
|
||||
// Enable gamepad navigation
|
||||
io.ConfigFlags |= ImGuiConfigFlags_NavEnableGamepad;
|
||||
// Enable multi-viewport (windows can float outside main window)
|
||||
io.ConfigFlags |= ImGuiConfigFlags_ViewportsEnable;
|
||||
|
||||
@@ -2273,8 +2558,6 @@ static bool InitImGui(SDL_Window* window, SDL_GLContext gl_context)
|
||||
|
||||
// Enable keyboard navigation
|
||||
io.ConfigFlags |= ImGuiConfigFlags_NavEnableKeyboard;
|
||||
// Enable gamepad navigation
|
||||
io.ConfigFlags |= ImGuiConfigFlags_NavEnableGamepad;
|
||||
// Enable multi-viewport (windows can float outside main window)
|
||||
io.ConfigFlags |= ImGuiConfigFlags_ViewportsEnable;
|
||||
|
||||
|
||||
@@ -252,16 +252,6 @@ void DX11Context::resize(int width, int height)
|
||||
(void)height;
|
||||
if (!swapChain_ || !deviceContext_) return;
|
||||
|
||||
cleanupRenderTarget();
|
||||
|
||||
// Unbind the render target from the pipeline — ResizeBuffers requires
|
||||
// ALL outstanding references to back-buffer resources to be released.
|
||||
// Without this, ResizeBuffers fails with DXGI_ERROR_INVALID_CALL when
|
||||
// growing the window (shrinking appears fine because the old larger
|
||||
// buffer simply gets cropped by DWM).
|
||||
deviceContext_->OMSetRenderTargets(0, nullptr, nullptr);
|
||||
deviceContext_->Flush();
|
||||
|
||||
// For composition swap chains (CreateSwapChainForComposition),
|
||||
// passing 0,0 means "keep current size" — NOT auto-detect from HWND.
|
||||
// We must pass the actual pixel dimensions from GetClientRect.
|
||||
@@ -272,7 +262,22 @@ void DX11Context::resize(int width, int height)
|
||||
w = static_cast<UINT>(rc.right - rc.left);
|
||||
h = static_cast<UINT>(rc.bottom - rc.top);
|
||||
}
|
||||
if (w == 0 || h == 0) return;
|
||||
if (w == 0 || h == 0) return; // minimized / zero-area: keep the current RTV, do nothing
|
||||
|
||||
// Already this size with a live RTV: nothing to do (skips the destroy/recreate churn).
|
||||
DXGI_SWAP_CHAIN_DESC1 d;
|
||||
if (SUCCEEDED(swapChain_->GetDesc1(&d)) && d.Width == w && d.Height == h && renderTargetView_)
|
||||
return;
|
||||
|
||||
cleanupRenderTarget();
|
||||
|
||||
// Unbind the render target from the pipeline — ResizeBuffers requires
|
||||
// ALL outstanding references to back-buffer resources to be released.
|
||||
// Without this, ResizeBuffers fails with DXGI_ERROR_INVALID_CALL when
|
||||
// growing the window (shrinking appears fine because the old larger
|
||||
// buffer simply gets cropped by DWM).
|
||||
deviceContext_->OMSetRenderTargets(0, nullptr, nullptr);
|
||||
deviceContext_->Flush();
|
||||
|
||||
HRESULT hr = swapChain_->ResizeBuffers(0, w, h, DXGI_FORMAT_UNKNOWN, 0);
|
||||
if (FAILED(hr)) {
|
||||
|
||||
@@ -1,10 +1,15 @@
|
||||
#include "embedded_resources.h"
|
||||
#include "../util/platform.h"
|
||||
#include "../util/logger.h"
|
||||
#include <algorithm>
|
||||
#include <cerrno>
|
||||
#include <fstream>
|
||||
#include <filesystem>
|
||||
#include <functional>
|
||||
#include <mutex>
|
||||
#include <vector>
|
||||
#include <cstdio>
|
||||
#include <cstring>
|
||||
#include <cctype>
|
||||
#include <chrono>
|
||||
|
||||
@@ -12,6 +17,7 @@
|
||||
#include <windows.h>
|
||||
#include <shlobj.h>
|
||||
#else
|
||||
#include <fcntl.h>
|
||||
#include <unistd.h>
|
||||
#include <pwd.h>
|
||||
#include <sys/types.h>
|
||||
@@ -83,8 +89,8 @@ bool hasEmbeddedResources()
|
||||
const EmbeddedResource* getEmbeddedResource(const std::string& name)
|
||||
{
|
||||
// Search static resource table (params, daemon binaries, etc.)
|
||||
for (const auto* res = s_resources; res->data != nullptr; ++res) {
|
||||
if (name == res->filename) {
|
||||
for (const auto* res = s_resources; res->filename != nullptr; ++res) {
|
||||
if (res->data && res->size && name == res->filename) {
|
||||
return res;
|
||||
}
|
||||
}
|
||||
@@ -116,6 +122,27 @@ const EmbeddedTheme* getEmbeddedThemes()
|
||||
return themes.data();
|
||||
}
|
||||
|
||||
int writeFileIfDifferent(const std::string& path, const uint8_t* data, size_t size)
|
||||
{
|
||||
{
|
||||
std::ifstream cur(path, std::ios::binary | std::ios::ate);
|
||||
if (cur.is_open()) {
|
||||
const std::streamoff len = cur.tellg();
|
||||
if (len >= 0 && static_cast<size_t>(len) == size) {
|
||||
std::string buf(size, '\0');
|
||||
cur.seekg(0);
|
||||
if (size == 0 || (cur.read(&buf[0], static_cast<std::streamsize>(size)) &&
|
||||
std::memcmp(buf.data(), data, size) == 0))
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
} // reader closed before the writer opens (Windows sharing)
|
||||
std::ofstream f(path, std::ios::binary | std::ios::trunc);
|
||||
if (!f.is_open()) return -1;
|
||||
f.write(reinterpret_cast<const char*>(data), static_cast<std::streamsize>(size));
|
||||
return f.good() ? 1 : -1;
|
||||
}
|
||||
|
||||
int extractBundledThemes(const std::string& destDir)
|
||||
{
|
||||
namespace fs = std::filesystem;
|
||||
@@ -134,14 +161,12 @@ int extractBundledThemes(const std::string& destDir)
|
||||
|
||||
for (const auto* t = themes; t->data != nullptr; ++t) {
|
||||
fs::path dest = fs::path(destDir) / t->filename;
|
||||
// Always overwrite — bundled themes should match the binary version
|
||||
std::ofstream f(dest, std::ios::binary);
|
||||
if (f.is_open()) {
|
||||
f.write(reinterpret_cast<const char*>(t->data), t->size);
|
||||
f.close();
|
||||
DEBUG_LOGF("[INFO] EmbeddedResources: Extracted theme: %s (%zu bytes)\n",
|
||||
t->filename, t->size);
|
||||
count++;
|
||||
// Bundled themes must match the binary version: rewrite only when the bytes differ (not size-only:
|
||||
// a same-length palette edit must still update).
|
||||
const int w = writeFileIfDifferent(dest.string(), t->data, t->size);
|
||||
if (w >= 0) {
|
||||
if (w == 1) DEBUG_LOGF("[INFO] EmbeddedResources: Extracted theme: %s (%zu bytes)\n", t->filename, t->size);
|
||||
count++; // present and current (caller only checks > 0)
|
||||
} else {
|
||||
DEBUG_LOGF("[ERROR] EmbeddedResources: Failed to write theme: %s\n", dest.string().c_str());
|
||||
}
|
||||
@@ -205,272 +230,461 @@ std::string getParamsDirectory()
|
||||
#endif
|
||||
}
|
||||
|
||||
// Forward declaration — defined below extractResource()
|
||||
static bool resourceNeedsUpdate(const EmbeddedResource* res, const std::string& destPath);
|
||||
// ---------------------------------------------------------------------------------------------------
|
||||
// Writing into the daemon directory: one lock, atomic (temp + flush + rename) files.
|
||||
// ---------------------------------------------------------------------------------------------------
|
||||
namespace {
|
||||
|
||||
bool needsParamsExtraction()
|
||||
// Every writer of the daemon directory (startup job, getDaemonPath(), getXmrigPath(), forceExtractXmrig(),
|
||||
// reextractBundledDaemon(), ensureWalletRebuildHelperExtracted(), copyBundledFiles()) holds this for its whole
|
||||
// write; whoever comes second finds the files complete and does nothing. Never taken re-entrantly. Lock order:
|
||||
// provisionMutex -> Logger mutex (DEBUG_LOGF) only. The in-app updaters do not take it (see residual risks).
|
||||
std::mutex& provisionMutex() { static std::mutex m; return m; }
|
||||
|
||||
struct ProvisionEntry {
|
||||
const char* name;
|
||||
bool refreshOnSizeMismatch; // re-extract when the bundled size differs (params, asmap)
|
||||
bool executable;
|
||||
bool fullNodeOnly; // not written by Lite builds (the lite backend bakes in its own params)
|
||||
bool startupTrigger; // makes needsParamsExtraction() true
|
||||
};
|
||||
|
||||
// Params and asmap follow the bundled size; the node binaries, the rebuild helper and the miner are placed only
|
||||
// when MISSING (user-updatable via the updaters: the user may run a specific dragonxd or an "Update miner..."
|
||||
// build; replacing the node is the explicit Settings action). asmap/cli/tx/rebuild are written along, never a trigger.
|
||||
const ProvisionEntry kProvisionEntries[] = {
|
||||
{ RESOURCE_SAPLING_SPEND, true, false, true, true },
|
||||
{ RESOURCE_SAPLING_OUTPUT, true, false, true, true },
|
||||
{ RESOURCE_ASMAP, true, false, true, false },
|
||||
#ifdef HAS_EMBEDDED_DAEMON
|
||||
{ RESOURCE_DRAGONXD, false, true, true, true },
|
||||
{ RESOURCE_DRAGONX_CLI, false, true, true, false },
|
||||
{ RESOURCE_DRAGONX_TX, false, true, true, false },
|
||||
#endif
|
||||
#ifdef HAS_EMBEDDED_XMRIG
|
||||
{ RESOURCE_XMRIG, kXmrigRefreshOnSizeMismatch, true, false, true },
|
||||
#endif
|
||||
#ifdef HAS_EMBEDDED_WALLET_REBUILD
|
||||
{ RESOURCE_DRAGONX_WALLET_REBUILD, false, true, true, false },
|
||||
#endif
|
||||
};
|
||||
|
||||
bool entryActive(const ProvisionEntry& e)
|
||||
{
|
||||
if (!hasEmbeddedResources()) {
|
||||
#if defined(DRAGONX_LITE_BUILD) && DRAGONX_LITE_BUILD
|
||||
return !e.fullNodeOnly;
|
||||
#else
|
||||
(void)e;
|
||||
return true;
|
||||
#endif
|
||||
}
|
||||
|
||||
std::string joinDir(const std::string& dir, const char* name)
|
||||
{
|
||||
#ifdef _WIN32
|
||||
return dir + "\\" + name;
|
||||
#else
|
||||
return dir + "/" + name;
|
||||
#endif
|
||||
}
|
||||
|
||||
constexpr std::size_t kCopyChunk = 4u * 1024u * 1024u;
|
||||
|
||||
bool prepareParentDir(const std::string& destPath)
|
||||
{
|
||||
const std::filesystem::path dest(destPath);
|
||||
if (!dest.has_parent_path()) return true;
|
||||
std::error_code ec;
|
||||
std::filesystem::create_directories(dest.parent_path(), ec);
|
||||
if (ec) {
|
||||
DEBUG_LOGF("[ERROR] Failed to create directory %s: %s\n", dest.parent_path().string().c_str(),
|
||||
ec.message().c_str());
|
||||
return false;
|
||||
}
|
||||
|
||||
// Check daemon directory (dragonx/) — the only extraction target
|
||||
std::string daemonDir = getDaemonDirectory();
|
||||
return true;
|
||||
}
|
||||
|
||||
// Temp-file sink for writeFileAtomic / copyFileAtomic. Windows: ONE CreateFileA handle with no SECURITY_ATTRIBUTES,
|
||||
// i.e. NOT inheritable: std::ofstream's CRT handle is, and a child created with bInheritHandles=TRUE meanwhile
|
||||
// (xmrig, runHiddenCapture) would hold "<name>.partial" without FILE_SHARE_DELETE, so the MoveFileEx in
|
||||
// replaceFromTemp fails while it runs (same fix as Platform::writeFileAtomically). commit() flushes on the same
|
||||
// handle (FlushFileBuffers / fsync) before close, so the bytes are on disk before the rename. POSIX: O_CLOEXEC.
|
||||
class TempSink {
|
||||
public:
|
||||
explicit TempSink(const std::string& path)
|
||||
{
|
||||
#ifdef _WIN32
|
||||
const char pathSep = '\\';
|
||||
h_ = CreateFileA(path.c_str(), GENERIC_WRITE, 0, nullptr, CREATE_ALWAYS, FILE_ATTRIBUTE_NORMAL, nullptr);
|
||||
#else
|
||||
const char pathSep = '/';
|
||||
#endif
|
||||
std::string spendPath = daemonDir + pathSep + RESOURCE_SAPLING_SPEND;
|
||||
std::string outputPath = daemonDir + pathSep + RESOURCE_SAPLING_OUTPUT;
|
||||
|
||||
// Check if params are missing or stale (size mismatch → updated in newer build)
|
||||
const auto* spendRes = getEmbeddedResource(RESOURCE_SAPLING_SPEND);
|
||||
const auto* outputRes = getEmbeddedResource(RESOURCE_SAPLING_OUTPUT);
|
||||
if (spendRes && resourceNeedsUpdate(spendRes, spendPath)) return true;
|
||||
if (outputRes && resourceNeedsUpdate(outputRes, outputPath)) return true;
|
||||
|
||||
// Daemon binaries are only auto-placed when MISSING (never auto-overwritten on a size
|
||||
// mismatch) — the user may be running a specific dragonxd. Replacing the bundled daemon is
|
||||
// an explicit action via Settings → daemon binary. So only trigger extraction if it's absent.
|
||||
#ifdef HAS_EMBEDDED_DAEMON
|
||||
const auto* daemonRes = getEmbeddedResource(RESOURCE_DRAGONXD);
|
||||
std::string daemonPath = daemonDir + pathSep + RESOURCE_DRAGONXD;
|
||||
if (daemonRes && !std::filesystem::exists(daemonPath)) return true;
|
||||
fd_ = ::open(path.c_str(), O_WRONLY | O_CREAT | O_TRUNC | O_CLOEXEC, 0644);
|
||||
#endif
|
||||
}
|
||||
~TempSink() { (void)close(); }
|
||||
TempSink(const TempSink&) = delete;
|
||||
TempSink& operator=(const TempSink&) = delete;
|
||||
|
||||
#ifdef HAS_EMBEDDED_XMRIG
|
||||
const auto* xmrigRes = getEmbeddedResource(RESOURCE_XMRIG);
|
||||
std::string xmrigPath = daemonDir + pathSep + RESOURCE_XMRIG;
|
||||
if (xmrigRes && resourceNeedsUpdate(xmrigRes, xmrigPath)) return true;
|
||||
bool ok() const
|
||||
{
|
||||
#ifdef _WIN32
|
||||
return h_ != INVALID_HANDLE_VALUE;
|
||||
#else
|
||||
return fd_ >= 0;
|
||||
#endif
|
||||
}
|
||||
bool write(const void* data, std::size_t n)
|
||||
{
|
||||
const char* p = static_cast<const char*>(data);
|
||||
while (n > 0) {
|
||||
#ifdef _WIN32
|
||||
const DWORD chunk = static_cast<DWORD>(n > (1u << 30) ? (1u << 30) : n);
|
||||
DWORD w = 0;
|
||||
if (!WriteFile(h_, p, chunk, &w, nullptr) || w == 0) return false;
|
||||
#else
|
||||
const ssize_t w = ::write(fd_, p, n);
|
||||
if (w < 0) { if (errno == EINTR) continue; return false; }
|
||||
if (w == 0) return false;
|
||||
#endif
|
||||
p += w;
|
||||
n -= static_cast<std::size_t>(w);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
// Flush to stable storage, then close. False on any error.
|
||||
bool commit()
|
||||
{
|
||||
bool good = ok();
|
||||
#ifdef _WIN32
|
||||
if (good && !FlushFileBuffers(h_)) good = false;
|
||||
#else
|
||||
if (good && ::fsync(fd_) != 0) good = false;
|
||||
#endif
|
||||
if (!close()) good = false;
|
||||
return good;
|
||||
}
|
||||
|
||||
private:
|
||||
bool close()
|
||||
{
|
||||
#ifdef _WIN32
|
||||
if (h_ == INVALID_HANDLE_VALUE) return true;
|
||||
const bool r = CloseHandle(h_) != 0;
|
||||
h_ = INVALID_HANDLE_VALUE;
|
||||
return r;
|
||||
#else
|
||||
if (fd_ < 0) return true;
|
||||
const bool r = ::close(fd_) == 0;
|
||||
fd_ = -1;
|
||||
return r;
|
||||
#endif
|
||||
}
|
||||
#ifdef _WIN32
|
||||
HANDLE h_ = INVALID_HANDLE_VALUE;
|
||||
#else
|
||||
int fd_ = -1;
|
||||
#endif
|
||||
};
|
||||
|
||||
// Move `tmp` over `dest`. POSIX: rename() replaces atomically (even a running binary) + best-effort dir fsync.
|
||||
// Windows (same primitive as Platform::writeFileAtomically): MoveFileEx REPLACE_EXISTING|WRITE_THROUGH. A
|
||||
// running .exe cannot be replaced but can be renamed, so it is moved aside to ".old" once (the running process
|
||||
// keeps it; cleaned on a later run). A just-closed file is often held for a moment by antivirus (sharing
|
||||
// violation), so retry for up to ~2 s, but stop at once if the temp vanished (quarantined). On failure the
|
||||
// temp is removed and a moved-aside original is put back.
|
||||
bool replaceFromTemp(const std::string& tmp, const std::string& dest)
|
||||
{
|
||||
#ifdef _WIN32
|
||||
const std::string aside = dest + ".old";
|
||||
bool movedAside = false;
|
||||
DWORD err = 0;
|
||||
for (int attempt = 0; attempt < 20; ++attempt) {
|
||||
if (MoveFileExA(tmp.c_str(), dest.c_str(), MOVEFILE_REPLACE_EXISTING | MOVEFILE_WRITE_THROUGH))
|
||||
return true;
|
||||
err = GetLastError();
|
||||
if (GetFileAttributesA(tmp.c_str()) == INVALID_FILE_ATTRIBUTES) break; // temp gone (AV quarantine)
|
||||
if (!movedAside && GetFileAttributesA(dest.c_str()) != INVALID_FILE_ATTRIBUTES) {
|
||||
DeleteFileA(aside.c_str());
|
||||
if (MoveFileExA(dest.c_str(), aside.c_str(), MOVEFILE_REPLACE_EXISTING)) {
|
||||
movedAside = true;
|
||||
DEBUG_LOGF("[INFO] %s is in use - moved the old copy to .old\n", dest.c_str());
|
||||
continue;
|
||||
}
|
||||
}
|
||||
Sleep(100);
|
||||
}
|
||||
DEBUG_LOGF("[ERROR] Could not move %s into place (Win32 error %lu)\n", dest.c_str(), (unsigned long)err);
|
||||
DeleteFileA(tmp.c_str());
|
||||
if (movedAside) MoveFileExA(aside.c_str(), dest.c_str(), MOVEFILE_REPLACE_EXISTING);
|
||||
return false;
|
||||
#else
|
||||
if (::rename(tmp.c_str(), dest.c_str()) != 0) {
|
||||
DEBUG_LOGF("[ERROR] Could not move %s into place: %s\n", dest.c_str(), std::strerror(errno));
|
||||
::remove(tmp.c_str());
|
||||
return false;
|
||||
}
|
||||
const std::string dir = std::filesystem::path(dest).parent_path().string();
|
||||
const int dfd = ::open(dir.empty() ? "." : dir.c_str(), O_RDONLY | O_CLOEXEC);
|
||||
if (dfd >= 0) { ::fsync(dfd); ::close(dfd); }
|
||||
return true;
|
||||
#endif
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
bool resourceNeedsWrite(std::uint64_t embeddedSize, bool refreshOnSizeMismatch, const std::string& destPath)
|
||||
{
|
||||
if (embeddedSize == 0) return false;
|
||||
std::error_code ec;
|
||||
if (!std::filesystem::exists(destPath, ec)) return true;
|
||||
if (!refreshOnSizeMismatch) return false;
|
||||
const auto diskSize = std::filesystem::file_size(destPath, ec);
|
||||
if (ec) return true; // can't stat -> rewrite
|
||||
return static_cast<std::uint64_t>(diskSize) != embeddedSize;
|
||||
}
|
||||
|
||||
// Check if an on-disk file is missing or differs in size from the embedded resource.
|
||||
// A size mismatch means the binary was updated in a newer wallet build.
|
||||
static bool resourceNeedsUpdate(const EmbeddedResource* res, const std::string& destPath)
|
||||
[[maybe_unused]] static bool resourceNeedsUpdate(const EmbeddedResource* res, const std::string& destPath)
|
||||
{
|
||||
if (!res || !res->data || res->size == 0) return false;
|
||||
if (!std::filesystem::exists(destPath)) return true;
|
||||
std::error_code ec;
|
||||
auto diskSize = std::filesystem::file_size(destPath, ec);
|
||||
if (ec) return true; // can't stat → re-extract
|
||||
return diskSize != static_cast<std::uintmax_t>(res->size);
|
||||
return resourceNeedsWrite(res->size, /*refreshOnSizeMismatch=*/true, destPath);
|
||||
}
|
||||
|
||||
static bool extractResource(const EmbeddedResource* res, const std::string& destPath)
|
||||
bool needsParamsExtraction()
|
||||
{
|
||||
if (!res || !res->data || res->size == 0) {
|
||||
return false;
|
||||
if (!hasEmbeddedResources()) return false;
|
||||
const std::string dir = getDaemonDirectory(); // the only extraction target
|
||||
for (const auto& e : kProvisionEntries) {
|
||||
if (!entryActive(e) || !e.startupTrigger) continue;
|
||||
const EmbeddedResource* res = getEmbeddedResource(e.name);
|
||||
if (res && resourceNeedsWrite(res->size, e.refreshOnSizeMismatch, joinDir(dir, e.name))) return true;
|
||||
}
|
||||
|
||||
// Create parent directories
|
||||
std::filesystem::path path(destPath);
|
||||
if (path.has_parent_path()) {
|
||||
std::error_code ec;
|
||||
std::filesystem::create_directories(path.parent_path(), ec);
|
||||
if (ec) {
|
||||
DEBUG_LOGF("[ERROR] Failed to create directory %s: %s\n",
|
||||
path.parent_path().string().c_str(), ec.message().c_str());
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// Write file
|
||||
std::ofstream file(destPath, std::ios::binary);
|
||||
if (!file) {
|
||||
// The destination may be locked because the previous daemon is still using the binary:
|
||||
// Windows locks a running .exe against truncation, Linux returns ETXTBSY. Both platforms
|
||||
// DO allow renaming/moving such a file — the running process keeps the moved copy — so move
|
||||
// the stale binary aside and write a fresh one at the original path.
|
||||
std::error_code ec;
|
||||
if (std::filesystem::exists(destPath)) {
|
||||
std::string sidelined = destPath + ".old";
|
||||
std::filesystem::remove(sidelined, ec); // clear any leftover from a prior swap
|
||||
ec.clear();
|
||||
std::filesystem::rename(destPath, sidelined, ec);
|
||||
if (!ec) {
|
||||
file.clear();
|
||||
file.open(destPath, std::ios::binary);
|
||||
if (file)
|
||||
DEBUG_LOGF("[INFO] Replaced in-use %s (old copy moved to .old)\n", destPath.c_str());
|
||||
} else {
|
||||
DEBUG_LOGF("[WARN] Could not move stale %s aside: %s\n",
|
||||
destPath.c_str(), ec.message().c_str());
|
||||
return false;
|
||||
}
|
||||
|
||||
bool writeFileAtomic(const std::string& destPath, const std::uint8_t* data, std::size_t size, bool executable,
|
||||
ProvisionProgress* progress, const CancelFn& cancelled)
|
||||
{
|
||||
if (!data || size == 0) return false;
|
||||
if (!prepareParentDir(destPath)) return false;
|
||||
const std::string tmp = destPath + kPartialFileSuffix;
|
||||
std::error_code ec;
|
||||
bool good = true;
|
||||
{
|
||||
TempSink f(tmp);
|
||||
if (!f.ok()) { DEBUG_LOGF("[ERROR] Failed to open %s for writing\n", tmp.c_str()); return false; }
|
||||
for (std::size_t off = 0; off < size; off += kCopyChunk) {
|
||||
if (cancelled && cancelled()) { good = false; break; }
|
||||
const std::size_t n = std::min(kCopyChunk, size - off);
|
||||
if (!f.write(data + off, n)) {
|
||||
DEBUG_LOGF("[ERROR] Failed to write %zu bytes to %s\n", n, tmp.c_str());
|
||||
good = false; break;
|
||||
}
|
||||
if (progress) progress->bytesDone.fetch_add(n, std::memory_order_relaxed);
|
||||
}
|
||||
if (!file) {
|
||||
DEBUG_LOGF("[ERROR] Failed to open %s for writing\n", destPath.c_str());
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
file.write(reinterpret_cast<const char*>(res->data), res->size);
|
||||
if (!file) {
|
||||
DEBUG_LOGF("[ERROR] Failed to write %zu bytes to %s\n", res->size, destPath.c_str());
|
||||
return false;
|
||||
}
|
||||
|
||||
DEBUG_LOGF("[INFO] Extracted %s (%zu bytes)\n", destPath.c_str(), res->size);
|
||||
// Flush on the same handle BEFORE the rename: a power cut can't leave a truncated file under the final name.
|
||||
if (good && !f.commit()) { DEBUG_LOGF("[ERROR] Failed to finish writing %s\n", tmp.c_str()); good = false; }
|
||||
} // closed here: Windows can't delete a file that is still open without FILE_SHARE_DELETE
|
||||
if (!good) { std::filesystem::remove(tmp, ec); return false; }
|
||||
#ifndef _WIN32
|
||||
if (executable) ::chmod(tmp.c_str(), 0755); // before it becomes visible under the final name
|
||||
#else
|
||||
(void)executable;
|
||||
#endif
|
||||
if (!replaceFromTemp(tmp, destPath)) return false;
|
||||
DEBUG_LOGF("[INFO] Extracted %s (%zu bytes)\n", destPath.c_str(), size);
|
||||
return true;
|
||||
}
|
||||
|
||||
bool extractEmbeddedResources()
|
||||
bool copyFileAtomic(const std::string& srcPath, const std::string& destPath,
|
||||
ProvisionProgress* progress, const CancelFn& cancelled)
|
||||
{
|
||||
namespace fs = std::filesystem;
|
||||
if (!prepareParentDir(destPath)) return false;
|
||||
const std::string tmp = destPath + kPartialFileSuffix;
|
||||
std::error_code ec;
|
||||
bool good = true;
|
||||
{
|
||||
std::ifstream in(srcPath, std::ios::binary); // a read handle on the source is harmless if inherited
|
||||
if (!in) { DEBUG_LOGF("[ERROR] Failed to open %s\n", srcPath.c_str()); return false; }
|
||||
TempSink out(tmp);
|
||||
if (!out.ok()) { DEBUG_LOGF("[ERROR] Failed to open %s for writing\n", tmp.c_str()); return false; }
|
||||
std::vector<char> buf(kCopyChunk);
|
||||
for (;;) {
|
||||
if (cancelled && cancelled()) { good = false; break; }
|
||||
in.read(buf.data(), static_cast<std::streamsize>(buf.size()));
|
||||
const std::streamsize got = in.gcount();
|
||||
if (got > 0) {
|
||||
if (!out.write(buf.data(), static_cast<std::size_t>(got))) {
|
||||
DEBUG_LOGF("[ERROR] Failed to write %s\n", tmp.c_str());
|
||||
good = false; break;
|
||||
}
|
||||
if (progress) progress->bytesDone.fetch_add(static_cast<std::uint64_t>(got), std::memory_order_relaxed);
|
||||
}
|
||||
if (!in) break; // EOF (eof|fail) or a read error (bad, checked below)
|
||||
}
|
||||
if (good && in.bad()) {
|
||||
DEBUG_LOGF("[ERROR] Failed to read %s\n", srcPath.c_str());
|
||||
good = false;
|
||||
}
|
||||
// Flush on the same handle BEFORE the rename: a power cut can't leave a truncated file under the final name.
|
||||
if (good && !out.commit()) { DEBUG_LOGF("[ERROR] Failed to finish writing %s\n", tmp.c_str()); good = false; }
|
||||
} // closed here: Windows can't delete a file that is still open without FILE_SHARE_DELETE
|
||||
if (!good) { fs::remove(tmp, ec); return false; }
|
||||
const auto st = fs::status(srcPath, ec);
|
||||
if (!ec) fs::permissions(tmp, st.permissions(), ec); // keeps dragonxd/cli/tx executable (as copy_file did)
|
||||
return replaceFromTemp(tmp, destPath);
|
||||
}
|
||||
|
||||
// Requires provisionMutex() held.
|
||||
static bool extractResourceLocked(const EmbeddedResource* res, const std::string& destPath, bool executable,
|
||||
ProvisionProgress* progress = nullptr, const CancelFn& cancelled = {})
|
||||
{
|
||||
if (!res || !res->data || res->size == 0) return false;
|
||||
return writeFileAtomic(destPath, res->data, res->size, executable, progress, cancelled);
|
||||
}
|
||||
|
||||
bool extractEmbeddedResources(ProvisionProgress* progress, const CancelFn& cancelled)
|
||||
{
|
||||
if (!hasEmbeddedResources()) {
|
||||
DEBUG_LOGF("[ERROR] No embedded resources available\n");
|
||||
return false;
|
||||
}
|
||||
|
||||
bool success = true;
|
||||
|
||||
#ifdef _WIN32
|
||||
const char pathSep = '\\';
|
||||
#else
|
||||
const char pathSep = '/';
|
||||
#endif
|
||||
|
||||
// All files go to <ObsidianDragonDir>/dragonx/
|
||||
std::string daemonDir = getDaemonDirectory();
|
||||
std::lock_guard<std::mutex> lock(provisionMutex());
|
||||
|
||||
// Extract Sapling params to daemon directory alongside dragonxd
|
||||
const EmbeddedResource* spendRes = getEmbeddedResource(RESOURCE_SAPLING_SPEND);
|
||||
if (spendRes) {
|
||||
std::string dest = daemonDir + pathSep + RESOURCE_SAPLING_SPEND;
|
||||
if (resourceNeedsUpdate(spendRes, dest)) {
|
||||
DEBUG_LOGF("[INFO] Extracting sapling-spend.params (%zu MB)...\n", spendRes->size / (1024*1024));
|
||||
if (!extractResource(spendRes, dest)) {
|
||||
success = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const EmbeddedResource* outputRes = getEmbeddedResource(RESOURCE_SAPLING_OUTPUT);
|
||||
if (outputRes) {
|
||||
std::string dest = daemonDir + pathSep + RESOURCE_SAPLING_OUTPUT;
|
||||
if (resourceNeedsUpdate(outputRes, dest)) {
|
||||
DEBUG_LOGF("[INFO] Extracting sapling-output.params (%zu MB)...\n", outputRes->size / (1024*1024));
|
||||
if (!extractResource(outputRes, dest)) {
|
||||
success = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Extract asmap.dat to daemon directory
|
||||
const EmbeddedResource* asmapRes = getEmbeddedResource(RESOURCE_ASMAP);
|
||||
if (asmapRes) {
|
||||
std::string dest = daemonDir + pathSep + RESOURCE_ASMAP;
|
||||
if (resourceNeedsUpdate(asmapRes, dest)) {
|
||||
DEBUG_LOGF("[INFO] Extracting asmap.dat...\n");
|
||||
if (!extractResource(asmapRes, dest)) {
|
||||
success = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Extract daemon binaries — NOT the data directory.
|
||||
// Running dragonxd.exe from inside the data directory (where it writes blockchain
|
||||
// data, debug.log, lock files, etc.) causes crashes on some Windows machines.
|
||||
#ifdef HAS_EMBEDDED_DAEMON
|
||||
// All files go to <ObsidianDragonDir>/dragonx/ — NOT the data directory (running dragonxd.exe from inside
|
||||
// the data directory, where it writes chain data / debug.log / lock files, crashes on some Windows machines).
|
||||
const std::string daemonDir = getDaemonDirectory();
|
||||
DEBUG_LOGF("[INFO] Daemon extraction directory: %s\n", daemonDir.c_str());
|
||||
|
||||
// Daemon binaries are placed ONLY when missing — never auto-overwritten on a size mismatch
|
||||
// (the user may run a specific dragonxd; replacing it is an explicit Settings action).
|
||||
const EmbeddedResource* daemonRes = getEmbeddedResource(RESOURCE_DRAGONXD);
|
||||
if (daemonRes) {
|
||||
std::string dest = daemonDir + pathSep + RESOURCE_DRAGONXD;
|
||||
if (!std::filesystem::exists(dest)) {
|
||||
DEBUG_LOGF("[INFO] Extracting dragonxd (%zu MB)...\n", daemonRes->size / (1024*1024));
|
||||
if (!extractResource(daemonRes, dest)) {
|
||||
success = false;
|
||||
}
|
||||
#ifndef _WIN32
|
||||
else { chmod(dest.c_str(), 0755); }
|
||||
#endif
|
||||
std::error_code ec;
|
||||
std::filesystem::create_directories(daemonDir, ec);
|
||||
ec.clear();
|
||||
|
||||
// Temp files left by an interrupted run (killed process, power cut). Only OUR suffix.
|
||||
for (const auto& e : kProvisionEntries) {
|
||||
std::filesystem::remove(joinDir(daemonDir, e.name) + kPartialFileSuffix, ec);
|
||||
ec.clear();
|
||||
}
|
||||
|
||||
struct Todo { const ProvisionEntry* entry; const EmbeddedResource* res; std::string dest; };
|
||||
std::vector<Todo> todo;
|
||||
std::uint64_t total = 0;
|
||||
for (const auto& e : kProvisionEntries) {
|
||||
if (!entryActive(e)) continue;
|
||||
const EmbeddedResource* res = getEmbeddedResource(e.name);
|
||||
if (!res) continue;
|
||||
std::string dest = joinDir(daemonDir, e.name);
|
||||
if (!resourceNeedsWrite(res->size, e.refreshOnSizeMismatch, dest)) continue;
|
||||
total += res->size;
|
||||
todo.push_back({ &e, res, std::move(dest) });
|
||||
}
|
||||
if (progress) {
|
||||
progress->bytesDone.store(0, std::memory_order_relaxed);
|
||||
progress->bytesTotal.store(total, std::memory_order_relaxed);
|
||||
progress->setPhase(ProvisionPhase::Extracting);
|
||||
}
|
||||
|
||||
bool success = true;
|
||||
for (const auto& t : todo) {
|
||||
if (cancelled && cancelled()) return false;
|
||||
DEBUG_LOGF("[INFO] Extracting %s (%zu MB)...\n", t.entry->name, t.res->size / (1024 * 1024));
|
||||
if (!extractResourceLocked(t.res, t.dest, t.entry->executable, progress, cancelled)) {
|
||||
if (cancelled && cancelled()) return false;
|
||||
success = false;
|
||||
}
|
||||
}
|
||||
|
||||
const EmbeddedResource* cliRes = getEmbeddedResource(RESOURCE_DRAGONX_CLI);
|
||||
if (cliRes) {
|
||||
std::string dest = daemonDir + pathSep + RESOURCE_DRAGONX_CLI;
|
||||
if (!std::filesystem::exists(dest)) {
|
||||
DEBUG_LOGF("[INFO] Extracting dragonx-cli (%zu MB)...\n", cliRes->size / (1024*1024));
|
||||
if (!extractResource(cliRes, dest)) {
|
||||
success = false;
|
||||
}
|
||||
#ifndef _WIN32
|
||||
else { chmod(dest.c_str(), 0755); }
|
||||
#endif
|
||||
}
|
||||
// Best-effort cleanup of ".old" copies left by an in-use replacement. Once the old process has exited the
|
||||
// file is no longer locked and removes cleanly; if it still runs, the remove fails and we retry next run.
|
||||
for (const auto& e : kProvisionEntries) {
|
||||
std::filesystem::remove(joinDir(daemonDir, e.name) + std::string(".old"), ec);
|
||||
ec.clear();
|
||||
}
|
||||
|
||||
const EmbeddedResource* txRes = getEmbeddedResource(RESOURCE_DRAGONX_TX);
|
||||
if (txRes) {
|
||||
std::string dest = daemonDir + pathSep + RESOURCE_DRAGONX_TX;
|
||||
if (!std::filesystem::exists(dest)) {
|
||||
DEBUG_LOGF("[INFO] Extracting dragonx-tx (%zu MB)...\n", txRes->size / (1024*1024));
|
||||
if (!extractResource(txRes, dest)) {
|
||||
success = false;
|
||||
}
|
||||
#ifndef _WIN32
|
||||
else { chmod(dest.c_str(), 0755); }
|
||||
#endif
|
||||
}
|
||||
}
|
||||
#endif
|
||||
|
||||
#ifdef HAS_EMBEDDED_XMRIG
|
||||
const EmbeddedResource* xmrigRes = getEmbeddedResource(RESOURCE_XMRIG);
|
||||
if (xmrigRes) {
|
||||
std::string dest = daemonDir + pathSep + RESOURCE_XMRIG;
|
||||
if (resourceNeedsUpdate(xmrigRes, dest)) {
|
||||
if (std::filesystem::exists(dest))
|
||||
DEBUG_LOGF("[INFO] Updating stale xmrig (size mismatch)...\n");
|
||||
DEBUG_LOGF("[INFO] Extracting xmrig (%zu MB)...\n", xmrigRes->size / (1024*1024));
|
||||
if (!extractResource(xmrigRes, dest)) {
|
||||
success = false;
|
||||
}
|
||||
#ifndef _WIN32
|
||||
else { chmod(dest.c_str(), 0755); }
|
||||
#endif
|
||||
}
|
||||
}
|
||||
#endif
|
||||
|
||||
#ifdef HAS_EMBEDDED_WALLET_REBUILD
|
||||
// Offline wallet-rebuild recovery helper — extracted next to the daemon so a bare, self-extracting
|
||||
// ObsidianDragon.exe still offers "Repair automatically" (findWalletRebuildHelper() checks this dir).
|
||||
const EmbeddedResource* rebuildRes = getEmbeddedResource(RESOURCE_DRAGONX_WALLET_REBUILD);
|
||||
if (rebuildRes) {
|
||||
std::string dest = daemonDir + pathSep + RESOURCE_DRAGONX_WALLET_REBUILD;
|
||||
if (!std::filesystem::exists(dest)) {
|
||||
DEBUG_LOGF("[INFO] Extracting dragonx-wallet-rebuild (%zu MB)...\n", rebuildRes->size / (1024*1024));
|
||||
if (!extractResource(rebuildRes, dest)) {
|
||||
success = false;
|
||||
}
|
||||
#ifndef _WIN32
|
||||
else { chmod(dest.c_str(), 0755); }
|
||||
#endif
|
||||
}
|
||||
}
|
||||
#endif
|
||||
|
||||
// Best-effort cleanup of any ".old" binaries left behind by a previous in-use replacement.
|
||||
// Once the old daemon/xmrig process has exited, the file is no longer locked and removes cleanly;
|
||||
// if it's still running, the remove fails harmlessly and we retry on the next startup.
|
||||
{
|
||||
std::error_code ec;
|
||||
for (const char* name : { RESOURCE_DRAGONXD, RESOURCE_XMRIG }) {
|
||||
std::filesystem::remove(daemonDir + pathSep + name + std::string(".old"), ec);
|
||||
ec.clear();
|
||||
}
|
||||
}
|
||||
|
||||
return success;
|
||||
}
|
||||
|
||||
std::vector<std::string> bundleSearchDirs()
|
||||
{
|
||||
std::vector<std::string> dirs;
|
||||
const std::string exeDir = util::Platform::getExecutableDirectory();
|
||||
if (exeDir.empty()) return dirs;
|
||||
#ifdef __APPLE__
|
||||
// Inside a .app the params live in Contents/Resources while the executable is in Contents/MacOS.
|
||||
std::error_code ec;
|
||||
const std::filesystem::path res = std::filesystem::path(exeDir).parent_path() / "Resources";
|
||||
if (std::filesystem::is_directory(res, ec)) dirs.push_back(res.string());
|
||||
#endif
|
||||
dirs.push_back(exeDir);
|
||||
return dirs;
|
||||
}
|
||||
|
||||
const std::vector<std::string>& saplingParamFileNames()
|
||||
{
|
||||
static const std::vector<std::string> v = { "sapling-spend.params", "sapling-output.params" };
|
||||
return v;
|
||||
}
|
||||
|
||||
const std::vector<std::string>& nodeBundleFileNames()
|
||||
{
|
||||
static const std::vector<std::string> v = { "sapling-spend.params", "sapling-output.params", "asmap.dat",
|
||||
"dragonxd", "dragonx-cli", "dragonx-tx" };
|
||||
return v;
|
||||
}
|
||||
|
||||
std::vector<BundleCopyItem> planBundleCopy(const std::vector<std::string>& searchDirs, const std::string& destDir,
|
||||
const std::vector<std::string>& names, bool replaceExisting)
|
||||
{
|
||||
namespace fs = std::filesystem;
|
||||
std::vector<BundleCopyItem> plan;
|
||||
std::error_code ec;
|
||||
for (const auto& name : names) {
|
||||
const fs::path dst = fs::path(destDir) / name;
|
||||
if (!replaceExisting && fs::exists(dst, ec)) continue;
|
||||
for (const auto& dir : searchDirs) {
|
||||
const fs::path src = fs::path(dir) / name;
|
||||
if (fs::exists(src, ec)) { plan.push_back({ name, src.string(), dst.string() }); break; }
|
||||
}
|
||||
}
|
||||
return plan;
|
||||
}
|
||||
|
||||
BundleCopyResult copyBundledFiles(const std::vector<std::string>& searchDirs, const std::string& destDir,
|
||||
const std::vector<std::string>& names, bool replaceExisting,
|
||||
ProvisionProgress* progress, const CancelFn& cancelled)
|
||||
{
|
||||
BundleCopyResult r;
|
||||
// Stat-only check first: the steady-state call (every daemon start) never touches the lock.
|
||||
if (planBundleCopy(searchDirs, destDir, names, replaceExisting).empty()) return r;
|
||||
std::lock_guard<std::mutex> lock(provisionMutex());
|
||||
const auto plan = planBundleCopy(searchDirs, destDir, names, replaceExisting); // re-check under the lock
|
||||
std::uint64_t total = 0;
|
||||
std::error_code ec;
|
||||
for (const auto& it : plan) {
|
||||
std::filesystem::remove(it.dst + kPartialFileSuffix, ec); // stale temp from an interrupted copy
|
||||
ec.clear();
|
||||
const auto sz = std::filesystem::file_size(it.src, ec);
|
||||
if (!ec) total += sz;
|
||||
ec.clear();
|
||||
}
|
||||
if (progress) {
|
||||
progress->bytesDone.store(0, std::memory_order_relaxed);
|
||||
progress->bytesTotal.store(total, std::memory_order_relaxed);
|
||||
progress->setPhase(ProvisionPhase::Copying);
|
||||
}
|
||||
for (const auto& it : plan) {
|
||||
if (cancelled && cancelled()) { r.ok = false; return r; }
|
||||
DEBUG_LOGF("Copying bundled %s to %s\n", it.name.c_str(), destDir.c_str());
|
||||
if (copyFileAtomic(it.src, it.dst, progress, cancelled)) {
|
||||
++r.copied;
|
||||
} else {
|
||||
if (cancelled && cancelled()) { r.ok = false; return r; }
|
||||
DEBUG_LOGF("[ERROR] Failed to copy %s\n", it.name.c_str());
|
||||
r.ok = false;
|
||||
if (r.firstFailure.empty()) r.firstFailure = it.name;
|
||||
}
|
||||
}
|
||||
return r;
|
||||
}
|
||||
|
||||
std::string ensureWalletRebuildHelperExtracted()
|
||||
{
|
||||
#ifdef HAS_EMBEDDED_WALLET_REBUILD
|
||||
@@ -484,12 +698,11 @@ std::string ensureWalletRebuildHelperExtracted()
|
||||
const std::string dir = getDaemonDirectory();
|
||||
const std::string dest = dir + sep + RESOURCE_DRAGONX_WALLET_REBUILD;
|
||||
std::error_code ec;
|
||||
if (std::filesystem::exists(dest, ec)) return dest; // already extracted
|
||||
if (std::filesystem::exists(dest, ec)) return dest; // already extracted (atomic write: exists == complete)
|
||||
std::lock_guard<std::mutex> lock(provisionMutex()); // serialise with the startup job / other writers
|
||||
if (std::filesystem::exists(dest, ec)) return dest; // written meanwhile by the startup job
|
||||
std::filesystem::create_directories(dir, ec);
|
||||
if (!extractResource(res, dest)) return {};
|
||||
#ifndef _WIN32
|
||||
chmod(dest.c_str(), 0755);
|
||||
#endif
|
||||
if (!extractResourceLocked(res, dest, /*executable=*/true)) return {};
|
||||
DEBUG_LOGF("[INFO] Extracted wallet-rebuild helper on demand: %s\n", dest.c_str());
|
||||
return dest;
|
||||
#else
|
||||
@@ -577,22 +790,6 @@ std::string getDaemonPath()
|
||||
return "";
|
||||
}
|
||||
|
||||
bool needsXmrigExtraction()
|
||||
{
|
||||
#ifdef HAS_EMBEDDED_XMRIG
|
||||
std::string daemonDir = getDaemonDirectory();
|
||||
#ifdef _WIN32
|
||||
std::string xmrigPath = daemonDir + "\\xmrig.exe";
|
||||
#else
|
||||
std::string xmrigPath = daemonDir + "/xmrig";
|
||||
#endif
|
||||
const auto* res = getEmbeddedResource(RESOURCE_XMRIG);
|
||||
return resourceNeedsUpdate(res, xmrigPath);
|
||||
#else
|
||||
return false;
|
||||
#endif
|
||||
}
|
||||
|
||||
bool hasXmrigAvailable()
|
||||
{
|
||||
#ifdef HAS_EMBEDDED_XMRIG
|
||||
@@ -623,14 +820,11 @@ bool forceExtractXmrig()
|
||||
}
|
||||
DEBUG_LOGF("[INFO] forceExtractXmrig: extracting xmrig (%zu MB) to %s\n",
|
||||
xmrigRes->size / (1024*1024), dest.c_str());
|
||||
if (!extractResource(xmrigRes, dest)) {
|
||||
std::lock_guard<std::mutex> lock(provisionMutex());
|
||||
if (!extractResourceLocked(xmrigRes, dest, /*executable=*/true)) {
|
||||
DEBUG_LOGF("[ERROR] forceExtractXmrig: extraction failed\n");
|
||||
return false;
|
||||
}
|
||||
#ifndef _WIN32
|
||||
// Set executable permission on Linux
|
||||
chmod(dest.c_str(), 0755);
|
||||
#endif
|
||||
return true;
|
||||
#else
|
||||
return false;
|
||||
@@ -639,12 +833,13 @@ bool forceExtractXmrig()
|
||||
|
||||
// Scan a binary blob for the daemon's version stamp: 'v' <maj>.<min>.<rev> optionally followed by
|
||||
// '-' <commit hash (>=6 hex)>, e.g. "v1.0.2-ddd851dc1". Returns the first match, or "" if none.
|
||||
static std::string scanBinaryVersion(const uint8_t* data, std::size_t size)
|
||||
static std::string scanBinaryVersion(const uint8_t* data, std::size_t size,
|
||||
std::size_t lastStart = static_cast<std::size_t>(-1))
|
||||
{
|
||||
if (!data || size < 6) return "";
|
||||
auto isdig = [](uint8_t c) { return std::isdigit(static_cast<unsigned char>(c)) != 0; };
|
||||
auto isxd = [](uint8_t c) { return std::isxdigit(static_cast<unsigned char>(c)) != 0; };
|
||||
for (std::size_t i = 0; i + 5 < size; ++i) {
|
||||
for (std::size_t i = 0; i + 5 < size && i <= lastStart; ++i) {
|
||||
if (data[i] != 'v') continue;
|
||||
std::size_t k = i + 1, s;
|
||||
s = k; while (k < size && isdig(data[k])) ++k; if (k == s) continue; // major
|
||||
@@ -663,14 +858,38 @@ static std::string scanBinaryVersion(const uint8_t* data, std::size_t size)
|
||||
return "";
|
||||
}
|
||||
|
||||
DaemonBinaryInfo getInstalledDaemonInfo()
|
||||
// Stream the file in 4 MiB chunks (bounded memory, stops at the first stamp). A non-final chunk only starts
|
||||
// matches where a whole stamp (<= kStampMax bytes) still fits, and the last kStampMax-1 bytes are carried into the
|
||||
// next chunk, so a stamp straddling a boundary is neither cut short nor missed.
|
||||
static std::string scanFileVersion(const std::string& path)
|
||||
{
|
||||
std::ifstream f(path, std::ios::binary);
|
||||
if (!f) return "";
|
||||
constexpr std::size_t kChunk = 4u << 20, kStampMax = 64;
|
||||
std::vector<uint8_t> buf(kChunk + kStampMax);
|
||||
std::size_t carry = 0;
|
||||
for (;;) {
|
||||
f.read(reinterpret_cast<char*>(buf.data() + carry), static_cast<std::streamsize>(kChunk));
|
||||
const std::size_t got = static_cast<std::size_t>(f.gcount());
|
||||
const std::size_t total = carry + got;
|
||||
const bool last = got < kChunk; // short read => EOF
|
||||
const std::size_t lastStart = (last || total < kStampMax) ? static_cast<std::size_t>(-1) : total - kStampMax;
|
||||
std::string v = scanBinaryVersion(buf.data(), total, lastStart);
|
||||
if (!v.empty()) return v;
|
||||
if (last) return "";
|
||||
carry = kStampMax - 1;
|
||||
std::memmove(buf.data(), buf.data() + total - carry, carry);
|
||||
}
|
||||
}
|
||||
|
||||
DaemonBinaryInfo getInstalledDaemonInfo(bool scanVersion)
|
||||
{
|
||||
DaemonBinaryInfo info;
|
||||
std::string daemonDir = getDaemonDirectory();
|
||||
#ifdef _WIN32
|
||||
info.path = daemonDir + "\\" + RESOURCE_DRAGONXD;
|
||||
info.path = daemonDir + "\\" + DAEMON_BINARY_NAME;
|
||||
#else
|
||||
info.path = daemonDir + "/" + RESOURCE_DRAGONXD;
|
||||
info.path = daemonDir + "/" + DAEMON_BINARY_NAME; // was RESOURCE_DRAGONXD ("dragonxd.exe"): never existed on POSIX (C123)
|
||||
#endif
|
||||
std::error_code ec;
|
||||
if (!std::filesystem::exists(info.path, ec)) return info; // exists stays false
|
||||
@@ -687,17 +906,24 @@ DaemonBinaryInfo getInstalledDaemonInfo()
|
||||
static_cast<std::int64_t>(std::chrono::system_clock::to_time_t(sysTime));
|
||||
}
|
||||
|
||||
// Read the binary and scan for its version stamp (one-off; caller caches the result).
|
||||
std::ifstream f(info.path, std::ios::binary);
|
||||
if (f) {
|
||||
f.seekg(0, std::ios::end);
|
||||
std::streamoff len = f.tellg();
|
||||
f.seekg(0, std::ios::beg);
|
||||
if (len > 0) {
|
||||
std::vector<uint8_t> buf(static_cast<std::size_t>(len));
|
||||
f.read(reinterpret_cast<char*>(buf.data()), len);
|
||||
info.version = scanBinaryVersion(buf.data(), static_cast<std::size_t>(f.gcount()));
|
||||
// Scan for the version stamp (streamed; call off the UI thread). Cached by (path, size, mtime): Refresh and the
|
||||
// post-install reload only rescan when the file actually changed.
|
||||
if (!scanVersion) return info;
|
||||
{
|
||||
static std::mutex cacheMutex;
|
||||
static std::string cPath, cVersion;
|
||||
static std::uintmax_t cSize = 0;
|
||||
static std::int64_t cMtime = 0;
|
||||
{
|
||||
std::lock_guard<std::mutex> lock(cacheMutex);
|
||||
if (cPath == info.path && cSize == info.size && cMtime == info.modifiedEpoch && !cVersion.empty()) {
|
||||
info.version = cVersion;
|
||||
return info;
|
||||
}
|
||||
}
|
||||
info.version = scanFileVersion(info.path);
|
||||
std::lock_guard<std::mutex> lock(cacheMutex);
|
||||
cPath = info.path; cSize = info.size; cMtime = info.modifiedEpoch; cVersion = info.version;
|
||||
}
|
||||
return info;
|
||||
}
|
||||
@@ -721,6 +947,7 @@ BundledDaemonInfo getBundledDaemonInfo()
|
||||
bool reextractBundledDaemon()
|
||||
{
|
||||
#ifdef HAS_EMBEDDED_DAEMON
|
||||
std::lock_guard<std::mutex> lock(provisionMutex());
|
||||
std::string daemonDir = getDaemonDirectory();
|
||||
#ifdef _WIN32
|
||||
const char pathSep = '\\';
|
||||
@@ -735,15 +962,12 @@ bool reextractBundledDaemon()
|
||||
if (!res) continue;
|
||||
std::string dest = daemonDir + pathSep + name;
|
||||
DEBUG_LOGF("[INFO] reextractBundledDaemon: writing %s (%zu MB)\n", name, res->size / (1024*1024));
|
||||
if (!extractResource(res, dest)) {
|
||||
if (!extractResourceLocked(res, dest, /*executable=*/true)) {
|
||||
DEBUG_LOGF("[ERROR] reextractBundledDaemon: failed to write %s\n", name);
|
||||
ok = false;
|
||||
continue;
|
||||
}
|
||||
wroteAny = true;
|
||||
#ifndef _WIN32
|
||||
chmod(dest.c_str(), 0755);
|
||||
#endif
|
||||
}
|
||||
return ok && wroteAny;
|
||||
#else
|
||||
@@ -769,8 +993,13 @@ std::string getXmrigPath()
|
||||
std::string embeddedPath = daemonDir + pathSep + xmrigName;
|
||||
DEBUG_LOGF("[DEBUG] getXmrigPath: checking embedded path %s\n", embeddedPath.c_str());
|
||||
if (!std::filesystem::exists(embeddedPath)) {
|
||||
DEBUG_LOGF("[DEBUG] getXmrigPath: not found, force re-extracting xmrig\n");
|
||||
forceExtractXmrig();
|
||||
// Serialise with the startup job: if it just wrote xmrig, the re-check finds it complete.
|
||||
std::lock_guard<std::mutex> lock(provisionMutex());
|
||||
if (!std::filesystem::exists(embeddedPath)) {
|
||||
DEBUG_LOGF("[DEBUG] getXmrigPath: not found, force re-extracting xmrig\n");
|
||||
const EmbeddedResource* xr = getEmbeddedResource(RESOURCE_XMRIG);
|
||||
if (xr) extractResourceLocked(xr, embeddedPath, /*executable=*/true);
|
||||
}
|
||||
}
|
||||
if (std::filesystem::exists(embeddedPath)) {
|
||||
DEBUG_LOGF("[INFO] getXmrigPath: found at %s\n", embeddedPath.c_str());
|
||||
|
||||
@@ -1,7 +1,12 @@
|
||||
#pragma once
|
||||
|
||||
#include <string>
|
||||
#include <cstddef>
|
||||
#include <cstdint>
|
||||
#include <functional>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
#include "provision_state.h"
|
||||
|
||||
namespace dragonx {
|
||||
namespace resources {
|
||||
@@ -20,9 +25,50 @@ bool hasEmbeddedResources();
|
||||
// Returns nullptr if not found or not embedded
|
||||
const EmbeddedResource* getEmbeddedResource(const std::string& name);
|
||||
|
||||
// Extract all embedded resources to appropriate locations
|
||||
// Returns true if all resources extracted successfully
|
||||
bool extractEmbeddedResources();
|
||||
using CancelFn = std::function<bool()>; // polled between 4 MB chunks; true = stop now
|
||||
|
||||
// Suffix of our own temp files in the daemon dir ("<name>.partial"). NOT ".tmp": the in-app miner/node
|
||||
// updaters stage "<name>.tmp" in the same directory and must never have it deleted under them.
|
||||
inline constexpr const char* kPartialFileSuffix = ".partial";
|
||||
|
||||
// Extract all embedded resources to the daemon directory. Serialised with every other writer of that
|
||||
// directory by an internal mutex; atomic per file (temp + flush + rename), so a destination is either the old
|
||||
// complete file or the new one. `progress` (optional) receives bytes written; `cancelled` (optional) aborts
|
||||
// between chunks leaving no partial destination. Returns true if every needed file was written (false also on
|
||||
// cancel). Safe on any thread.
|
||||
bool extractEmbeddedResources(ProvisionProgress* progress = nullptr, const CancelFn& cancelled = {});
|
||||
|
||||
// Does `destPath` need (re)writing? Missing -> yes. Present: only when `refreshOnSizeMismatch` and the size
|
||||
// differs. embeddedSize == 0 -> never. (Pure apart from the stat; exposed for tests.)
|
||||
bool resourceNeedsWrite(std::uint64_t embeddedSize, bool refreshOnSizeMismatch, const std::string& destPath);
|
||||
// The bundled miner is placed only when MISSING: a present xmrig is never replaced by the bundled copy, even when
|
||||
// its size differs, so an "Update miner..." install survives a wallet restart or upgrade.
|
||||
inline constexpr bool kXmrigRefreshOnSizeMismatch = false; // the in-app updater owns the miner once placed
|
||||
// Write `data` to `destPath` via `destPath + kPartialFileSuffix`, flush it to disk, then move it into place
|
||||
// (POSIX rename; Windows MoveFileEx with retry and move-aside of a running .exe to ".old"). POSIX: chmod 0755
|
||||
// first when `executable`. Returns false (temp removed, original kept) on any failure or on cancel.
|
||||
// Takes no lock itself (extractEmbeddedResources / copyBundledFiles / the on-demand extractors do).
|
||||
bool writeFileAtomic(const std::string& destPath, const std::uint8_t* data, std::size_t size, bool executable,
|
||||
ProvisionProgress* progress = nullptr, const CancelFn& cancelled = {});
|
||||
// Same contract, streaming from a source file in 4 MB chunks; keeps the source's permissions.
|
||||
bool copyFileAtomic(const std::string& srcPath, const std::string& destPath,
|
||||
ProvisionProgress* progress = nullptr, const CancelFn& cancelled = {});
|
||||
|
||||
// Node files shipped NEXT TO the executable (zip / AppImage / .app; no embedded_data.h).
|
||||
struct BundleCopyItem { std::string name, src, dst; };
|
||||
std::vector<std::string> bundleSearchDirs(); // macOS: .app Contents/Resources first; then exe dir
|
||||
const std::vector<std::string>& saplingParamFileNames(); // sapling-spend.params, sapling-output.params
|
||||
const std::vector<std::string>& nodeBundleFileNames(); // params + asmap.dat + dragonxd + dragonx-cli + dragonx-tx
|
||||
// First search dir holding a name wins; names with no source are skipped (optional); names whose destination
|
||||
// exists are skipped unless replaceExisting. Stat-only.
|
||||
std::vector<BundleCopyItem> planBundleCopy(const std::vector<std::string>& searchDirs, const std::string& destDir,
|
||||
const std::vector<std::string>& names, bool replaceExisting);
|
||||
struct BundleCopyResult { bool ok = true; int copied = 0; std::string firstFailure; };
|
||||
// Executes planBundleCopy under the provisioning lock (checks the plan lock-free first: the common
|
||||
// "nothing to copy" call never touches the lock).
|
||||
BundleCopyResult copyBundledFiles(const std::vector<std::string>& searchDirs, const std::string& destDir,
|
||||
const std::vector<std::string>& names, bool replaceExisting,
|
||||
ProvisionProgress* progress = nullptr, const CancelFn& cancelled = {});
|
||||
|
||||
// Check if params need to be extracted (not already present)
|
||||
bool needsParamsExtraction();
|
||||
@@ -48,7 +94,8 @@ struct BundledDaemonInfo {
|
||||
};
|
||||
|
||||
// Read + scan the installed dragonxd (reads the file; call off the UI thread or cache the result).
|
||||
DaemonBinaryInfo getInstalledDaemonInfo();
|
||||
// scanVersion=false: stat only (exists / size / modifiedEpoch), no 16 MB read.
|
||||
DaemonBinaryInfo getInstalledDaemonInfo(bool scanVersion = true);
|
||||
// Info about the bundled daemon (scans the embedded bytes once, cached).
|
||||
BundledDaemonInfo getBundledDaemonInfo();
|
||||
// Force-overwrite the installed dragonx binaries (dragonxd/cli/tx) with the bundled ones. The
|
||||
@@ -65,7 +112,13 @@ std::string ensureWalletRebuildHelperExtracted();
|
||||
constexpr const char* RESOURCE_SAPLING_SPEND = "sapling-spend.params";
|
||||
constexpr const char* RESOURCE_SAPLING_OUTPUT = "sapling-output.params";
|
||||
constexpr const char* RESOURCE_ASMAP = "asmap.dat";
|
||||
constexpr const char* RESOURCE_DRAGONXD = "dragonxd.exe";
|
||||
constexpr const char* RESOURCE_DRAGONXD = "dragonxd.exe"; // embedded-resource table key (Windows builds only)
|
||||
// The daemon binary's file name on THIS platform. Use it to build paths; RESOURCE_DRAGONXD is only the table key.
|
||||
#ifdef _WIN32
|
||||
constexpr const char* DAEMON_BINARY_NAME = "dragonxd.exe";
|
||||
#else
|
||||
constexpr const char* DAEMON_BINARY_NAME = "dragonxd";
|
||||
#endif
|
||||
constexpr const char* RESOURCE_DRAGONX_CLI = "dragonx-cli.exe";
|
||||
constexpr const char* RESOURCE_DRAGONX_TX = "dragonx-tx.exe";
|
||||
constexpr const char* RESOURCE_DRAGONX_WALLET_REBUILD = "dragonx-wallet-rebuild.exe";
|
||||
@@ -87,6 +140,10 @@ const EmbeddedTheme* getEmbeddedThemes();
|
||||
// Returns number of themes extracted
|
||||
int extractBundledThemes(const std::string& destDir);
|
||||
|
||||
// Write `size` bytes to `path` unless the file already holds exactly those bytes.
|
||||
// Returns 1 = written, 0 = already identical (untouched), -1 = write failed.
|
||||
int writeFileIfDifferent(const std::string& path, const uint8_t* data, size_t size);
|
||||
|
||||
// Update stale bundled theme files in the given directory.
|
||||
// Compares on-disk file size to embedded size; overwrites on mismatch.
|
||||
// Returns number of themes updated.
|
||||
@@ -104,9 +161,6 @@ bool hasDaemonAvailable();
|
||||
// Get daemon executable path (extracts if needed)
|
||||
std::string getDaemonPath();
|
||||
|
||||
// Check if xmrig needs to be extracted
|
||||
bool needsXmrigExtraction();
|
||||
|
||||
// Check if xmrig is available (embedded or in app directory)
|
||||
bool hasXmrigAvailable();
|
||||
|
||||
|
||||
@@ -0,0 +1,118 @@
|
||||
// DragonX Wallet - ImGui Edition
|
||||
// Copyright 2024-2026 The Hush Developers
|
||||
// Released under the GPLv3
|
||||
//
|
||||
// provision_state.h - state of the startup "node files" job (App::startStartupFilesProvision): on Windows it
|
||||
// extracts the bundled Sapling params / asmap / node / miner from the .exe, on Linux/macOS it copies them
|
||||
// from the executable's directory, and on full-node builds it then verifies the Sapling params. The job runs
|
||||
// on a worker thread; the UI thread only polls this. No app dependencies, so tests include it directly.
|
||||
#pragma once
|
||||
|
||||
#include <atomic>
|
||||
#include <chrono>
|
||||
#include <condition_variable>
|
||||
#include <cstdint>
|
||||
#include <mutex>
|
||||
|
||||
namespace dragonx {
|
||||
namespace resources {
|
||||
|
||||
enum class ProvisionPhase : int { Idle = 0, Extracting, Copying, Verifying };
|
||||
|
||||
// Lock-free progress: written by the worker, read by the UI every frame.
|
||||
struct ProvisionProgress {
|
||||
std::atomic<std::uint64_t> bytesDone{0};
|
||||
std::atomic<std::uint64_t> bytesTotal{0};
|
||||
std::atomic<int> phase{static_cast<int>(ProvisionPhase::Idle)};
|
||||
void setPhase(ProvisionPhase p) { phase.store(static_cast<int>(p), std::memory_order_relaxed); }
|
||||
ProvisionPhase getPhase() const { return static_cast<ProvisionPhase>(phase.load(std::memory_order_relaxed)); }
|
||||
};
|
||||
|
||||
// 0..100; 0 while the total is unknown.
|
||||
inline int provisionPercent(std::uint64_t done, std::uint64_t total)
|
||||
{
|
||||
if (total == 0) return 0;
|
||||
if (done >= total) return 100;
|
||||
return static_cast<int>((done * 100u) / total);
|
||||
}
|
||||
|
||||
// The chip stays hidden for a short grace period so a job that ends in a few ms never flashes.
|
||||
inline bool provisionChipVisible(bool running, double elapsedSeconds, double delaySeconds)
|
||||
{
|
||||
return running && elapsedSeconds >= delaySeconds;
|
||||
}
|
||||
|
||||
// What App::startEmbeddedDaemon() does while the job may still be writing the node files: never launch the
|
||||
// node over half-written files, never block the UI thread (defer + replay), workers simply wait.
|
||||
enum class DaemonStartGate { Proceed, Defer, Wait };
|
||||
inline DaemonStartGate daemonStartGate(bool jobRunning, bool onUiThread)
|
||||
{
|
||||
if (!jobRunning) return DaemonStartGate::Proceed;
|
||||
return onUiThread ? DaemonStartGate::Defer : DaemonStartGate::Wait;
|
||||
}
|
||||
|
||||
class StartupProvision {
|
||||
public:
|
||||
struct Result {
|
||||
bool extractOk = true; // every needed file written/copied (false: disk full / permissions / AV)
|
||||
bool verifyOk = true; // Sapling SHA-256 check (full node only)
|
||||
bool cancelled = false; // shutdown stopped it; nothing is wrong
|
||||
};
|
||||
enum class State : int { Idle, Running, Finished, Reaped };
|
||||
|
||||
ProvisionProgress progress;
|
||||
// The Sapling param repair copy (replace the daemon-dir params from the bundle, ~51.5 MB) ran this session:
|
||||
// set by the job and by App::startEmbeddedDaemon(), so a still-failing verify never repeats it on every
|
||||
// connect retry (the UI thread).
|
||||
std::atomic<bool> paramRepairTried{false};
|
||||
|
||||
void begin() {
|
||||
std::lock_guard<std::mutex> lk(m_);
|
||||
state_ = State::Running;
|
||||
startedAt_ = std::chrono::steady_clock::now();
|
||||
}
|
||||
// Nothing needed doing (the steady-state launch): no thread was started.
|
||||
void markNothingToDo() { std::lock_guard<std::mutex> lk(m_); state_ = State::Reaped; }
|
||||
void finish(const Result& r) {
|
||||
{ std::lock_guard<std::mutex> lk(m_); result_ = r; state_ = State::Finished; }
|
||||
cv_.notify_all();
|
||||
}
|
||||
bool isRunning() const { std::lock_guard<std::mutex> lk(m_); return state_ == State::Running; }
|
||||
// Non-reaping peek: the job has ended and shutdown cancelled it (synchronized with finish() through m_).
|
||||
bool endedCancelled() const {
|
||||
std::lock_guard<std::mutex> lk(m_);
|
||||
return state_ != State::Running && result_.cancelled;
|
||||
}
|
||||
// App::hasPendingBackgroundResults(): finished and not yet reaped (the reap needs one update() tick).
|
||||
// NOT while Running: nothing is reapable then, and the chip already requests progress frames when visible.
|
||||
bool hasCompletion() const { std::lock_guard<std::mutex> lk(m_); return state_ == State::Finished; }
|
||||
// UI thread: true exactly once, after the job finished.
|
||||
bool takeCompletion(Result* out) {
|
||||
std::lock_guard<std::mutex> lk(m_);
|
||||
if (state_ != State::Finished) return false;
|
||||
if (out) *out = result_;
|
||||
state_ = State::Reaped;
|
||||
return true;
|
||||
}
|
||||
// Worker threads: true when the job is no longer running.
|
||||
bool waitUntilDone(int timeoutMs) {
|
||||
std::unique_lock<std::mutex> lk(m_);
|
||||
cv_.wait_for(lk, std::chrono::milliseconds(timeoutMs), [&] { return state_ != State::Running; });
|
||||
return state_ != State::Running;
|
||||
}
|
||||
double elapsedSeconds() const {
|
||||
std::lock_guard<std::mutex> lk(m_);
|
||||
if (state_ != State::Running) return 0.0;
|
||||
return std::chrono::duration<double>(std::chrono::steady_clock::now() - startedAt_).count();
|
||||
}
|
||||
|
||||
private:
|
||||
mutable std::mutex m_;
|
||||
std::condition_variable cv_;
|
||||
State state_ = State::Idle;
|
||||
Result result_;
|
||||
std::chrono::steady_clock::time_point startedAt_{};
|
||||
};
|
||||
|
||||
} // namespace resources
|
||||
} // namespace dragonx
|
||||
@@ -241,6 +241,30 @@ bool Connection::verifySaplingParams()
|
||||
return verifySaplingParamsIn(getSaplingParamsDir(), digests);
|
||||
}
|
||||
|
||||
Connection::SaplingParamState Connection::saplingParamStateIn(
|
||||
const std::string& dir,
|
||||
const std::vector<std::pair<std::string, std::string>>& digests)
|
||||
{
|
||||
if (dir.empty() || digests.empty()) return SaplingParamState::Missing;
|
||||
std::vector<std::string> current;
|
||||
current.reserve(digests.size());
|
||||
for (const auto& d : digests) {
|
||||
const std::string p = joinParamPath(dir, d.first);
|
||||
std::error_code ec;
|
||||
if (!fs::exists(p, ec)) return SaplingParamState::Missing;
|
||||
current.push_back(paramStatLine(p));
|
||||
}
|
||||
return saplingMarkerMatches(joinParamPath(dir, ".sapling_verified"), current)
|
||||
? SaplingParamState::Verified : SaplingParamState::NeedsHash;
|
||||
}
|
||||
|
||||
Connection::SaplingParamState Connection::saplingParamState()
|
||||
{
|
||||
std::vector<std::pair<std::string, std::string>> digests;
|
||||
for (const auto& d : kSaplingParamDigests) digests.emplace_back(d.first, d.second);
|
||||
return saplingParamStateIn(getSaplingParamsDir(), digests);
|
||||
}
|
||||
|
||||
ConnectionConfig Connection::parseConfFile(const std::string& path)
|
||||
{
|
||||
ConnectionConfig config;
|
||||
|
||||
@@ -83,6 +83,14 @@ public:
|
||||
const std::string& dir,
|
||||
const std::vector<std::pair<std::string, std::string>>& digests);
|
||||
|
||||
// Stat-only (never reads or hashes the params): Missing = a param is absent, Verified = the
|
||||
// .sapling_verified marker matches every param's size:mtime, NeedsHash = present but unverified.
|
||||
enum class SaplingParamState { Missing, NeedsHash, Verified };
|
||||
static SaplingParamState saplingParamState();
|
||||
static SaplingParamState saplingParamStateIn(
|
||||
const std::string& dir,
|
||||
const std::vector<std::pair<std::string, std::string>>& digests);
|
||||
|
||||
/**
|
||||
* @brief Get the Sapling params directory
|
||||
*/
|
||||
|
||||
@@ -0,0 +1,113 @@
|
||||
// DragonX Wallet - ImGui Edition
|
||||
// Copyright 2024-2026 The Hush Developers
|
||||
// Released under the GPLv3
|
||||
|
||||
#pragma once
|
||||
|
||||
// Long wallet RPCs (C61). A rescan or a key import holds the daemon's cs_main for as long as the wallet scan
|
||||
// takes: minutes to hours on a big wallet. On the shared clients they used to hit the 30 s curl timeout, report
|
||||
// a failure that wasn't one and flip the app into "node busy" (a reply timeout is recorded by the busy gate).
|
||||
// They now run one at a time on a dedicated, ungated client with no client timeout, abortable from any thread.
|
||||
|
||||
#include "connection.h" // ConnectionConfig
|
||||
#include "rpc_client.h"
|
||||
|
||||
#include <atomic>
|
||||
#include <mutex>
|
||||
#include <string>
|
||||
|
||||
namespace dragonx {
|
||||
namespace rpc {
|
||||
|
||||
// The wallet RPCs that scan the chain inside the call: they run on the long-call lane, never on the shared
|
||||
// clients (rpc_ / the console's fast lane), whose 30 s timeout they outlast.
|
||||
inline bool isLongWalletMethod(const std::string& method)
|
||||
{
|
||||
return method == "rescan" || method == "importprivkey" || method == "importaddress" ||
|
||||
method == "importwallet" || method == "z_importkey" || method == "z_importviewingkey" ||
|
||||
method == "z_importwallet";
|
||||
}
|
||||
|
||||
// How a long wallet call that threw an RpcTransportError ended, which picks the wording shown to the user.
|
||||
enum class LongCallCutOff {
|
||||
None, // the request never reached the node: its error is the whole story
|
||||
Cancelled, // our own abort cut the transfer; the node may still be scanning
|
||||
Interrupted, // the request was sent but no answer came back (a dropped connection, or a proxy's 502/504
|
||||
// while the node still runs it): the key may already be in the wallet
|
||||
Refused, // the node's HTTP layer turned it away before the method ran (401, 503, queue full,
|
||||
// unhandled), so nothing was imported
|
||||
};
|
||||
|
||||
inline LongCallCutOff classifyLongCallCutOff(RpcFailureKind kind, bool requestSent)
|
||||
{
|
||||
switch (kind) {
|
||||
case RpcFailureKind::Aborted:
|
||||
return LongCallCutOff::Cancelled;
|
||||
case RpcFailureKind::Auth:
|
||||
case RpcFailureKind::ShuttingDown:
|
||||
case RpcFailureKind::QueueFull:
|
||||
case RpcFailureKind::Unhandled:
|
||||
return LongCallCutOff::Refused;
|
||||
default:
|
||||
// Any other HTTP status (a proxy's 502/504) or a lost reply proves nothing about the node's side.
|
||||
return requestSent ? LongCallCutOff::Interrupted : LongCallCutOff::None;
|
||||
}
|
||||
}
|
||||
|
||||
// One long wallet RPC at a time on its own ungated client, with no client timeout, abortable from any thread.
|
||||
class LongCallLane {
|
||||
public:
|
||||
// Main thread: claim the lane. false when a long call is already running. A new call starts un-aborted.
|
||||
bool tryBegin()
|
||||
{
|
||||
bool idle = false;
|
||||
if (!active_.compare_exchange_strong(idle, true)) return false;
|
||||
abort_ = false;
|
||||
return true;
|
||||
}
|
||||
// The call and its continuation are done: the lane is free again.
|
||||
void end() { active_ = false; }
|
||||
bool active() const { return active_.load(); }
|
||||
bool aborted() const { return abort_.load(); }
|
||||
|
||||
// Any thread. Sticky until the next tryBegin(): a call that has not opened its client yet is aborted as soon
|
||||
// as it does, and an in-flight transfer is cut through the client's progress callback (about a second).
|
||||
void abort()
|
||||
{
|
||||
std::lock_guard<std::mutex> lk(mx_);
|
||||
abort_ = true;
|
||||
if (client_) client_->requestAbort();
|
||||
}
|
||||
|
||||
// Lane thread. open() FIRST (it clears the client's abort flag), THEN publish the client, THEN re-check the
|
||||
// sticky flag under the same mutex, so an abort that landed before or during open() is never erased.
|
||||
// An open() failure is not handled here: the body's first call then throws "Not connected".
|
||||
template <class Body>
|
||||
void run(RPCClient& c, const ConnectionConfig& cfg, Body&& body)
|
||||
{
|
||||
c.open(cfg.host, cfg.port, cfg.rpcuser, cfg.rpcpassword, cfg.use_tls);
|
||||
{
|
||||
std::lock_guard<std::mutex> lk(mx_);
|
||||
client_ = &c;
|
||||
if (abort_) c.requestAbort();
|
||||
}
|
||||
struct Unpublish {
|
||||
LongCallLane& lane;
|
||||
~Unpublish()
|
||||
{
|
||||
std::lock_guard<std::mutex> lk(lane.mx_);
|
||||
lane.client_ = nullptr;
|
||||
}
|
||||
} unpublish{*this};
|
||||
body(c);
|
||||
}
|
||||
|
||||
private:
|
||||
std::mutex mx_;
|
||||
RPCClient* client_ = nullptr; // guarded by mx_; set only while run() executes the body
|
||||
std::atomic<bool> abort_{false};
|
||||
std::atomic<bool> active_{false};
|
||||
};
|
||||
|
||||
} // namespace rpc
|
||||
} // namespace dragonx
|
||||
@@ -0,0 +1,169 @@
|
||||
#pragma once
|
||||
|
||||
// Keeping the GUI from flooding a busy node (S6). dragonxd's HTTP server has 8 workers and a 16-deep
|
||||
// queue. While a long operation holds cs_main (a witness rebuild can take an hour), every request that
|
||||
// needs cs_main waits, and a client that gives up does NOT free its slot. The GUI's polls used to time
|
||||
// out and be re-sent until all 24 slots were its own abandoned requests, after which even "stop" was
|
||||
// refused. The gate stops that: after the first sign the node is busy (a reply timeout, or a queue-full
|
||||
// refusal), the GUI's clients send nothing that needs cs_main until a single "canary" request (which
|
||||
// waits as long as it takes) gets through. Pure and header-only; shared by the GUI's RPC clients.
|
||||
|
||||
#include "rpc_failure.h"
|
||||
|
||||
#include <chrono>
|
||||
#include <mutex>
|
||||
#include <string>
|
||||
|
||||
namespace dragonx {
|
||||
namespace rpc {
|
||||
|
||||
// Kinds that mean "reached a node that is too busy to answer", as opposed to "no node there".
|
||||
inline bool rpcKindMeansBusy(RpcFailureKind kind)
|
||||
{
|
||||
return kind == RpcFailureKind::ReplyTimeout || kind == RpcFailureKind::QueueFull ||
|
||||
kind == RpcFailureKind::Deferred;
|
||||
}
|
||||
|
||||
// Kinds that mean the connection itself is in doubt (counted toward a reconnect). Busy kinds, a
|
||||
// warmup reply, an application error and our own abort are not: the node is there.
|
||||
inline bool rpcKindMeansLost(RpcFailureKind kind)
|
||||
{
|
||||
switch (kind) {
|
||||
case RpcFailureKind::None:
|
||||
case RpcFailureKind::ReplyTimeout:
|
||||
case RpcFailureKind::QueueFull:
|
||||
case RpcFailureKind::Deferred:
|
||||
case RpcFailureKind::Warmup:
|
||||
case RpcFailureKind::AppError:
|
||||
case RpcFailureKind::Aborted:
|
||||
return false;
|
||||
default:
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
class RpcBusyGate {
|
||||
public:
|
||||
// Methods dragonxd runs without cs_main: always sent, and their results say nothing about cs_main.
|
||||
static bool isLockFree(const std::string& method)
|
||||
{
|
||||
// getmempoolinfo takes only mempool.cs (rpc/blockchain.cpp), and the dispatcher no global lock.
|
||||
return method == "stop" || method == "help" || method == "getrescaninfo" || method == "getmempoolinfo";
|
||||
}
|
||||
|
||||
// May this request be sent now? Lock-free methods always; the rest only while the node isn't busy.
|
||||
bool admit(const std::string& method) const
|
||||
{
|
||||
if (isLockFree(method)) return true;
|
||||
std::lock_guard<std::mutex> lk(mx_);
|
||||
return !busy_;
|
||||
}
|
||||
|
||||
// How a sent request went. Any real answer (even an error) means the node is serving cs_main
|
||||
// requests again; a reply timeout or a queue-full refusal means it isn't. Other failures (nothing
|
||||
// listening, auth, our own abort) say nothing about busyness and leave the state alone.
|
||||
void record(const std::string& method, RpcFailureKind kind)
|
||||
{
|
||||
if (isLockFree(method)) return;
|
||||
std::lock_guard<std::mutex> lk(mx_);
|
||||
if (kind == RpcFailureKind::None || kind == RpcFailureKind::AppError || kind == RpcFailureKind::Warmup) {
|
||||
clearLocked();
|
||||
} else if (kind == RpcFailureKind::ReplyTimeout || kind == RpcFailureKind::QueueFull) {
|
||||
if (!busy_) {
|
||||
busy_ = true;
|
||||
since_ = std::chrono::steady_clock::now();
|
||||
++busyPeriods_;
|
||||
}
|
||||
if (kind == RpcFailureKind::ReplyTimeout) ++abandoned_; // it still holds a daemon slot
|
||||
}
|
||||
}
|
||||
|
||||
// The canary got through (or the node is gone): poll normally again.
|
||||
void clear()
|
||||
{
|
||||
std::lock_guard<std::mutex> lk(mx_);
|
||||
clearLocked();
|
||||
}
|
||||
|
||||
bool busy() const
|
||||
{
|
||||
std::lock_guard<std::mutex> lk(mx_);
|
||||
return busy_;
|
||||
}
|
||||
// Requests we gave up on that the node may still be holding, since the gate last cleared.
|
||||
int abandoned() const
|
||||
{
|
||||
std::lock_guard<std::mutex> lk(mx_);
|
||||
return abandoned_;
|
||||
}
|
||||
// Increments each time the gate turns busy: lets the owner arm exactly one canary per busy period.
|
||||
int busyPeriods() const
|
||||
{
|
||||
std::lock_guard<std::mutex> lk(mx_);
|
||||
return busyPeriods_;
|
||||
}
|
||||
double busySeconds() const
|
||||
{
|
||||
std::lock_guard<std::mutex> lk(mx_);
|
||||
if (!busy_) return 0.0;
|
||||
return std::chrono::duration<double>(std::chrono::steady_clock::now() - since_).count();
|
||||
}
|
||||
|
||||
private:
|
||||
void clearLocked()
|
||||
{
|
||||
busy_ = false;
|
||||
abandoned_ = 0;
|
||||
}
|
||||
|
||||
mutable std::mutex mx_;
|
||||
bool busy_ = false;
|
||||
int abandoned_ = 0;
|
||||
int busyPeriods_ = 0;
|
||||
std::chrono::steady_clock::time_point since_{};
|
||||
};
|
||||
|
||||
/**
|
||||
* How long to wait before the next connection attempt after a failure of this kind. Each repeat of the
|
||||
* same kind waits longer, up to its cap; a success or a different kind starts over.
|
||||
* nothing listening / connect timeout: 1, 2, 5s (the node is starting, or gone)
|
||||
* queue full: 2, 4, 8, 16, 30s (the queue is someone's; hammering it only adds to it)
|
||||
* busy (reply timeout / deferred): 30s; the canary brings the connection back as soon as it can
|
||||
* anything else: 4s
|
||||
*/
|
||||
class ConnectRetryBackoff {
|
||||
public:
|
||||
float next(RpcFailureKind kind)
|
||||
{
|
||||
streak_ = (kind == last_) ? streak_ + 1 : 0;
|
||||
last_ = kind;
|
||||
switch (kind) {
|
||||
case RpcFailureKind::NotListening:
|
||||
case RpcFailureKind::ConnectTimeout: {
|
||||
static const float steps[] = {1.0f, 2.0f, 5.0f};
|
||||
return steps[streak_ < 2 ? streak_ : 2];
|
||||
}
|
||||
case RpcFailureKind::QueueFull: {
|
||||
static const float steps[] = {2.0f, 4.0f, 8.0f, 16.0f, 30.0f};
|
||||
return steps[streak_ < 4 ? streak_ : 4];
|
||||
}
|
||||
case RpcFailureKind::ReplyTimeout:
|
||||
case RpcFailureKind::Deferred:
|
||||
return 30.0f;
|
||||
default:
|
||||
return 4.0f;
|
||||
}
|
||||
}
|
||||
void reset()
|
||||
{
|
||||
streak_ = 0;
|
||||
last_ = RpcFailureKind::None;
|
||||
}
|
||||
|
||||
private:
|
||||
RpcFailureKind last_ = RpcFailureKind::None;
|
||||
int streak_ = 0;
|
||||
};
|
||||
|
||||
} // namespace rpc
|
||||
} // namespace dragonx
|
||||
+138
-98
@@ -163,6 +163,17 @@ bool RPCClient::connect(const std::string& host, const std::string& port,
|
||||
bool useTls)
|
||||
{
|
||||
std::lock_guard<std::recursive_mutex> lk(curl_mutex_);
|
||||
if (!open(host, port, user, password, useTls)) return false;
|
||||
return probeConnection(host);
|
||||
}
|
||||
|
||||
bool RPCClient::open(const std::string& host, const std::string& port,
|
||||
const std::string& user, const std::string& password,
|
||||
bool useTls)
|
||||
{
|
||||
std::lock_guard<std::recursive_mutex> lk(curl_mutex_);
|
||||
connected_ = false; // only a successful probe (connect()) sets these
|
||||
warming_up_ = false;
|
||||
host_ = host;
|
||||
port_ = port;
|
||||
last_connect_info_ = json();
|
||||
@@ -214,17 +225,26 @@ bool RPCClient::connect(const std::string& host, const std::string& port,
|
||||
// budget for the TCP + TLS handshake over real network latency (1s would spuriously fail).
|
||||
const long connectTimeout = Connection::isLocalHost(host) ? 2L : 10L;
|
||||
curl_easy_setopt(impl_->curl, CURLOPT_CONNECTTIMEOUT, connectTimeout);
|
||||
// Loopback RPC carries basic-auth creds + wallet data: never route it through an env proxy
|
||||
// (http_proxy / all_proxy). Remote daemons keep proxy support. NOPROXY also overrides no_proxy.
|
||||
if (Connection::isLocalHost(host))
|
||||
curl_easy_setopt(impl_->curl, CURLOPT_NOPROXY, "*");
|
||||
// Enforce TLS certificate + hostname verification explicitly rather than relying on libcurl's
|
||||
// build defaults. Harmless on the localhost http:// case; essential for a remote https daemon.
|
||||
curl_easy_setopt(impl_->curl, CURLOPT_SSL_VERIFYPEER, 1L);
|
||||
curl_easy_setopt(impl_->curl, CURLOPT_SSL_VERIFYHOST, 2L);
|
||||
return true;
|
||||
}
|
||||
|
||||
bool RPCClient::probeConnection(const std::string& host)
|
||||
{
|
||||
// Test connection with getinfo. Use a SHORT timeout for the probe on localhost: a healthy
|
||||
// local daemon answers in milliseconds and a warming one returns -28 just as fast, so a long
|
||||
// hang means a wedged/loading occupant — no point blocking the full 30s before we retry and
|
||||
// update the UI. (call(timeoutSec) restores the persistent 30s afterwards, so normal RPC calls
|
||||
// that legitimately take longer are unaffected.) Remote/TLS daemons keep the full budget.
|
||||
const long probeTimeout = Connection::isLocalHost(host) ? 8L : 30L;
|
||||
last_connect_kind_ = RpcFailureKind::Other; // until the probe says otherwise
|
||||
try {
|
||||
json result = call("getinfo", json::array(), probeTimeout);
|
||||
if (result.contains("version")) {
|
||||
@@ -232,36 +252,32 @@ bool RPCClient::connect(const std::string& host, const std::string& port,
|
||||
warming_up_ = false;
|
||||
warmup_status_.clear();
|
||||
last_connect_error_.clear();
|
||||
last_connect_kind_ = RpcFailureKind::None;
|
||||
last_connect_info_ = result;
|
||||
if (connect_error_repeats_ > 0)
|
||||
DEBUG_LOGF("Connection failed: the last error repeated %u more time(s)\n", connect_error_repeats_);
|
||||
logged_connect_error_.clear();
|
||||
connect_error_repeats_ = 0;
|
||||
DEBUG_LOGF("Connected to dragonxd v%d\n", result["version"].get<int>());
|
||||
return true;
|
||||
}
|
||||
} catch (const std::exception& e) {
|
||||
last_connect_error_ = e.what();
|
||||
// Daemon warmup messages (Loading block index, Verifying blocks, etc.)
|
||||
// are normal startup progress — the daemon is reachable and auth works,
|
||||
// it just hasn't finished initializing yet. Mark as connected+warmup
|
||||
// so the wallet can show the UI instead of a blocking overlay.
|
||||
last_connect_kind_ = rpcFailureKindOf(e);
|
||||
// Daemon warmup (Loading block index, Verifying blocks, etc.) is normal startup progress — the
|
||||
// daemon is reachable and auth works, it just hasn't finished initializing yet. Mark as
|
||||
// connected+warmup so the wallet can show the UI instead of a blocking overlay. Only the
|
||||
// JSON-RPC code (-28, RPC_IN_WARMUP) counts: matching the message text would also match a
|
||||
// plain-text HTTP body that happens to say "Loading".
|
||||
std::string msg = e.what();
|
||||
// Warmup is JSON-RPC error code -28 (RPC_IN_WARMUP) — the robust signal. Fall back
|
||||
// to message substrings for any path that didn't carry the numeric code.
|
||||
int code = 0;
|
||||
if (const auto* re = dynamic_cast<const RpcError*>(&e)) code = re->code;
|
||||
bool isWarmup = (code == -28) ||
|
||||
(msg.find("Loading") != std::string::npos ||
|
||||
msg.find("Verifying") != std::string::npos ||
|
||||
msg.find("Activating") != std::string::npos ||
|
||||
msg.find("Rewinding") != std::string::npos ||
|
||||
msg.find("Rescanning") != std::string::npos ||
|
||||
msg.find("Pruning") != std::string::npos);
|
||||
if (isWarmup) {
|
||||
if (last_connect_kind_ == RpcFailureKind::Warmup) {
|
||||
connected_ = true;
|
||||
warming_up_ = true;
|
||||
warmup_status_ = msg;
|
||||
DEBUG_LOGF("Daemon warming up: %s\n", msg.c_str());
|
||||
return true;
|
||||
} else {
|
||||
DEBUG_LOGF("Connection failed: %s\n", msg.c_str());
|
||||
logConnectFailure(msg);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -272,6 +288,34 @@ bool RPCClient::connect(const std::string& host, const std::string& port,
|
||||
return false;
|
||||
}
|
||||
|
||||
// A busy or absent daemon makes every connect() fail the same way, and logging each one flooded the
|
||||
// log (60k lines in one incident). Log a new error at once; a repeat only every 100th time.
|
||||
void RPCClient::logConnectFailure(const std::string& msg)
|
||||
{
|
||||
const std::string line = std::string("[") + rpcFailureKindName(last_connect_kind_) + "] " + msg;
|
||||
if (line != logged_connect_error_) {
|
||||
if (connect_error_repeats_ > 0)
|
||||
DEBUG_LOGF("Connection failed: the last error repeated %u more time(s)\n", connect_error_repeats_);
|
||||
logged_connect_error_ = line;
|
||||
connect_error_repeats_ = 0;
|
||||
DEBUG_LOGF("Connection failed %s\n", line.c_str());
|
||||
return;
|
||||
}
|
||||
if (++connect_error_repeats_ % 100 == 0)
|
||||
DEBUG_LOGF("Connection failed %s (repeated %u times)\n", line.c_str(), connect_error_repeats_);
|
||||
}
|
||||
|
||||
RpcFailureKind RPCClient::trySendStop(long timeoutSec)
|
||||
{
|
||||
try {
|
||||
TraceScope trace("Daemon lifecycle / Stop command");
|
||||
call("stop", json::array(), timeoutSec);
|
||||
return RpcFailureKind::None;
|
||||
} catch (const std::exception& e) {
|
||||
return rpcFailureKindOf(e);
|
||||
}
|
||||
}
|
||||
|
||||
json RPCClient::getLastConnectInfo() const
|
||||
{
|
||||
std::lock_guard<std::recursive_mutex> lk(curl_mutex_);
|
||||
@@ -320,6 +364,8 @@ json RPCClient::makePayload(const std::string& method, const json& params)
|
||||
|
||||
std::string RPCClient::performCall(const std::string& method, const json& params, long& httpCodeOut)
|
||||
{
|
||||
if (busy_gate_ && !busy_gate_->admit(method))
|
||||
throw RpcTransportError(RpcFailureKind::Deferred, "RPC deferred: the node is busy", CURLE_OK, 0, false);
|
||||
emitRpcTrace(method);
|
||||
|
||||
json payload = makePayload(method, params);
|
||||
@@ -335,38 +381,60 @@ std::string RPCClient::performCall(const std::string& method, const json& params
|
||||
CURLcode res = curl_easy_perform(impl_->curl);
|
||||
|
||||
if (res != CURLE_OK) {
|
||||
throw std::runtime_error("RPC request failed: " + std::string(curl_easy_strerror(res)));
|
||||
// libcurl reports connect- and reply-phase timeouts with the same code. The request size
|
||||
// (reset for every transfer) tells them apart: 0 means it never reached the daemon.
|
||||
long requestSize = 0;
|
||||
curl_easy_getinfo(impl_->curl, CURLINFO_REQUEST_SIZE, &requestSize);
|
||||
const bool sent = requestSize > 0;
|
||||
const RpcFailureKind kind = classifyRpcFailure(res, sent, 0, 0, std::string());
|
||||
if (busy_gate_) busy_gate_->record(method, kind);
|
||||
throw RpcTransportError(kind, "RPC request failed: " + std::string(curl_easy_strerror(res)), res, 0, sent);
|
||||
}
|
||||
|
||||
// Check HTTP response code
|
||||
httpCodeOut = 0;
|
||||
curl_easy_getinfo(impl_->curl, CURLINFO_RESPONSE_CODE, &httpCodeOut);
|
||||
if (busy_gate_) {
|
||||
// Any HTTP answer means a daemon worker ran our request, except the queue-full refusal.
|
||||
const bool queueFull = httpCodeOut == 500 &&
|
||||
response_data.find("Work queue depth exceeded") != std::string::npos;
|
||||
busy_gate_->record(method, queueFull ? RpcFailureKind::QueueFull : RpcFailureKind::None);
|
||||
}
|
||||
|
||||
return response_data;
|
||||
}
|
||||
|
||||
void RPCClient::throwHttpFailure(long httpCode, const std::string& body, bool hideBody)
|
||||
{
|
||||
// Bitcoin/Hush RPC returns HTTP 500 for application-level errors (insufficient funds, bad
|
||||
// params, etc.), and 400/404 for a bad request / unknown method, with a JSON body carrying the
|
||||
// real message. Parse the body first to extract it.
|
||||
bool isJson = false;
|
||||
try {
|
||||
json response = json::parse(body);
|
||||
isJson = true;
|
||||
if (response.contains("error") && response["error"].is_object()) {
|
||||
int errCode = 0;
|
||||
if (response["error"].contains("code") && response["error"]["code"].is_number_integer())
|
||||
errCode = response["error"]["code"].get<int>();
|
||||
if (response["error"].contains("message") && response["error"]["message"].is_string())
|
||||
throw RpcError(errCode, response["error"]["message"].get<std::string>(), httpCode);
|
||||
// message missing/non-string — keep the detail instead of a bare HTTP code
|
||||
throw RpcError(errCode, "RPC error: " + response["error"].dump(), httpCode);
|
||||
}
|
||||
} catch (const json::exception&) {
|
||||
// Not JSON: the daemon's HTTP layer refused the request itself ("Work queue depth exceeded",
|
||||
// "Unhandled request") or sent a 401/503 page.
|
||||
}
|
||||
const std::string plain = isJson ? std::string() : body;
|
||||
throw RpcTransportError(classifyRpcFailure(CURLE_OK, true, httpCode, 0, plain),
|
||||
formatHttpError(httpCode, hideBody ? std::string() : plain),
|
||||
CURLE_OK, httpCode, true);
|
||||
}
|
||||
|
||||
json RPCClient::parseRpcResult(long httpCode, const std::string& body, bool scrubSource)
|
||||
{
|
||||
// Bitcoin/Hush RPC returns HTTP 500 for application-level errors
|
||||
// (insufficient funds, bad params, etc.) with a valid JSON body.
|
||||
// Parse the body first to extract the real error message.
|
||||
if (httpCode != 200) {
|
||||
int errCode = 0;
|
||||
try {
|
||||
json response = json::parse(body);
|
||||
if (response.contains("error") && response["error"].is_object()) {
|
||||
if (response["error"].contains("code") && response["error"]["code"].is_number_integer())
|
||||
errCode = response["error"]["code"].get<int>();
|
||||
if (response["error"].contains("message") && response["error"]["message"].is_string())
|
||||
throw RpcError(errCode, response["error"]["message"].get<std::string>());
|
||||
// message missing/non-string — keep the detail instead of a bare HTTP code
|
||||
throw RpcError(errCode, "RPC error: " + response["error"].dump());
|
||||
}
|
||||
} catch (const json::exception&) {
|
||||
// Body wasn't valid JSON — fall through to generic HTTP error
|
||||
}
|
||||
throw RpcError(errCode, "RPC error: HTTP " + std::to_string(httpCode));
|
||||
}
|
||||
if (httpCode != 200) throwHttpFailure(httpCode, body, /*hideBody=*/scrubSource);
|
||||
|
||||
json response = json::parse(body);
|
||||
|
||||
@@ -380,11 +448,11 @@ json RPCClient::parseRpcResult(long httpCode, const std::string& body, bool scru
|
||||
err_msg = response["error"]["message"].get<std::string>();
|
||||
}
|
||||
if (err_msg.empty()) err_msg = response["error"].dump();
|
||||
throw RpcError(errCode, "RPC error: " + err_msg);
|
||||
throw RpcError(errCode, "RPC error: " + err_msg, httpCode);
|
||||
}
|
||||
|
||||
json result = response["result"]; // a COPY of the subobject (operator[] yields an lvalue ref)
|
||||
if (scrubSource) scrubJsonSecrets(response); // zero the discarded tree's secret before it frees (B7)
|
||||
json result = std::move(response["result"]); // move the subtree out: the secret now lives only here
|
||||
if (scrubSource) scrubJsonSecrets(response); // response["result"] is now moved-from (null); harmless (B7)
|
||||
return result;
|
||||
}
|
||||
|
||||
@@ -481,34 +549,9 @@ std::string RPCClient::callRaw(const std::string& method, const json& params)
|
||||
throw std::runtime_error("Not connected");
|
||||
}
|
||||
|
||||
emitRpcTrace(method);
|
||||
|
||||
json payload = makePayload(method, params);
|
||||
std::string body = payload.dump();
|
||||
std::string response_data;
|
||||
|
||||
curl_easy_setopt(impl_->curl, CURLOPT_POSTFIELDS, body.c_str());
|
||||
curl_easy_setopt(impl_->curl, CURLOPT_POSTFIELDSIZE, (long)body.size());
|
||||
curl_easy_setopt(impl_->curl, CURLOPT_WRITEDATA, &response_data);
|
||||
|
||||
CURLcode res = curl_easy_perform(impl_->curl);
|
||||
if (res != CURLE_OK) {
|
||||
throw std::runtime_error("RPC request failed: " + std::string(curl_easy_strerror(res)));
|
||||
}
|
||||
|
||||
long http_code = 0;
|
||||
curl_easy_getinfo(impl_->curl, CURLINFO_RESPONSE_CODE, &http_code);
|
||||
|
||||
if (http_code != 200) {
|
||||
try {
|
||||
json response = json::parse(response_data);
|
||||
if (response.contains("error") && !response["error"].is_null()) {
|
||||
std::string err_msg = response["error"]["message"].get<std::string>();
|
||||
throw std::runtime_error(err_msg);
|
||||
}
|
||||
} catch (const json::exception&) {}
|
||||
throw std::runtime_error("RPC error: HTTP " + std::to_string(http_code));
|
||||
}
|
||||
std::string response_data = performCall(method, params, http_code);
|
||||
if (http_code != 200) throwHttpFailure(http_code, response_data, /*hideBody=*/false);
|
||||
|
||||
// Parse with ordered_json to preserve the daemon's original key order
|
||||
nlohmann::ordered_json oj = nlohmann::ordered_json::parse(response_data);
|
||||
@@ -517,11 +560,14 @@ std::string RPCClient::callRaw(const std::string& method, const json& params)
|
||||
// error (missing/non-string message) must yield a clean RPC error, not a json type-exception.
|
||||
const auto& err = oj["error"];
|
||||
std::string err_msg;
|
||||
int errCode = 0;
|
||||
if (err.is_object() && err.contains("code") && err["code"].is_number_integer())
|
||||
errCode = err["code"].get<int>();
|
||||
if (err.is_object() && err.contains("message") && err["message"].is_string())
|
||||
err_msg = err["message"].get<std::string>();
|
||||
else
|
||||
err_msg = err.dump();
|
||||
throw std::runtime_error("RPC error: " + err_msg);
|
||||
throw RpcError(errCode, "RPC error: " + err_msg, http_code);
|
||||
}
|
||||
|
||||
auto& result = oj["result"];
|
||||
@@ -543,6 +589,28 @@ std::string RPCClient::callRaw(const std::string& method, const json& params)
|
||||
return out;
|
||||
}
|
||||
|
||||
std::string RPCClient::callRaw(const std::string& method, const json& params, long timeoutSec)
|
||||
{
|
||||
std::lock_guard<std::recursive_mutex> lk(curl_mutex_);
|
||||
if (!impl_->curl) {
|
||||
throw std::runtime_error("Not connected");
|
||||
}
|
||||
|
||||
// Temporarily override the timeout, as call(method, params, timeoutSec) does
|
||||
long prevTimeout = 30L;
|
||||
curl_easy_setopt(impl_->curl, CURLOPT_TIMEOUT, timeoutSec);
|
||||
|
||||
try {
|
||||
std::string out = callRaw(method, params);
|
||||
curl_easy_setopt(impl_->curl, CURLOPT_TIMEOUT, prevTimeout);
|
||||
return out;
|
||||
} catch (...) {
|
||||
// Ensure timeout is always restored
|
||||
curl_easy_setopt(impl_->curl, CURLOPT_TIMEOUT, prevTimeout);
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
void RPCClient::doRPC(const std::string& method, const json& params, Callback cb, ErrorCallback err)
|
||||
{
|
||||
try {
|
||||
@@ -616,7 +684,7 @@ void RPCClient::z_getNewAddress(Callback cb, ErrorCallback err)
|
||||
|
||||
void RPCClient::listTransactions(int count, Callback cb, ErrorCallback err)
|
||||
{
|
||||
doRPC("listtransactions", {"", count}, cb, err);
|
||||
doRPC("listtransactions", {"*", count}, cb, err);
|
||||
}
|
||||
|
||||
void RPCClient::z_viewTransaction(const std::string& txid, Callback cb, ErrorCallback err)
|
||||
@@ -790,24 +858,6 @@ void RPCClient::z_mergeToAddress(const std::vector<std::string>& fromAddrs, cons
|
||||
doRPC("z_mergetoaddress", {addrs, toAddr, fee, 0, limit}, cb, err);
|
||||
}
|
||||
|
||||
void RPCClient::z_getOperationStatus(const std::vector<std::string>& opids, Callback cb, ErrorCallback err)
|
||||
{
|
||||
json ids = json::array();
|
||||
for (const auto& id : opids) {
|
||||
ids.push_back(id);
|
||||
}
|
||||
doRPC("z_getoperationstatus", {ids}, cb, err);
|
||||
}
|
||||
|
||||
void RPCClient::z_getOperationResult(const std::vector<std::string>& opids, Callback cb, ErrorCallback err)
|
||||
{
|
||||
json ids = json::array();
|
||||
for (const auto& id : opids) {
|
||||
ids.push_back(id);
|
||||
}
|
||||
doRPC("z_getoperationresult", {ids}, cb, err);
|
||||
}
|
||||
|
||||
void RPCClient::z_listReceivedByAddress(const std::string& address, int minconf, Callback cb, ErrorCallback err)
|
||||
{
|
||||
doRPC("z_listreceivedbyaddress", {address, minconf}, cb, err);
|
||||
@@ -860,16 +910,6 @@ void RPCClient::z_mergeToAddress(const std::vector<std::string>& fromAddrs, cons
|
||||
doRPC("z_mergetoaddress", {addrs, toAddr, fee, 0, limit}, handlers.first, handlers.second);
|
||||
}
|
||||
|
||||
void RPCClient::z_getOperationStatus(const std::vector<std::string>& opids, UnifiedCallback cb)
|
||||
{
|
||||
json ids = json::array();
|
||||
for (const auto& id : opids) {
|
||||
ids.push_back(id);
|
||||
}
|
||||
auto handlers = splitUnified(std::move(cb));
|
||||
doRPC("z_getoperationstatus", {ids}, handlers.first, handlers.second);
|
||||
}
|
||||
|
||||
void RPCClient::getBlock(int height, UnifiedCallback cb)
|
||||
{
|
||||
// First get block hash, then get block
|
||||
|
||||
+49
-25
@@ -5,6 +5,8 @@
|
||||
#pragma once
|
||||
|
||||
#include "types.h"
|
||||
#include "rpc_failure.h" // RpcError, RpcTransportError, RpcFailureKind
|
||||
#include "rpc_busy_gate.h"
|
||||
#include <atomic>
|
||||
#include <string>
|
||||
#include <functional>
|
||||
@@ -20,21 +22,6 @@ using json = nlohmann::json;
|
||||
using Callback = std::function<void(const json&)>;
|
||||
using ErrorCallback = std::function<void(const std::string&)>;
|
||||
|
||||
/**
|
||||
* @brief A JSON-RPC error carrying the daemon's numeric error code.
|
||||
*
|
||||
* what() preserves the exact human-readable message (so existing string matching
|
||||
* still works); `code` exposes the JSON-RPC error code — notably -28 (RPC_IN_WARMUP)
|
||||
* for a daemon still starting up. Derives from std::runtime_error, so every existing
|
||||
* `catch (const std::exception&)` continues to handle it unchanged.
|
||||
*/
|
||||
class RpcError : public std::runtime_error {
|
||||
public:
|
||||
RpcError(int errorCode, const std::string& message)
|
||||
: std::runtime_error(message), code(errorCode) {}
|
||||
int code = 0;
|
||||
};
|
||||
|
||||
/**
|
||||
* @brief JSON-RPC client for dragonxd
|
||||
*
|
||||
@@ -78,6 +65,31 @@ public:
|
||||
const std::string& user, const std::string& password,
|
||||
bool useTls);
|
||||
|
||||
/**
|
||||
* @brief Set up the connection without probing it (connect() = open() + a getinfo probe)
|
||||
*
|
||||
* For sending "stop": the getinfo probe needs the daemon's cs_main, so against a busy node it
|
||||
* times out (holding one of the daemon's few RPC slots) or is refused, and stop never went out.
|
||||
* isConnected() stays false; call() works once this returns true.
|
||||
*/
|
||||
bool open(const std::string& host, const std::string& port,
|
||||
const std::string& user, const std::string& password, bool useTls);
|
||||
|
||||
/**
|
||||
* @brief Send "stop" once, with its own timeout; returns how it went (None = the daemon ran it)
|
||||
*
|
||||
* A ReplyTimeout here is NOT a failure: the request reached the daemon and waits for a worker.
|
||||
*/
|
||||
RpcFailureKind trySendStop(long timeoutSec = 2);
|
||||
|
||||
/**
|
||||
* @brief Share a busy gate with the app's other polling clients (nullptr: none)
|
||||
*
|
||||
* While the gate is busy, calls that need the daemon's cs_main fail at once with a Deferred
|
||||
* RpcTransportError instead of being sent; every sent call reports how it went to the gate.
|
||||
*/
|
||||
void setBusyGate(std::shared_ptr<RpcBusyGate> gate) { busy_gate_ = std::move(gate); }
|
||||
|
||||
/**
|
||||
* @brief Disconnect from dragonxd
|
||||
*/
|
||||
@@ -117,6 +129,8 @@ public:
|
||||
* @brief Get the error message from the last failed connect() attempt.
|
||||
*/
|
||||
const std::string& getLastConnectError() const { return last_connect_error_; }
|
||||
/** @brief How the last connect() probe went: None on success, Warmup for a warming-up node, else the failure. */
|
||||
RpcFailureKind getLastConnectFailureKind() const { return last_connect_kind_; }
|
||||
json getLastConnectInfo() const;
|
||||
|
||||
static void setTraceCallback(TraceCallback callback);
|
||||
@@ -170,6 +184,12 @@ public:
|
||||
*/
|
||||
std::string callRaw(const std::string& method, const json& params = json::array());
|
||||
|
||||
/**
|
||||
* @brief callRaw() with a custom timeout, restored to the default afterwards (like the 3-arg call())
|
||||
* @param timeoutSec Timeout in seconds (0 = no timeout)
|
||||
*/
|
||||
std::string callRaw(const std::string& method, const json& params, long timeoutSec);
|
||||
|
||||
// High-level API methods - mirror the Qt version
|
||||
|
||||
// Info methods
|
||||
@@ -243,10 +263,6 @@ public:
|
||||
void z_mergeToAddress(const std::vector<std::string>& fromAddrs, const std::string& toAddr,
|
||||
double fee, int limit, Callback cb, ErrorCallback err = nullptr);
|
||||
|
||||
// Operation status monitoring
|
||||
void z_getOperationStatus(const std::vector<std::string>& opids, Callback cb, ErrorCallback err = nullptr);
|
||||
void z_getOperationResult(const std::vector<std::string>& opids, Callback cb, ErrorCallback err = nullptr);
|
||||
|
||||
// Received transactions
|
||||
void z_listReceivedByAddress(const std::string& address, int minconf, Callback cb, ErrorCallback err = nullptr);
|
||||
|
||||
@@ -258,7 +274,6 @@ public:
|
||||
double fee, int limit, UnifiedCallback cb);
|
||||
void z_mergeToAddress(const std::vector<std::string>& fromAddrs, const std::string& toAddr,
|
||||
double fee, int limit, UnifiedCallback cb);
|
||||
void z_getOperationStatus(const std::vector<std::string>& opids, UnifiedCallback cb);
|
||||
void getBlock(int height, UnifiedCallback cb);
|
||||
|
||||
private:
|
||||
@@ -270,10 +285,15 @@ private:
|
||||
// hold curl_mutex_ and have verified impl_->curl.
|
||||
std::string performCall(const std::string& method, const json& params, long& httpCodeOut);
|
||||
// Centralizes the HTTP-code check and JSON error->RpcError extraction, returning
|
||||
// response["result"] on success. When scrubSource is true (secret-bearing calls), the intermediate
|
||||
// parse tree's string values are zeroed before it is discarded — parseRpcResult returns a COPY of
|
||||
// the result node, so its own tree would otherwise free the secret un-wiped (B7).
|
||||
// response["result"] on success. The result subtree is moved out of the parse tree, so a secret lives
|
||||
// only in the returned node (which callSecret/callSecretString callers wipe). When scrubSource is true
|
||||
// (secret-bearing calls), any remaining parse-tree string values are zeroed before it is discarded (B7).
|
||||
static json parseRpcResult(long httpCode, const std::string& body, bool scrubSource = false);
|
||||
// A non-200 reply: RpcError when the body carries a JSON-RPC error, else RpcTransportError classified
|
||||
// from the status and plain-text body. hideBody keeps the body out of the message (secret calls).
|
||||
[[noreturn]] static void throwHttpFailure(long httpCode, const std::string& body, bool hideBody);
|
||||
bool probeConnection(const std::string& host); // connect()'s getinfo probe, after open()
|
||||
void logConnectFailure(const std::string& msg);
|
||||
|
||||
// Splits a UnifiedCallback into the (Callback, ErrorCallback) pair used by doRPC.
|
||||
static std::pair<Callback, ErrorCallback> splitUnified(UnifiedCallback cb);
|
||||
@@ -281,11 +301,15 @@ private:
|
||||
std::string host_;
|
||||
std::string port_;
|
||||
std::string auth_; // Base64 encoded "user:password"
|
||||
bool connected_ = false;
|
||||
std::atomic<bool> connected_{false};
|
||||
std::atomic<bool> abort_{false}; // set cross-thread to abort an in-flight transfer
|
||||
bool warming_up_ = false;
|
||||
std::atomic<bool> warming_up_{false};
|
||||
std::string warmup_status_;
|
||||
std::string last_connect_error_;
|
||||
RpcFailureKind last_connect_kind_ = RpcFailureKind::None;
|
||||
std::shared_ptr<RpcBusyGate> busy_gate_;
|
||||
std::string logged_connect_error_; // last "Connection failed" text logged (rate limit)
|
||||
unsigned connect_error_repeats_ = 0; // identical failures since it was logged
|
||||
json last_connect_info_;
|
||||
mutable std::recursive_mutex curl_mutex_; // serializes all curl handle access
|
||||
|
||||
|
||||
@@ -0,0 +1,181 @@
|
||||
// DragonX Wallet - ImGui Edition
|
||||
// Copyright 2024-2026 The Hush Developers
|
||||
// Released under the GPLv3
|
||||
|
||||
#pragma once
|
||||
|
||||
// Classifying a failed RPC call from structured data (curl code, request-sent bit, HTTP status,
|
||||
// JSON-RPC error code, non-JSON body) instead of substrings of what(). Header-only and pure, so the
|
||||
// test target can use it without compiling rpc_client.cpp.
|
||||
|
||||
#include <curl/curl.h>
|
||||
|
||||
#include <stdexcept>
|
||||
#include <string>
|
||||
|
||||
namespace dragonx {
|
||||
namespace rpc {
|
||||
|
||||
enum class RpcFailureKind {
|
||||
None, // the call succeeded
|
||||
NotListening, // connection refused: nothing bound to the RPC port (not started, or already unbound)
|
||||
ConnectTimeout, // TCP connect didn't complete in time (Windows after the daemon unbinds; filtered port)
|
||||
ReplyTimeout, // request sent, no reply in time: the daemon holds it (busy, e.g. blocked on cs_main)
|
||||
QueueFull, // HTTP 500 "Work queue depth exceeded": rejected before any handler ran; holds no slot
|
||||
ShuttingDown, // HTTP 503: the daemon's HTTP server was interrupted for shutdown
|
||||
Unhandled, // HTTP 500 "Unhandled request": queued, then dropped by a shutting-down daemon
|
||||
Auth, // HTTP 401: wrong rpcuser/rpcpassword (or a stale .cookie)
|
||||
Warmup, // JSON-RPC -28 (RPC_IN_WARMUP): the node is still loading
|
||||
AppError, // the daemon ran the method and answered with a JSON-RPC error
|
||||
Aborted, // our own requestAbort() cut the transfer
|
||||
Deferred, // not sent: the node is busy (RpcBusyGate), so we held back rather than queue more
|
||||
Other,
|
||||
};
|
||||
|
||||
inline const char* rpcFailureKindName(RpcFailureKind kind)
|
||||
{
|
||||
switch (kind) {
|
||||
case RpcFailureKind::None: return "None";
|
||||
case RpcFailureKind::NotListening: return "NotListening";
|
||||
case RpcFailureKind::ConnectTimeout: return "ConnectTimeout";
|
||||
case RpcFailureKind::ReplyTimeout: return "ReplyTimeout";
|
||||
case RpcFailureKind::QueueFull: return "QueueFull";
|
||||
case RpcFailureKind::ShuttingDown: return "ShuttingDown";
|
||||
case RpcFailureKind::Unhandled: return "Unhandled";
|
||||
case RpcFailureKind::Auth: return "Auth";
|
||||
case RpcFailureKind::Warmup: return "Warmup";
|
||||
case RpcFailureKind::AppError: return "AppError";
|
||||
case RpcFailureKind::Aborted: return "Aborted";
|
||||
case RpcFailureKind::Deferred: return "Deferred";
|
||||
case RpcFailureKind::Other: return "Other";
|
||||
}
|
||||
return "Other";
|
||||
}
|
||||
|
||||
// True for the two curl timeouts: what the old `what().find("Timeout")` checks meant.
|
||||
inline bool isRpcTimeout(RpcFailureKind kind)
|
||||
{
|
||||
return kind == RpcFailureKind::ConnectTimeout || kind == RpcFailureKind::ReplyTimeout;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param curlCode CURLcode of the transfer (CURLE_OK when a response arrived)
|
||||
* @param requestSent the request reached the daemon (CURLINFO_REQUEST_SIZE > 0). libcurl reports
|
||||
* connect- and reply-phase timeouts with the same code; this tells them apart.
|
||||
* @param httpStatus HTTP status, 0 when there was no response
|
||||
* @param rpcCode JSON-RPC error.code from a JSON error body, 0 when there was none
|
||||
* @param plainBody the response body when it was NOT JSON (dragonxd's HTTP-layer replies), else ""
|
||||
*/
|
||||
inline RpcFailureKind classifyRpcFailure(int curlCode, bool requestSent, long httpStatus, int rpcCode,
|
||||
const std::string& plainBody)
|
||||
{
|
||||
if (curlCode != CURLE_OK) {
|
||||
switch (curlCode) {
|
||||
case CURLE_ABORTED_BY_CALLBACK: return RpcFailureKind::Aborted;
|
||||
case CURLE_COULDNT_CONNECT: return RpcFailureKind::NotListening;
|
||||
case CURLE_OPERATION_TIMEDOUT:
|
||||
return requestSent ? RpcFailureKind::ReplyTimeout : RpcFailureKind::ConnectTimeout;
|
||||
default: return RpcFailureKind::Other;
|
||||
}
|
||||
}
|
||||
if (rpcCode == -28) return RpcFailureKind::Warmup; // takes precedence over the HTTP status
|
||||
if (rpcCode != 0) return RpcFailureKind::AppError; // 500, or 400/404 (invalid request / no method)
|
||||
if (httpStatus == 200) return RpcFailureKind::None;
|
||||
if (httpStatus == 401) return RpcFailureKind::Auth;
|
||||
if (httpStatus == 503) return RpcFailureKind::ShuttingDown;
|
||||
if (httpStatus == 500) {
|
||||
if (plainBody.find("Work queue depth exceeded") != std::string::npos) return RpcFailureKind::QueueFull;
|
||||
if (plainBody.find("Unhandled request") != std::string::npos) return RpcFailureKind::Unhandled;
|
||||
}
|
||||
return RpcFailureKind::Other;
|
||||
}
|
||||
|
||||
/**
|
||||
* "RPC error: HTTP <code>", plus the body when the daemon sent a plain-text one (e.g. ": Work queue
|
||||
* depth exceeded"), trimmed and limited to 120 printable ASCII characters so an HTML page or binary
|
||||
* junk can't flood a log line. Exactly "RPC error: HTTP <code>" when nothing printable remains.
|
||||
*/
|
||||
inline std::string formatHttpError(long httpStatus, const std::string& plainBody)
|
||||
{
|
||||
std::string text;
|
||||
bool pendingSpace = false;
|
||||
for (unsigned char c : plainBody) {
|
||||
if (text.size() >= 120) break;
|
||||
if (c == ' ' || c == '\t' || c == '\r' || c == '\n') {
|
||||
pendingSpace = !text.empty();
|
||||
} else if (c >= 0x21 && c <= 0x7e) {
|
||||
if (pendingSpace && text.size() < 119) text.push_back(' ');
|
||||
pendingSpace = false;
|
||||
text.push_back(static_cast<char>(c));
|
||||
}
|
||||
}
|
||||
std::string out = "RPC error: HTTP " + std::to_string(httpStatus);
|
||||
if (!text.empty()) out += ": " + text;
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* The daemon ran the method and answered with a JSON-RPC error. what() is the daemon's message;
|
||||
* `code` is its JSON-RPC error code, notably -28 (RPC_IN_WARMUP). Callers catch this type to mean
|
||||
* "the daemon said no" (e.g. no mnemonic -> fall back), as opposed to a transport failure.
|
||||
*/
|
||||
class RpcError : public std::runtime_error {
|
||||
public:
|
||||
RpcError(int errorCode, const std::string& message, long status = 0)
|
||||
: std::runtime_error(message), code(errorCode), httpStatus(status) {}
|
||||
RpcFailureKind kind() const { return code == -28 ? RpcFailureKind::Warmup : RpcFailureKind::AppError; }
|
||||
int code = 0;
|
||||
long httpStatus = 0;
|
||||
};
|
||||
|
||||
/**
|
||||
* The call produced no JSON-RPC answer: curl failed (refused, timed out, aborted), or the daemon's
|
||||
* HTTP layer turned the request away (401, 503, queue full, unhandled). what() keeps the historic
|
||||
* text ("RPC request failed: <curl error>" / "RPC error: HTTP <code>[: body]").
|
||||
*/
|
||||
class RpcTransportError : public std::runtime_error {
|
||||
public:
|
||||
RpcTransportError(RpcFailureKind failureKind, const std::string& message, int curl = CURLE_OK,
|
||||
long status = 0, bool sent = false)
|
||||
: std::runtime_error(message), kind(failureKind), curlCode(curl), httpStatus(status), requestSent(sent) {}
|
||||
RpcFailureKind kind = RpcFailureKind::Other;
|
||||
int curlCode = CURLE_OK;
|
||||
long httpStatus = 0;
|
||||
bool requestSent = false;
|
||||
};
|
||||
|
||||
// The kind of any exception an RPC call threw (Other for anything that isn't an RPC error).
|
||||
inline RpcFailureKind rpcFailureKindOf(const std::exception& e)
|
||||
{
|
||||
if (const auto* t = dynamic_cast<const RpcTransportError*>(&e)) return t->kind;
|
||||
if (const auto* r = dynamic_cast<const RpcError*>(&e)) return r->kind();
|
||||
return RpcFailureKind::Other;
|
||||
}
|
||||
|
||||
// What a failed walletpassphrase call means. Only WrongPassphrase (-14) may count toward the lockout;
|
||||
// a busy/warming-up node, a wallet that is not encrypted, or anything unknown is never a bad guess.
|
||||
enum class UnlockFailure { WrongPassphrase, NodeBusy, NotEncrypted, Other };
|
||||
|
||||
inline UnlockFailure classifyUnlockFailure(const std::exception& e)
|
||||
{
|
||||
if (const auto* r = dynamic_cast<const RpcError*>(&e)) {
|
||||
if (r->code == -14) return UnlockFailure::WrongPassphrase; // RPC_WALLET_PASSPHRASE_INCORRECT
|
||||
if (r->code == -15) return UnlockFailure::NotEncrypted; // RPC_WALLET_WRONG_ENC_STATE
|
||||
if (r->code == -28) return UnlockFailure::NodeBusy; // RPC_IN_WARMUP
|
||||
return UnlockFailure::Other;
|
||||
}
|
||||
switch (rpcFailureKindOf(e)) {
|
||||
case RpcFailureKind::ReplyTimeout:
|
||||
case RpcFailureKind::QueueFull:
|
||||
case RpcFailureKind::Deferred:
|
||||
case RpcFailureKind::Warmup:
|
||||
case RpcFailureKind::ConnectTimeout:
|
||||
case RpcFailureKind::NotListening:
|
||||
return UnlockFailure::NodeBusy;
|
||||
default:
|
||||
return UnlockFailure::Other;
|
||||
}
|
||||
}
|
||||
|
||||
} // namespace rpc
|
||||
} // namespace dragonx
|
||||
@@ -142,6 +142,10 @@ void RPCWorker::run()
|
||||
}
|
||||
} catch (const std::exception& e) {
|
||||
DEBUG_LOGF("[RPCWorker] Task threw: %s\n", e.what());
|
||||
} catch (...) {
|
||||
// A raw post() job that throws still loses its MainCb (no worse than the
|
||||
// std::exception path); this only keeps the worker thread alive.
|
||||
DEBUG_LOGF("[RPCWorker] Task threw unknown exception\n");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,172 @@
|
||||
// DragonX Wallet - ImGui Edition
|
||||
// Copyright 2024-2026 The Hush Developers
|
||||
// Released under the GPLv3
|
||||
|
||||
#pragma once
|
||||
|
||||
// When the HushChat scans are worth sending. Both are wallet-wide (they hold dragonxd's cs_main): the inbox
|
||||
// scan (z_listreceivedbyaddress of the chat address, every note ever) and the note-buffer scan
|
||||
// (z_listunspent). They used to run on a timer whether or not anything had changed. Here they run when
|
||||
// something they depend on can have changed, and the WalletScanGovernor decides when (the scan budget).
|
||||
//
|
||||
// Pure, header-only.
|
||||
|
||||
#include "rpc/rpc_failure.h"
|
||||
#include "services/wallet_scan_governor.h"
|
||||
|
||||
#include <nlohmann/json.hpp>
|
||||
|
||||
#include <algorithm>
|
||||
#include <cstdint>
|
||||
#include <string>
|
||||
#include <unordered_map>
|
||||
|
||||
namespace dragonx {
|
||||
namespace services {
|
||||
|
||||
constexpr double kChatTipSettleSec = 3.0; // the wallet notifier stores a block's txs ~1 s after it connects
|
||||
constexpr double kChatNoteScanFloorSec = 4.0; // spacing between note scans (a failing need can't spin), not a duty rule
|
||||
constexpr double kChatProbeMaxDuty = 0.02;
|
||||
constexpr double kChatProbeMaxSpacingSec = 60.0; // the probe is the inbox safety net's only path
|
||||
|
||||
// getwalletinfo's txcount (mapWallet.size(), read under cs_wallet): it changes only after the daemon's wallet
|
||||
// notifier has stored a tx and its notes. A mempool or tip change can run ahead of that (the notifier runs
|
||||
// about once a second), so the inbox is gated on this instead. -1 when absent, malformed or negative.
|
||||
inline std::int64_t parseWalletTxCount(const nlohmann::json& reply)
|
||||
{
|
||||
if (!reply.is_object() || !reply.contains("txcount") || !reply["txcount"].is_number_integer()) return -1;
|
||||
const auto v = reply["txcount"].get<std::int64_t>();
|
||||
return v < 0 ? -1 : v;
|
||||
}
|
||||
|
||||
// The inbox probe (getwalletinfo: three passes over the wallet under cs_main) isn't a governed scan: at most 2%
|
||||
// of wall-clock, and at least once a minute (it is the only way the inbox safety net is checked).
|
||||
inline bool chatProbeDue(double lastProbeMs, double lastProbeAtSec, double nowSec)
|
||||
{
|
||||
return nowSec - lastProbeAtSec >= std::min((std::max(0.0, lastProbeMs) / 1000.0) / kChatProbeMaxDuty,
|
||||
kChatProbeMaxSpacingSec);
|
||||
}
|
||||
|
||||
// Safety nets when no change signal fired: a minute, stretched with the scan's own cost (the inbox is gated on
|
||||
// txcount, so its net is only a backstop; the note fallback refreshes the buffer status and the payer view).
|
||||
inline double chatInboxSafetyNetSec(double emaCostMs) { return std::max(60.0, 50.0 * emaCostMs / 1000.0); }
|
||||
inline double chatNoteFallbackSec(double emaCostMs) { return std::max(60.0, 20.0 * emaCostMs / 1000.0); }
|
||||
|
||||
struct ChatInboxScanInputs {
|
||||
std::int64_t basisTxCount = -1; // the count the last successful scan is current for (-1: none)
|
||||
std::int64_t txCount = -1; // the probe's read
|
||||
double nowSec = 0.0;
|
||||
double lastScanAtSec = 0.0;
|
||||
double safetyNetSec = 60.0;
|
||||
};
|
||||
|
||||
// The inbox needs a scan: the count is unknown (no basis yet, or a daemon without txcount: as the budget
|
||||
// allows), the wallet gained or lost a transaction, or the safety net is due.
|
||||
inline bool chatInboxScanNeeded(const ChatInboxScanInputs& in)
|
||||
{
|
||||
if (in.txCount < 0 || in.basisTxCount < 0) return true;
|
||||
if (in.txCount != in.basisTxCount) return true;
|
||||
return in.nowSec - in.lastScanAtSec >= in.safetyNetSec;
|
||||
}
|
||||
|
||||
struct ChatNoteScanInputs {
|
||||
bool onChatPage = false; // the Chat page is shown (and the window isn't in the background)
|
||||
bool queueWaiting = false; // a chat send waits for a verified note
|
||||
bool splitOutstanding = false; // a self-split is confirming
|
||||
bool rescanRequested = false; // a chat operation finished
|
||||
bool modelSeen = false; // a scan (or the address scan's feed) succeeded this session
|
||||
int verifiedBudget = 0;
|
||||
int pipelineNotes = 0;
|
||||
std::int64_t tipHeight = 0;
|
||||
std::int64_t lastScanTip = -1;
|
||||
double nowSec = 0.0;
|
||||
double lastScanAtSec = 0.0;
|
||||
double tipChangedAtSec = 0.0;
|
||||
double fallbackSec = 60.0;
|
||||
double floorSec = kChatNoteScanFloorSec;
|
||||
double tipSettleSec = kChatTipSettleSec;
|
||||
};
|
||||
|
||||
// The note view is worth refreshing: something can use it (the Chat page, a waiting send, a split) and it
|
||||
// can have changed (first look, a chat op finished, a block that matters once the notifier caught up, or the
|
||||
// fallback). How often it actually runs is the scan budget's call.
|
||||
inline bool shouldScanChatNotes(const ChatNoteScanInputs& in)
|
||||
{
|
||||
if (!in.onChatPage && !in.queueWaiting && !in.splitOutstanding) return false; // nothing can use it
|
||||
const bool ever = in.lastScanAtSec > 0.0;
|
||||
if (ever && in.nowSec - in.lastScanAtSec < in.floorSec) return false;
|
||||
if (!in.modelSeen || in.rescanRequested) return true;
|
||||
const bool tipMoved = in.tipHeight != in.lastScanTip && in.nowSec - in.tipChangedAtSec >= in.tipSettleSec;
|
||||
const bool starved = in.queueWaiting && in.verifiedBudget < 1;
|
||||
const bool maturing = in.onChatPage && in.verifiedBudget < in.pipelineNotes; // notes still confirming
|
||||
if (tipMoved && (starved || in.splitOutstanding || maturing)) return true;
|
||||
return !ever || in.nowSec - in.lastScanAtSec >= in.fallbackSec;
|
||||
}
|
||||
|
||||
// Verified notes (at least a fee, unlocked, with the confirmations a chat send needs) by z-address.
|
||||
// z_sendmany pays from ONE address and spends its largest notes first.
|
||||
struct ChatPayerFunds {
|
||||
std::uint64_t zat = 0;
|
||||
int notes = 0;
|
||||
};
|
||||
using ChatPayerMap = std::unordered_map<std::string, ChatPayerFunds>;
|
||||
|
||||
// `preferred` if it has a verified note and covers needZat, else the richest that does (ties: the smallest
|
||||
// address). "" if none does.
|
||||
inline std::string chooseChatPayer(const ChatPayerMap& funds, const std::string& preferred, std::uint64_t needZat)
|
||||
{
|
||||
auto eligible = [&](const ChatPayerFunds& f) { return f.notes > 0 && f.zat >= needZat; };
|
||||
if (!preferred.empty()) {
|
||||
const auto it = funds.find(preferred);
|
||||
if (it != funds.end() && eligible(it->second)) return preferred;
|
||||
}
|
||||
std::string best;
|
||||
std::uint64_t bestZat = 0;
|
||||
for (const auto& [address, f] : funds) {
|
||||
if (address.empty() || !eligible(f)) continue;
|
||||
if (best.empty() || f.zat > bestZat || (f.zat == bestZat && address < best)) {
|
||||
best = address;
|
||||
bestZat = f.zat;
|
||||
}
|
||||
}
|
||||
return best;
|
||||
}
|
||||
|
||||
inline std::uint64_t richestChatPayerZat(const ChatPayerMap& funds)
|
||||
{
|
||||
std::uint64_t best = 0;
|
||||
for (const auto& [address, f] : funds) {
|
||||
if (!address.empty() && f.notes > 0) best = std::max(best, f.zat);
|
||||
}
|
||||
return best;
|
||||
}
|
||||
|
||||
enum class ChatPayerSpend { OneNote, All };
|
||||
|
||||
// Until the next scan restores the truth: a chat send spends one whole note (its change is unverified for a
|
||||
// block); a split may spend all of the payer's verified notes.
|
||||
inline void debitChatPayer(ChatPayerMap& funds, const std::string& payer, ChatPayerSpend spend)
|
||||
{
|
||||
const auto it = funds.find(payer);
|
||||
if (it == funds.end()) return;
|
||||
auto& f = it->second;
|
||||
if (spend == ChatPayerSpend::All || f.notes <= 1) {
|
||||
f = {};
|
||||
return;
|
||||
}
|
||||
f.zat -= f.zat / static_cast<std::uint64_t>(f.notes);
|
||||
--f.notes;
|
||||
}
|
||||
|
||||
// How a chat scan's result is charged to the scan budget.
|
||||
inline ScanOutcome chatScanOutcome(bool ok, rpc::RpcFailureKind kind)
|
||||
{
|
||||
if (ok) return ScanOutcome::Ok;
|
||||
if (kind == rpc::RpcFailureKind::ReplyTimeout) return ScanOutcome::TimedOut; // the daemon still runs it
|
||||
if (kind == rpc::RpcFailureKind::Deferred || kind == rpc::RpcFailureKind::QueueFull)
|
||||
return ScanOutcome::Skipped; // never ran
|
||||
return ScanOutcome::Failed;
|
||||
}
|
||||
|
||||
} // namespace services
|
||||
} // namespace dragonx
|
||||
@@ -0,0 +1,63 @@
|
||||
// DragonX Wallet - ImGui Edition
|
||||
// Copyright 2024-2026 The Hush Developers
|
||||
// Released under the GPLv3
|
||||
|
||||
#pragma once
|
||||
|
||||
// Holds the wallet-wide scans (the balance, the address scan, the history) for a moment after the node
|
||||
// starts answering: until the lock state is known and either the node knows the chain tip or the wallet is
|
||||
// small. Right after warmup a node that is behind doesn't know it yet (longestchain stays 0 until its first
|
||||
// UpdateTip), so the first Core poll would read "synced" and a large wallet's scans would start, holding
|
||||
// cs_main ahead of the catch-up. Opens after kGraceSeconds whatever happens.
|
||||
|
||||
#include <chrono>
|
||||
|
||||
namespace dragonx {
|
||||
namespace services {
|
||||
|
||||
class ConnectScanGate {
|
||||
public:
|
||||
using Clock = std::chrono::steady_clock;
|
||||
static constexpr int kGraceSeconds = 20;
|
||||
static constexpr int kMinWalletTxs = 500; // below this the scans are cheap: don't hold them for the tip
|
||||
|
||||
void arm(Clock::time_point now, bool tipKnown)
|
||||
{
|
||||
armed_ = true;
|
||||
edgePending_ = true;
|
||||
tipKnown_ = tipKnown;
|
||||
armedAt_ = now;
|
||||
}
|
||||
void noteTipKnown() { tipKnown_ = true; }
|
||||
void disarm()
|
||||
{
|
||||
armed_ = false;
|
||||
edgePending_ = false;
|
||||
}
|
||||
bool armed() const { return armed_; }
|
||||
|
||||
// walletTxCount: getwalletinfo's txcount, -1 when unknown.
|
||||
bool open(Clock::time_point now, bool lockStateKnown, int walletTxCount) const
|
||||
{
|
||||
if (!armed_) return true;
|
||||
const bool smallWallet = walletTxCount >= 0 && walletTxCount < kMinWalletTxs;
|
||||
if (lockStateKnown && (tipKnown_ || smallWallet)) return true;
|
||||
return now - armedAt_ >= std::chrono::seconds(kGraceSeconds);
|
||||
}
|
||||
// True once per arm(), the first time open() is: what the gate held is owed now.
|
||||
bool consumeOpenEdge(Clock::time_point now, bool lockStateKnown, int walletTxCount)
|
||||
{
|
||||
if (!armed_ || !edgePending_ || !open(now, lockStateKnown, walletTxCount)) return false;
|
||||
edgePending_ = false;
|
||||
return true;
|
||||
}
|
||||
|
||||
private:
|
||||
bool armed_ = false;
|
||||
bool edgePending_ = false;
|
||||
bool tipKnown_ = false;
|
||||
Clock::time_point armedAt_{};
|
||||
};
|
||||
|
||||
} // namespace services
|
||||
} // namespace dragonx
|
||||
@@ -0,0 +1,242 @@
|
||||
// DragonX Wallet - ImGui Edition
|
||||
// Copyright 2024-2026 The Hush Developers
|
||||
// Released under the GPLv3
|
||||
|
||||
#include "history_scan_policy.h"
|
||||
|
||||
#include <algorithm>
|
||||
#include <cmath>
|
||||
|
||||
namespace dragonx {
|
||||
namespace services {
|
||||
|
||||
int historyStaleAfterBlocks(std::size_t addressCount, double perAddressScanMs)
|
||||
{
|
||||
if (addressCount == 0 || !(perAddressScanMs > 0.0)) return kHistoryMinStaleBlocks;
|
||||
const double blocks = std::ceil((static_cast<double>(addressCount) * perAddressScanMs / 1000.0) /
|
||||
(kHistoryBlockSeconds * kHistoryScanDuty));
|
||||
if (!(blocks < kHistoryMaxStaleBlocks)) return kHistoryMaxStaleBlocks; // also NaN / inf
|
||||
return std::max(kHistoryMinStaleBlocks, static_cast<int>(blocks));
|
||||
}
|
||||
|
||||
double updateScanCostEstimateMs(double previousMs, double sampleMs)
|
||||
{
|
||||
if (!(sampleMs > 0.0)) return previousMs;
|
||||
if (!(previousMs > 0.0) || sampleMs > previousMs) return sampleMs;
|
||||
return 0.75 * previousMs + 0.25 * sampleMs;
|
||||
}
|
||||
|
||||
int transparentPageSize(std::size_t previousRowCount)
|
||||
{
|
||||
const std::size_t want = previousRowCount + std::max<std::size_t>(200, previousRowCount / 10);
|
||||
return static_cast<int>(std::clamp<std::size_t>(want, kTransparentPageMin, kTransparentPageMax));
|
||||
}
|
||||
|
||||
bool sentSweepIsFull(int tip, int fromHeight)
|
||||
{
|
||||
return tip <= 0 || fromHeight < 0 || fromHeight > tip;
|
||||
}
|
||||
|
||||
nlohmann::json sentSweepParams(const std::string& anyZAddress, int tip, int fromHeight, int margin)
|
||||
{
|
||||
nlohmann::json params = nlohmann::json::array();
|
||||
params.push_back(anyZAddress);
|
||||
params.push_back(0);
|
||||
if (sentSweepIsFull(tip, fromHeight)) return params;
|
||||
const int maxDepth = (tip - fromHeight) + std::max(0, margin);
|
||||
params.push_back(2); // filter type 2: depth
|
||||
params.push_back(maxDepth); // at most this deep (mempool txs, depth 0, always pass)
|
||||
return params;
|
||||
}
|
||||
|
||||
int sentSweepMargin(bool firstAfterLoad)
|
||||
{
|
||||
return firstAfterLoad ? (kSentSweepReorgMarginBlocks + kSentSweepFirstLoadExtraBlocks)
|
||||
: kSentSweepReorgMarginBlocks;
|
||||
}
|
||||
|
||||
int sentSweepAnchor(int fromHeight, bool firstAfterLoad)
|
||||
{
|
||||
return std::max(1, fromHeight - sentSweepMargin(firstAfterLoad));
|
||||
}
|
||||
|
||||
bool sentSweepNeedsLongCall(int tip, int fromHeight)
|
||||
{
|
||||
return tip > 0 && fromHeight >= 0 && (tip - fromHeight) > kSentSweepLongCallBlocks;
|
||||
}
|
||||
|
||||
bool sentSweepSafetyNetDue(std::int64_t nowSec, std::int64_t lastFullSec)
|
||||
{
|
||||
return lastFullSec > 0 && (nowSec - lastFullSec) > kSentSweepSafetyNetSec;
|
||||
}
|
||||
|
||||
int sentSweepNextBandSize(int prevSize, double prevMs)
|
||||
{
|
||||
if (!(prevMs > 0.0)) return std::clamp(prevSize, kSentSweepBandMinBlocks, kSentSweepBandMaxBlocks);
|
||||
double scale = static_cast<double>(kSentSweepBandTargetMs) / prevMs;
|
||||
scale = std::clamp(scale, 0.5, 4.0); // don't swing more than 2x smaller / 4x larger per step
|
||||
int next = static_cast<int>(std::lround(prevSize * scale));
|
||||
return std::clamp(next, kSentSweepBandMinBlocks, kSentSweepBandMaxBlocks);
|
||||
}
|
||||
|
||||
long fullSentSweepRetryDelaySec(int consecutiveFailures)
|
||||
{
|
||||
if (consecutiveFailures <= 0) return 0;
|
||||
if (consecutiveFailures == 1) return 600;
|
||||
if (consecutiveFailures == 2) return 3600;
|
||||
return 21600;
|
||||
}
|
||||
|
||||
bool fullSentSweepFailureCounts(rpc::RpcFailureKind kind)
|
||||
{
|
||||
using K = rpc::RpcFailureKind;
|
||||
return kind == K::ReplyTimeout || kind == K::AppError || kind == K::Other;
|
||||
}
|
||||
|
||||
std::size_t oldestScannedAddressIndex(const std::vector<std::string>& addresses,
|
||||
const std::unordered_map<std::string, int>& heights)
|
||||
{
|
||||
std::size_t best = 0;
|
||||
int bestHeight = 0;
|
||||
bool haveBest = false;
|
||||
for (std::size_t i = 0; i < addresses.size(); ++i) {
|
||||
const auto it = heights.find(addresses[i]);
|
||||
if (it == heights.end()) return i; // never scanned: first
|
||||
if (!haveBest || it->second < bestHeight) {
|
||||
best = i;
|
||||
bestHeight = it->second;
|
||||
haveBest = true;
|
||||
}
|
||||
}
|
||||
return best;
|
||||
}
|
||||
|
||||
std::string ProbeRotation::next(const std::vector<std::string>& addresses,
|
||||
const std::unordered_map<std::string, int>& heights)
|
||||
{
|
||||
std::unordered_set<std::string> listed(addresses.begin(), addresses.end());
|
||||
for (auto it = left_.begin(); it != left_.end();) {
|
||||
if (listed.count(*it)) ++it;
|
||||
else it = left_.erase(it);
|
||||
}
|
||||
if (left_.empty()) left_ = std::move(listed);
|
||||
std::vector<std::string> left;
|
||||
for (const auto& address : addresses) {
|
||||
if (left_.count(address)) left.push_back(address);
|
||||
}
|
||||
if (left.empty()) return {};
|
||||
return left[oldestScannedAddressIndex(left, heights)];
|
||||
}
|
||||
|
||||
int ShieldedHistoryPass::floorAt(int tip) const
|
||||
{
|
||||
return tip > 0 ? std::min(startHeight_, tip) : startHeight_;
|
||||
}
|
||||
|
||||
ShieldedHistoryPass::Work ShieldedHistoryPass::evaluate(int tip, const std::vector<std::string>& addresses,
|
||||
const std::unordered_map<std::string, int>& heights,
|
||||
int staleAfter, bool initialComplete) const
|
||||
{
|
||||
Work work;
|
||||
work.total = addresses.size();
|
||||
const int floor = active_ ? floorAt(tip) : -1;
|
||||
std::size_t below = 0;
|
||||
bool staleDue = false;
|
||||
for (const auto& address : addresses) {
|
||||
const auto it = heights.find(address);
|
||||
if (it == heights.end()) {
|
||||
++work.missing;
|
||||
continue;
|
||||
}
|
||||
if (active_) {
|
||||
if (it->second < floor) ++below;
|
||||
} else if (tip > 0 && it->second < tip - staleAfter) {
|
||||
staleDue = true;
|
||||
}
|
||||
}
|
||||
work.pending = work.missing + (active_ ? below : (staleDue ? work.total - work.missing : 0));
|
||||
work.explicitWork = !stalled() &&
|
||||
(work.missing > 0 || (active_ ? (explicit_ && below > 0) : (staleDue && !initialComplete)));
|
||||
work.routineWork = !work.explicitWork && work.pending > 0;
|
||||
return work;
|
||||
}
|
||||
|
||||
void ShieldedHistoryPass::begin(int tip, const std::vector<std::string>& addresses,
|
||||
const std::unordered_map<std::string, int>& heights, int staleAfter,
|
||||
bool initialComplete, bool routineAllowed)
|
||||
{
|
||||
if (active_) {
|
||||
if (tip > 0 && startHeight_ > tip) startHeight_ = tip; // the chain reorganised below the floor
|
||||
return;
|
||||
}
|
||||
if (tip <= 0 || !(routineAllowed || !initialComplete)) return;
|
||||
bool staleDue = false;
|
||||
for (const auto& address : addresses) {
|
||||
const auto it = heights.find(address);
|
||||
if (it != heights.end() && it->second < tip - staleAfter) {
|
||||
staleDue = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (!staleDue) return;
|
||||
active_ = true;
|
||||
explicit_ = !initialComplete;
|
||||
startHeight_ = tip;
|
||||
}
|
||||
|
||||
int ShieldedHistoryPass::scanFloor(int tip, bool routineAllowed) const
|
||||
{
|
||||
return (active_ && (explicit_ || routineAllowed)) ? floorAt(tip) : -1;
|
||||
}
|
||||
|
||||
void ShieldedHistoryPass::finish(int tip, const std::vector<std::string>& addresses,
|
||||
const std::unordered_map<std::string, int>& heights, std::size_t attempted,
|
||||
std::size_t succeeded)
|
||||
{
|
||||
if (attempted > 0) stalledCycles_ = succeeded > 0 ? 0 : stalledCycles_ + 1;
|
||||
// An empty list proves nothing (a failed enumeration blanks it); a real wallet change resets the pass.
|
||||
if (!active_ || addresses.empty()) return;
|
||||
const int floor = floorAt(tip);
|
||||
for (const auto& address : addresses) {
|
||||
const auto it = heights.find(address);
|
||||
if (it == heights.end() || it->second < floor) return; // still work left in this pass
|
||||
}
|
||||
active_ = false;
|
||||
explicit_ = false;
|
||||
}
|
||||
|
||||
void ShieldedHistoryPass::reset()
|
||||
{
|
||||
*this = ShieldedHistoryPass{};
|
||||
}
|
||||
|
||||
TxCycle decideTxCycle(const TxCycleInputs& in)
|
||||
{
|
||||
const bool wanted = in.neverFetched || in.newTip || in.dirty || in.explicitScanWork || in.routineScanWork;
|
||||
const bool scansAllowed = !in.syncing && in.heightKnown;
|
||||
if (wanted) {
|
||||
if (scansAllowed && (in.explicitScanWork || in.gateOpen)) return TxCycle::Full;
|
||||
// Rows now (a mutation, a tracked send at a new tip); wallet-wide scans wait for the throttle.
|
||||
if (in.neverFetched || in.dirty || (in.sendTracking && in.newTip)) return TxCycle::Light;
|
||||
}
|
||||
if (in.recentDue) return (scansAllowed && in.gateOpen) ? TxCycle::RecentWithProbe : TxCycle::Recent;
|
||||
return TxCycle::None;
|
||||
}
|
||||
|
||||
TxCycleInputs shapeTxCycleForMiningPage(TxCycleInputs in)
|
||||
{
|
||||
if (!in.sendTracking) { // tracked sends need their Light cycle at a new tip, wherever the user is
|
||||
in.newTip = false;
|
||||
in.routineScanWork = false;
|
||||
}
|
||||
return in;
|
||||
}
|
||||
|
||||
bool miningTxPollDue(const MiningTxPollInputs& in)
|
||||
{
|
||||
return in.historyLoaded && in.tip > 0 && in.tip != in.lastPolledTip && in.txAgeDue && !in.dirty &&
|
||||
!in.addressesDirty;
|
||||
}
|
||||
|
||||
} // namespace services
|
||||
} // namespace dragonx
|
||||
@@ -0,0 +1,200 @@
|
||||
// DragonX Wallet - ImGui Edition
|
||||
// Copyright 2024-2026 The Hush Developers
|
||||
// Released under the GPLv3
|
||||
|
||||
#pragma once
|
||||
|
||||
// When the transaction history refresh may scan what. The history comes from three wallet-wide RPCs, each
|
||||
// holding the daemon's cs_main for its whole run (seconds per call on a large wallet): listtransactions,
|
||||
// z_listreceivedbyaddress (once per z-address) and z_listsentbyaddress. This decides, per refresh cycle,
|
||||
// which of them run, so the cost stays bounded and every pass over the z-addresses finishes. Pure and
|
||||
// main-thread only; App::refreshTransactionData() feeds it and the test target drives it directly.
|
||||
|
||||
#include "rpc/rpc_failure.h"
|
||||
|
||||
#include <nlohmann/json.hpp>
|
||||
|
||||
#include <cstddef>
|
||||
#include <cstdint>
|
||||
#include <string>
|
||||
#include <unordered_map>
|
||||
#include <unordered_set>
|
||||
#include <vector>
|
||||
|
||||
namespace dragonx {
|
||||
namespace services {
|
||||
|
||||
constexpr int kHistoryBlockSeconds = 36; // -ac_blocktime=36
|
||||
constexpr double kHistoryScanDuty = 0.10; // the same 10% as the other scan throttles
|
||||
constexpr int kHistoryMinStaleBlocks = 8; // re-scan an address at most every 8 blocks...
|
||||
constexpr int kHistoryMaxStaleBlocks = 500; // ...and at least every 500 (a safety net)
|
||||
constexpr int kSentSweepReorgMarginBlocks = 100; // > MAX_REORG_LENGTH (99), plus tip drift
|
||||
constexpr int kSentSweepFirstLoadExtraBlocks = 1000; // E: extra margin on the first sweep after a load,
|
||||
// to absorb wallet-notifier lag at the last save
|
||||
constexpr int kSentSweepLongCallBlocks = 2400; // tip - W beyond this: use callLong + count failures
|
||||
constexpr long kSentSweepSafetyNetSec = 7 * 24 * 3600; // ~weekly full re-sweep: catches external wallet
|
||||
// changes the per-event guards can't see
|
||||
constexpr long kFullSentSweepTimeoutSec = 120; // a whole-wallet sweep must be able to finish
|
||||
// Banded full sweep: cover [0, tip] in depth bands (minconf=a + filter-2 maxDepth=b) so each cs_main hold is
|
||||
// short (~kSentSweepBandTargetMs), instead of one call that holds it for the whole sweep. The band grows in
|
||||
// sparse depth (no sends) and shrinks in dense depth, so it stays near the target duration either way.
|
||||
constexpr long kSentSweepBandTargetMs = 5000;
|
||||
constexpr int kSentSweepBandInitialBlocks = 2000;
|
||||
constexpr int kSentSweepBandMinBlocks = 500;
|
||||
constexpr int kSentSweepBandMaxBlocks = 100000;
|
||||
constexpr int kSentSweepBandOverlap = 20; // overlap adjacent bands; dedupe by txid
|
||||
constexpr int kTransparentPageMin = 1000;
|
||||
constexpr int kTransparentPageMax = 5000;
|
||||
constexpr int kStallCyclesBeforeGating = 1;
|
||||
|
||||
// Blocks after which an address's last receive scan counts as stale and a new routine pass is due: the
|
||||
// pass then costs about kHistoryScanDuty of wall-clock (one z_listreceivedbyaddress of perAddressScanMs per
|
||||
// address, spread over that many blocks), within [kHistoryMinStaleBlocks, kHistoryMaxStaleBlocks].
|
||||
int historyStaleAfterBlocks(std::size_t addressCount, double perAddressScanMs);
|
||||
|
||||
// Running cost estimate: a higher sample replaces it at once, a lower one pulls it down 25%. A sample of 0
|
||||
// or less (nothing measured) leaves it as it is.
|
||||
double updateScanCostEstimateMs(double previousMs, double sampleMs);
|
||||
|
||||
// listtransactions rows to ask for in the first page: last time's count plus headroom, so most wallets
|
||||
// get every row in one call instead of re-paging the whole history in 1000-row pages.
|
||||
int transparentPageSize(std::size_t previousRowCount);
|
||||
|
||||
// z_listsentbyaddress: the whole wallet when there's no usable watermark (none yet, or it is above the
|
||||
// tip after a reorg), else only txs at most (tip - fromHeight) + kSentSweepReorgMarginBlocks deep.
|
||||
bool sentSweepIsFull(int tip, int fromHeight);
|
||||
// [z, 0] for the full sweep, [z, 0, 2, maxDepth] for the window: JSON integers, and never 3 params (the
|
||||
// daemon rejects that count). `margin` widens the window (the first sweep after a load uses a bigger one).
|
||||
nlohmann::json sentSweepParams(const std::string& anyZAddress, int tip, int fromHeight,
|
||||
int margin = kSentSweepReorgMarginBlocks);
|
||||
// Window margin: steady-state kSentSweepReorgMarginBlocks, bigger on the first sweep after a load.
|
||||
int sentSweepMargin(bool firstAfterLoad);
|
||||
// A: the bottom edge the window covers (W - margin), clamped to >= 1. The anchor whose block hash is stored
|
||||
// (H_A) and re-verified on the next load.
|
||||
int sentSweepAnchor(int fromHeight, bool firstAfterLoad);
|
||||
// A watermark saved long ago makes a huge "windowed" call: past this gap, use callLong and count failures
|
||||
// toward the backoff, so it doesn't time out on every block while the daemon keeps working.
|
||||
bool sentSweepNeedsLongCall(int tip, int fromHeight);
|
||||
// A watermarked wallet is due a periodic full re-sweep to catch changes made outside the GUI (a Console
|
||||
// import, an external same-seed restore) that the per-event guards miss. lastFullSec == 0 (no full sweep
|
||||
// recorded yet) never triggers one: the seeding full sweep records it.
|
||||
bool sentSweepSafetyNetDue(std::int64_t nowSec, std::int64_t lastFullSec);
|
||||
// Next banded-sweep depth size, adapting the previous band's block count toward kSentSweepBandTargetMs (at
|
||||
// most 2x down / 4x up per step), clamped to [min, max]. prevMs <= 0 (unmeasured) keeps the size.
|
||||
int sentSweepNextBandSize(int prevSize, double prevMs);
|
||||
// Backoff after full sweeps that failed in a way that cost the daemon the work: 0, 10 min, 1 h, then 6 h.
|
||||
long fullSentSweepRetryDelaySec(int consecutiveFailures);
|
||||
// Whether a failed full sweep counts toward that backoff: only when the daemon did the work (reply timeout,
|
||||
// an error answer, anything unclassified), not when the call never ran (held back, aborted, not listening).
|
||||
bool fullSentSweepFailureCounts(rpc::RpcFailureKind kind);
|
||||
|
||||
// The address scanned longest ago (never-scanned first), for the between-blocks probe.
|
||||
std::size_t oldestScannedAddressIndex(const std::vector<std::string>& addresses,
|
||||
const std::unordered_map<std::string, int>& heights);
|
||||
|
||||
// The between-blocks probe scans one address per run. After what it watches changed (the mempool, a wallet
|
||||
// mutation) it isn't done until it has probed every z-address once: the change may be any of them. Tracks
|
||||
// which are left, by address (heights can't tell: a rotation at one tip leaves them all equal).
|
||||
class ProbeRotation {
|
||||
public:
|
||||
// The next address to probe: the oldest-scanned of those left. Drops addresses no longer listed, and
|
||||
// starts a new rotation when none are left. Empty when there are no addresses.
|
||||
std::string next(const std::vector<std::string>& addresses, const std::unordered_map<std::string, int>& heights);
|
||||
void probed(const std::string& address) { left_.erase(address); }
|
||||
bool complete() const { return left_.empty(); }
|
||||
void reset() { left_.clear(); }
|
||||
|
||||
private:
|
||||
std::unordered_set<std::string> left_;
|
||||
};
|
||||
|
||||
// One pass of receive scans over every z-address. An address is done once it was scanned at or above the
|
||||
// pass's start height (its floor), so a pass ends after ceil(A / budget) cycles however the tip moves. An
|
||||
// explicit pass (the first load of a wallet, or after an invalidation) may run back to back; a routine
|
||||
// re-pass (addresses gone stale) runs only as the scan throttle allows.
|
||||
class ShieldedHistoryPass {
|
||||
public:
|
||||
struct Work {
|
||||
std::size_t total = 0; // z-addresses
|
||||
std::size_t missing = 0; // never scanned
|
||||
std::size_t pending = 0; // still to scan in this pass (or in the pass that is due)
|
||||
bool explicitWork = false; // may skip the scan throttle
|
||||
bool routineWork = false; // waits for the scan throttle
|
||||
};
|
||||
|
||||
Work evaluate(int tip, const std::vector<std::string>& addresses,
|
||||
const std::unordered_map<std::string, int>& heights, int staleAfter, bool initialComplete) const;
|
||||
// Start a pass if one is due and allowed (routine passes need routineAllowed, unless the first load
|
||||
// isn't complete yet). Clamps an active pass's floor after a reorg below it.
|
||||
void begin(int tip, const std::vector<std::string>& addresses, const std::unordered_map<std::string, int>& heights,
|
||||
int staleAfter, bool initialComplete, bool routineAllowed);
|
||||
// The floor a cycle scans up to: -1 means only never-scanned (and priority) addresses, which is all an
|
||||
// explicit-only cycle may do while a routine pass is active.
|
||||
int scanFloor(int tip, bool routineAllowed) const;
|
||||
// After a cycle's heights are merged: count stalls (scans attempted, none succeeded), end the pass once
|
||||
// no address is missing or below its floor.
|
||||
void finish(int tip, const std::vector<std::string>& addresses, const std::unordered_map<std::string, int>& heights,
|
||||
std::size_t attempted, std::size_t succeeded);
|
||||
void reset();
|
||||
|
||||
bool active() const { return active_; }
|
||||
bool explicitPass() const { return explicit_; }
|
||||
int startHeight() const { return startHeight_; }
|
||||
// Scans keep failing: explicit work is treated as routine (gated) until one succeeds again.
|
||||
bool stalled() const { return stalledCycles_ >= kStallCyclesBeforeGating; }
|
||||
|
||||
private:
|
||||
int floorAt(int tip) const;
|
||||
|
||||
bool active_ = false;
|
||||
bool explicit_ = false;
|
||||
int startHeight_ = -1;
|
||||
int stalledCycles_ = 0;
|
||||
};
|
||||
|
||||
enum class TxCycle {
|
||||
None,
|
||||
Full, // listtransactions, receive scans, the sent sweep, enrichment
|
||||
Light, // listtransactions and enrichment only: no wallet-wide z-scans
|
||||
Recent, // the between-blocks poll: recent listtransactions rows
|
||||
RecentWithProbe, // ...plus one receive scan of the address scanned longest ago
|
||||
};
|
||||
|
||||
struct TxCycleInputs {
|
||||
bool neverFetched = false; // no history loaded yet this session
|
||||
bool newTip = false; // the tip moved since the last history apply
|
||||
bool dirty = false; // something changed the wallet (a send, an import, ...)
|
||||
bool sendTracking = false; // tracked sends still waiting for their view
|
||||
bool explicitScanWork = false; // ShieldedHistoryPass::Work::explicitWork
|
||||
bool routineScanWork = false; // ShieldedHistoryPass::Work::routineWork
|
||||
bool gateOpen = false; // the scan throttle allows a wallet-wide scan now
|
||||
bool syncing = false; // the node is catching up (or settling after it)
|
||||
bool heightKnown = false; // the tip height is known
|
||||
bool recentDue = false; // the between-blocks poll is due on a wallet page
|
||||
};
|
||||
|
||||
// Only explicit work skips the throttle with scans; sends and other mutations get rows now through Light
|
||||
// cycles; nothing scans while syncing or before the tip is known. Tracking alone needs a new tip for a
|
||||
// Light cycle, so it can't loop them at one tip.
|
||||
TxCycle decideTxCycle(const TxCycleInputs& in);
|
||||
|
||||
// The Mining tab shows history-derived earnings but is not a wallet-data page: it gets the cheap between-blocks
|
||||
// poll (recent listtransactions rows) and nothing routine. Explicit work (first load, dirty, new addresses) and
|
||||
// tracked sends still go through the normal cycles on any page.
|
||||
TxCycleInputs shapeTxCycleForMiningPage(TxCycleInputs in);
|
||||
|
||||
struct MiningTxPollInputs {
|
||||
int tip = 0; // current chain height
|
||||
int lastPolledTip = -1; // the tip the last Mining poll was dispatched at (-1 = none)
|
||||
bool txAgeDue = false; // RefreshTimer::TxAge due (15 s since the last Recent poll / full cycle)
|
||||
bool historyLoaded = false; // last_tx_block_height_ >= 0
|
||||
bool dirty = false; // transactions_dirty_
|
||||
bool addressesDirty = false; // addresses_dirty_
|
||||
};
|
||||
// One poll per new block (confirmation counts and new coinbase rows only change with a block), after the history
|
||||
// has loaded, never while a dirty cycle is already owed. Unlike the wallet pages, an EMPTY history may poll (a fresh
|
||||
// miner's first block must appear).
|
||||
bool miningTxPollDue(const MiningTxPollInputs& in);
|
||||
|
||||
} // namespace services
|
||||
} // namespace dragonx
|
||||
@@ -0,0 +1,90 @@
|
||||
// DragonX Wallet - ImGui Edition
|
||||
// Copyright 2024-2026 The Hush Developers
|
||||
// Released under the GPLv3
|
||||
//
|
||||
// Pure search for the lowest block height whose data the node can still read (a node restored from a
|
||||
// bootstrap snapshot lacks everything below the snapshot base). No I/O: the caller supplies the probe.
|
||||
// Header-only so the test target needs no new source.
|
||||
#pragma once
|
||||
|
||||
#include "../rpc/rpc_failure.h"
|
||||
|
||||
#include <nlohmann/json.hpp>
|
||||
|
||||
#include <exception>
|
||||
#include <functional>
|
||||
#include <string>
|
||||
|
||||
namespace dragonx {
|
||||
namespace services {
|
||||
|
||||
enum class BlockProbe { Present, Missing, Unknown };
|
||||
|
||||
struct LowestBlockResult {
|
||||
bool ok = false; // false: could not tell (never read as "missing")
|
||||
int lowest = 0; // lowest readable height (valid when ok)
|
||||
bool fullHistory = false; // block 1 readable: not a bootstrapped / pruned node
|
||||
};
|
||||
|
||||
// What a failed getblockhash / getblock means. Only "the daemon says it cannot read the block data"
|
||||
// (RPC_INTERNAL_ERROR -32603: "Can't read block from disk" / "Block not available (pruned data)",
|
||||
// external/dragonx/src/rpc/blockchain.cpp) is Missing. Every transport problem (timeout, busy gate
|
||||
// "RPC deferred", queue full, abort, not listening), warmup (-28), out of range (-8), not found (-5) and
|
||||
// anything unrecognised is Unknown, which stops the search.
|
||||
inline BlockProbe classifyBlockProbeError(const std::exception& e)
|
||||
{
|
||||
if (const auto* r = dynamic_cast<const rpc::RpcError*>(&e)) {
|
||||
if (r->code == -32603) return BlockProbe::Missing;
|
||||
const std::string m = r->what();
|
||||
if (m.find("from disk") != std::string::npos || m.find("pruned") != std::string::npos)
|
||||
return BlockProbe::Missing;
|
||||
return BlockProbe::Unknown;
|
||||
}
|
||||
return BlockProbe::Unknown; // RpcTransportError, runtime_error("Not connected"), json errors
|
||||
}
|
||||
|
||||
// call(method, params) -> json, throwing like RPCClient::call. Present = both calls answered.
|
||||
template <typename Call>
|
||||
BlockProbe probeBlockPresence(Call&& call, int height)
|
||||
{
|
||||
try {
|
||||
nlohmann::json h = call("getblockhash", nlohmann::json::array({height}));
|
||||
if (!h.is_string()) return BlockProbe::Unknown;
|
||||
nlohmann::json b = call("getblock", nlohmann::json::array({h.get<std::string>()}));
|
||||
return b.is_null() ? BlockProbe::Unknown : BlockProbe::Present;
|
||||
} catch (const std::exception& e) {
|
||||
return classifyBlockProbeError(e);
|
||||
}
|
||||
}
|
||||
|
||||
// Assumes block data is present from some base height upward (as the old search did). tip = a height known
|
||||
// to exist (the caller's synced tip). Probe order: 1 (a normal full-history node answers in ONE probe), then
|
||||
// the tip (the search's premise), then a binary search over (1, tip]. Any Unknown ends it with ok=false:
|
||||
// nothing is guessed.
|
||||
inline LowestBlockResult findLowestAvailableBlock(int tip, const std::function<BlockProbe(int)>& probe)
|
||||
{
|
||||
LowestBlockResult out;
|
||||
if (tip <= 1) return out;
|
||||
switch (probe(1)) {
|
||||
case BlockProbe::Present: out.ok = true; out.lowest = 1; out.fullHistory = true; return out;
|
||||
case BlockProbe::Unknown: return out;
|
||||
case BlockProbe::Missing: break;
|
||||
}
|
||||
if (probe(tip) != BlockProbe::Present) return out; // an unreadable tip is as unusable as Unknown
|
||||
int lo = 1, hi = tip; // invariant: lo Missing, hi Present
|
||||
while (hi - lo > 1) {
|
||||
const int mid = lo + (hi - lo) / 2;
|
||||
switch (probe(mid)) {
|
||||
case BlockProbe::Present: hi = mid; break;
|
||||
case BlockProbe::Missing: lo = mid; break;
|
||||
case BlockProbe::Unknown: return out;
|
||||
}
|
||||
}
|
||||
out.ok = true;
|
||||
out.lowest = hi;
|
||||
out.fullHistory = false;
|
||||
return out;
|
||||
}
|
||||
|
||||
} // namespace services
|
||||
} // namespace dragonx
|
||||
@@ -0,0 +1,63 @@
|
||||
// DragonX Wallet - ImGui Edition
|
||||
// Copyright 2024-2026 The Hush Developers
|
||||
// Released under the GPLv3
|
||||
|
||||
#pragma once
|
||||
|
||||
// Coalesces mining thread-count picks. The Mining tab reports every intermediate value of a gesture (a tile
|
||||
// drag crosses up to ~24 tiles; the -/+ stepper is clicked repeatedly); only the LAST one should reach the
|
||||
// settings file and the miner, once, when the input settles: on drag release, or kDebounceSec after the
|
||||
// last click. Pure: time is passed in; nothing here touches settings, the miner or ImGui.
|
||||
|
||||
namespace dragonx {
|
||||
namespace services {
|
||||
|
||||
class MiningThreadCommit {
|
||||
public:
|
||||
static constexpr double kDebounceSec = 0.5;
|
||||
|
||||
// The user picked `threads` at `now` (seconds, any monotonic clock). Latest wins.
|
||||
void select(int threads, double now)
|
||||
{
|
||||
target_ = threads;
|
||||
pending_ = true;
|
||||
due_ = now + kDebounceSec;
|
||||
}
|
||||
|
||||
// The drag that produced the pending pick ended: it is due now, not after the debounce.
|
||||
void release(double now)
|
||||
{
|
||||
if (pending_ && due_ > now) due_ = now;
|
||||
}
|
||||
|
||||
void cancel() { pending_ = false; }
|
||||
bool pending() const { return pending_; }
|
||||
|
||||
// Once per frame. `held`: a drag is still in progress (nothing commits until it ends). Returns true
|
||||
// exactly once per settled pick and sets `out` to it.
|
||||
bool take(double now, bool held, int& out)
|
||||
{
|
||||
if (!pending_ || held || now < due_) return false;
|
||||
pending_ = false;
|
||||
out = target_;
|
||||
return true;
|
||||
}
|
||||
|
||||
private:
|
||||
int target_ = 0;
|
||||
bool pending_ = false;
|
||||
double due_ = 0.0;
|
||||
};
|
||||
|
||||
// The pool thread count a wanted count is compared with: the newest start/retarget posted (it includes a
|
||||
// restart still queued on the mining-control thread), else the count the running xmrig was started with,
|
||||
// else what its HTTP API last reported (that lags a restart by seconds, so it is only a last resort).
|
||||
inline int effectivePoolThreads(int queuedTarget, int requested, int apiActive)
|
||||
{
|
||||
if (queuedTarget > 0) return queuedTarget;
|
||||
if (requested > 0) return requested;
|
||||
return apiActive;
|
||||
}
|
||||
|
||||
} // namespace services
|
||||
} // namespace dragonx
|
||||
File diff suppressed because it is too large
Load Diff
@@ -4,6 +4,7 @@
|
||||
#include "data/wallet_state.h"
|
||||
#include "refresh_scheduler.h"
|
||||
#include "rpc/rpc_worker.h"
|
||||
#include "rpc/rpc_failure.h"
|
||||
|
||||
#include <nlohmann/json.hpp>
|
||||
|
||||
@@ -34,6 +35,13 @@ public:
|
||||
virtual ~RefreshRpcGateway() = default;
|
||||
virtual nlohmann::json call(const std::string& method,
|
||||
const nlohmann::json& params) = 0;
|
||||
// A call allowed to outlast the default timeout (a whole-wallet sweep on a large wallet).
|
||||
// Gateways without a separate timeout just call().
|
||||
virtual nlohmann::json callLong(const std::string& method, const nlohmann::json& params, long timeoutSec)
|
||||
{
|
||||
(void)timeoutSec;
|
||||
return call(method, params);
|
||||
}
|
||||
};
|
||||
|
||||
enum class Job {
|
||||
@@ -83,12 +91,16 @@ public:
|
||||
bool ok = false;
|
||||
bool encrypted = false;
|
||||
std::int64_t unlockedUntil = 0;
|
||||
std::optional<int> txCount; // getwalletinfo's txcount (the wallet's transactions)
|
||||
};
|
||||
|
||||
struct WarmupPollResult {
|
||||
bool ready = false;
|
||||
ConnectionInfoResult info;
|
||||
std::string errorMessage;
|
||||
// Why the poll failed: a -28 warmup reply means the node is there; a refused/reset connection
|
||||
// means it's gone (the caller counts those toward its lost-connection check).
|
||||
rpc::RpcFailureKind errorKind = rpc::RpcFailureKind::None;
|
||||
};
|
||||
|
||||
struct ConnectionInitResult {
|
||||
@@ -112,6 +124,31 @@ public:
|
||||
std::optional<int> longestChain;
|
||||
std::optional<int> notarized;
|
||||
double balanceScanMs = 0.0; // wall-clock spent in z_gettotalbalance this refresh (0 if balance skipped)
|
||||
// How those calls failed (None when they succeeded or weren't made), so a caller can tell a busy
|
||||
// node (ReplyTimeout / QueueFull) from a gone one (NotListening) without reading what().
|
||||
rpc::RpcFailureKind blockchainErrKind = rpc::RpcFailureKind::None;
|
||||
rpc::RpcFailureKind balanceErrKind = rpc::RpcFailureKind::None;
|
||||
// The minconf=0 call reached the daemon (it may still have failed). False when the balance was
|
||||
// skipped (behind, busy chain read) or held back by the busy gate (Deferred) before it was sent.
|
||||
bool balanceAttempted = false;
|
||||
bool spendableQueried = false; // the minconf=1 call was made
|
||||
std::optional<std::int64_t> totalBalanceZat; // the minconf=0 "total" in zatoshis, exact
|
||||
// getmempoolinfo (CoreRefreshPlan::fetchMempool): a change signal that takes no cs_main.
|
||||
bool mempoolFetched = false;
|
||||
bool mempoolOk = false;
|
||||
std::int64_t mempoolTxs = 0;
|
||||
std::int64_t mempoolBytes = 0;
|
||||
};
|
||||
|
||||
// What one Core refresh fetches. The minconf=1 (spendable) z_gettotalbalance is a second whole-wallet
|
||||
// scan under cs_main; it only tells something the minconf=0 call doesn't while funds are unconfirmed
|
||||
// or pending, so it runs only when needSpendable or the exact total moved since prevTotalZat.
|
||||
struct CoreRefreshPlan {
|
||||
bool includeBalance = true;
|
||||
bool needSpendable = true;
|
||||
std::optional<std::int64_t> prevTotalZat;
|
||||
bool fetchMempool = false; // add getmempoolinfo after a successful getblockchaininfo
|
||||
bool evenIfBehind = false; // the session's first balance read: also while the node is behind
|
||||
};
|
||||
|
||||
struct MiningRefreshResult {
|
||||
@@ -153,6 +190,9 @@ public:
|
||||
double amount = 0.0; // note value, DRGX
|
||||
bool locked = false; // tied up by an in-flight send
|
||||
int confirmations = 0; // TRUE depth (rawconfirmations when present, else confirmations)
|
||||
std::string address; // the z-address holding it
|
||||
std::string txid; // the transaction that created it
|
||||
bool change = false; // change from the wallet's own spend (not a receive)
|
||||
};
|
||||
|
||||
struct AddressRefreshResult {
|
||||
@@ -168,6 +208,12 @@ public:
|
||||
// The wallet's unspent notes from this same z_listunspent scan, so a consumer (e.g. the chat
|
||||
// note-budget) can be fed for free instead of running its own duplicate z_listunspent.
|
||||
std::vector<UnspentNoteLite> unspentNotes;
|
||||
bool balancesOk = true; // false: a balance call failed, the per-address balances may be stale
|
||||
bool replyTimedOut = false; // a call hit the reply timeout (the daemon kept running it)
|
||||
// The lists only: no z_listunspent / listunspent, or a balance call failed (the pass then degrades to
|
||||
// list-only). Applying it carries the previous balances over.
|
||||
bool listOnly = false;
|
||||
bool nodeBusy = false; // a call failed because the node was busy (timeout, full queue, held back)
|
||||
};
|
||||
|
||||
struct AddressRefreshSnapshot {
|
||||
@@ -199,14 +245,31 @@ public:
|
||||
std::set<std::string> miningAddresses;
|
||||
std::unordered_map<std::string, int> shieldedScanHeights;
|
||||
std::size_t shieldedScanStartIndex = 0;
|
||||
std::size_t maxShieldedReceiveScans = 0;
|
||||
// How many blocks the tip may advance past an address's last scan before it counts as stale
|
||||
// and needs re-scanning. 0 = strict (must be scanned at the exact current tip). A small
|
||||
// tolerance lets a multi-cycle pass over many shielded addresses COMPLETE even though new
|
||||
// blocks arrive mid-pass — otherwise the "scanned at tip" bar moves every block and the scan
|
||||
// (and the transactions_dirty_ flag it drives) never finishes. It also naturally throttles
|
||||
// full rescans to roughly once per `tolerance` blocks.
|
||||
int shieldedScanTipTolerance = 0;
|
||||
std::size_t maxShieldedReceiveScans = 0; // receive scans this cycle; 0 = no limit
|
||||
// The pass's floor (services::ShieldedHistoryPass): an address scanned below it is scanned again.
|
||||
// -1: only never-scanned addresses (and priority ones) are.
|
||||
int shieldedScanFloorHeight = -1;
|
||||
// Scanned first, whatever the cursor, when never scanned or scanned below their floor: new
|
||||
// addresses, the chat reply address, addresses holding notes the history doesn't show yet.
|
||||
std::unordered_map<std::string, int> priorityScanFloors;
|
||||
bool skipShieldedScans = false; // a Light cycle (or a probe-less recent poll): no receive scans
|
||||
bool skipSentSweep = false;
|
||||
int sentScanFromHeight = -1; // the sweep's watermark (W); -1 = sweep the whole wallet
|
||||
// A loaded watermark owes an anchor verify before its first windowed sweep: getblockhash(A) must
|
||||
// still equal H_A (no reorg deeper than the margin). A mismatch forces a full sweep. While pending,
|
||||
// the window also uses the bigger first-after-load margin.
|
||||
bool sentVerifyPending = false;
|
||||
int sentVerifyAnchorHeight = -1; // A
|
||||
std::string sentVerifyAnchorHash; // H_A
|
||||
std::size_t transparentRowHint = 0; // listtransactions rows last time: sizes the first page
|
||||
// Same tip and nothing changed since the rows on screen were fetched: reuse their transparent rows
|
||||
// instead of paging listtransactions again.
|
||||
bool reuseTransparentRows = false;
|
||||
std::unordered_set<std::string> enrichBacklogTxids; // discovered earlier, not enriched yet
|
||||
// Run a windowed sent sweep even though the receive pass isn't complete (the caller rate-limits
|
||||
// this), so untracked sends (from the Console, or another wallet on the same seed) don't wait for a
|
||||
// whole pass. The whole-wallet sweep stays tied to a completed pass.
|
||||
bool allowMidPassSentSweep = false;
|
||||
};
|
||||
|
||||
struct TransactionRefreshResult {
|
||||
@@ -214,14 +277,36 @@ public:
|
||||
std::vector<chat::HushChatTransactionMetadata> hushChatMetadata;
|
||||
int blockHeight = -1;
|
||||
TransactionViewCache newViewTxEntries;
|
||||
// Tracked sends whose view result is final although it shows no outgoing output: the op only
|
||||
// paid the wallet itself (shield, merge/consolidate, auto-shield, chat note split, a transfer
|
||||
// between own addresses) and is now mined or conflicted. Applying the result stops tracking them.
|
||||
std::unordered_set<std::string> resolvedSendTxids;
|
||||
std::size_t nextShieldedScanStartIndex = 0;
|
||||
std::size_t shieldedAddressesScanned = 0;
|
||||
std::size_t shieldedAddressCount = 0;
|
||||
std::unordered_map<std::string, int> shieldedScanHeights;
|
||||
bool shieldedScanComplete = true;
|
||||
// Wall-clock spent in the history scan (z_listreceivedbyaddress — O(mapWallet), holds cs_main).
|
||||
// Lets the caller throttle the routine full rescan by its measured cost (txRefreshDue()).
|
||||
// Wall-clock spent in the whole cycle: listtransactions, the receive scans and the sent sweep each
|
||||
// hold cs_main. Lets the caller throttle wallet-wide scans by their measured cost (txRefreshDue()).
|
||||
double scanMs = 0.0;
|
||||
std::size_t shieldedScansSucceeded = 0; // shieldedAddressesScanned counts attempts
|
||||
double receiveScanOkMs = 0.0; // time in the receive scans that succeeded
|
||||
std::size_t transparentRowCount = 0; // rows the listtransactions chain returned
|
||||
bool transparentChainOk = false;
|
||||
bool transparentRowsReused = false;
|
||||
bool sentSweepRan = false;
|
||||
bool sentSweepOk = false;
|
||||
bool sentSweepFull = false;
|
||||
int sentSweepHeight = -1; // W: the tip a successful sweep covered
|
||||
int sentSweepAnchorHeight = -1; // A = W - margin, the bottom edge the window covered
|
||||
std::string sentSweepAnchorHash; // H_A = getblockhash(A), fetched after a successful sweep
|
||||
double sentSweepMs = 0.0; // time in the sweep (part of scanMs)
|
||||
rpc::RpcFailureKind sentSweepErrKind = rpc::RpcFailureKind::None;
|
||||
// Txids found but not enriched (over the per-cycle cap, or the view failed transiently): the caller
|
||||
// keeps them and feeds them back as enrichBacklogTxids.
|
||||
std::unordered_set<std::string> unenrichedTxids;
|
||||
bool rpcError = false; // a wallet-wide call failed
|
||||
bool replyTimedOut = false; // ...with a reply timeout (the daemon kept running it)
|
||||
};
|
||||
|
||||
struct OperationStatusPollResult {
|
||||
@@ -234,6 +319,16 @@ public:
|
||||
bool anySuccess = false;
|
||||
};
|
||||
|
||||
// Who started a tracked async operation, i.e. what its completion re-reads (App::markWalletMutated).
|
||||
// Absent from the App's origin map = User.
|
||||
enum class OperationOrigin : std::uint8_t { User, Auto, Chat };
|
||||
struct OperationBatchPlan {
|
||||
bool userSucceeded = false; // >= 1 User op succeeded: re-read the wallet, force the balance
|
||||
bool autoSucceeded = false; // >= 1 Auto op (client auto-shield) succeeded: re-read, unforced
|
||||
bool walletTerminal = false; // >= 1 non-chat op finished (any outcome) or went stale
|
||||
bool chatTerminal = false; // >= 1 chat op finished or went stale: refresh the chat note view
|
||||
};
|
||||
|
||||
struct TransactionCacheUpdate {
|
||||
TransactionViewCache& viewTxCache;
|
||||
std::unordered_set<std::string>& sendTxids;
|
||||
@@ -246,6 +341,10 @@ public:
|
||||
static Intervals intervalsForPage(ui::NavPage page) { return RefreshScheduler::intervalsForPage(page); }
|
||||
|
||||
static ConnectionInfoResult parseConnectionInfoResult(const nlohmann::json& info);
|
||||
// The node knows the chain tip: longestchain stays 0 after a start until its first UpdateTip.
|
||||
static bool connectionInfoTipKnown(const ConnectionInfoResult& info);
|
||||
// The tip is known and the node is more than 2 blocks behind it (the sync tolerance).
|
||||
static bool connectionInfoShowsBehind(const ConnectionInfoResult& info);
|
||||
static WalletEncryptionResult parseWalletEncryptionResult(const nlohmann::json& walletInfo);
|
||||
static WarmupPollResult collectWarmupPollResult(RefreshRpcGateway& rpc);
|
||||
static ConnectionInitResult collectConnectionInitResult(
|
||||
@@ -259,7 +358,24 @@ public:
|
||||
// includeBalance=false skips z_gettotalbalance (which takes the wallet lock + cs_main) and only
|
||||
// fetches getblockchaininfo — used while syncing, where the balance is incomplete anyway and the
|
||||
// wallet should minimise lock contention with block connection.
|
||||
static CoreRefreshResult collectCoreRefreshResult(RefreshRpcGateway& rpc, bool includeBalance = true);
|
||||
static CoreRefreshResult collectCoreRefreshResult(RefreshRpcGateway& rpc, const CoreRefreshPlan& plan);
|
||||
static CoreRefreshResult collectCoreRefreshResult(RefreshRpcGateway& rpc, bool includeBalance = true)
|
||||
{
|
||||
CoreRefreshPlan plan;
|
||||
plan.includeBalance = includeBalance;
|
||||
return collectCoreRefreshResult(rpc, plan);
|
||||
}
|
||||
// An amount as the daemon formats it ("[-]digits[.up to 8 digits]") in zatoshis; nullopt for anything
|
||||
// else or anything that would overflow. Exact, so "did the total move" never trips on rounding.
|
||||
static std::optional<std::int64_t> parseMoneyZat(const std::string& text);
|
||||
// Whether the next balance poll needs the minconf=1 (spendable) call whatever the total does: never
|
||||
// fetched, unconfirmed or pending funds, or a forced refresh after the user's own send.
|
||||
static bool spendableBalanceNeeded(const WalletState& state, bool pendingSendWork, bool forced);
|
||||
// The total the next poll compares against (CoreRefreshPlan::prevTotalZat). None after a poll whose
|
||||
// minconf=1 call was made but brought back no total (timeout, held back, error): that left spendable
|
||||
// degraded to display, so the next poll must count the total as moved and measure it again.
|
||||
static std::optional<std::int64_t> nextTotalBaseline(const CoreRefreshResult& result,
|
||||
std::optional<std::int64_t> previous);
|
||||
static MiningRefreshResult parseMiningRefreshResult(const nlohmann::json& miningInfo,
|
||||
bool miningOk,
|
||||
const nlohmann::json& localHashrate,
|
||||
@@ -290,9 +406,13 @@ public:
|
||||
static void applyTransparentBalancesFromUnspent(std::vector<AddressInfo>& addresses,
|
||||
const nlohmann::json& unspent);
|
||||
static AddressRefreshSnapshot buildAddressRefreshSnapshot(const WalletState& state);
|
||||
// listOnly: enumerate the addresses only (cheap), without the wallet-wide balance scans; for while the
|
||||
// scans are held (the node catching up, the connect gate). Both lists are enumerated first, and the
|
||||
// balances are scanned only when both enumerations succeeded (a failed pass is discarded whole).
|
||||
static AddressRefreshResult collectAddressRefreshResult(
|
||||
RefreshRpcGateway& rpc,
|
||||
const AddressRefreshSnapshot& snapshot = {});
|
||||
const AddressRefreshSnapshot& snapshot = {},
|
||||
bool listOnly = false);
|
||||
static TransactionRefreshSnapshot buildTransactionRefreshSnapshot(const WalletState& state,
|
||||
const TransactionViewCache& viewTxCache,
|
||||
const std::unordered_set<std::string>& sendTxids);
|
||||
@@ -310,6 +430,12 @@ public:
|
||||
static void appendViewTransactionOutputs(std::vector<TransactionInfo>& transactions,
|
||||
const std::string& txid,
|
||||
const TransactionViewCacheEntry& entry);
|
||||
// Build "send" rows from a z_listsentbyaddress entry's sends.transparentSends[] — the transparent
|
||||
// (z->t deshield) outgoing outputs that z_viewtransaction does not surface. Deduped by
|
||||
// txid+address+amount against rows already present (e.g. listtransactions-derived t->t sends).
|
||||
static void appendSentTransparentOutputs(std::vector<TransactionInfo>& transactions,
|
||||
const std::string& txid,
|
||||
const nlohmann::json& sentEntry);
|
||||
static void sortTransactionsNewestFirst(std::vector<TransactionInfo>& transactions);
|
||||
static TransactionRefreshResult collectTransactionRefreshResult(RefreshRpcGateway& rpc,
|
||||
const TransactionRefreshSnapshot& snapshot,
|
||||
@@ -322,6 +448,17 @@ public:
|
||||
int pageSize = 100);
|
||||
static OperationStatusPollResult parseOperationStatusPoll(const nlohmann::json& result,
|
||||
const std::vector<std::string>& requestedOpids);
|
||||
// Params for z_getoperationstatus/result filtered to these opids: ONE argument, an array of ids, so
|
||||
// [[id, ...]]. nullopt when no non-empty id remains: an empty filter means every op the node has
|
||||
// retained. Built explicitly, because a one-element braced list of a json ({ids}) collapses to the
|
||||
// inner array, which the daemon rejects ("JSON value is not an array as expected").
|
||||
static std::optional<nlohmann::json> buildOperationFilterParams(const std::vector<std::string>& opids);
|
||||
// Classify a poll's terminal ops by origin (absent from the map = User).
|
||||
static OperationBatchPlan planOperationBatch(const OperationStatusPollResult& parsed,
|
||||
const std::unordered_map<std::string, OperationOrigin>& origins);
|
||||
// Any pending id is a User op (absent from the map = User).
|
||||
static bool anyUserOperationPending(const std::vector<std::string>& pendingOpids,
|
||||
const std::unordered_map<std::string, OperationOrigin>& origins);
|
||||
|
||||
struct DaemonRescanScan {
|
||||
bool foundRescan = false;
|
||||
@@ -375,6 +512,8 @@ public:
|
||||
void reset(Timer timer) { scheduler_.reset(timer); }
|
||||
void markDue(Timer timer) { scheduler_.markDue(timer); }
|
||||
void setTimer(Timer timer, float seconds) { scheduler_.setTimer(timer, seconds); }
|
||||
void deferFor(Timer timer, float seconds) { scheduler_.deferFor(timer, seconds); }
|
||||
void retrySoon(Timer timer, float seconds) { scheduler_.retrySoon(timer, seconds); }
|
||||
float timer(Timer timer) const { return scheduler_.timer(timer); }
|
||||
float interval(Timer timer) const { return scheduler_.interval(timer); }
|
||||
void markImmediateRefresh() { scheduler_.markImmediateRefresh(); }
|
||||
|
||||
@@ -0,0 +1,649 @@
|
||||
// DragonX Wallet - ImGui Edition
|
||||
// Copyright 2024-2026 The Hush Developers
|
||||
// Released under the GPLv3
|
||||
|
||||
#include "note_consolidation.h"
|
||||
|
||||
#include <algorithm>
|
||||
#include <cctype>
|
||||
#include <climits>
|
||||
#include <map>
|
||||
#include <set>
|
||||
|
||||
namespace dragonx {
|
||||
namespace services {
|
||||
|
||||
std::vector<NoteGroup> groupSpendableNotes(const nlohmann::json& zListUnspent)
|
||||
{
|
||||
std::map<std::string, NoteGroup> byAddress;
|
||||
if (zListUnspent.is_array()) {
|
||||
for (const auto& n : zListUnspent) {
|
||||
if (!n.is_object()) continue;
|
||||
const std::string address = n.value("address", std::string());
|
||||
if (address.empty()) continue;
|
||||
if (n.value("confirmations", 0) < 1) continue; // z_mergetoaddress spends confirmed notes only
|
||||
if (!n.value("spendable", true)) continue; // watch-only: no key to spend it
|
||||
if (noteIsLocked(n)) continue; // held by an operation in flight
|
||||
NoteGroup& g = byAddress[address];
|
||||
g.address = address;
|
||||
++g.notes;
|
||||
g.amount += n.value("amount", 0.0);
|
||||
}
|
||||
}
|
||||
std::vector<NoteGroup> out;
|
||||
out.reserve(byAddress.size());
|
||||
for (auto& kv : byAddress) out.push_back(std::move(kv.second));
|
||||
std::sort(out.begin(), out.end(), [](const NoteGroup& a, const NoteGroup& b) {
|
||||
if (a.notes != b.notes) return a.notes > b.notes;
|
||||
return a.address < b.address;
|
||||
});
|
||||
return out;
|
||||
}
|
||||
|
||||
bool noteIsLocked(const nlohmann::json& zListUnspentEntry)
|
||||
{
|
||||
if (!zListUnspentEntry.is_object()) return false;
|
||||
const auto it = zListUnspentEntry.find("locked");
|
||||
return it != zListUnspentEntry.end() && it->is_boolean() && it->get<bool>();
|
||||
}
|
||||
|
||||
bool worthConsolidating(const NoteGroup& group, double fee)
|
||||
{
|
||||
// The daemon rejects a merge whose fee is more than what it nets (merged value < 2 x fee).
|
||||
return group.notes >= 2 && group.amount > 2.0 * fee;
|
||||
}
|
||||
|
||||
int estimateBatches(int notes, int limit)
|
||||
{
|
||||
if (notes < 2) return 0;
|
||||
if (limit < 2) limit = 2;
|
||||
return notes / limit + ((notes % limit) >= 2 ? 1 : 0);
|
||||
}
|
||||
|
||||
bool recordMergeResult(AddressRun& run, const MergeCallResult& r, int maxBatches)
|
||||
{
|
||||
if (!r.error.empty()) {
|
||||
run.error = r.error; // earlier batches (if any) are already submitted and stand
|
||||
return false;
|
||||
}
|
||||
run.opids.push_back(r.opid);
|
||||
run.notesMerged += r.merging;
|
||||
run.remaining = r.remaining;
|
||||
// One leftover note can't be merged into its own address.
|
||||
return r.remaining >= 2 && static_cast<int>(run.opids.size()) < maxBatches;
|
||||
}
|
||||
|
||||
AddressRun consolidateInPlace(const std::string& address, const MergeCall& call, int maxBatches)
|
||||
{
|
||||
AddressRun run;
|
||||
run.address = address;
|
||||
if (maxBatches <= 0) return run;
|
||||
while (recordMergeResult(run, call(address), maxBatches)) {}
|
||||
return run;
|
||||
}
|
||||
|
||||
namespace {
|
||||
std::string lower(std::string s)
|
||||
{
|
||||
for (auto& c : s) c = static_cast<char>(std::tolower(static_cast<unsigned char>(c)));
|
||||
return s;
|
||||
}
|
||||
bool has(const std::string& haystack, const char* needle) { return haystack.find(needle) != std::string::npos; }
|
||||
|
||||
int clampedCount(const nlohmann::json& obj, const char* key)
|
||||
{
|
||||
const auto it = obj.find(key);
|
||||
if (it == obj.end()) return 0;
|
||||
if (it->is_number_unsigned()) {
|
||||
const auto v = it->get<std::uint64_t>();
|
||||
return v > static_cast<std::uint64_t>(INT_MAX) ? INT_MAX : static_cast<int>(v);
|
||||
}
|
||||
if (it->is_number_integer()) {
|
||||
const auto v = it->get<std::int64_t>();
|
||||
return v < 0 ? 0 : (v > INT_MAX ? INT_MAX : static_cast<int>(v));
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
std::string stringField(const nlohmann::json& obj, const char* key)
|
||||
{
|
||||
const auto it = obj.find(key);
|
||||
return (it != obj.end() && it->is_string()) ? it->get<std::string>() : std::string();
|
||||
}
|
||||
} // namespace
|
||||
|
||||
void readMergeCallResult(const nlohmann::json& res, MergeCallResult& out)
|
||||
{
|
||||
out = MergeCallResult{};
|
||||
if (res.is_object()) {
|
||||
out.opid = stringField(res, "opid");
|
||||
out.merging = clampedCount(res, "mergingNotes");
|
||||
out.remaining = clampedCount(res, "remainingNotes");
|
||||
}
|
||||
if (out.opid.empty()) {
|
||||
out = MergeCallResult{};
|
||||
out.error = "no operation id";
|
||||
}
|
||||
}
|
||||
|
||||
OpState opStateFromStatus(const std::string& status)
|
||||
{
|
||||
if (status == "queued") return OpState::Queued;
|
||||
if (status == "executing") return OpState::Executing;
|
||||
if (status == "success") return OpState::Success;
|
||||
if (status == "failed" || status == "cancelled" || status == "canceled") return OpState::Failed;
|
||||
return OpState::Unknown;
|
||||
}
|
||||
|
||||
std::vector<OpReport> parseOpReports(const nlohmann::json& statusArray, const std::set<std::string>& wanted)
|
||||
{
|
||||
std::vector<OpReport> out;
|
||||
if (!statusArray.is_array()) return out;
|
||||
for (const auto& op : statusArray) {
|
||||
if (!op.is_object()) continue;
|
||||
OpReport rep;
|
||||
rep.id = stringField(op, "id");
|
||||
if (rep.id.empty()) continue;
|
||||
if (!wanted.empty() && !wanted.count(rep.id)) continue;
|
||||
rep.status = stringField(op, "status");
|
||||
const auto err = op.find("error");
|
||||
if (err != op.end() && err->is_object()) rep.error = stringField(*err, "message");
|
||||
const auto res = op.find("result");
|
||||
if (res != op.end() && res->is_object()) rep.txid = stringField(*res, "txid");
|
||||
out.push_back(std::move(rep));
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
MergeFailure classifyMergeFailure(rpc::RpcFailureKind kind, const std::string& message)
|
||||
{
|
||||
using K = rpc::RpcFailureKind;
|
||||
switch (kind) {
|
||||
case K::None: return MergeFailure::Uncertain; // answered without an opid: it may have queued the merge
|
||||
case K::Deferred:
|
||||
case K::NotListening:
|
||||
case K::ConnectTimeout:
|
||||
case K::Warmup:
|
||||
case K::QueueFull: return MergeFailure::NotSent; // turned away before z_mergetoaddress ran
|
||||
case K::ReplyTimeout: // the daemon keeps running it and may queue the merge
|
||||
case K::Aborted: // our abort: if sent it may have queued; if not, the session is ending (result stale)
|
||||
return MergeFailure::Uncertain;
|
||||
case K::ShuttingDown:
|
||||
case K::Unhandled: return MergeFailure::Transient; // the HTTP layer dropped it
|
||||
case K::Auth: return MergeFailure::Fatal;
|
||||
default: break; // AppError, Other: by message
|
||||
}
|
||||
const std::string m = lower(message);
|
||||
if (has(m, "not connected")) return MergeFailure::NotSent; // RPCClient throws before sending
|
||||
if (has(m, "still syncing") || has(m, "fully synced")) return MergeFailure::Syncing; // THROW_IF_SYNCING (cheap)
|
||||
if (has(m, "spending key") || has(m, "wallet passphrase") || has(m, "walletpassphrase")) return MergeFailure::NeedsUnlock;
|
||||
if (has(m, "queue is shutting down")) return MergeFailure::Transient; // "...; the operation was not queued"
|
||||
if (has(m, "could not find any funds") || has(m, "already merged") || has(m, "insufficient funds") ||
|
||||
has(m, "greater than the net amount"))
|
||||
return MergeFailure::NothingLeft;
|
||||
return MergeFailure::Fatal;
|
||||
}
|
||||
|
||||
rpc::RpcFailureKind mergeCallFailureKind(const std::exception& e)
|
||||
{
|
||||
const rpc::RpcFailureKind kind = rpc::rpcFailureKindOf(e);
|
||||
if (kind == rpc::RpcFailureKind::Other) {
|
||||
if (const auto* t = dynamic_cast<const rpc::RpcTransportError*>(&e); t && t->requestSent && t->httpStatus == 0)
|
||||
return rpc::RpcFailureKind::ReplyTimeout;
|
||||
}
|
||||
return kind;
|
||||
}
|
||||
|
||||
RunVerdict runVerdict(const RunSummary& s)
|
||||
{
|
||||
switch (s.end) {
|
||||
case RunEnd::None: return RunVerdict::None;
|
||||
case RunEnd::Stopped: return RunVerdict::Stopped;
|
||||
case RunEnd::Halted: return RunVerdict::Halted;
|
||||
case RunEnd::Aborted: return RunVerdict::Aborted;
|
||||
case RunEnd::Completed: break;
|
||||
}
|
||||
if (s.batchesSubmitted == 0 && s.addressesUnconfirmed == 0) return RunVerdict::NothingSubmitted;
|
||||
if (s.addressesFailed > 0) return RunVerdict::SomeFailed;
|
||||
if (s.addressesUnconfirmed > 0) return RunVerdict::Unconfirmed;
|
||||
return RunVerdict::Clean;
|
||||
}
|
||||
|
||||
const char* runWaitKey(RunWait wait)
|
||||
{
|
||||
switch (wait) {
|
||||
case RunWait::Node:
|
||||
case RunWait::Busy: return "merge_waiting_node";
|
||||
case RunWait::Syncing: return "merge_run_wait_sync";
|
||||
case RunWait::Locked: return "merge_wait_locked";
|
||||
case RunWait::UserSend: return "merge_run_wait_send";
|
||||
case RunWait::Scan:
|
||||
case RunWait::Batch: return "merge_run_wait_batch";
|
||||
case RunWait::None:
|
||||
case RunWait::InFlight: break;
|
||||
}
|
||||
return nullptr;
|
||||
}
|
||||
|
||||
bool ConsolidationRun::start(const std::vector<NoteGroup>& groups, RunConfig cfg, double now)
|
||||
{
|
||||
if (phase_ == Phase::Running) return false;
|
||||
cfg.limit = std::max(2, cfg.limit);
|
||||
cfg.maxBatches = std::max(1, cfg.maxBatches);
|
||||
std::vector<RunAddress> addrs;
|
||||
for (const auto& g : groups) {
|
||||
if (g.address.empty() || !worthConsolidating(g, cfg.fee)) continue;
|
||||
RunAddress a;
|
||||
a.group = g;
|
||||
a.run.address = g.address;
|
||||
addrs.push_back(std::move(a));
|
||||
}
|
||||
if (addrs.empty()) return false;
|
||||
const std::uint64_t gen = gen_ + 1;
|
||||
*this = ConsolidationRun();
|
||||
gen_ = gen;
|
||||
cfg_ = cfg;
|
||||
addrs_ = std::move(addrs);
|
||||
phase_ = Phase::Running;
|
||||
nextCallAt_ = nextPollAt_ = now;
|
||||
lastPumpAt_ = now;
|
||||
return true;
|
||||
}
|
||||
|
||||
void ConsolidationRun::requestStop()
|
||||
{
|
||||
if (phase_ == Phase::Running) stopping_ = true;
|
||||
}
|
||||
|
||||
void ConsolidationRun::dismiss()
|
||||
{
|
||||
if (phase_ == Phase::Running) return;
|
||||
const std::uint64_t gen = gen_; // a stale result can never match a later run
|
||||
*this = ConsolidationRun();
|
||||
gen_ = gen;
|
||||
}
|
||||
|
||||
bool ConsolidationRun::abort()
|
||||
{
|
||||
if (phase_ != Phase::Running) return false;
|
||||
++gen_;
|
||||
if (callInFlight_ && callAddr_ >= 0 && callAddr_ < static_cast<int>(addrs_.size())) {
|
||||
auto& a = addrs_[callAddr_];
|
||||
a.done = true;
|
||||
worsen(a, AddrOutcome::Uncertain);
|
||||
}
|
||||
callInFlight_ = pollInFlight_ = false;
|
||||
for (auto& b : batches_) {
|
||||
if (!opStateTerminal(b.state)) applyOpState(b, OpState::Lost, nullptr); // "could not be confirmed"
|
||||
}
|
||||
phase_ = Phase::Finished;
|
||||
end_ = RunEnd::Aborted;
|
||||
lastWait_ = RunWait::None;
|
||||
return true;
|
||||
}
|
||||
|
||||
void ConsolidationRun::endAddress(RunAddress& a, AddrOutcome o, const std::string& msg)
|
||||
{
|
||||
a.done = true;
|
||||
worsen(a, o);
|
||||
if (a.run.error.empty()) a.run.error = msg;
|
||||
if (a.run.opids.empty() && ++deadStreak_ >= kRunMaxFailStreak) halt(msg);
|
||||
}
|
||||
|
||||
void ConsolidationRun::applyOpState(RunBatch& b, OpState st, const OpReport* rep)
|
||||
{
|
||||
b.state = st;
|
||||
if (b.addrIdx < 0 || b.addrIdx >= static_cast<int>(addrs_.size())) return;
|
||||
RunAddress& a = addrs_[b.addrIdx];
|
||||
switch (st) {
|
||||
case OpState::Success:
|
||||
if (rep) b.txid = rep->txid;
|
||||
++a.opsDone;
|
||||
opFailStreak_ = 0;
|
||||
break;
|
||||
case OpState::Failed:
|
||||
if (rep) b.error = rep->error;
|
||||
if (a.failure.empty()) a.failure = b.error;
|
||||
a.done = true;
|
||||
worsen(a, AddrOutcome::Failed);
|
||||
if (++opFailStreak_ >= kRunMaxFailStreak) halt(b.error);
|
||||
break;
|
||||
case OpState::Lost:
|
||||
a.done = true;
|
||||
worsen(a, AddrOutcome::Uncertain);
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
RunStep ConsolidationRun::next(const RunEnv& env, double now)
|
||||
{
|
||||
if (phase_ != Phase::Running) return {};
|
||||
if (callInFlight_ && now - callPostedAt_ > kRunWatchdogSec) { // its result never came: a late reply is an orphan
|
||||
callInFlight_ = false;
|
||||
++gen_;
|
||||
if (callAddr_ >= 0 && callAddr_ < static_cast<int>(addrs_.size()))
|
||||
endAddress(addrs_[callAddr_], AddrOutcome::Uncertain, "");
|
||||
nextCallAt_ = std::max(nextCallAt_, now + kRunMaxGapSec);
|
||||
}
|
||||
if (pollInFlight_ && now - pollPostedAt_ > kRunWatchdogSec) {
|
||||
pollInFlight_ = false;
|
||||
++gen_;
|
||||
}
|
||||
// An op the daemon never reports done is presumed lost after an hour in which it could have been polled
|
||||
// (not while the node was gone or busy: it may simply not have been asked), or after 4 h whatever happened.
|
||||
const double dt = std::max(0.0, now - lastPumpAt_);
|
||||
lastPumpAt_ = now;
|
||||
const bool pollable = env.nodeUsable && !env.busy;
|
||||
for (auto& b : batches_) {
|
||||
if (opStateTerminal(b.state)) continue;
|
||||
if (pollable) b.pollableSec += std::min(dt, std::max(0.0, now - b.acceptedAt));
|
||||
if (b.pollableSec > kRunOpMaxSec || now - b.acceptedAt > kRunOpHardMaxSec) applyOpState(b, OpState::Lost, nullptr);
|
||||
}
|
||||
while (cur_ < addrs_.size() && addrs_[cur_].done) ++cur_;
|
||||
const bool moreCalls = !stopping_ && !halted_ && cur_ < addrs_.size();
|
||||
const int outstanding = static_cast<int>(std::count_if(batches_.begin(), batches_.end(),
|
||||
[](const RunBatch& b) { return !opStateTerminal(b.state); }));
|
||||
auto wait = [&](RunWait w) {
|
||||
lastWait_ = w;
|
||||
return RunStep{};
|
||||
};
|
||||
if (!callInFlight_ && !pollInFlight_ && !moreCalls && outstanding == 0) {
|
||||
phase_ = Phase::Finished;
|
||||
end_ = halted_ ? RunEnd::Halted : (stopping_ && cur_ < addrs_.size()) ? RunEnd::Stopped : RunEnd::Completed;
|
||||
lastWait_ = RunWait::None;
|
||||
return {RunAction::Finish, -1}; // exactly once
|
||||
}
|
||||
if (callInFlight_ || pollInFlight_) return wait(RunWait::InFlight); // one run RPC at a time
|
||||
if (!env.nodeUsable) return wait(RunWait::Node); // blocks polls too
|
||||
if (env.busy) return wait(RunWait::Busy); // z_getoperationstatus needs cs_main
|
||||
if (outstanding > 0 && now >= nextPollAt_) {
|
||||
lastWait_ = RunWait::None;
|
||||
return {RunAction::Poll, -1};
|
||||
}
|
||||
if (!moreCalls) return wait(RunWait::None); // waiting for submitted batches
|
||||
if (!env.callsAllowed || now < syncHoldUntil_) return wait(RunWait::Syncing);
|
||||
if (env.locked || now < unlockHoldUntil_) return wait(RunWait::Locked);
|
||||
if (env.userSendBusy) return wait(RunWait::UserSend);
|
||||
if (now < nextCallAt_) return wait(RunWait::Batch);
|
||||
if (!batches_.empty() && (batches_.back().state == OpState::Unknown || batches_.back().state == OpState::Queued))
|
||||
return wait(RunWait::Batch); // at most 2 unfinished ops, at most 2 x limit notes locked
|
||||
if (env.scanBusy) return wait(RunWait::Scan); // another GUI wallet scan is in the daemon
|
||||
lastWait_ = RunWait::None;
|
||||
return {RunAction::Call, static_cast<int>(cur_)};
|
||||
}
|
||||
|
||||
void ConsolidationRun::markCallPosted(int addrIdx, double now)
|
||||
{
|
||||
if (phase_ != Phase::Running || addrIdx < 0 || addrIdx >= static_cast<int>(addrs_.size())) return;
|
||||
callInFlight_ = true;
|
||||
callAddr_ = addrIdx;
|
||||
callPostedAt_ = now;
|
||||
worsen(addrs_[addrIdx], AddrOutcome::Running);
|
||||
}
|
||||
|
||||
void ConsolidationRun::markPollPosted(double now)
|
||||
{
|
||||
if (phase_ != Phase::Running) return;
|
||||
pollInFlight_ = true;
|
||||
pollPostedAt_ = now;
|
||||
}
|
||||
|
||||
std::string ConsolidationRun::onCallResult(std::uint64_t gen, int addrIdx, const MergeCallResult& r,
|
||||
rpc::RpcFailureKind kind, double callSec, double now)
|
||||
{
|
||||
const bool accepted = r.error.empty() && !r.opid.empty();
|
||||
if (gen != gen_ || phase_ != Phase::Running || !callInFlight_ || addrIdx != callAddr_)
|
||||
return accepted ? r.opid : std::string();
|
||||
callInFlight_ = false;
|
||||
const double gapAt = now + std::clamp(callSec, kRunMinGapSec, kRunMaxGapSec);
|
||||
nextCallAt_ = gapAt;
|
||||
RunAddress& a = addrs_[addrIdx];
|
||||
if (accepted) {
|
||||
a.transient = 0;
|
||||
deadStreak_ = 0;
|
||||
const bool more = recordMergeResult(a.run, r, cfg_.maxBatches);
|
||||
RunBatch b;
|
||||
b.addrIdx = addrIdx;
|
||||
b.opid = r.opid;
|
||||
b.merging = r.merging;
|
||||
b.acceptedAt = now;
|
||||
batches_.push_back(std::move(b));
|
||||
nextPollAt_ = now; // learn its state before the next call
|
||||
if (!more) {
|
||||
a.done = true;
|
||||
worsen(a, AddrOutcome::Done);
|
||||
}
|
||||
return {};
|
||||
}
|
||||
const std::string msg = r.error.empty() ? std::string("no operation id") : r.error;
|
||||
switch (classifyMergeFailure(kind, msg)) {
|
||||
case MergeFailure::NotSent: // the same batch again, no retry budget
|
||||
nextCallAt_ = std::max(gapAt, now + kRunNotSentRetrySec);
|
||||
break;
|
||||
case MergeFailure::Syncing: // no retry budget
|
||||
syncHoldUntil_ = now + kRunSyncHoldSec;
|
||||
break;
|
||||
case MergeFailure::NeedsUnlock:
|
||||
if (++a.unlockRefusals >= kRunMaxUnlockRefusals) {
|
||||
endAddress(a, AddrOutcome::Failed, msg);
|
||||
} else {
|
||||
unlockHoldUntil_ = now + kRunUnlockHoldSec;
|
||||
lockResync_ = true;
|
||||
}
|
||||
break;
|
||||
case MergeFailure::NothingLeft:
|
||||
if (a.run.opids.empty()) {
|
||||
endAddress(a, AddrOutcome::Failed, msg);
|
||||
} else {
|
||||
a.done = true;
|
||||
worsen(a, AddrOutcome::Done);
|
||||
}
|
||||
break;
|
||||
case MergeFailure::Uncertain: // never resent
|
||||
endAddress(a, AddrOutcome::Uncertain, msg);
|
||||
break;
|
||||
case MergeFailure::Transient:
|
||||
if (++a.transient > kRunMaxTransientRetries) {
|
||||
endAddress(a, AddrOutcome::Failed, msg);
|
||||
} else {
|
||||
nextCallAt_ = std::max(gapAt, now + std::min(60.0, 5.0 * static_cast<double>(1 << (a.transient - 1))));
|
||||
}
|
||||
break;
|
||||
case MergeFailure::Fatal:
|
||||
endAddress(a, AddrOutcome::Failed, msg);
|
||||
break;
|
||||
}
|
||||
return {};
|
||||
}
|
||||
|
||||
void ConsolidationRun::onPollResult(std::uint64_t gen, bool ok, const std::vector<OpReport>& reports, double now)
|
||||
{
|
||||
if (gen != gen_ || phase_ != Phase::Running || !pollInFlight_) return;
|
||||
pollInFlight_ = false;
|
||||
nextPollAt_ = now + kRunPollIntervalSec;
|
||||
if (!ok) return;
|
||||
// No call can be in flight during a poll, so the non-terminal batches now are exactly the ids it asked about.
|
||||
std::map<std::string, const OpReport*> byId;
|
||||
for (const auto& rep : reports) byId[rep.id] = &rep;
|
||||
for (auto& b : batches_) {
|
||||
if (opStateTerminal(b.state)) continue;
|
||||
const auto it = byId.find(b.opid);
|
||||
if (it == byId.end()) {
|
||||
if (++b.misses >= kRunMaxPollMisses) applyOpState(b, OpState::Lost, nullptr);
|
||||
continue;
|
||||
}
|
||||
b.misses = 0;
|
||||
const OpState st = opStateFromStatus(it->second->status);
|
||||
if (st != OpState::Unknown) applyOpState(b, st, it->second);
|
||||
}
|
||||
}
|
||||
|
||||
bool ConsolidationRun::consumeLockResyncRequest()
|
||||
{
|
||||
const bool r = lockResync_;
|
||||
lockResync_ = false;
|
||||
return r;
|
||||
}
|
||||
|
||||
std::vector<std::string> ConsolidationRun::outstandingOpids() const
|
||||
{
|
||||
std::vector<std::string> ids;
|
||||
for (const auto& b : batches_) {
|
||||
if (!opStateTerminal(b.state)) ids.push_back(b.opid);
|
||||
}
|
||||
return ids;
|
||||
}
|
||||
|
||||
RunProgress ConsolidationRun::progress() const
|
||||
{
|
||||
RunProgress p;
|
||||
p.addresses = static_cast<int>(addrs_.size());
|
||||
for (const auto& a : addrs_) {
|
||||
if (a.done) ++p.addressesDone;
|
||||
const int submitted = static_cast<int>(a.run.opids.size());
|
||||
p.batchesEstimated += a.done ? submitted
|
||||
: std::max(std::min(estimateBatches(a.group.notes, cfg_.limit), cfg_.maxBatches), submitted);
|
||||
}
|
||||
p.batchesSubmitted = static_cast<int>(batches_.size());
|
||||
for (const auto& b : batches_) {
|
||||
if (opStateTerminal(b.state)) ++p.batchesDone;
|
||||
if (b.state == OpState::Success) p.notesMerged += b.merging;
|
||||
}
|
||||
p.stopping = stopping_;
|
||||
p.wait = lastWait_;
|
||||
return p;
|
||||
}
|
||||
|
||||
RunSummary ConsolidationRun::summary() const
|
||||
{
|
||||
RunSummary s;
|
||||
s.end = end_;
|
||||
s.addresses = static_cast<int>(addrs_.size());
|
||||
for (const auto& a : addrs_) {
|
||||
if (a.outcome == AddrOutcome::Failed) ++s.addressesFailed;
|
||||
if (a.outcome == AddrOutcome::Uncertain) ++s.addressesUnconfirmed;
|
||||
}
|
||||
s.batchesSubmitted = static_cast<int>(batches_.size());
|
||||
for (const auto& b : batches_) {
|
||||
if (b.state == OpState::Success) {
|
||||
++s.batchesSucceeded;
|
||||
s.notesMerged += b.merging;
|
||||
}
|
||||
}
|
||||
s.firstError = haltError_;
|
||||
if (s.firstError.empty()) {
|
||||
for (const auto& a : addrs_) {
|
||||
if (a.outcome != AddrOutcome::Failed) continue;
|
||||
s.firstError = !a.failure.empty() ? a.failure : a.run.error;
|
||||
if (!s.firstError.empty()) break;
|
||||
}
|
||||
}
|
||||
return s;
|
||||
}
|
||||
|
||||
ScopeWant scopeScanWant(const ScopeScan& scan, const ScopeInputs& in)
|
||||
{
|
||||
if (!in.mergeMode || scan.phase == ScopePhase::Loaded) return ScopeWant::Nothing;
|
||||
if (scan.inFlight || in.busyWithOp) return ScopeWant::Nothing;
|
||||
if (scan.phase == ScopePhase::Failed && !scan.autoRetry) return ScopeWant::Nothing; // waits for Retry
|
||||
if (in.now < scan.retryAt) return ScopeWant::WaitBackoff;
|
||||
if (!in.nodeReady) return ScopeWant::WaitNode;
|
||||
return ScopeWant::Post;
|
||||
}
|
||||
|
||||
std::uint32_t scopeScanBegin(ScopeScan& scan, double now)
|
||||
{
|
||||
scan.phase = ScopePhase::Loading;
|
||||
scan.inFlight = true;
|
||||
scan.inFlightGen = scan.gen;
|
||||
scan.startedAt = now;
|
||||
return scan.gen;
|
||||
}
|
||||
|
||||
bool scopeScanFinish(ScopeScan& scan, std::uint32_t gen, const std::string& error, rpc::RpcFailureKind kind,
|
||||
double now)
|
||||
{
|
||||
if (scan.inFlight && gen == scan.inFlightGen) scan.inFlight = false;
|
||||
if (gen != scan.gen) return false; // a scan from an earlier session: its data is not this one's
|
||||
if (error.empty()) {
|
||||
scan.phase = ScopePhase::Loaded;
|
||||
scan.failures = 0;
|
||||
scan.autoRetry = false;
|
||||
scan.retryAt = 0.0;
|
||||
scan.error.clear();
|
||||
return true;
|
||||
}
|
||||
scan.phase = ScopePhase::Failed;
|
||||
++scan.failures;
|
||||
scan.error = error;
|
||||
scan.autoRetry = scopeFailureRetries(kind, error) && scan.failures <= kScopeMaxAutoRetries;
|
||||
scan.retryAt = scan.autoRetry ? now + scopeRetryDelaySec(scan.failures) : 0.0;
|
||||
return false;
|
||||
}
|
||||
|
||||
void scopeScanOpen(ScopeScan& scan, double now)
|
||||
{
|
||||
if (scan.inFlight && now - scan.startedAt > kScopeInFlightMaxSec) scan.inFlight = false; // lost
|
||||
if (scan.inFlight && scan.phase == ScopePhase::Loading) return; // still loading for the last session
|
||||
++scan.gen;
|
||||
scan.phase = ScopePhase::Idle;
|
||||
scan.failures = 0;
|
||||
scan.autoRetry = false;
|
||||
scan.retryAt = 0.0;
|
||||
scan.error.clear();
|
||||
}
|
||||
|
||||
void scopeScanInvalidate(ScopeScan& scan)
|
||||
{
|
||||
++scan.gen;
|
||||
scan.phase = ScopePhase::Idle;
|
||||
scan.failures = 0;
|
||||
scan.autoRetry = false;
|
||||
scan.retryAt = 0.0;
|
||||
scan.error.clear();
|
||||
}
|
||||
|
||||
void scopeScanRetry(ScopeScan& scan)
|
||||
{
|
||||
if (scan.phase != ScopePhase::Failed) return;
|
||||
scan.phase = ScopePhase::Idle;
|
||||
scan.failures = 0;
|
||||
scan.autoRetry = false;
|
||||
scan.retryAt = 0.0;
|
||||
scan.error.clear();
|
||||
}
|
||||
|
||||
double scopeRetryDelaySec(int failures)
|
||||
{
|
||||
if (failures <= 1) return 10.0;
|
||||
if (failures == 2) return 30.0;
|
||||
return 60.0;
|
||||
}
|
||||
|
||||
bool scopeFailureRetries(rpc::RpcFailureKind kind, const std::string& message)
|
||||
{
|
||||
using K = rpc::RpcFailureKind;
|
||||
switch (kind) {
|
||||
case K::NotListening:
|
||||
case K::ConnectTimeout:
|
||||
case K::Warmup:
|
||||
case K::Deferred:
|
||||
case K::ShuttingDown:
|
||||
case K::Unhandled:
|
||||
case K::Aborted:
|
||||
return true;
|
||||
case K::Other: {
|
||||
std::string lower = message;
|
||||
std::transform(lower.begin(), lower.end(), lower.begin(),
|
||||
[](unsigned char c) { return static_cast<char>(std::tolower(c)); });
|
||||
return lower.find("not connected") != std::string::npos;
|
||||
}
|
||||
default:
|
||||
return false; // ReplyTimeout, QueueFull, AppError, Auth, parse/type errors
|
||||
}
|
||||
}
|
||||
|
||||
} // namespace services
|
||||
} // namespace dragonx
|
||||
@@ -0,0 +1,288 @@
|
||||
// DragonX Wallet - ImGui Edition
|
||||
// Copyright 2024-2026 The Hush Developers
|
||||
// Released under the GPLv3
|
||||
|
||||
#pragma once
|
||||
|
||||
// Consolidating notes WITHOUT moving funds between addresses: each address's notes are merged back into
|
||||
// that same address (z_mergetoaddress [addr] addr), so funds kept apart under different labels stay apart
|
||||
// while the wallet sheds notes (fewer notes = smaller wallet, faster witness rebuilds and scans).
|
||||
// Shielded only: a transparent address can't receive a non-coinbase output on DragonX (private chain).
|
||||
// Pure apart from the merge call it is handed, so the test target can drive it.
|
||||
|
||||
#include "rpc/rpc_failure.h"
|
||||
|
||||
#include <nlohmann/json.hpp>
|
||||
|
||||
#include <chrono>
|
||||
#include <cstdint>
|
||||
#include <functional>
|
||||
#include <set>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
namespace dragonx {
|
||||
namespace services {
|
||||
|
||||
// One shielded address's spendable notes (confirmed, spendable), from z_listunspent.
|
||||
struct NoteGroup {
|
||||
std::string address;
|
||||
int notes = 0;
|
||||
double amount = 0.0;
|
||||
};
|
||||
|
||||
// Spendable shielded notes grouped by address, most notes first. Transparent UTXOs and unconfirmed,
|
||||
// watch-only or locked notes are left out (z_mergetoaddress only spends confirmed, unlocked notes it has
|
||||
// keys for).
|
||||
std::vector<NoteGroup> groupSpendableNotes(const nlohmann::json& zListUnspent);
|
||||
|
||||
// A z_listunspent entry the wallet has locked, e.g. for an operation in flight ("locked": true).
|
||||
bool noteIsLocked(const nlohmann::json& zListUnspentEntry);
|
||||
|
||||
// Worth consolidating in place: at least two notes, and more than one batch's fee to work with.
|
||||
bool worthConsolidating(const NoteGroup& group, double fee);
|
||||
|
||||
// Merge calls needed to bring `notes` down when each call takes at most `limit` of them. A lone leftover
|
||||
// note isn't a call (the daemon refuses to merge one note into its own address).
|
||||
int estimateBatches(int notes, int limit);
|
||||
|
||||
// What one z_mergetoaddress call reported.
|
||||
struct MergeCallResult {
|
||||
std::string opid;
|
||||
int merging = 0; // notes this call is merging
|
||||
int remaining = 0; // notes of the address it left for a later call
|
||||
std::string error; // non-empty: the call failed (nothing was submitted)
|
||||
};
|
||||
using MergeCall = std::function<MergeCallResult(const std::string& address)>;
|
||||
|
||||
// The outcome for one address.
|
||||
struct AddressRun {
|
||||
std::string address;
|
||||
std::vector<std::string> opids; // one per submitted batch
|
||||
int notesMerged = 0;
|
||||
int remaining = 0; // notes still left after the last batch
|
||||
std::string error; // the call that stopped it, if any (earlier batches still went out)
|
||||
};
|
||||
|
||||
// Submit merges of `address` into itself until fewer than two of its notes are left unmerged. The daemon
|
||||
// locks a batch's notes before the call returns, so each call takes the next batch. At most maxBatches.
|
||||
AddressRun consolidateInPlace(const std::string& address, const MergeCall& call, int maxBatches = 20);
|
||||
|
||||
// ── Merge-call stepper ──
|
||||
// One call's result into the address's run (r.error set: run.error = r.error, false). True while another call
|
||||
// for this address is worth making: at least 2 unlocked notes left and fewer than maxBatches submitted.
|
||||
bool recordMergeResult(AddressRun& run, const MergeCallResult& r, int maxBatches = 20);
|
||||
// z_mergetoaddress's reply. Never throws. A non-empty string "opid" = accepted; mergingNotes/remainingNotes only
|
||||
// when integral (clamped to [0, INT_MAX]), else 0; no opid: error "no operation id".
|
||||
void readMergeCallResult(const nlohmann::json& res, MergeCallResult& out);
|
||||
|
||||
// ── Async op status ──
|
||||
enum class OpState : std::uint8_t { Unknown, Queued, Executing, Success, Failed, Lost };
|
||||
inline bool opStateTerminal(OpState s) { return s == OpState::Success || s == OpState::Failed || s == OpState::Lost; }
|
||||
struct OpReport {
|
||||
std::string id, status, error, txid; // error = error.message, txid = result.txid
|
||||
};
|
||||
OpState opStateFromStatus(const std::string& status); // queued/executing/success; failed|cancelled|canceled: Failed
|
||||
// Type-checked, never throws. Skips non-objects and missing/non-string/empty ids; keeps only `wanted` (all if empty).
|
||||
std::vector<OpReport> parseOpReports(const nlohmann::json& statusArray, const std::set<std::string>& wanted = {});
|
||||
|
||||
// ── Merge-call failures ──
|
||||
enum class MergeFailure : std::uint8_t {
|
||||
NotSent, // turned away before z_mergetoaddress ran: the same batch again, later
|
||||
Syncing, // THROW_IF_SYNCING: hold the run while the node catches up
|
||||
NeedsUnlock, // a locked keystore ("could not find spending key")
|
||||
NothingLeft, // no funds / already merged / too small for the fee
|
||||
Uncertain, // it may have queued the merge (a reply timeout, an answer without an opid): never resent
|
||||
Transient, // the HTTP layer dropped it, or the async queue is shutting down: retry with backoff
|
||||
Fatal, // anything else: this address is done
|
||||
};
|
||||
MergeFailure classifyMergeFailure(rpc::RpcFailureKind kind, const std::string& message);
|
||||
// The failure kind of a z_mergetoaddress call that threw. Sent, then cut off before any HTTP status
|
||||
// (connection reset, an empty or partial reply): the daemon may have queued the merge, exactly as after a
|
||||
// reply timeout, so it reads as one. Any other failure keeps its kind.
|
||||
rpc::RpcFailureKind mergeCallFailureKind(const std::exception& e);
|
||||
|
||||
// ── Paced per-address consolidation run (owned by App, pumped from App::update(); main thread) ──
|
||||
// The "Keep in each address" mode used to post one task that called z_mergetoaddress for every batch of every
|
||||
// address back to back. Each call is a whole-wallet scan under cs_main, and the daemon runs the ops one at a
|
||||
// time anyway. Here: one run RPC at a time, the next call only after the previous batch left the queue and a
|
||||
// gap as long as the last call took, failures classified (nothing that may have queued is ever resent), and a
|
||||
// breaker after three dead addresses or three failed ops in a row.
|
||||
inline constexpr double kRunMinGapSec = 2.0;
|
||||
inline constexpr double kRunMaxGapSec = 120.0;
|
||||
inline constexpr double kRunPollIntervalSec = 3.0;
|
||||
inline constexpr long kRunCallTimeoutSec = 120;
|
||||
inline constexpr double kRunWatchdogSec = 1800.0; // a call or poll outstanding this long is presumed lost
|
||||
inline constexpr double kRunOpMaxSec = 3600.0; // an op not terminal after this much pollable time is presumed lost
|
||||
inline constexpr double kRunOpHardMaxSec = 4 * kRunOpMaxSec; // ...and after this long whatever happened
|
||||
inline constexpr double kRunNotSentRetrySec = 5.0;
|
||||
inline constexpr double kRunSyncHoldSec = 30.0;
|
||||
inline constexpr double kRunUnlockHoldSec = 30.0;
|
||||
inline constexpr int kRunMaxTransientRetries = 5;
|
||||
inline constexpr int kRunMaxUnlockRefusals = 2;
|
||||
inline constexpr int kRunMaxFailStreak = 3;
|
||||
inline constexpr int kRunMaxPollMisses = 2;
|
||||
|
||||
struct RunConfig {
|
||||
double fee = 0.0001;
|
||||
int limit = 50;
|
||||
int maxBatches = 20;
|
||||
};
|
||||
struct RunEnv {
|
||||
bool nodeUsable = false; // connected, not warming up or rescanning
|
||||
bool busy = false; // the busy gate holds RPCs that need cs_main
|
||||
bool callsAllowed = true; // not syncing / settling
|
||||
bool locked = false;
|
||||
bool userSendBusy = false; // a send / shield / auto-shield / chat op of ours is in progress
|
||||
bool scanBusy = false; // another GUI wallet scan is in the daemon
|
||||
};
|
||||
enum class RunAction : std::uint8_t { None, Call, Poll, Finish };
|
||||
enum class RunWait : std::uint8_t { None, InFlight, Node, Busy, Syncing, Locked, UserSend, Scan, Batch };
|
||||
enum class RunEnd : std::uint8_t { None, Completed, Stopped, Halted, Aborted };
|
||||
enum class AddrOutcome : std::uint8_t { Pending, Running, Done, Uncertain, Failed }; // ordered: worse wins
|
||||
enum class RunVerdict : std::uint8_t { None, Clean, NothingSubmitted, SomeFailed, Unconfirmed, Stopped, Halted, Aborted };
|
||||
struct RunStep {
|
||||
RunAction action = RunAction::None;
|
||||
int addrIdx = -1;
|
||||
};
|
||||
struct RunAddress {
|
||||
NoteGroup group;
|
||||
AddressRun run;
|
||||
AddrOutcome outcome = AddrOutcome::Pending;
|
||||
bool done = false;
|
||||
int opsDone = 0;
|
||||
int transient = 0;
|
||||
int unlockRefusals = 0;
|
||||
std::string failure;
|
||||
};
|
||||
struct RunBatch {
|
||||
int addrIdx = 0;
|
||||
std::string opid;
|
||||
int merging = 0;
|
||||
OpState state = OpState::Unknown;
|
||||
std::string error, txid;
|
||||
int misses = 0;
|
||||
double acceptedAt = 0.0;
|
||||
double pollableSec = 0.0; // time since accepted in which a status poll could have gone out
|
||||
};
|
||||
struct RunProgress {
|
||||
int addresses = 0, addressesDone = 0, batchesSubmitted = 0, batchesEstimated = 0, batchesDone = 0, notesMerged = 0;
|
||||
bool stopping = false;
|
||||
RunWait wait = RunWait::None;
|
||||
};
|
||||
struct RunSummary {
|
||||
RunEnd end = RunEnd::None;
|
||||
int addresses = 0, addressesFailed = 0, addressesUnconfirmed = 0, batchesSubmitted = 0, batchesSucceeded = 0,
|
||||
notesMerged = 0;
|
||||
std::string firstError;
|
||||
};
|
||||
RunVerdict runVerdict(const RunSummary& s);
|
||||
const char* runWaitKey(RunWait wait); // i18n key of the wait line, nullptr when there is nothing to say
|
||||
|
||||
class ConsolidationRun {
|
||||
public:
|
||||
bool start(const std::vector<NoteGroup>& groups, RunConfig cfg, double now);
|
||||
void requestStop();
|
||||
bool abort();
|
||||
RunStep next(const RunEnv& env, double now);
|
||||
void markCallPosted(int addrIdx, double now);
|
||||
void markPollPosted(double now);
|
||||
// Returns an orphan opid (an accepted merge whose result is stale) for the caller to track, else "".
|
||||
std::string onCallResult(std::uint64_t gen, int addrIdx, const MergeCallResult& r, rpc::RpcFailureKind kind,
|
||||
double callSec, double now);
|
||||
void onPollResult(std::uint64_t gen, bool ok, const std::vector<OpReport>& reports, double now);
|
||||
bool consumeLockResyncRequest();
|
||||
std::vector<std::string> outstandingOpids() const;
|
||||
void dismiss();
|
||||
bool active() const { return phase_ == Phase::Running; }
|
||||
bool hasRun() const { return phase_ != Phase::Idle; }
|
||||
bool stopping() const { return stopping_; }
|
||||
std::uint64_t gen() const { return gen_; }
|
||||
RunWait lastWait() const { return lastWait_; }
|
||||
RunEnd end() const { return end_; }
|
||||
RunProgress progress() const;
|
||||
RunSummary summary() const;
|
||||
const std::vector<RunAddress>& addresses() const { return addrs_; }
|
||||
const std::vector<RunBatch>& batches() const { return batches_; }
|
||||
const RunConfig& config() const { return cfg_; }
|
||||
|
||||
private:
|
||||
enum class Phase : std::uint8_t { Idle, Running, Finished };
|
||||
static void worsen(RunAddress& a, AddrOutcome o) { if (o > a.outcome) a.outcome = o; }
|
||||
void endAddress(RunAddress& a, AddrOutcome o, const std::string& msg);
|
||||
void applyOpState(RunBatch& b, OpState st, const OpReport* rep);
|
||||
void halt(const std::string& msg) { halted_ = true; if (haltError_.empty()) haltError_ = msg; }
|
||||
|
||||
Phase phase_ = Phase::Idle;
|
||||
RunEnd end_ = RunEnd::None;
|
||||
RunConfig cfg_;
|
||||
std::vector<RunAddress> addrs_;
|
||||
std::vector<RunBatch> batches_;
|
||||
std::uint64_t gen_ = 0;
|
||||
std::size_t cur_ = 0;
|
||||
int callAddr_ = -1;
|
||||
bool callInFlight_ = false, pollInFlight_ = false, stopping_ = false, halted_ = false, lockResync_ = false;
|
||||
double callPostedAt_ = 0, pollPostedAt_ = 0, nextCallAt_ = 0, nextPollAt_ = 0, syncHoldUntil_ = 0,
|
||||
unlockHoldUntil_ = 0;
|
||||
int deadStreak_ = 0, opFailStreak_ = 0;
|
||||
std::string haltError_;
|
||||
RunWait lastWait_ = RunWait::None;
|
||||
double lastPumpAt_ = 0.0;
|
||||
};
|
||||
|
||||
// ── Scope scan (the Merge/Consolidate dialog's listunspent + z_listunspent) ─────────────────────────
|
||||
// Both calls are whole-wallet scans under the daemon's cs_main. The dialog used to post the pair again on
|
||||
// the frame after any failure, every frame for as long as it stayed open (and forever on Lite, which has
|
||||
// no node). This decides when a scan may be posted; the dialog owns one ScopeScan.
|
||||
|
||||
inline double steadySeconds() // ImGui::GetTime() stands still while the window is minimized
|
||||
{
|
||||
return std::chrono::duration<double>(std::chrono::steady_clock::now().time_since_epoch()).count();
|
||||
}
|
||||
|
||||
inline constexpr long kScopeCallTimeoutSec = 120; // each call; a large wallet can take minutes
|
||||
inline constexpr double kScopeInFlightMaxSec = 1800; // a scan outstanding this long is presumed lost
|
||||
inline constexpr int kScopeMaxAutoRetries = 3;
|
||||
|
||||
enum class ScopePhase { Idle, Loading, Loaded, Failed };
|
||||
|
||||
struct ScopeScan {
|
||||
ScopePhase phase = ScopePhase::Idle;
|
||||
std::uint32_t gen = 0; // bumped per dialog session; a result from another session is dropped
|
||||
bool inFlight = false; // a scan is posted and its result not yet in (survives close/reopen)
|
||||
std::uint32_t inFlightGen = 0; // the session that posted it: only its result clears inFlight
|
||||
double startedAt = 0.0;
|
||||
int failures = 0;
|
||||
bool autoRetry = false; // the last failure was "node not ready yet": retry by itself
|
||||
double retryAt = 0.0;
|
||||
std::string error;
|
||||
};
|
||||
|
||||
struct ScopeInputs {
|
||||
bool mergeMode = false;
|
||||
bool busyWithOp = false; // a merge was submitted or is in flight
|
||||
bool nodeReady = false; // App::walletScanReady()
|
||||
double now = 0.0;
|
||||
};
|
||||
|
||||
enum class ScopeWant { Nothing, Post, WaitNode, WaitBackoff };
|
||||
|
||||
ScopeWant scopeScanWant(const ScopeScan& scan, const ScopeInputs& in);
|
||||
// Mark a scan posted: Loading, in flight for this session. Returns the generation to finish it with.
|
||||
std::uint32_t scopeScanBegin(ScopeScan& scan, double now);
|
||||
// Record a scan's result. True when it belongs to the current session and succeeded: apply its data.
|
||||
bool scopeScanFinish(ScopeScan& scan, std::uint32_t gen, const std::string& error, rpc::RpcFailureKind kind,
|
||||
double now);
|
||||
// The dialog opened: start a new session, unless a scan of the last one is still loading (adopt it).
|
||||
void scopeScanOpen(ScopeScan& scan, double now);
|
||||
// The data went stale (reconnect): the next want is a fresh scan. An in-flight scan stays guarded.
|
||||
void scopeScanInvalidate(ScopeScan& scan);
|
||||
// The user pressed Retry after a failure.
|
||||
void scopeScanRetry(ScopeScan& scan);
|
||||
double scopeRetryDelaySec(int failures); // 10, 30, then 60 s
|
||||
// Retry by itself only when the node wasn't ready (refused, warming up, held back as busy, aborted):
|
||||
// not after a reply timeout or a real error, each of which would cost another pair of full scans.
|
||||
bool scopeFailureRetries(rpc::RpcFailureKind kind, const std::string& message);
|
||||
|
||||
} // namespace services
|
||||
} // namespace dragonx
|
||||
@@ -26,6 +26,18 @@ RefreshScheduler::Intervals RefreshScheduler::intervalsForPage(ui::NavPage page)
|
||||
}
|
||||
}
|
||||
|
||||
RefreshScheduler::Intervals RefreshScheduler::selectIntervals(ui::NavPage page, bool syncing, bool background)
|
||||
{
|
||||
if (background) return kBackgroundProfile;
|
||||
if (syncing) return kSyncProfile;
|
||||
return intervalsForPage(page);
|
||||
}
|
||||
|
||||
static_assert(RefreshScheduler::kSyncProfile.core * 2.0f <= RefreshScheduler::kSyncSettleSeconds,
|
||||
"the sync settle window must outlast two Core polls");
|
||||
static_assert(RefreshScheduler::kBackgroundProfile.core * 2.0f <= RefreshScheduler::kSyncSettleSeconds,
|
||||
"the sync settle window must outlast two Core polls");
|
||||
|
||||
void RefreshScheduler::applyPage(ui::NavPage page)
|
||||
{
|
||||
setIntervals(intervalsForPage(page));
|
||||
@@ -47,10 +59,16 @@ void RefreshScheduler::tick(float deltaSeconds)
|
||||
timers_.fast += delta;
|
||||
timers_.txAge += delta;
|
||||
timers_.opid += delta;
|
||||
for (float& h : holds_) {
|
||||
if (h > 0.0f) h = std::max(0.0f, h - delta);
|
||||
}
|
||||
}
|
||||
|
||||
bool RefreshScheduler::isDue(Timer timer) const
|
||||
{
|
||||
const float h = hold(timer);
|
||||
if (h > 0.0f) return false; // held by deferFor()
|
||||
if (h == 0.0f) return true; // the hold ran out: due now, whatever the interval
|
||||
float timerInterval = interval(timer);
|
||||
return timerInterval > 0.0f && timerRef(timer) >= timerInterval;
|
||||
}
|
||||
@@ -65,19 +83,33 @@ bool RefreshScheduler::consumeDue(Timer timer)
|
||||
void RefreshScheduler::reset(Timer timer)
|
||||
{
|
||||
timerRef(timer) = 0.0f;
|
||||
holdRef(timer) = -1.0f;
|
||||
}
|
||||
|
||||
void RefreshScheduler::markDue(Timer timer)
|
||||
{
|
||||
holdRef(timer) = -1.0f; // an explicit "now" (user action) overrides a backoff
|
||||
float timerInterval = interval(timer);
|
||||
timerRef(timer) = timerInterval > 0.0f ? timerInterval : 0.0f;
|
||||
}
|
||||
|
||||
void RefreshScheduler::setTimer(Timer timer, float seconds)
|
||||
{
|
||||
holdRef(timer) = -1.0f;
|
||||
timerRef(timer) = std::max(0.0f, seconds);
|
||||
}
|
||||
|
||||
void RefreshScheduler::deferFor(Timer timer, float seconds)
|
||||
{
|
||||
holdRef(timer) = std::max(0.0f, seconds);
|
||||
}
|
||||
|
||||
void RefreshScheduler::retrySoon(Timer timer, float seconds)
|
||||
{
|
||||
holdRef(timer) = -1.0f;
|
||||
timerRef(timer) = std::max(0.0f, interval(timer) - std::max(0.0f, seconds));
|
||||
}
|
||||
|
||||
float RefreshScheduler::timer(Timer timer) const
|
||||
{
|
||||
return timerRef(timer);
|
||||
|
||||
@@ -2,6 +2,8 @@
|
||||
|
||||
#include "ui/sidebar.h"
|
||||
|
||||
#include <array>
|
||||
|
||||
namespace dragonx {
|
||||
namespace services {
|
||||
|
||||
@@ -41,8 +43,17 @@ public:
|
||||
// the lightweight Console tab syncs faster than the Peers tab. Reverts to the per-tab profile
|
||||
// once sync completes. (Tx/address/balance data is incomplete mid-sync anyway.)
|
||||
static constexpr Intervals kSyncProfile{10.0f, 0.0f, 0.0f, 0.0f};
|
||||
// While the window is in the background (minimized, hidden, or covered without focus): only the chain
|
||||
// state, slower. It keeps sync detection, the lost-connection check and the balance's own rare poll
|
||||
// (auto-shield) alive; the wallet scans and peers wait for the window to come back.
|
||||
static constexpr Intervals kBackgroundProfile{15.0f, 0.0f, 0.0f, 0.0f};
|
||||
// How long the sync profile holds after the node last read "behind" (see the Core callback). Must
|
||||
// outlast two Core polls in every held profile, or it could lapse between two "behind" readings.
|
||||
static constexpr float kSyncSettleSeconds = 30.0f;
|
||||
|
||||
static Intervals intervalsForPage(ui::NavPage page);
|
||||
// The profile in force: background, else syncing, else the page's.
|
||||
static Intervals selectIntervals(ui::NavPage page, bool syncing, bool background);
|
||||
|
||||
void applyPage(ui::NavPage page);
|
||||
void setIntervals(Intervals intervals);
|
||||
@@ -55,6 +66,14 @@ public:
|
||||
void reset(Timer timer);
|
||||
void markDue(Timer timer);
|
||||
void setTimer(Timer timer, float seconds);
|
||||
// Hold a timer for exactly `seconds`, whatever the page's interval (which only measures elapsed
|
||||
// time): not due until then, due as soon as it runs out. consumeDue/reset/markDue/setTimer end it.
|
||||
// For retry backoff: the old "setTimer(interval - delay)" collapsed to an immediate retry wherever
|
||||
// the page interval was shorter than the delay (every frame on Overview/Send).
|
||||
void deferFor(Timer timer, float seconds);
|
||||
// Due again `seconds` from now (a scan the governor held back asks again soon). Unlike deferFor, never
|
||||
// due on a 0-interval timer (the sync profile's), so a held-back scan can't fire mid-sync.
|
||||
void retrySoon(Timer timer, float seconds);
|
||||
float timer(Timer timer) const;
|
||||
float interval(Timer timer) const;
|
||||
|
||||
@@ -79,9 +98,14 @@ private:
|
||||
|
||||
float& timerRef(Timer timer);
|
||||
const float& timerRef(Timer timer) const;
|
||||
static constexpr int kTimerCount = 8;
|
||||
float& holdRef(Timer timer) { return holds_[static_cast<int>(timer)]; }
|
||||
float hold(Timer timer) const { return holds_[static_cast<int>(timer)]; }
|
||||
|
||||
Intervals intervals_{kCoreDefault, kTransactionDefault, kAddressDefault, kPeerDefault};
|
||||
Timers timers_;
|
||||
// deferFor() holds: > 0 counting down (not due), 0 ran out (due), < 0 none.
|
||||
std::array<float, kTimerCount> holds_{{-1.0f, -1.0f, -1.0f, -1.0f, -1.0f, -1.0f, -1.0f, -1.0f}};
|
||||
};
|
||||
|
||||
} // namespace services
|
||||
|
||||
@@ -0,0 +1,146 @@
|
||||
// DragonX Wallet - ImGui Edition
|
||||
// Copyright 2024-2026 The Hush Developers
|
||||
// Released under the GPLv3
|
||||
|
||||
#pragma once
|
||||
|
||||
// The hard gates in front of the wallet-wide scans: whether the node can take one at all right now. The
|
||||
// WalletScanGovernor decides on top of them how often. The scan entry points (refreshAddressData,
|
||||
// refreshTransactionData) check these themselves, so no caller (tab entry, refreshData, an import, the
|
||||
// history's re-route through the address scan) can send a scan into a node that is syncing, warming up,
|
||||
// rescanning, busy or locked, or while the window is in the background. The cheap address listing (no
|
||||
// wallet scan) still runs for a stale list, and the session's first balances are read once even while the
|
||||
// node catches up (otherwise every balance would read 0 for the whole catch-up).
|
||||
//
|
||||
// Pure, header-only.
|
||||
|
||||
#include "ui/sidebar.h"
|
||||
|
||||
#include <algorithm>
|
||||
|
||||
namespace dragonx {
|
||||
namespace services {
|
||||
|
||||
enum class ScanTier {
|
||||
None, // nothing
|
||||
ListOnly, // the address lists only (z_listaddresses, getaddressesbyaccount): cheap, no wallet scan
|
||||
Full, // the lists and the per-address balances (z_listunspent, listunspent)
|
||||
};
|
||||
|
||||
struct ScanContext {
|
||||
bool connected = false;
|
||||
bool locked = false;
|
||||
bool warmingUp = false;
|
||||
bool runtimeRescan = false;
|
||||
bool rpcBusy = false;
|
||||
bool syncing = false; // effectivelySyncing(): behind, or settling after catching up
|
||||
bool gateOpen = true; // the ConnectScanGate
|
||||
bool background = false; // the window is minimized, hidden, or covered without focus
|
||||
};
|
||||
|
||||
// The node answers RPCs that take cs_main without queueing behind something long.
|
||||
inline bool nodeCanAnswerScans(const ScanContext& c)
|
||||
{
|
||||
return c.connected && !c.warmingUp && !c.runtimeRescan && !c.rpcBusy;
|
||||
}
|
||||
|
||||
// A wallet-wide scan (O(wallet), holds cs_main) may be sent.
|
||||
inline bool wholeWalletScanAllowed(const ScanContext& c)
|
||||
{
|
||||
return nodeCanAnswerScans(c) && !c.background && !c.locked && !c.syncing && c.gateOpen;
|
||||
}
|
||||
|
||||
// The session's first read of a wallet value (the balance, the per-address balances): like
|
||||
// wholeWalletScanAllowed, but also while the node catches up. Once, so the catch-up shows the wallet's last
|
||||
// known values instead of 0.
|
||||
inline bool firstReadAllowed(const ScanContext& c)
|
||||
{
|
||||
return nodeCanAnswerScans(c) && !c.background && !c.locked && c.gateOpen;
|
||||
}
|
||||
|
||||
// listStale: never loaded this connection, or the address set may have changed (import, sweep,
|
||||
// decrypt-import). A stale list still loads while the scans are held, without the balances.
|
||||
// balancesOwed: no full pass has read the per-address balances yet this session.
|
||||
inline ScanTier planAddressScan(const ScanContext& c, bool listStale, bool balancesOwed = false)
|
||||
{
|
||||
if (wholeWalletScanAllowed(c)) return ScanTier::Full;
|
||||
if (balancesOwed && firstReadAllowed(c)) return ScanTier::Full;
|
||||
return (nodeCanAnswerScans(c) && listStale) ? ScanTier::ListOnly : ScanTier::None;
|
||||
}
|
||||
|
||||
// What entering a page does. Wallet data isn't fetched on entry any more (the periodic refresh and the
|
||||
// scan governor decide), except what the page can't show without: a stale address list, a never-loaded
|
||||
// history.
|
||||
struct PageEntryPlan {
|
||||
bool core = false;
|
||||
bool peers = false;
|
||||
bool mining = false;
|
||||
ScanTier addressesNow = ScanTier::None;
|
||||
bool transactionsNow = false;
|
||||
bool markAddresses = false;
|
||||
bool markTransactions = false;
|
||||
};
|
||||
|
||||
inline PageEntryPlan planPageEntry(ui::NavPage page, const ScanContext& c, bool addressListStale,
|
||||
bool transactionsNeverLoaded)
|
||||
{
|
||||
using NP = ui::NavPage;
|
||||
PageEntryPlan p;
|
||||
if (!c.connected) return p;
|
||||
switch (page) {
|
||||
case NP::Overview:
|
||||
p.core = !c.runtimeRescan && !c.rpcBusy;
|
||||
break;
|
||||
case NP::Peers:
|
||||
p.peers = !c.rpcBusy;
|
||||
break;
|
||||
case NP::Mining:
|
||||
p.mining = !c.warmingUp && !c.runtimeRescan && !c.rpcBusy;
|
||||
break;
|
||||
case NP::Send:
|
||||
case NP::Receive:
|
||||
case NP::History: {
|
||||
const ScanTier tier = planAddressScan(c, addressListStale);
|
||||
const bool loadNow = addressListStale && tier != ScanTier::None;
|
||||
if (loadNow) p.addressesNow = tier;
|
||||
else if (page != NP::History) p.markAddresses = true; // History needs the addresses only when stale
|
||||
if (page == NP::History) {
|
||||
if (!loadNow && transactionsNeverLoaded && wholeWalletScanAllowed(c)) p.transactionsNow = true;
|
||||
else p.markTransactions = true;
|
||||
}
|
||||
break;
|
||||
}
|
||||
default:
|
||||
break;
|
||||
}
|
||||
return p;
|
||||
}
|
||||
|
||||
// The window counts as in the background when it is minimized or hidden, or covered while none of the
|
||||
// app's windows has focus (SDL reports occlusion on X11, Wayland and macOS, never on Windows).
|
||||
inline bool windowLooksBackground(bool minimized, bool hidden, bool occluded, bool appHasFocus)
|
||||
{
|
||||
return minimized || hidden || (occluded && !appHasFocus);
|
||||
}
|
||||
|
||||
constexpr double kBackgroundDebounceSeconds = 3.0;
|
||||
constexpr double kBackgroundInputGraceSeconds = 2.0;
|
||||
|
||||
// Background once it has looked so for a few seconds with no input meanwhile (a restore is immediate).
|
||||
inline bool isBackgroundWindow(bool flagged, double flaggedForSeconds, double secondsSinceInput)
|
||||
{
|
||||
return flagged && flaggedForSeconds >= kBackgroundDebounceSeconds &&
|
||||
secondsSinceInput >= kBackgroundInputGraceSeconds;
|
||||
}
|
||||
|
||||
// After a failed address enumeration, hold address refreshes for max(10 s, 10x what that pass cost). A pass
|
||||
// that failed because the node was busy (a reply timeout, a full queue, held back by the busy gate) gets the
|
||||
// floor: its wall time was the wait for cs_main, not its cost, and the busy gate already paces a busy node.
|
||||
inline double addressRetryDelaySeconds(double failedScanMs, bool nodeBusy = false)
|
||||
{
|
||||
if (nodeBusy) return 10.0;
|
||||
return std::max(10.0, (std::max(0.0, failedScanMs) / 1000.0) / 0.10);
|
||||
}
|
||||
|
||||
} // namespace services
|
||||
} // namespace dragonx
|
||||
@@ -0,0 +1,20 @@
|
||||
// DragonX Wallet - ImGui Edition
|
||||
// Copyright 2024-2026 The Hush Developers
|
||||
// Released under the GPLv3
|
||||
|
||||
#pragma once
|
||||
|
||||
namespace dragonx {
|
||||
namespace services {
|
||||
|
||||
// The expensive z_gettotalbalance only changes the seed-migration adopt gate once the sweep is mined and
|
||||
// we have no post-mine figure yet (priorRemaining < 0). A manual Refresh forces it; an active sync
|
||||
// suppresses the automatic scan (the Refresh button still overrides).
|
||||
inline bool migrationWantsBalanceScan(bool forceBalance, bool syncing, int confs, double priorRemaining)
|
||||
{
|
||||
if (forceBalance) return true;
|
||||
return !syncing && confs >= 1 && priorRemaining < 0.0;
|
||||
}
|
||||
|
||||
} // namespace services
|
||||
} // namespace dragonx
|
||||
@@ -0,0 +1,331 @@
|
||||
// DragonX Wallet - ImGui Edition
|
||||
// Copyright 2024-2026 The Hush Developers
|
||||
// Released under the GPLv3
|
||||
|
||||
#include "wallet_scan_governor.h"
|
||||
|
||||
#include <algorithm>
|
||||
#include <limits>
|
||||
|
||||
namespace dragonx {
|
||||
namespace services {
|
||||
|
||||
ScanVisibility scanVisibilityForPage(ui::NavPage page, bool windowBackground)
|
||||
{
|
||||
ScanVisibility v;
|
||||
if (windowBackground) return v;
|
||||
using NP = ui::NavPage;
|
||||
switch (page) {
|
||||
case NP::Overview:
|
||||
v.balance = v.addresses = v.history = true; // totals, per-address balances, recent activity
|
||||
break;
|
||||
case NP::Send:
|
||||
case NP::Receive:
|
||||
v.addresses = v.history = true; // per-address balances, recent sends / receives
|
||||
break;
|
||||
case NP::History:
|
||||
v.history = true;
|
||||
break;
|
||||
case NP::Market:
|
||||
v.balance = true; // portfolio value
|
||||
break;
|
||||
case NP::Chat:
|
||||
v.chat = true;
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
return v;
|
||||
}
|
||||
|
||||
double hiddenBalanceIntervalSec(double emaCostSec, bool autoShieldArmed)
|
||||
{
|
||||
const double floorSec = autoShieldArmed ? 120.0 : 600.0;
|
||||
return std::max(floorSec, 50.0 * std::max(0.0, emaCostSec));
|
||||
}
|
||||
|
||||
// Minimum gap between VISIBLE balance scans, as a multiple of the measured scan cost. A whole-wallet
|
||||
// z_gettotalbalance / z_listunspent holds the daemon's cs_main for its whole run (tens of seconds on a
|
||||
// very large note set); re-polling before it recovers starves block connection and times the next scan
|
||||
// out, which leaves per-address balances at 0. 3x the cost gives the node room to breathe while staying
|
||||
// responsive — on a normal wallet the cost is milliseconds, so this floor is effectively zero.
|
||||
constexpr double kVisibleBalanceCostMultiple = 3.0;
|
||||
|
||||
BalancePollDecision decideBalancePoll(const BalancePollInputs& in)
|
||||
{
|
||||
if (!in.allowBalance || !in.gateOpen) return BalancePollDecision::Skip;
|
||||
// The session's first read runs even while the node catches up, once per connection and only when
|
||||
// someone can see it: otherwise the total reads 0 for the whole catch-up. After that (or if that one
|
||||
// attempt failed) the balance waits for the sync to finish, a forced one too.
|
||||
if (in.neverFetched && !(in.syncing && (in.background || in.firstReadTried))) return BalancePollDecision::Explicit;
|
||||
if (in.syncing) return BalancePollDecision::Skip;
|
||||
// The user's own send, now committed. In the background it waits for the window (the routine rules below
|
||||
// still apply, e.g. the auto-shield poll); coming back forces it.
|
||||
if (in.forced && !in.background) return BalancePollDecision::Explicit;
|
||||
if (!in.changed) return BalancePollDecision::Skip;
|
||||
if (in.visible) {
|
||||
// Cost-proportional floor: don't re-scan before ~3x the measured scan cost has elapsed, so a
|
||||
// wallet whose whole-wallet sweep costs many seconds can't saturate the node by re-polling before
|
||||
// it recovers. Negligible for small wallets. The forced / first-read paths above bypass this.
|
||||
if (in.sinceLastScanSec < kVisibleBalanceCostMultiple * std::max(0.0, in.emaCostSec))
|
||||
return BalancePollDecision::Skip;
|
||||
// Just came into view with an old value: fetch it now rather than queue behind the other streams.
|
||||
if (!in.waiting && in.shownAgeSec >= in.visibleFloorSec && in.sinceLastScanSec >= in.visibleFloorSec)
|
||||
return BalancePollDecision::Explicit;
|
||||
return BalancePollDecision::AskGovernor;
|
||||
}
|
||||
if (in.sinceLastScanSec < hiddenBalanceIntervalSec(in.emaCostSec, in.autoShieldArmed))
|
||||
return BalancePollDecision::Skip;
|
||||
return BalancePollDecision::AskGovernor;
|
||||
}
|
||||
|
||||
double WalletScanGovernor::budgetForTipLag(double base, int blocksBehind)
|
||||
{
|
||||
if (blocksBehind <= 0) return base;
|
||||
if (blocksBehind == 1) return base * 2.0 / 3.0;
|
||||
return base / 3.0;
|
||||
}
|
||||
|
||||
bool WalletScanGovernor::signalsMatch(ScanStream stream, const ScanSignals& current) const
|
||||
{
|
||||
const Stream& st = at(stream);
|
||||
if (!st.basisValid) return false;
|
||||
const unsigned mask = cfg_.streams[static_cast<std::size_t>(stream)].signalMask;
|
||||
if (mask & kScanSignalTip) {
|
||||
if (current.tipHash.empty() || st.basis.tipHash.empty() || current.tipHash != st.basis.tipHash) return false;
|
||||
}
|
||||
if (mask & kScanSignalMempool) {
|
||||
if (!current.mempoolKnown || !st.basis.mempoolKnown) return false;
|
||||
if (current.mempoolTxs != st.basis.mempoolTxs || current.mempoolBytes != st.basis.mempoolBytes) return false;
|
||||
}
|
||||
if (mask & kScanSignalEpoch) {
|
||||
if (current.walletEpoch != st.basis.walletEpoch) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
bool WalletScanGovernor::needed(ScanStream stream, const ScanSignals& current, double now) const
|
||||
{
|
||||
const Stream& st = at(stream);
|
||||
if (!st.basisValid) return true;
|
||||
if (!signalsMatch(stream, current)) return true;
|
||||
const double maxAge = cfg_.streams[static_cast<std::size_t>(stream)].maxAgeSec;
|
||||
return maxAge > 0.0 && now - st.basisAt >= maxAge;
|
||||
}
|
||||
|
||||
double WalletScanGovernor::floorSec(const Stream& stream) const
|
||||
{
|
||||
return std::max(stream.askVisible ? cfg_.visibleFloorSec : cfg_.hiddenFloorSec,
|
||||
cfg_.floorCostMultiple * stream.emaCostMs / 1000.0);
|
||||
}
|
||||
|
||||
ScanStream WalletScanGovernor::bestWaiter(double now) const
|
||||
{
|
||||
const bool quiet = now < quietUntil_;
|
||||
ScanStream best = ScanStream::Count;
|
||||
double bestKey = std::numeric_limits<double>::infinity();
|
||||
for (std::size_t i = 0; i < streams_.size(); ++i) {
|
||||
const Stream& st = streams_[i];
|
||||
if (st.waitingSince < 0.0) continue;
|
||||
if (now - st.lastAskAt > cfg_.waiterTtlSec) continue; // stopped asking
|
||||
if (quiet && now - st.waitingSince < floorSec(st)) continue; // during quiet, only via its floor
|
||||
const double key = st.waitingSince - (st.askVisible ? cfg_.visibleHeadStartSec : 0.0);
|
||||
if (key < bestKey) {
|
||||
bestKey = key;
|
||||
best = static_cast<ScanStream>(i);
|
||||
}
|
||||
}
|
||||
return best;
|
||||
}
|
||||
|
||||
ScanTicket WalletScanGovernor::issue(ScanStream stream, ScanKind kind, const ScanSignals& current, double now,
|
||||
bool servesRoutine)
|
||||
{
|
||||
ScanTicket t;
|
||||
t.stream = stream;
|
||||
t.kind = kind;
|
||||
t.id = ++nextId_;
|
||||
t.generation = generation_;
|
||||
t.dispatchedAt = now;
|
||||
t.servesRoutine = servesRoutine;
|
||||
t.basis = current;
|
||||
live_.push_back(Live{t.id, stream, kind, now, servesRoutine});
|
||||
return t;
|
||||
}
|
||||
|
||||
ScanTicket WalletScanGovernor::ticketFor(const Live& live) const
|
||||
{
|
||||
ScanTicket t;
|
||||
t.stream = live.stream;
|
||||
t.kind = live.kind;
|
||||
t.id = live.id;
|
||||
t.generation = generation_;
|
||||
t.dispatchedAt = live.dispatchedAt;
|
||||
t.servesRoutine = live.servesRoutine;
|
||||
return t;
|
||||
}
|
||||
|
||||
ScanTicket WalletScanGovernor::tryAdmit(ScanStream stream, bool visible, const ScanSignals& current, double now)
|
||||
{
|
||||
Stream& st = at(stream);
|
||||
if (st.waitingSince < 0.0 || now - st.lastAskAt > cfg_.waitLapseSec) st.waitingSince = now; // a new wait
|
||||
st.lastAskAt = now;
|
||||
st.askVisible = visible;
|
||||
pruneLeases(now);
|
||||
if (!live_.empty()) return {}; // one wallet scan in the daemon at a time
|
||||
if (bestWaiter(now) != stream) return {};
|
||||
ScanTicket t = issue(stream, ScanKind::Routine, current, now, true);
|
||||
t.waitedSince = st.waitingSince;
|
||||
st.waitingSince = -1.0;
|
||||
return t;
|
||||
}
|
||||
|
||||
ScanTicket WalletScanGovernor::beginExplicit(ScanStream stream, const ScanSignals& current, double now,
|
||||
bool servesRoutine)
|
||||
{
|
||||
ScanTicket t = issue(stream, ScanKind::Explicit, current, now, servesRoutine);
|
||||
if (servesRoutine) {
|
||||
Stream& st = at(stream);
|
||||
t.waitedSince = st.waitingSince;
|
||||
st.waitingSince = -1.0; // served
|
||||
}
|
||||
return t;
|
||||
}
|
||||
|
||||
void WalletScanGovernor::eraseLive(std::uint64_t id)
|
||||
{
|
||||
live_.erase(std::remove_if(live_.begin(), live_.end(), [id](const Live& l) { return l.id == id; }), live_.end());
|
||||
}
|
||||
|
||||
namespace {
|
||||
// A ticket that sent nothing gives back the wait it ended (keeping a wait begun since, if older).
|
||||
void restoreWait(double& waitingSince, double waitedSince)
|
||||
{
|
||||
if (waitedSince < 0.0) return;
|
||||
waitingSince = waitingSince < 0.0 ? waitedSince : std::min(waitingSince, waitedSince);
|
||||
}
|
||||
} // namespace
|
||||
|
||||
void WalletScanGovernor::cancel(const ScanTicket& ticket)
|
||||
{
|
||||
if (!ticket || ticket.generation != generation_) return;
|
||||
const bool wasLive = std::any_of(live_.begin(), live_.end(), [&](const Live& l) { return l.id == ticket.id; });
|
||||
if (!wasLive) return;
|
||||
eraseLive(ticket.id);
|
||||
restoreWait(at(ticket.stream).waitingSince, ticket.waitedSince);
|
||||
}
|
||||
|
||||
void WalletScanGovernor::complete(const ScanTicket& ticket, double costMs, ScanOutcome outcome, double now)
|
||||
{
|
||||
if (!ticket || ticket.generation != generation_) return; // from before a reset()
|
||||
const bool wasLive = std::any_of(live_.begin(), live_.end(), [&](const Live& l) { return l.id == ticket.id; });
|
||||
if (!wasLive) return; // already completed (or dropped as an orphan)
|
||||
eraseLive(ticket.id);
|
||||
Stream& st = at(ticket.stream);
|
||||
if (outcome == ScanOutcome::Skipped) { // nothing was sent: no charge, and it keeps its place in line
|
||||
restoreWait(st.waitingSince, ticket.waitedSince);
|
||||
return;
|
||||
}
|
||||
st.lastScanAt = std::max(st.lastScanAt, ticket.dispatchedAt);
|
||||
double ms = std::max(0.0, costMs);
|
||||
if (outcome == ScanOutcome::TimedOut && ticket.servesRoutine) ms = std::max(ms, st.emaCostMs);
|
||||
if (ms > 0.0) {
|
||||
// Quiet long enough that this scan was `budget` of the time since it started.
|
||||
const double debt = ms / 1000.0 * (1.0 / budgetForTipLag(cfg_.budget, tipLag_) - 1.0);
|
||||
const double cap = now + (ticket.kind == ScanKind::Explicit ? cfg_.explicitDebtCapSec : cfg_.maxQuietSec);
|
||||
quietUntil_ = std::max(quietUntil_, std::min(std::max(quietUntil_, now) + debt, cap));
|
||||
}
|
||||
if (!ticket.servesRoutine) return; // charged; the routine scans' cost and basis are theirs
|
||||
if (outcome == ScanOutcome::Ok || outcome == ScanOutcome::Partial || outcome == ScanOutcome::TimedOut) {
|
||||
// Rises at once, decays slowly: a scan that got expensive is spaced out right away.
|
||||
st.emaCostMs = (st.emaCostMs <= 0.0 || ms > st.emaCostMs) ? ms : 0.7 * st.emaCostMs + 0.3 * ms;
|
||||
}
|
||||
if (outcome == ScanOutcome::Failed || outcome == ScanOutcome::TimedOut) {
|
||||
if (st.pendingValid) st.pendingPoisoned = true;
|
||||
} else if (outcome == ScanOutcome::Partial) {
|
||||
if (!st.pendingValid) {
|
||||
st.pending = ticket.basis;
|
||||
st.pendingAt = ticket.dispatchedAt;
|
||||
st.pendingValid = true;
|
||||
st.pendingPoisoned = false;
|
||||
}
|
||||
} else if (outcome == ScanOutcome::Ok) {
|
||||
// A pass that started at the pending basis is current for that basis (the earliest), unless one of
|
||||
// its dispatches failed.
|
||||
if (!(st.pendingValid && st.pendingPoisoned)) {
|
||||
st.basis = st.pendingValid ? st.pending : ticket.basis;
|
||||
st.basisAt = st.pendingValid ? st.pendingAt : ticket.dispatchedAt;
|
||||
st.basisValid = true;
|
||||
}
|
||||
st.pendingValid = false;
|
||||
st.pendingPoisoned = false;
|
||||
}
|
||||
}
|
||||
|
||||
void WalletScanGovernor::dropOrphans(ScanStream stream, double now)
|
||||
{
|
||||
std::vector<Live> orphans;
|
||||
for (const auto& l : live_) {
|
||||
if (l.stream == stream) orphans.push_back(l);
|
||||
}
|
||||
for (const auto& l : orphans) complete(ticketFor(l), 0.0, ScanOutcome::TimedOut, now);
|
||||
}
|
||||
|
||||
void WalletScanGovernor::pruneLeases(double now)
|
||||
{
|
||||
std::vector<Live> expired;
|
||||
for (const auto& l : live_) {
|
||||
const double lease = std::max(cfg_.minLeaseSec, 4.0 * at(l.stream).emaCostMs / 1000.0);
|
||||
if (now - l.dispatchedAt > lease) expired.push_back(l);
|
||||
}
|
||||
for (const auto& l : expired) complete(ticketFor(l), 0.0, ScanOutcome::TimedOut, now);
|
||||
}
|
||||
|
||||
void WalletScanGovernor::invalidate(ScanStream stream)
|
||||
{
|
||||
Stream& st = at(stream);
|
||||
st.basisValid = false;
|
||||
st.pendingValid = false;
|
||||
st.pendingPoisoned = false;
|
||||
}
|
||||
|
||||
void WalletScanGovernor::reset()
|
||||
{
|
||||
const std::uint64_t nextGeneration = generation_ + 1;
|
||||
const std::uint64_t nextId = nextId_;
|
||||
*this = WalletScanGovernor(cfg_);
|
||||
generation_ = nextGeneration;
|
||||
nextId_ = nextId;
|
||||
}
|
||||
|
||||
bool WalletScanGovernor::inFlight(ScanStream stream) const
|
||||
{
|
||||
return std::any_of(live_.begin(), live_.end(), [stream](const Live& l) { return l.stream == stream; });
|
||||
}
|
||||
|
||||
double WalletScanGovernor::secondsSinceScan(ScanStream stream, double now) const
|
||||
{
|
||||
const double at_ = at(stream).lastScanAt;
|
||||
return at_ < 0.0 ? 1e9 : now - at_;
|
||||
}
|
||||
|
||||
bool WalletScanGovernor::waiting(ScanStream stream, double now) const
|
||||
{
|
||||
const Stream& st = at(stream);
|
||||
return st.waitingSince >= 0.0 && now - st.lastAskAt <= cfg_.waitLapseSec;
|
||||
}
|
||||
|
||||
bool WalletScanGovernor::heldWithinFloor(ScanStream stream, double now) const
|
||||
{
|
||||
if (!waiting(stream, now)) return false;
|
||||
const Stream& st = at(stream);
|
||||
return now - st.waitingSince < floorSec(st) + st.emaCostMs / 1000.0;
|
||||
}
|
||||
|
||||
double WalletScanGovernor::visibleFloorSecFor(ScanStream stream) const
|
||||
{
|
||||
return std::max(cfg_.visibleFloorSec, cfg_.floorCostMultiple * at(stream).emaCostMs / 1000.0);
|
||||
}
|
||||
|
||||
} // namespace services
|
||||
} // namespace dragonx
|
||||
@@ -0,0 +1,219 @@
|
||||
// DragonX Wallet - ImGui Edition
|
||||
// Copyright 2024-2026 The Hush Developers
|
||||
// Released under the GPLv3
|
||||
|
||||
#pragma once
|
||||
|
||||
// One budget for every wallet-wide scan the GUI sends on its own: the balance (z_gettotalbalance), the
|
||||
// address scan (z_listunspent + listunspent), the history (z_listreceivedbyaddress, z_listsentbyaddress) and
|
||||
// the between-blocks probe. Each holds dragonxd's cs_main for its whole run, seconds per call on a large
|
||||
// wallet, which starves block connection. They used to be throttled one by one at 10% each, which adds up
|
||||
// to 30-40% with all of them due. Here: one scan in flight at a time across both RPC lanes, and one shared
|
||||
// quiet clock that keeps the total near `budget` of wall-clock. A stream that keeps asking is served FIFO
|
||||
// (visible ones a little first), and a floor scaled to its own cost keeps any stream from starving.
|
||||
// Explicit needs (the user's own send, a dirty set, a never-loaded list) are never refused, but are charged.
|
||||
//
|
||||
// Each stream also keeps the signals it last scanned at (tip hash, mempool size, a wallet-mutation epoch):
|
||||
// a routine scan is only needed when they changed, or after the stream's safety sweep.
|
||||
//
|
||||
// An explicit ticket that doesn't do the stream's routine work (a history cycle that only lists rows) is
|
||||
// charged to the quiet clock but leaves the stream's wait, cost estimate and basis alone (servesRoutine).
|
||||
//
|
||||
// Pure and main-thread only: every call takes `now` in seconds of a steady clock.
|
||||
|
||||
#include "ui/sidebar.h"
|
||||
|
||||
#include <array>
|
||||
#include <cstddef>
|
||||
#include <cstdint>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
namespace dragonx {
|
||||
namespace services {
|
||||
|
||||
enum class ScanStream : std::uint8_t { Balance, Addresses, History, RecentTx, ChatNotes, ChatFast, Adhoc, Count };
|
||||
enum class ScanKind : std::uint8_t { Routine, Explicit };
|
||||
enum class ScanOutcome : std::uint8_t {
|
||||
Ok, // applied: the stream's signals basis advances
|
||||
Partial, // applied, more dispatches needed before the basis may advance (a multi-cycle pass)
|
||||
Failed, // charged; the basis stays (and a pending pass is poisoned)
|
||||
TimedOut, // a reply timeout: charged at least the stream's usual cost (the daemon still runs it)
|
||||
Skipped, // admitted, but no scan was sent (node behind, busy, held back): not charged
|
||||
};
|
||||
enum : unsigned { kScanSignalTip = 1, kScanSignalMempool = 2, kScanSignalEpoch = 4, kScanSignalAll = 7 };
|
||||
|
||||
// What a scan's result depends on, read without cs_main: the tip (bestblockhash, fetched each Core cycle),
|
||||
// the mempool (getmempoolinfo takes only mempool.cs) and the App's wallet-mutation epoch.
|
||||
struct ScanSignals {
|
||||
std::string tipHash;
|
||||
bool mempoolKnown = false;
|
||||
std::int64_t mempoolTxs = 0;
|
||||
std::int64_t mempoolBytes = 0;
|
||||
std::uint64_t walletEpoch = 0;
|
||||
};
|
||||
|
||||
struct ScanTicket {
|
||||
ScanStream stream = ScanStream::Balance;
|
||||
ScanKind kind = ScanKind::Routine;
|
||||
std::uint64_t id = 0;
|
||||
std::uint64_t generation = 0;
|
||||
double dispatchedAt = 0.0;
|
||||
double waitedSince = -1.0; // when its wait began, if it ended one (cancel() / Skipped restore it)
|
||||
bool servesRoutine = true; // does the stream's routine work: ends its wait, feeds its cost and basis
|
||||
ScanSignals basis; // the signals at dispatch: what an Ok result is current for
|
||||
explicit operator bool() const { return id != 0; }
|
||||
};
|
||||
|
||||
// Which streams the current page shows (visible streams get a head start and a shorter starvation floor).
|
||||
struct ScanVisibility {
|
||||
bool balance = false;
|
||||
bool addresses = false;
|
||||
bool history = false;
|
||||
bool chat = false;
|
||||
};
|
||||
ScanVisibility scanVisibilityForPage(ui::NavPage page, bool windowBackground);
|
||||
|
||||
// The balance part of a Core poll.
|
||||
enum class BalancePollDecision { Skip, AskGovernor, Explicit };
|
||||
struct BalancePollInputs {
|
||||
bool allowBalance = true; // false for the locked wallet's chain-state poll
|
||||
bool syncing = false; // the node is catching up (or settling after it): only a first read runs
|
||||
bool background = false; // the window is in the background: no first read while syncing
|
||||
bool firstReadTried = false; // the first read while syncing was already sent this connection
|
||||
bool gateOpen = true; // the connect gate (C44): closed right after a connect, at most ~20 s
|
||||
bool forced = false; // the user's own send (waits while the window is in the background)
|
||||
bool neverFetched = false; // no balance yet this session
|
||||
bool changed = true; // the governor's change gate
|
||||
bool visible = true; // the page shows the aggregate balance
|
||||
bool autoShieldArmed = false; // client-side auto-shield runs from balance results
|
||||
double sinceLastScanSec = 1e9; // since the last balance scan that was sent
|
||||
double emaCostSec = 0.0;
|
||||
// Coming into view with an old balance (back to Overview, the window restored): one explicit poll
|
||||
// instead of a fresh wait behind the other streams. Only when the stream isn't already waiting and
|
||||
// both the value on screen and the last scan are older than the stream's visible floor.
|
||||
bool waiting = false; // the Balance stream is waiting for admission
|
||||
double shownAgeSec = 0.0; // age of the value on screen (last fetch, or last poll that confirmed it)
|
||||
double visibleFloorSec = 30.0; // WalletScanGovernor::visibleFloorSecFor(Balance)
|
||||
};
|
||||
// How often a page that doesn't show the balance still polls it: max(10 min, 50x its cost), or every 2 min
|
||||
// while client-side auto-shield needs it.
|
||||
double hiddenBalanceIntervalSec(double emaCostSec, bool autoShieldArmed);
|
||||
BalancePollDecision decideBalancePoll(const BalancePollInputs& in);
|
||||
|
||||
class WalletScanGovernor {
|
||||
public:
|
||||
struct StreamConfig {
|
||||
unsigned signalMask = kScanSignalAll; // which signals the change gate compares
|
||||
double maxAgeSec = 0.0; // safety sweep: needed this long after its basis (0 = never)
|
||||
};
|
||||
struct Config {
|
||||
double budget = 0.15; // share of wall-clock for all routine scans together
|
||||
double explicitDebtCapSec = 120; // an explicit scan never pushes the quiet clock past now + this
|
||||
double maxQuietSec = 900; // no scan pushes it past now + this (no runaway debt)
|
||||
double visibleFloorSec = 30; // a waiting visible stream is admitted after this, quiet or not...
|
||||
double hiddenFloorSec = 900; // ...a hidden one after this...
|
||||
double floorCostMultiple = 15; // ...or after 15x its own cost if longer (<= ~6% each from floors)
|
||||
double visibleHeadStartSec = 60; // visible waiters rank as if they had waited this much longer
|
||||
double waiterTtlSec = 15; // a stream that hasn't asked for this long isn't waiting
|
||||
double waitLapseSec = 60; // asking again after this long starts a new wait
|
||||
double minLeaseSec = 600; // a ticket older than max(this, 4x its cost) is presumed lost
|
||||
std::array<StreamConfig, static_cast<std::size_t>(ScanStream::Count)> streams{{
|
||||
{kScanSignalAll, 300}, // Balance
|
||||
{kScanSignalAll, 300}, // Addresses
|
||||
{kScanSignalTip | kScanSignalEpoch, 0}, // History
|
||||
{kScanSignalMempool | kScanSignalEpoch, 0}, // RecentTx (between blocks: the tip is History's)
|
||||
{0, 0}, // ChatNotes: admission only; the need is services::shouldScanChatNotes (tip settle, chat ops)
|
||||
{0, 0}, // ChatFast: admission only; the need is getwalletinfo's txcount (chatInboxScanNeeded). Not
|
||||
// the tip or mempool: they move before the wallet notifier stores the tx, so a scan fired
|
||||
// on them can read the old wallet and consume the change.
|
||||
{0, 0}, // Adhoc
|
||||
}};
|
||||
};
|
||||
|
||||
WalletScanGovernor() = default;
|
||||
explicit WalletScanGovernor(const Config& config) : cfg_(config) {}
|
||||
|
||||
// The budget while the node is behind: full at the tip, 2/3 one block behind, 1/3 two or more.
|
||||
static double budgetForTipLag(double base, int blocksBehind);
|
||||
|
||||
// A routine scan of this stream is needed: no basis yet, the signals changed, or the sweep is due.
|
||||
bool needed(ScanStream stream, const ScanSignals& current, double now) const;
|
||||
// The stream's basis matches these signals (masked). An unknown tip or mempool never matches.
|
||||
bool signalsMatch(ScanStream stream, const ScanSignals& current) const;
|
||||
|
||||
// Ask to run a routine scan now: a ticket when admitted, else an empty one (the stream keeps waiting
|
||||
// while it keeps asking). Admitted only when no scan is in flight and this stream is the best waiter.
|
||||
ScanTicket tryAdmit(ScanStream stream, bool visible, const ScanSignals& current, double now);
|
||||
// An explicit need: never refused, charged (with capped debt). servesRoutine = false for one that
|
||||
// doesn't do the stream's routine work: it is charged, but the stream keeps waiting for that work and
|
||||
// its cost estimate and basis stay those of the routine scans.
|
||||
ScanTicket beginExplicit(ScanStream stream, const ScanSignals& current, double now, bool servesRoutine = true);
|
||||
// The scan wasn't sent after all (the job queue refused it): drop the ticket with no charge.
|
||||
void cancel(const ScanTicket& ticket);
|
||||
// The scan's result is in. `basis` may differ from the ticket's (e.g. what the worker saw).
|
||||
void complete(const ScanTicket& ticket, double costMs, ScanOutcome outcome, double now);
|
||||
// Tickets of this stream whose job is gone without a result: completed as timed out.
|
||||
void dropOrphans(ScanStream stream, double now);
|
||||
void setTipLag(int blocksBehind) { tipLag_ = blocksBehind > 0 ? blocksBehind : 0; }
|
||||
void invalidate(ScanStream stream); // forget its basis: the next routine scan is needed
|
||||
void reset(); // a new connection: forget everything; old tickets complete as no-ops
|
||||
|
||||
bool inFlight(ScanStream stream) const;
|
||||
bool anyInFlight() const { return !live_.empty(); }
|
||||
double quietUntil() const { return quietUntil_; }
|
||||
double emaCostMs(ScanStream stream) const { return at(stream).emaCostMs; }
|
||||
// Since the dispatch of the last scan that was sent (cancelled and Skipped tickets don't count); 1e9 if none.
|
||||
double secondsSinceScan(ScanStream stream, double now) const;
|
||||
double currentBudget() const { return budgetForTipLag(cfg_.budget, tipLag_); }
|
||||
// The stream asked within the lapse window and hasn't been admitted yet.
|
||||
bool waiting(ScanStream stream, double now) const;
|
||||
// Waiting, and still within what the budget promises it: its floor plus one scan of its own. Past that,
|
||||
// or not asking, a stale value means something else is wrong.
|
||||
bool heldWithinFloor(ScanStream stream, double now) const;
|
||||
// The floor it would get as a visible waiter: max(visibleFloorSec, floorCostMultiple x its cost).
|
||||
double visibleFloorSecFor(ScanStream stream) const;
|
||||
|
||||
private:
|
||||
struct Stream {
|
||||
double waitingSince = -1.0;
|
||||
double lastAskAt = -1e18;
|
||||
bool askVisible = false;
|
||||
double emaCostMs = 0.0;
|
||||
double lastScanAt = -1.0; // dispatch time of the last scan that was sent
|
||||
bool basisValid = false;
|
||||
ScanSignals basis;
|
||||
double basisAt = 0.0;
|
||||
bool pendingValid = false;
|
||||
bool pendingPoisoned = false;
|
||||
ScanSignals pending;
|
||||
double pendingAt = 0.0;
|
||||
};
|
||||
struct Live {
|
||||
std::uint64_t id = 0;
|
||||
ScanStream stream = ScanStream::Balance;
|
||||
ScanKind kind = ScanKind::Routine;
|
||||
double dispatchedAt = 0.0;
|
||||
bool servesRoutine = true;
|
||||
};
|
||||
|
||||
Stream& at(ScanStream stream) { return streams_[static_cast<std::size_t>(stream)]; }
|
||||
const Stream& at(ScanStream stream) const { return streams_[static_cast<std::size_t>(stream)]; }
|
||||
double floorSec(const Stream& stream) const;
|
||||
ScanStream bestWaiter(double now) const;
|
||||
ScanTicket issue(ScanStream stream, ScanKind kind, const ScanSignals& current, double now, bool servesRoutine);
|
||||
ScanTicket ticketFor(const Live& live) const;
|
||||
void eraseLive(std::uint64_t id);
|
||||
void pruneLeases(double now);
|
||||
|
||||
Config cfg_;
|
||||
std::array<Stream, static_cast<std::size_t>(ScanStream::Count)> streams_{};
|
||||
std::vector<Live> live_;
|
||||
double quietUntil_ = 0.0;
|
||||
int tipLag_ = 0;
|
||||
std::uint64_t nextId_ = 0;
|
||||
std::uint64_t generation_ = 1;
|
||||
};
|
||||
|
||||
} // namespace services
|
||||
} // namespace dragonx
|
||||
@@ -64,8 +64,10 @@ WalletSecurityWorkflowExecutor::Outcome WalletSecurityWorkflowExecutor::restartD
|
||||
if (!waitForMs(preRestartDelayMs)) return {false, "", false};
|
||||
|
||||
if (daemon.isUsingEmbeddedDaemon()) {
|
||||
daemon.stopEmbeddedDaemon();
|
||||
const bool stopped = daemon.stopEmbeddedDaemon();
|
||||
if (daemon.cancelled() || daemon.shuttingDown()) return {false, "", false};
|
||||
// Never start a second node over one that hasn't exited (it holds wallet.dat and the datadir lock).
|
||||
if (!stopped) return {false, "The node did not stop in time, so it was not restarted.", false};
|
||||
if (!waitForMs(embeddedRestartSettleMs)) return {false, "", false};
|
||||
daemon.startEmbeddedDaemon();
|
||||
}
|
||||
|
||||
@@ -49,7 +49,7 @@ public:
|
||||
public:
|
||||
virtual ~DaemonGateway() = default;
|
||||
virtual bool isUsingEmbeddedDaemon() const = 0;
|
||||
virtual void stopEmbeddedDaemon() = 0;
|
||||
virtual bool stopEmbeddedDaemon() = 0; // true: the node exited (false: still running; never killed)
|
||||
virtual bool startEmbeddedDaemon() = 0;
|
||||
virtual bool cancelled() const = 0;
|
||||
virtual bool shuttingDown() const = 0;
|
||||
|
||||
+72
-162
@@ -33,109 +33,6 @@ namespace dragonx {
|
||||
namespace ui {
|
||||
namespace effects {
|
||||
|
||||
// ============================================================================
|
||||
// Composite Shader Source (GLEW Available)
|
||||
// ============================================================================
|
||||
|
||||
namespace {
|
||||
|
||||
const char* COMPOSITE_VERTEX = R"glsl(
|
||||
#version 330 core
|
||||
|
||||
layout (location = 0) in vec2 aPos;
|
||||
layout (location = 1) in vec2 aTexCoord;
|
||||
|
||||
out vec2 vTexCoord;
|
||||
out vec2 vNoiseCoord;
|
||||
|
||||
uniform vec2 uTexScale; // For noise tiling
|
||||
|
||||
void main()
|
||||
{
|
||||
gl_Position = vec4(aPos, 0.0, 1.0);
|
||||
vTexCoord = aTexCoord;
|
||||
vNoiseCoord = aTexCoord * uTexScale; // Tile noise
|
||||
}
|
||||
)glsl";
|
||||
|
||||
const char* COMPOSITE_FRAGMENT = R"glsl(
|
||||
#version 330 core
|
||||
|
||||
in vec2 vTexCoord;
|
||||
in vec2 vNoiseCoord;
|
||||
|
||||
out vec4 FragColor;
|
||||
|
||||
uniform sampler2D uBlurTex;
|
||||
uniform sampler2D uNoiseTex;
|
||||
|
||||
uniform vec4 uTintColor;
|
||||
uniform float uTintOpacity;
|
||||
uniform float uLuminosity;
|
||||
uniform float uNoiseOpacity;
|
||||
|
||||
void main()
|
||||
{
|
||||
// Sample blurred background
|
||||
vec3 blur = texture(uBlurTex, vTexCoord).rgb;
|
||||
|
||||
// Apply luminosity (desaturation effect)
|
||||
float lum = dot(blur, vec3(0.299, 0.587, 0.114));
|
||||
blur = mix(vec3(lum), blur, uLuminosity);
|
||||
|
||||
// Apply tint color
|
||||
vec3 tinted = mix(blur, uTintColor.rgb, uTintOpacity);
|
||||
|
||||
// Sample and apply noise
|
||||
float noise = texture(uNoiseTex, vNoiseCoord).r;
|
||||
// Noise is centered at 0.5, so we subtract 0.5 and scale
|
||||
float noiseOffset = (noise - 0.5) * 2.0 * uNoiseOpacity;
|
||||
tinted += vec3(noiseOffset);
|
||||
|
||||
// Output with slight transparency
|
||||
FragColor = vec4(tinted, uTintColor.a);
|
||||
}
|
||||
)glsl";
|
||||
|
||||
bool compileShader(GLuint shader, const char* source)
|
||||
{
|
||||
glShaderSource(shader, 1, &source, nullptr);
|
||||
glCompileShader(shader);
|
||||
|
||||
GLint success;
|
||||
glGetShaderiv(shader, GL_COMPILE_STATUS, &success);
|
||||
|
||||
if (!success) {
|
||||
GLint logLength;
|
||||
glGetShaderiv(shader, GL_INFO_LOG_LENGTH, &logLength);
|
||||
std::vector<char> log(logLength + 1);
|
||||
glGetShaderInfoLog(shader, logLength, nullptr, log.data());
|
||||
DEBUG_LOGF("Shader compile error:\n%s\n", log.data());
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
bool linkProgram(GLuint program)
|
||||
{
|
||||
glLinkProgram(program);
|
||||
|
||||
GLint success;
|
||||
glGetProgramiv(program, GL_LINK_STATUS, &success);
|
||||
|
||||
if (!success) {
|
||||
GLint logLength;
|
||||
glGetProgramiv(program, GL_INFO_LOG_LENGTH, &logLength);
|
||||
std::vector<char> log(logLength + 1);
|
||||
glGetProgramInfoLog(program, logLength, nullptr, log.data());
|
||||
DEBUG_LOGF("Program link error:\n%s\n", log.data());
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
} // anonymous namespace
|
||||
|
||||
// ============================================================================
|
||||
// AcrylicMaterial Implementation (GLEW Available)
|
||||
// ============================================================================
|
||||
@@ -172,52 +69,6 @@ bool AcrylicMaterial::init()
|
||||
return false;
|
||||
}
|
||||
|
||||
// Create composite shader
|
||||
GLuint vs = glCreateShader(GL_VERTEX_SHADER);
|
||||
GLuint fs = glCreateShader(GL_FRAGMENT_SHADER);
|
||||
|
||||
if (!compileShader(vs, COMPOSITE_VERTEX)) {
|
||||
glDeleteShader(vs);
|
||||
glDeleteShader(fs);
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!compileShader(fs, COMPOSITE_FRAGMENT)) {
|
||||
glDeleteShader(vs);
|
||||
glDeleteShader(fs);
|
||||
return false;
|
||||
}
|
||||
|
||||
compositeShader_ = glCreateProgram();
|
||||
glAttachShader(compositeShader_, vs);
|
||||
glAttachShader(compositeShader_, fs);
|
||||
|
||||
if (!linkProgram(compositeShader_)) {
|
||||
glDeleteProgram(compositeShader_);
|
||||
glDeleteShader(vs);
|
||||
glDeleteShader(fs);
|
||||
compositeShader_ = 0;
|
||||
return false;
|
||||
}
|
||||
|
||||
glDeleteShader(vs);
|
||||
glDeleteShader(fs);
|
||||
|
||||
// Get uniform locations
|
||||
uCompositeTintColor_ = glGetUniformLocation(compositeShader_, "uTintColor");
|
||||
uCompositeTintOpacity_ = glGetUniformLocation(compositeShader_, "uTintOpacity");
|
||||
uCompositeLuminosity_ = glGetUniformLocation(compositeShader_, "uLuminosity");
|
||||
uCompositeNoiseOpacity_ = glGetUniformLocation(compositeShader_, "uNoiseOpacity");
|
||||
uCompositeBlurTex_ = glGetUniformLocation(compositeShader_, "uBlurTex");
|
||||
uCompositeNoiseTex_ = glGetUniformLocation(compositeShader_, "uNoiseTex");
|
||||
uCompositeTexScale_ = glGetUniformLocation(compositeShader_, "uTexScale");
|
||||
|
||||
DEBUG_LOGF("AcrylicMaterial: Composite shader uniforms:\n");
|
||||
DEBUG_LOGF(" uTintColor=%d, uTintOpacity=%d, uLuminosity=%d\n",
|
||||
uCompositeTintColor_, uCompositeTintOpacity_, uCompositeLuminosity_);
|
||||
DEBUG_LOGF(" uNoiseOpacity=%d, uBlurTex=%d, uNoiseTex=%d, uTexScale=%d\n",
|
||||
uCompositeNoiseOpacity_, uCompositeBlurTex_, uCompositeNoiseTex_, uCompositeTexScale_);
|
||||
|
||||
// Detect system capabilities
|
||||
refreshCapabilities();
|
||||
currentFallback_ = detectFallback();
|
||||
@@ -239,11 +90,6 @@ void AcrylicMaterial::shutdown()
|
||||
quad_.destroy();
|
||||
noiseTexture_.destroy();
|
||||
|
||||
if (compositeShader_) {
|
||||
glDeleteProgram(compositeShader_);
|
||||
compositeShader_ = 0;
|
||||
}
|
||||
|
||||
initialized_ = false;
|
||||
viewportWidth_ = 0;
|
||||
viewportHeight_ = 0;
|
||||
@@ -260,6 +106,7 @@ void AcrylicMaterial::resize(int width, int height)
|
||||
viewportWidth_ = width;
|
||||
viewportHeight_ = height;
|
||||
blurCacheValid_ = false; // Invalidate cache on resize
|
||||
if (shouldSkipEffect()) { needsRealloc_ = true; return; } // targets freed/rebuilt by syncBufferLifetime()
|
||||
|
||||
// Determine blur buffer size based on quality
|
||||
int blurWidth = width;
|
||||
@@ -301,16 +148,46 @@ void AcrylicMaterial::resize(int width, int height)
|
||||
|
||||
DEBUG_LOGF("AcrylicMaterial: Resized to %dx%d (blur: %dx%d)\n",
|
||||
width, height, blurWidth, blurHeight);
|
||||
needsRealloc_ = false;
|
||||
dirtyFrames_ = 2; // recapture after resize
|
||||
}
|
||||
|
||||
void AcrylicMaterial::releaseBuffers()
|
||||
{
|
||||
captureBuffer_.destroy();
|
||||
blurBuffers_.destroy();
|
||||
hasValidCapture_ = false; // drawRect -> drawTintedRect until a fresh capture
|
||||
blurCacheValid_ = false;
|
||||
needsRealloc_ = true;
|
||||
DEBUG_LOGF("AcrylicMaterial: released targets (effect skipped)\n");
|
||||
}
|
||||
|
||||
void AcrylicMaterial::syncBufferLifetime()
|
||||
{
|
||||
if (!initialized_) return;
|
||||
if (shouldSkipEffect()) {
|
||||
// Test the names, not isValid(): a half-allocated ping-pong pair must be freed too.
|
||||
if (captureBuffer_.getFbo() || blurBuffers_.getSourceTexture() || blurBuffers_.getDestTexture())
|
||||
releaseBuffers();
|
||||
return;
|
||||
}
|
||||
if (needsRealloc_ && viewportWidth_ > 0 && viewportHeight_ > 0) {
|
||||
needsRealloc_ = false; // one attempt; no per-frame retry on alloc failure
|
||||
int w = viewportWidth_, h = viewportHeight_;
|
||||
viewportWidth_ = viewportHeight_ = 0; // force resize() past its early-out
|
||||
resize(w, h);
|
||||
hasValidCapture_ = false;
|
||||
markBackgroundDirty();
|
||||
}
|
||||
}
|
||||
|
||||
void AcrylicMaterial::captureBackground()
|
||||
{
|
||||
if (!initialized_ || shouldSkipEffect()) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (viewportWidth_ <= 0 || viewportHeight_ <= 0) {
|
||||
if (viewportWidth_ <= 0 || viewportHeight_ <= 0 || !captureBuffer_.isValid()) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -321,7 +198,7 @@ void AcrylicMaterial::captureBackground()
|
||||
--dirtyFrames_;
|
||||
|
||||
// Capture current screen content
|
||||
captureBuffer_.captureScreen(viewportWidth_, viewportHeight_);
|
||||
captureBuffer_.captureScreen(captureSrcWidth(), captureSrcHeight());
|
||||
hasValidCapture_ = true;
|
||||
blurCacheValid_ = false; // New capture invalidates blur cache
|
||||
}
|
||||
@@ -332,7 +209,7 @@ void AcrylicMaterial::captureBackgroundDirect()
|
||||
return;
|
||||
}
|
||||
|
||||
if (viewportWidth_ <= 0 || viewportHeight_ <= 0) {
|
||||
if (viewportWidth_ <= 0 || viewportHeight_ <= 0 || !captureBuffer_.isValid()) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -349,7 +226,7 @@ void AcrylicMaterial::captureBackgroundDirect()
|
||||
// draw framebuffer, and ImGui has scissor enabled during rendering.
|
||||
// The subsequent ImDrawCallback_ResetRenderState re-enables it.
|
||||
glDisable(GL_SCISSOR_TEST);
|
||||
captureBuffer_.captureScreen(viewportWidth_, viewportHeight_);
|
||||
captureBuffer_.captureScreen(captureSrcWidth(), captureSrcHeight());
|
||||
|
||||
hasValidCapture_ = true;
|
||||
blurCacheValid_ = false; // force re-blur with fresh capture
|
||||
@@ -362,9 +239,9 @@ void AcrylicMaterial::captureLiveFramebuffer()
|
||||
// inserted at the overlay window's draw-list start), so FB 0 already holds the app UI drawn
|
||||
// below the overlay. A paired ImDrawCallback_ResetRenderState restores ImGui's GL state.
|
||||
if (!initialized_ || shouldSkipEffect()) return;
|
||||
if (viewportWidth_ <= 0 || viewportHeight_ <= 0) return;
|
||||
if (viewportWidth_ <= 0 || viewportHeight_ <= 0 || !captureBuffer_.isValid()) return;
|
||||
glDisable(GL_SCISSOR_TEST);
|
||||
captureBuffer_.captureScreen(viewportWidth_, viewportHeight_);
|
||||
captureBuffer_.captureScreen(captureSrcWidth(), captureSrcHeight());
|
||||
hasValidCapture_ = true;
|
||||
blurCacheValid_ = false; // force re-blur with the fresh live capture
|
||||
}
|
||||
@@ -390,7 +267,7 @@ void AcrylicMaterial::setQuality(AcrylicQuality quality)
|
||||
|
||||
void AcrylicMaterial::applyBlur(float radius, bool ignoreMultiplier)
|
||||
{
|
||||
if (!blurBuffers_.isValid() || !blurShader_.isValid()) {
|
||||
if (!blurBuffers_.isValid() || !blurShader_.isValid() || !captureBuffer_.isValid()) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -1188,7 +1065,7 @@ void AcrylicMaterial::resize(int width, int height)
|
||||
viewportHeight_ = height;
|
||||
blurCacheValid_ = false;
|
||||
|
||||
if (settings_.quality == AcrylicQuality::Off) return;
|
||||
if (shouldSkipEffect()) { needsRealloc_ = true; return; } // targets freed/rebuilt by syncBufferLifetime()
|
||||
|
||||
// (Re)create capture buffer at full resolution — must be B8G8R8A8 to match swap chain for CopyResource
|
||||
dx_releaseRenderTarget(dx_captureTex_, dx_captureSRV_, dx_captureRTV_);
|
||||
@@ -1215,10 +1092,43 @@ void AcrylicMaterial::resize(int width, int height)
|
||||
dx_blurWidth_ = bw;
|
||||
dx_blurHeight_ = bh;
|
||||
dx_blurCurrent_ = 0;
|
||||
needsRealloc_ = false;
|
||||
dirtyFrames_ = 2;
|
||||
VERBOSE_LOGF("[Acrylic DX11] Resized %dx%d (blur %dx%d)\n", width, height, bw, bh);
|
||||
}
|
||||
|
||||
void AcrylicMaterial::releaseBuffers()
|
||||
{
|
||||
dx_releaseRenderTarget(dx_captureTex_, dx_captureSRV_, dx_captureRTV_);
|
||||
for (int i = 0; i < 2; ++i)
|
||||
dx_releaseRenderTarget(dx_blurTex_[i], dx_blurSRV_[i], dx_blurRTV_[i]);
|
||||
dx_blurWidth_ = dx_blurHeight_ = 0;
|
||||
dx_blurCurrent_ = 0;
|
||||
hasValidCapture_ = false; // drawRect -> drawTintedRect until a fresh capture
|
||||
blurCacheValid_ = false;
|
||||
needsRealloc_ = true;
|
||||
DEBUG_LOGF("[Acrylic DX11] released targets (effect skipped)\n");
|
||||
}
|
||||
|
||||
void AcrylicMaterial::syncBufferLifetime()
|
||||
{
|
||||
if (!initialized_) return;
|
||||
if (shouldSkipEffect()) {
|
||||
// Per texture: each dx_createRenderTarget can fail on its own.
|
||||
if (dx_captureTex_ || dx_blurTex_[0] || dx_blurTex_[1])
|
||||
releaseBuffers();
|
||||
return;
|
||||
}
|
||||
if (needsRealloc_ && viewportWidth_ > 0 && viewportHeight_ > 0) {
|
||||
needsRealloc_ = false; // one attempt; no per-frame retry on alloc failure
|
||||
int w = viewportWidth_, h = viewportHeight_;
|
||||
viewportWidth_ = viewportHeight_ = 0; // force resize() past its early-out
|
||||
resize(w, h);
|
||||
hasValidCapture_ = false;
|
||||
markBackgroundDirty();
|
||||
}
|
||||
}
|
||||
|
||||
void AcrylicMaterial::captureBackground()
|
||||
{
|
||||
// Unused on DX11 — capture is done via captureBackgroundDirect()
|
||||
|
||||
+17
-16
@@ -147,6 +147,13 @@ public:
|
||||
* @param height Viewport height
|
||||
*/
|
||||
void resize(int width, int height);
|
||||
|
||||
/// Size of FB 0 in pixels (differs from the logical viewport on HiDPI GL). Used only as the capture
|
||||
/// blit source. Kept apart from viewportWidth_/Height_ so setQuality()/syncBufferLifetime() resets
|
||||
/// of those never lose it. DX11 ignores it (CopyResource needs identical dimensions, scale is 1).
|
||||
void setFramebufferSize(int w, int h) {
|
||||
if (w != fbWidth_ || h != fbHeight_) { fbWidth_ = w; fbHeight_ = h; markBackgroundDirty(); }
|
||||
}
|
||||
|
||||
/**
|
||||
* @brief Capture current screen content for blur source
|
||||
@@ -194,6 +201,10 @@ public:
|
||||
/// has actually been rendered (theme switch happens mid-frame, before the
|
||||
/// BackgroundDrawList is rebuilt with new colors/images).
|
||||
void markBackgroundDirty() { dirtyFrames_ = 2; }
|
||||
|
||||
/// Frame-boundary maintenance. Frees the capture/blur targets while the effect is skipped
|
||||
/// (Off / disabled / reduced transparency) and rebuilds them when wanted again.
|
||||
void syncBufferLifetime();
|
||||
|
||||
/**
|
||||
* @brief Render an acrylic-filled rectangle
|
||||
@@ -348,12 +359,6 @@ private:
|
||||
*/
|
||||
void applyBlur(float radius, bool ignoreMultiplier = false);
|
||||
|
||||
/**
|
||||
* @brief Composite final acrylic appearance
|
||||
*/
|
||||
void compositeAcrylic(const ImVec2& pMin, const ImVec2& pMax,
|
||||
const AcrylicParams& params);
|
||||
|
||||
/**
|
||||
* @brief Check if effect should be skipped (settings/quality)
|
||||
*/
|
||||
@@ -377,6 +382,10 @@ private:
|
||||
// Screen dimensions
|
||||
int viewportWidth_ = 0;
|
||||
int viewportHeight_ = 0;
|
||||
int fbWidth_ = 0; // FB 0 pixel size (0 = unknown -> use the viewport size)
|
||||
int fbHeight_ = 0;
|
||||
int captureSrcWidth() const { return fbWidth_ > 0 ? fbWidth_ : viewportWidth_; }
|
||||
int captureSrcHeight() const { return fbHeight_ > 0 ? fbHeight_ : viewportHeight_; }
|
||||
|
||||
// Noise texture config
|
||||
int noiseTextureSize_ = 256; // Texture resolution (larger = less repetition)
|
||||
@@ -386,6 +395,8 @@ private:
|
||||
bool blurCacheValid_ = false;
|
||||
int dirtyFrames_ = 2; // >0 means recapture needed; counts down each frame
|
||||
bool hasValidCapture_ = false; // true once a clean BG capture exists
|
||||
void releaseBuffers();
|
||||
bool needsRealloc_ = false; // targets were freed or left stale while the effect was skipped
|
||||
|
||||
// Capture and blur framebuffers
|
||||
Framebuffer captureBuffer_;
|
||||
@@ -400,16 +411,6 @@ private:
|
||||
// Noise texture
|
||||
NoiseTexture noiseTexture_;
|
||||
|
||||
// Composite shader (tint + noise overlay)
|
||||
GLuint compositeShader_ = 0;
|
||||
GLint uCompositeTintColor_ = -1;
|
||||
GLint uCompositeTintOpacity_ = -1;
|
||||
GLint uCompositeLuminosity_ = -1;
|
||||
GLint uCompositeNoiseOpacity_ = -1;
|
||||
GLint uCompositeBlurTex_ = -1;
|
||||
GLint uCompositeNoiseTex_ = -1;
|
||||
GLint uCompositeTexScale_ = -1;
|
||||
|
||||
#ifdef DRAGONX_USE_DX11
|
||||
// ---- DX11 acrylic resources (used instead of GL objects above) ----
|
||||
ID3D11Device* dx_device_ = nullptr;
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user